fix(config): reach the marketplace when the plugin is already gone (#4034)

`wt config plugins claude uninstall` cannot finish a half-done
uninstall. It returns early on `!is_plugin_installed()`, so once the
plugin is gone the command stops there — and the state where the plugin
is gone but the marketplace is not is exactly what a first run leaves
behind when the plugin uninstall succeeds and the marketplace removal
then fails. Re-running prints `Plugin not installed` and exits 0 with
the marketplace still configured, and nothing in `wt` will remove it.

The early return now requires both halves to be gone, and takes only a
confident `Some(false)` for the marketplace, matching what
`run_plugin_removal` is willing to trust. The plugin step becomes
conditional, and the `?` preview lists it only when it will run, so the
preview stays equal to the set of commands the uninstall spawns — the
property `test_plugins_claude_prompt_previews_commands` pins.

The marketplace removal still runs unconditionally. Gating it on the
config reading `Some(false)` was the first thing I tried, and it is
wrong: it makes `run_plugin_removal`'s tolerance unreachable in the
cases that exercise it, so
`test_plugins_claude_uninstall_tolerates_absent_marketplace` and its
siblings would pass while never attempting the removal they exist to
tolerate. Leaving the removal unconditional keeps that tolerance the
thing that decides whether an absent marketplace is a failure.

`test_plugins_claude_uninstall_removes_marketplace_left_without_plugin`
covers the recovered state: no `installed_plugins.json`, a
`known_marketplaces.json` holding worktrunk, and a run that skips the
plugin step and removes the marketplace. Every other uninstall snapshot
is unchanged, which is what confirms this reaches only the state that
was previously unreachable.

`wt config plugins claude uninstall --help` promised the opposite of the
new behavior — "Skips gracefully if the plugin is not installed, leaving
the marketplace in place" is precisely the state the command no longer
skips — so it now states the real condition. Nothing caught that: the
string is in no generated mirror and no snapshot, so
`test_docs_are_in_sync` and the help snapshots both stayed green while
the sentence described behavior that was gone. Both uninstall pages are
`test_help` cases now, since what each says about skipping depends on
what the harness's config still holds.

The Codex uninstall has no equivalent stranded state, which is
measurable rather than a judgment call: `codex plugin remove` exits 0 on
an absent plugin, so its first step never blocks the re-run from
reaching the marketplace step. `claude plugin uninstall` exits 1 there,
which is what made the `is_plugin_installed` guard necessary on that
side and gave it a state to strand.

Raised as a non-blocking observation on #4033. It is pre-existing rather
than introduced there, but it is in the function that PR was editing.

> _This was written by Claude Code on behalf of max-sixty_

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maximilian Roos
2026-09-07 00:32:51 -07:00
committed by GitHub
parent 74ad58ac6f
commit c40e1220ce
7 changed files with 295 additions and 7 deletions
+4 -2
View File
@@ -442,12 +442,14 @@ Requires `claude` CLI. Skips gracefully if already installed."#
/// Remove the Worktrunk plugin
#[command(
after_long_help = r#"Uninstalls the Worktrunk plugin from Claude Code and removes its marketplace. Skips gracefully if the plugin is not installed, leaving the marketplace in place. Equivalent to:
after_long_help = r#"Uninstalls the Worktrunk plugin from Claude Code and removes its marketplace. Equivalent to:
```console
$ claude plugin uninstall worktrunk@worktrunk
$ claude plugin marketplace remove worktrunk
```"#
```
Requires `claude` CLI. Skips gracefully only when both are already gone. An uninstall that removed the plugin and then failed leaves the marketplace behind; running it again removes that half."#
)]
Uninstall,
+26 -5
View File
@@ -52,17 +52,36 @@ pub fn handle_claude_install(yes: bool) -> anyhow::Result<()> {
pub fn handle_claude_uninstall(yes: bool) -> anyhow::Result<()> {
require_claude_cli()?;
if !is_plugin_installed() {
// The marketplace can outlive the plugin: an uninstall that removed the
// plugin and then failed on the marketplace leaves exactly that. Asking
// only about the plugin would report "not installed" and exit 0 with the
// marketplace still there and no way left to finish the job, so the early
// return needs both halves gone. Only a confident `Some(false)` counts as
// gone, for the reason `run_plugin_removal` gives.
let plugin_installed = is_plugin_installed();
if !plugin_installed && is_marketplace_configured() == Some(false) {
eprintln!("{}", info_message("Plugin not installed"));
return Ok(());
}
// The marketplace removal always runs, so its own tolerance decides
// whether an absent marketplace is a failure. Only the plugin step is
// conditional, and the preview says so rather than naming a command that
// will not run.
let mut commands = Vec::new();
if plugin_installed {
commands.push("claude plugin uninstall worktrunk@worktrunk");
}
commands.push("claude plugin marketplace remove worktrunk");
if !yes {
match prompt_yes_no_preview(
&cformat!("Uninstall Worktrunk plugin from <bold>Claude Code</>?"),
|| {
let commands = "claude plugin uninstall worktrunk@worktrunk\nclaude plugin marketplace remove worktrunk";
eprintln!("{}", worktrunk::styling::format_bash_with_gutter(commands));
eprintln!(
"{}",
worktrunk::styling::format_bash_with_gutter(&commands.join("\n"))
);
},
)? {
PromptResponse::Accepted => {}
@@ -70,8 +89,10 @@ pub fn handle_claude_uninstall(yes: bool) -> anyhow::Result<()> {
}
}
eprintln!("{}", progress_message("Uninstalling plugin..."));
super::run_plugin_cli("claude", &["plugin", "uninstall", "worktrunk@worktrunk"])?;
if plugin_installed {
eprintln!("{}", progress_message("Uninstalling plugin..."));
super::run_plugin_cli("claude", &["plugin", "uninstall", "worktrunk@worktrunk"])?;
}
eprintln!(
"{}",
+29
View File
@@ -4367,6 +4367,35 @@ fn test_plugins_claude_uninstall_tolerates_absent_marketplace(
});
}
/// A first `uninstall` that removed the plugin and then failed on the
/// marketplace leaves the marketplace behind. Re-running has to finish that
/// job rather than reporting "Plugin not installed" and exiting 0 with the
/// marketplace still configured.
#[rstest]
fn test_plugins_claude_uninstall_removes_marketplace_left_without_plugin(
mut repo: TestRepo,
temp_home: TempDir,
) {
repo.setup_mock_ci_tools_unauthenticated();
repo.setup_mock_claude_with_plugins();
// No `installed_plugins.json`: the plugin is already gone, and only the
// marketplace is left to remove.
TestRepo::setup_claude_marketplaces(
temp_home.path(),
TestRepo::CLAUDE_MARKETPLACES_WITH_WORKTRUNK,
);
let settings = setup_snapshot_settings_with_home(&repo, &temp_home);
settings.bind(|| {
let mut cmd = repo.wt_command();
cmd.args(["config", "plugins", "claude", "uninstall", "--yes"])
.current_dir(repo.root_path());
set_temp_home_env(&mut cmd, temp_home.path());
assert_cmd_snapshot!(cmd);
});
}
/// The Claude counterpart of the key-absent case: `known_marketplaces.json`
/// exists because the user has other marketplaces, and worktrunk's entry is
/// gone.
+12
View File
@@ -110,6 +110,18 @@ fn test_merge_help_describes_exact_shape_no_rebase() {
"help_config_plugins_codex_install",
"config plugins codex install --help"
)]
// The uninstall pages describe when each command skips, which depends on what
// the harness's config still holds. Snapshot them so that text cannot drift
// away from the handlers the way it did when the marketplace removal became
// reachable with the plugin already gone.
#[case(
"help_config_plugins_claude_uninstall",
"config plugins claude uninstall --help"
)]
#[case(
"help_config_plugins_codex_uninstall",
"config plugins codex uninstall --help"
)]
#[case("help_config_state", "config state --help")]
#[case("help_config_state_cache", "config state cache --help")]
#[case(
@@ -0,0 +1,66 @@
---
source: tests/integration_tests/config_show.rs
info:
program: wt
args:
- config
- plugins
- claude
- uninstall
- "--yes"
env:
APPDATA: "[TEST_CONFIG_HOME]"
CLAUDE_CONFIG_DIR: "[TEST_CLAUDE_CONFIG]"
CLICOLOR_FORCE: "1"
COLUMNS: "500"
GIT_ALLOW_PROTOCOL: file
GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z"
GIT_AUTHOR_EMAIL: test@example.com
GIT_AUTHOR_NAME: Test User
GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z"
GIT_COMMITTER_EMAIL: test@example.com
GIT_COMMITTER_NAME: Test User
GIT_CONFIG_COUNT: "2"
GIT_CONFIG_GLOBAL: /nonexistent/wt/gitconfig
GIT_CONFIG_KEY_0: user.useConfigOnly
GIT_CONFIG_KEY_1: rerere.enabled
GIT_CONFIG_SYSTEM: /nonexistent/wt/gitconfig
GIT_CONFIG_VALUE_0: "true"
GIT_CONFIG_VALUE_1: "false"
GIT_TERMINAL_PROMPT: "0"
HOME: "[TEST_HOME]"
LANG: C
LC_ALL: C
LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]"
OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]"
PATH: "[PATH]"
TERM: alacritty
USERPROFILE: "[TEST_HOME]"
WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]"
WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]"
WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]"
WORKTRUNK_TEST_BASH_INSTALLED: "0"
WORKTRUNK_TEST_CLAUDE_INSTALLED: "1"
WORKTRUNK_TEST_CODEX_INSTALLED: "0"
WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1"
WORKTRUNK_TEST_EPOCH: "1735776000"
WORKTRUNK_TEST_FISH_INSTALLED: "0"
WORKTRUNK_TEST_GEMINI_INSTALLED: "0"
WORKTRUNK_TEST_MOCK_CONFIG_DIR: "[TEST_MOCK_CONFIG]"
WORKTRUNK_TEST_NUSHELL_ENV: "0"
WORKTRUNK_TEST_OPENCODE_INSTALLED: "0"
WORKTRUNK_TEST_PARENT_SHELL: ""
WORKTRUNK_TEST_POWERSHELL_ENV: "0"
WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0"
WORKTRUNK_TEST_PROBE_TIMEOUT_MS: "60000"
WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1"
WORKTRUNK_TEST_ZSH_INSTALLED: "0"
XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]"
---
success: true
exit_code: 0
----- stdout -----
----- stderr -----
◎ Removing Claude Code plugin marketplace...
✓ Plugin & marketplace removed
@@ -0,0 +1,79 @@
---
source: tests/integration_tests/help.rs
info:
program: wt
args:
- config
- plugins
- claude
- uninstall
- "--help"
env:
CLICOLOR_FORCE: "1"
COLUMNS: "500"
GIT_ALLOW_PROTOCOL: file
GIT_CONFIG_COUNT: "2"
GIT_CONFIG_GLOBAL: /nonexistent/wt/gitconfig
GIT_CONFIG_KEY_0: user.useConfigOnly
GIT_CONFIG_KEY_1: rerere.enabled
GIT_CONFIG_SYSTEM: /nonexistent/wt/gitconfig
GIT_CONFIG_VALUE_0: "true"
GIT_CONFIG_VALUE_1: "false"
LANG: C
LC_ALL: C
LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]"
TERM: alacritty
WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]"
WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]"
WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]"
WORKTRUNK_TEST_BASH_INSTALLED: "0"
WORKTRUNK_TEST_CLAUDE_INSTALLED: "0"
WORKTRUNK_TEST_CODEX_INSTALLED: "0"
WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1"
WORKTRUNK_TEST_EPOCH: "1735776000"
WORKTRUNK_TEST_FISH_INSTALLED: "0"
WORKTRUNK_TEST_GEMINI_INSTALLED: "0"
WORKTRUNK_TEST_NUSHELL_ENV: "0"
WORKTRUNK_TEST_OPENCODE_INSTALLED: "0"
WORKTRUNK_TEST_PARENT_SHELL: ""
WORKTRUNK_TEST_POWERSHELL_ENV: "0"
WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0"
WORKTRUNK_TEST_PROBE_TIMEOUT_MS: "60000"
WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1"
WORKTRUNK_TEST_ZSH_INSTALLED: "0"
---
success: true
exit_code: 0
----- stdout -----
wt config plugins claude uninstall - Remove the Worktrunk plugin
Usage: wt config plugins claude uninstall [OPTIONS]
Options:
-h, --help
Print help (see a summary with '-h')
Global Options:
-C <path>
Working directory for this command
--config <path>
User config file path
--config-set <toml>
Override config with inline TOML, e.g. --config-set list.full=true (repeatable)
-v, --verbose...
Verbose output (-v: info logs + hook/alias template variables on stderr; -vv: also debug logs and raw subprocess output written to .git/wt/logs/). Set WORKTRUNK_VERBOSE=0|1|2 to apply the same level everywhere — including shell completion, which no flag can reach
-y, --yes
Skip approval prompts
Uninstalls the Worktrunk plugin from Claude Code and removes its marketplace. Equivalent to:
  claude plugin uninstall worktrunk@worktrunk
  claude plugin marketplace remove worktrunk
Requires claude CLI. Skips gracefully only when both are already gone. An uninstall that removed the plugin and then failed leaves the marketplace behind; running it again removes that half.
----- stderr -----
@@ -0,0 +1,79 @@
---
source: tests/integration_tests/help.rs
info:
program: wt
args:
- config
- plugins
- codex
- uninstall
- "--help"
env:
CLICOLOR_FORCE: "1"
COLUMNS: "500"
GIT_ALLOW_PROTOCOL: file
GIT_CONFIG_COUNT: "2"
GIT_CONFIG_GLOBAL: /nonexistent/wt/gitconfig
GIT_CONFIG_KEY_0: user.useConfigOnly
GIT_CONFIG_KEY_1: rerere.enabled
GIT_CONFIG_SYSTEM: /nonexistent/wt/gitconfig
GIT_CONFIG_VALUE_0: "true"
GIT_CONFIG_VALUE_1: "false"
LANG: C
LC_ALL: C
LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]"
TERM: alacritty
WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]"
WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]"
WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]"
WORKTRUNK_TEST_BASH_INSTALLED: "0"
WORKTRUNK_TEST_CLAUDE_INSTALLED: "0"
WORKTRUNK_TEST_CODEX_INSTALLED: "0"
WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1"
WORKTRUNK_TEST_EPOCH: "1735776000"
WORKTRUNK_TEST_FISH_INSTALLED: "0"
WORKTRUNK_TEST_GEMINI_INSTALLED: "0"
WORKTRUNK_TEST_NUSHELL_ENV: "0"
WORKTRUNK_TEST_OPENCODE_INSTALLED: "0"
WORKTRUNK_TEST_PARENT_SHELL: ""
WORKTRUNK_TEST_POWERSHELL_ENV: "0"
WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0"
WORKTRUNK_TEST_PROBE_TIMEOUT_MS: "60000"
WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1"
WORKTRUNK_TEST_ZSH_INSTALLED: "0"
---
success: true
exit_code: 0
----- stdout -----
wt config plugins codex uninstall - Remove the Worktrunk plugin
Usage: wt config plugins codex uninstall [OPTIONS]
Options:
-h, --help
Print help (see a summary with '-h')
Global Options:
-C <path>
Working directory for this command
--config <path>
User config file path
--config-set <toml>
Override config with inline TOML, e.g. --config-set list.full=true (repeatable)
-v, --verbose...
Verbose output (-v: info logs + hook/alias template variables on stderr; -vv: also debug logs and raw subprocess output written to .git/wt/logs/). Set WORKTRUNK_VERBOSE=0|1|2 to apply the same level everywhere — including shell completion, which no flag can reach
-y, --yes
Skip approval prompts
Uninstalls the Worktrunk plugin from Codex and removes its marketplace. Equivalent to:
  codex plugin remove worktrunk@worktrunk
  codex plugin marketplace remove worktrunk
Requires codex CLI.
----- stderr -----