From b6ccfecc9124854077ad06d76d67e4e9cb58862d Mon Sep 17 00:00:00 2001 From: Multica Daemon Date: Thu, 10 Sep 2026 21:20:51 -0700 Subject: [PATCH] aikit 2.1.0: explicit uninstall for curl+npm-check agent family + uninstall --dry-run (POK-314) Audit of every AGENTS entry with a curl installer and an npm version_check, verified against each vendor's install script: - qwen: standalone tree at ~/.local/lib/qwen-code + wrapper; npm only a fallback channel -> remove tree/wrapper/~/.qwen state, npm cleanup only when a global copy exists - blackbox: ~/.blackbox-cli-v2 tree vendors its npm deps internally; both ~/.local/bin/blackbox{,.mjs} wrappers + config dirs removed - amp: ~/.amp/bin/amp binary + PATH symlinks (~/.local/bin, ~/bin, ~/.bin) + ~/.config/amp - continue: install.sh wraps npm install -g -> npm uninstall kept, adds ~/.continue cleanup - openclaw: install.sh wraps npm install -g -> npm uninstall added before the shim/vendor cleanup it previously lacked Contract test now pins all nine family members (mimo, omp, roo, qoder, qwen, blackbox, amp + the npm-wrapping continue/openclaw sub-family); qoder's conditional npm-channel cleanup factored into a shared helper. New `aikit uninstall --dry-run` previews resolved commands without executing. Co-authored-by: multica-agent --- CHANGELOG.md | 32 ++++++ aikit | 141 +++++++++++++++++++++++---- docs/aikit.md | 2 +- tests/test_tools_characterization.py | 127 +++++++++++++++++++++--- 4 files changed, 265 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 383fc2e..c42b9af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,38 @@ Newest entries on top, within each tool. ## aikit +### 2.1.0 — 2026-09-10 +- **New `aikit uninstall --dry-run`** — prints each agent's resolved uninstall + command (with a vendor-config/data note) without executing anything or + prompting. Built for auditing registry changes like the ones below. With no + agent keys it previews every installed agent instead of opening the picker. +- **Fix: curl-installed agents with an npm `version_check` now uninstall what + aikit actually installed (POK-314).** The whole family was audited against + each vendor's install script; `qwen`, `blackbox` and `amp` install a + standalone footprint (never an npm global), so the npm-derived + `npm uninstall -g` was a no-op against aikit's own install: + - **qwen** — removes `~/.local/lib/qwen-code` (standalone tree with a + bundled node runtime), the `~/.local/bin/qwen` wrapper, and the + `~/.qwen` / `~/.config/qwen` state dirs; npm uninstall only when the + installer's npm-fallback channel actually left a global copy. + - **blackbox** — removes `~/.blackbox-cli-v2` (the curl installer vendors + its npm deps *inside* that tree) plus both `~/.local/bin/blackbox{,.mjs}` + wrappers and the `~/.blackbox` / `~/.blackboxai` / `~/.blackboxcli` + config dirs discover_auth reads. + - **amp** — removes `~/.amp` (binary at `~/.amp/bin/amp`), its PATH symlinks + (`~/.local/bin`, `~/bin`, `~/.bin`) and `~/.config/amp`. + - **continue** — install.sh wraps `npm install -g @continuedev/cli`, so the + npm uninstall stays and now also cleans `~/.continue`. + - **openclaw** — install.sh wraps `npm install -g openclaw`; the previous + shim-only uninstall left the npm tree behind, so npm uninstall now runs + first, followed by the shim + vendor-dir cleanup. + All nine family members (including POK-313's `mimo`/`omp` and the later + `roo`/`qoder`) are pinned in + `test_aikit_curl_installed_npm_version_check_agents_have_explicit_uninstall`, + which also recomputes the family from the live registry so the next + curl+npm agent can't silently escape the contract; qoder's conditional + npm-channel cleanup was factored into a shared helper reused by qwen. + ### 2.0.1 — 2026-09-11 - Stop rendering the Cursor/Grok bare-`agent` collision warning in `aikit list` (and bare `aikit`, which defaults to `list`) where it read as noisy output diff --git a/aikit b/aikit index a875285..a8f879a 100755 --- a/aikit +++ b/aikit @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -aikit — AI Coding Agent CLI Installer & Manager v2.0.1 +aikit — AI Coding Agent CLI Installer & Manager v2.1.0 Install, update, authenticate, and manage 38 AI coding agent CLIs from one tool. Architecture: @@ -57,7 +57,7 @@ from rich.progress import Progress, SpinnerColumn, TextColumn from rich.table import Table from rich.text import Text -__version__ = "2.0.1" +__version__ = "2.1.0" import scriptkit as sk @@ -264,17 +264,84 @@ def roo_uninstall_cmd(): return binary_uninstall_cmd("roo", vendor_dirs=["$HOME/.roo"]) +def _npm_channel_cleanup_cmds(package): + """npm uninstall commands for an alternate npm install channel — emitted + only for global prefixes where the package is actually present, so a + curl-installed primary footprint is never treated as an npm install.""" + cmds = [] + for prefix in _npm_global_prefixes(): + if _npm_package_dir(prefix, package).exists(): + cmds.append(f"npm uninstall -g {package} --prefix {shlex.quote(str(prefix))}") + return cmds + + def qoder_uninstall_cmd(): """Qoder CLI: the curl installer drops a self-contained binary (npm is only an alternate channel), so remove the binary plus ~/.qoder; fall back to npm uninstall when the package is present in a global prefix.""" - parts = [binary_uninstall_cmd("qoder", vendor_dirs=["$HOME/.qoder"])] - for prefix in _npm_global_prefixes(): - if _npm_package_dir(prefix, "@qoder-ai/qodercli").exists(): - parts.insert(0, f"npm uninstall -g @qoder-ai/qodercli --prefix {shlex.quote(str(prefix))}") + parts = _npm_channel_cleanup_cmds("@qoder-ai/qodercli") + parts.append(binary_uninstall_cmd("qoder", vendor_dirs=["$HOME/.qoder"])) + return " ; ".join(parts) + + +def qwen_uninstall_cmd(): + """Qwen Code: the standalone curl installer unpacks a self-contained tree + (bin/qwen + a bundled node runtime) to ~/.local/lib/qwen-code and a wrapper + at ~/.local/bin/qwen — not an npm global install despite the npm + version_check (npm is only the installer's fallback channel), so derive + nothing. Remove the tree, the wrapper and the ~/.qwen / XDG state dirs the + agent and aikit's discover_auth reference; fall back to npm uninstall only + when the package is present in a global prefix.""" + parts = _npm_channel_cleanup_cmds("@qwen-code/qwen-code") + parts.append( + binary_uninstall_cmd( + "qwen", + vendor_dirs=["$HOME/.local/lib/qwen-code", "$HOME/.qwen", "$HOME/.config/qwen"], + ), + ) return " ; ".join(parts) +def blackbox_uninstall_cmd(): + """Blackbox CLI v2: the curl installer unzips a self-contained tree to + ~/.blackbox-cli-v2 (its npm dependency installs run *inside* that tree — + never a global npm install, despite the npm version_check) and writes + wrapper scripts to ~/.local/bin, so derive nothing. Remove the install + tree, both wrappers and the config dirs aikit's discover_auth reads.""" + return binary_uninstall_cmd( + "blackbox", + extra_paths=["$HOME/.local/bin/blackbox.mjs"], + vendor_dirs=["$HOME/.blackbox-cli-v2", "$HOME/.blackbox", "$HOME/.blackboxai", "$HOME/.blackboxcli"], + ) + + +def amp_uninstall_cmd(): + """Amp: the curl installer drops a self-contained binary at ~/.amp/bin/amp + and symlinks it into PATH (~/.local/bin preferred; ~/bin and ~/.bin are + alternates) — not an npm install despite the npm version_check, so derive + nothing. Remove the binary, the symlinks and the ~/.amp / ~/.config/amp + trees the installer and aikit's discover_auth reference.""" + return binary_uninstall_cmd( + "amp", + extra_paths=["$HOME/bin/amp", "$HOME/.bin/amp"], + vendor_dirs=["$HOME/.amp", "$HOME/.config/amp"], + ) + + +def openclaw_uninstall_cmd(): + """OpenClaw: install.sh wraps `npm install -g openclaw` (its --version flag + takes an npm dist-tag/spec, hence the npm version_check), so npm uninstall + IS the removal for what aikit installed. Also remove the PATH shims and + the state dirs the agent and aikit's discover_auth reference.""" + return " ; ".join([ + npm_uninstall_cmd("openclaw"), + binary_uninstall_cmd( + "openclaw", + vendor_dirs=["$HOME/.openclaw", "$HOME/.claw", "$HOME/.config/openclaw"], + ), + ]) + + def grok_uninstall_cmd(): """Grok Build: remove ~/.grok data dir and PATH shims (incl. shared 'agent' alias).""" return binary_uninstall_cmd( @@ -513,6 +580,10 @@ AGENTS = { "update_cmd": "qwen upgrade", "path_markers": ["/.qwen/", "qwen-code"], "version_check": {"type": "npm", "package": "@qwen-code/qwen-code"}, + # Explicit uninstall: the curl install is the standalone tree under + # ~/.local/lib/qwen-code, not an npm global, so the npm-derived + # uninstall would no-op against aikit's own install (POK-314). + "uninstall_cmd": qwen_uninstall_cmd, "auth_type": "account_or_byok", "auth_env_vars": ["DASHSCOPE_API_KEY", "BAILIAN_CODING_PLAN_API_KEY", "OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY"], "auth_note": "Run 'qwen' and use '/auth' for Qwen OAuth, Coding Plan, or API keys. Or configure ~/.qwen/settings.json / env vars.", @@ -821,10 +892,9 @@ AGENTS = { # install.sh wraps `npm install -g openclaw` (its --version flag takes an # npm dist-tag/spec), so the npm registry is the canonical version source. "version_check": {"type": "npm", "package": "openclaw"}, - "uninstall_cmd": binary_uninstall_cmd( - "openclaw", - vendor_dirs=["$HOME/.openclaw", "$HOME/.claw", "$HOME/.config/openclaw"], - ), + # POK-314: the installer's npm global tree survived the shim-only + # uninstall — run npm uninstall first, then the shim/vendor cleanup. + "uninstall_cmd": openclaw_uninstall_cmd, "auth_type": "oauth_onboard", "auth_env_vars": ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"], "auth_note": "Run 'openclaw onboard' for guided setup and auth.", @@ -846,6 +916,10 @@ AGENTS = { "update_cmd": "blackbox update", "path_markers": ["blackbox", "/.blackbox"], "version_check": {"type": "npm", "package": "@blackboxai/cli"}, + # Explicit uninstall: the curl install unzips ~/.blackbox-cli-v2 with + # its deps vendored inside — nothing lands in an npm global, so the + # npm-derived uninstall would no-op (POK-314). + "uninstall_cmd": blackbox_uninstall_cmd, "auth_type": "api_key", "auth_env_vars": ["BLACKBOX_API_KEY"], "auth_note": "Get API key from https://app.blackbox.ai/dashboard then run 'blackbox configure'.", @@ -956,6 +1030,9 @@ AGENTS = { "update_cmd": "amp update", "path_markers": ["/.config/amp", "@ampcode/cli"], "version_check": {"type": "npm", "package": "@ampcode/cli"}, + # Explicit uninstall: the curl install drops ~/.amp/bin/amp, not an + # npm global, so the npm-derived uninstall would no-op (POK-314). + "uninstall_cmd": amp_uninstall_cmd, "auth_type": "oauth_browser", "auth_env_vars": ["AMP_API_KEY"], "auth_cmd": "amp login", @@ -1021,6 +1098,10 @@ AGENTS = { "update_cmd": npm_install_cmd("@continuedev/cli@latest"), "path_markers": ["@continuedev/cli", "/.continue/"], "version_check": {"type": "npm", "package": "@continuedev/cli"}, + # install.sh wraps `npm install -g @continuedev/cli`, so npm uninstall + # removes what aikit installed; the explicit cmd exists to also clean + # the ~/.continue config tree the derived form left behind (POK-314). + "uninstall_cmd": lambda: npm_uninstall_cmd("@continuedev/cli") + " ; rm -rf $HOME/.continue", "auth_type": "oauth_browser", "auth_env_vars": ["ANTHROPIC_API_KEY"], "auth_cmd": "cn login", @@ -2988,36 +3069,53 @@ def _do_update_impl(agent_keys, *, force=False): return 1 if failed or unchanged_outdated else 0 -def do_uninstall(agent_keys, yes=False): - """Uninstall agents. +def do_uninstall(agent_keys, yes=False, dry_run=False): + """Uninstall agents (or preview the uninstall commands with ``dry_run``). Returns the count of installed agents that had no automated uninstall command (i.e. were not removed). Callers should exit non-zero when > 0. """ try: - return _do_uninstall_impl(agent_keys, yes) + return _do_uninstall_impl(agent_keys, yes, dry_run) except KeyboardInterrupt: console.print("\n[yellow]⏹ Uninstall interrupted.[/yellow]") sys.exit(130) -def _do_uninstall_impl(agent_keys, yes): +def _do_uninstall_impl(agent_keys, yes, dry_run=False): if not agent_keys: installed = [k for k in AGENTS if detect_agent_bin(k)] if not installed: print_warning("No agents installed.") return - try: - agent_keys = interactive_agent_picker("Select agents to uninstall", preselect_installed=True) - except KeyboardInterrupt: - console.print("\n[yellow]⏹ Cancelled.[/yellow]") - return + if dry_run: + # No keys + --dry-run: preview every installed agent non-interactively + # instead of opening the picker (nothing destructive is pending). + agent_keys = installed + else: + try: + agent_keys = interactive_agent_picker("Select agents to uninstall", preselect_installed=True) + except KeyboardInterrupt: + console.print("\n[yellow]⏹ Cancelled.[/yellow]") + return if not agent_keys: return validate_agent_keys(agent_keys) + if dry_run: + for i, key in enumerate(agent_keys, 1): + agent = AGENTS[key] + uninstall_cmd = resolve_uninstall_cmd(agent) + if not uninstall_cmd: + print_warning(f"{agent['icon']} {agent['name']} has no automated uninstall — remove manually") + continue + note = " (removes vendor config/data)" if uninstall_removes_vendor_data(uninstall_cmd) else "" + print_step(i, len(agent_keys), f"Would uninstall {agent['icon']} {agent['name']}{note}") + console.print(f" [dim]{uninstall_cmd}[/dim]") + return 0 + vendor_cleanup_keys = [ key for key in agent_keys if uninstall_removes_vendor_data(resolve_uninstall_cmd(AGENTS[key])) @@ -7566,6 +7664,9 @@ def build_parser(): uninstall_p.add_argument("agents", nargs="*", help="Agent keys to uninstall (none = interactive picker)") uninstall_p.add_argument("-y", "--yes", action="store_true", help="Skip confirmations (including vendor config/data removal)") + uninstall_p.add_argument("--dry-run", action="store_true", + help="Print the uninstall commands instead of running them " + "(no agent keys = preview all installed agents)") # list sub.add_parser("list", help="Show all agents and installation status") @@ -7657,7 +7758,7 @@ def cmd_update(args): def cmd_uninstall(args): - no_uninstall_cmd = do_uninstall(args.agents, yes=args.yes) + no_uninstall_cmd = do_uninstall(args.agents, yes=args.yes, dry_run=args.dry_run) return 1 if no_uninstall_cmd else None diff --git a/docs/aikit.md b/docs/aikit.md index 252e65c..c2209fe 100644 --- a/docs/aikit.md +++ b/docs/aikit.md @@ -81,7 +81,7 @@ aikit setup | `aikit setup` | Interactive first-run wizard | | `aikit install [agents...]` | Install agents (multi-select picker if none) | | `aikit update [agents...]` | Update agents (all installed if none) | -| `aikit uninstall [agent...]` | Remove agents | +| `aikit uninstall [agent...] [--dry-run]` | Remove agents (`--dry-run` prints the commands without running them; no keys = preview all installed) | | `aikit list` | Status table of all 38 agents | | `aikit auth [agent] [--force]` | Guided authentication setup; `--force` replaces cached auth status and signs in again | | `aikit doctor` | Diagnose environment and agent health | diff --git a/tests/test_tools_characterization.py b/tests/test_tools_characterization.py index 4cad5b9..ff35f02 100644 --- a/tests/test_tools_characterization.py +++ b/tests/test_tools_characterization.py @@ -630,10 +630,13 @@ def test_aikit_openclaw_registry_entry(tool_loader): # POK-317: openclaw must have a version_check — install.sh wraps # `npm install -g openclaw`, so the npm registry is the version source. assert openclaw["version_check"] == {"type": "npm", "package": "openclaw"} - # Curl-installed npm-published agent: explicit uninstall stays non-npm-derived. + # POK-314: install.sh wraps `npm install -g`, so the explicit uninstall + # must run npm uninstall (what aikit actually installed) AND clean the + # shim + vendor dirs the previous shim-only uninstall left behind. cmd = m.resolve_uninstall_cmd(openclaw) - assert cmd and "npm uninstall -g openclaw" not in cmd + assert cmd and "npm uninstall -g openclaw" in cmd assert ".openclaw" in cmd + assert ".local/bin/openclaw" in cmd def test_aikit_every_agent_has_version_check(tool_loader): @@ -1009,19 +1012,18 @@ def test_aikit_goose_uninstall_uses_brew_only_for_homebrew_install(tool_loader, def test_aikit_npm_agent_uninstall_derived_from_version_check(tool_loader): # POK-87: npm agents omit explicit uninstall_cmd; npm uninstall is derived. + # POK-314: qwen/blackbox/amp/continue moved to the explicit-uninstall + # contract test — their installs are curl-based (or npm-wrapping), so the + # derived form was wrong or incomplete. m = tool_loader("aikit") npm_agents = [ ("kilo", "@kilocode/cli"), ("opencode", "opencode-ai"), - ("qwen", "@qwen-code/qwen-code"), ("qodo", "@qodo/command"), ("pi", "@earendil-works/pi-coding-agent"), - ("blackbox", "@blackboxai/cli"), ("cline", "cline"), ("crush", "@charmland/crush"), - ("amp", "@ampcode/cli"), ("gemini", "@google/gemini-cli"), - ("continue", "@continuedev/cli"), ("auggie", "@augmentcode/auggie"), ] for key, package in npm_agents: @@ -1031,18 +1033,45 @@ def test_aikit_npm_agent_uninstall_derived_from_version_check(tool_loader): assert cmd and f"npm uninstall -g {package}" in cmd, key -def test_aikit_curl_installed_npm_version_check_agents_have_explicit_uninstall(tool_loader): - # POK-313 review: curl-installed agents that ALSO publish to npm (and so use - # the npm version_check) must NOT rely on the derived `npm uninstall -g` — - # the curl installer didn't install via npm, so the derived uninstall would - # be a no-op against aikit's own install. Each such agent sets an explicit - # `uninstall_cmd` that removes the real install footprint (vendor dir + binary). - m = tool_loader("aikit") +def test_aikit_curl_installed_npm_version_check_agents_have_explicit_uninstall(tool_loader, monkeypatch): + # POK-313 review / POK-314 audit: curl-installed agents that ALSO publish + # to npm (and so use the npm version_check) must NOT rely on the derived + # `npm uninstall -g` — the curl installer didn't install via npm, so the + # derived uninstall would be a no-op against aikit's own install. Each + # such agent sets an explicit `uninstall_cmd` that removes the real + # install footprint (binary + vendor dirs). POK-314 enumerated the family + # from the installers themselves; every member is pinned here. + m = tool_loader("aikit") + # Deterministic npm state: pretend no global prefix holds these packages, + # so the qwen/qoder alternate-channel npm cleanup is absent and the "no + # derived npm uninstall" assertion cannot flake on a machine that happens + # to have one of them npm-installed. + monkeypatch.setattr(m, "_npm_global_prefixes", lambda: []) cases = [ - # (key, expected_in_cmd, vendor_dir_in_cmd) - ("mimo", ".mimocode/bin/mimo", ".mimocode"), - ("omp", ".local/bin/omp", ".omp"), + # (key, expected_in_cmd, vendor_dir_in_cmd) + ("mimo", ".mimocode/bin/mimo", ".mimocode"), + ("omp", ".local/bin/omp", ".omp"), + ("roo", ".local/bin/roo", ".roo"), + ("qoder", ".local/bin/qoder", ".qoder"), + ("qwen", ".local/lib/qwen-code", ".qwen"), + ("blackbox", ".blackbox-cli-v2", ".blackbox"), + ("amp", ".local/bin/amp", ".amp"), ] + # Drift protection (review follow-up): recompute the family from the live + # registry so the next curl-installed agent with an npm version_check + # fails here until it is classified — the exact gap that let roo/qoder + # drift past the POK-313 version of this test. + def _install_cmds(agent): + ic = agent.get("install", {}) + return [str(c) for c in ic.values()] if isinstance(ic, dict) else [str(ic)] + + enumerated = { + key for key, agent in m.AGENTS.items() + if any(c.startswith("curl") for c in _install_cmds(agent)) + and agent.get("version_check", {}).get("type") == "npm" + } + assert enumerated == {c[0] for c in cases} | {"continue", "openclaw"}, sorted(enumerated) + for key, expected_path_bit, vendor_dir in cases: agent = m.AGENTS[key] # 1. Explicit uninstall_cmd is set (not derived from the npm version_check). @@ -1053,11 +1082,28 @@ def test_aikit_curl_installed_npm_version_check_agents_have_explicit_uninstall(t cmd = m.resolve_uninstall_cmd(agent) assert cmd and expected_path_bit in cmd, f"{key}: {cmd!r} missing {expected_path_bit}" assert vendor_dir in cmd, f"{key}: {cmd!r} missing vendor dir {vendor_dir}" + assert m.uninstall_removes_vendor_data(cmd), f"{key}: vendor data survives uninstall" # 4. Critically: the derived `npm uninstall -g …` is NOT what we run. npm_pkg = agent["version_check"]["package"] assert f"npm uninstall -g {npm_pkg}" not in cmd, ( f"{key}: curl install wasn't npm-based; derived npm uninstall would no-op") + # Sub-family: curl installers that wrap `npm install -g` (verified from + # their install.sh — continue, openclaw). npm uninstall IS the removal of + # what aikit installed; the explicit cmd exists to add the vendor-dir + # cleanup the derived form lacked. + for key, npm_pkg, vendor_dir in [ + ("continue", "@continuedev/cli", ".continue"), + ("openclaw", "openclaw", ".openclaw"), + ]: + agent = m.AGENTS[key] + assert "uninstall_cmd" in agent, f"{key} must set explicit uninstall_cmd" + assert agent["version_check"]["type"] == "npm", key + cmd = m.resolve_uninstall_cmd(agent) + assert cmd and f"npm uninstall -g {npm_pkg}" in cmd, f"{key}: {cmd!r} missing npm removal" + assert vendor_dir in cmd, f"{key}: {cmd!r} missing vendor dir {vendor_dir}" + assert m.uninstall_removes_vendor_data(cmd), f"{key}: vendor data survives uninstall" + def test_aikit_explicit_uninstall_cmd_none_blocks_npm_derivation(tool_loader): # POK-87: explicit uninstall_cmd: None means manual-only, even for npm agents. @@ -1069,6 +1115,55 @@ def test_aikit_explicit_uninstall_cmd_none_blocks_npm_derivation(tool_loader): assert m.resolve_uninstall_cmd(agent) is None +def test_aikit_uninstall_dry_run_previews_without_executing(tool_loader, monkeypatch, capsys): + # POK-314: `aikit uninstall --dry-run` previews the resolved uninstall + # commands and must not execute anything (no prompts, no config writes). + m = tool_loader("aikit") + executed = [] + monkeypatch.setattr(m, "_npm_global_prefixes", lambda: []) + monkeypatch.setattr(m, "run", lambda cmd, **kw: executed.append(cmd) or (0, "", "")) + monkeypatch.setattr(m, "save_config", lambda cfg: None) + monkeypatch.setattr(m, "discover_and_persist", lambda: None) + rc = m.do_uninstall(["qwen", "blackbox", "amp", "continue"], yes=False, dry_run=True) + assert rc == 0 + assert executed == [] + out = capsys.readouterr().out + assert ".local/lib/qwen-code" in out + assert ".blackbox-cli-v2" in out + assert "$HOME/.amp" in out + assert "$HOME/.continue" in out + assert "removes vendor config/data" in out + + +def test_aikit_uninstall_dry_run_without_keys_previews_installed(tool_loader, monkeypatch, capsys): + # Review follow-up: `aikit uninstall --dry-run` with no agent keys must + # preview every installed agent non-interactively — no picker, and nothing + # for agents that aren't installed. + m = tool_loader("aikit") + monkeypatch.setattr(m, "_npm_global_prefixes", lambda: []) + monkeypatch.setattr(m, "detect_agent_bin", lambda key: key in ("qwen", "amp")) + monkeypatch.setattr( + m, "interactive_agent_picker", + lambda *a, **kw: pytest.fail("dry-run without keys must not open the picker"), + ) + rc = m.do_uninstall([], yes=False, dry_run=True) + assert rc == 0 + out = capsys.readouterr().out + assert "Qwen Code" in out and "Amp" in out + assert "Blackbox" not in out + + +def test_aikit_uninstall_dry_run_without_keys_none_installed(tool_loader, monkeypatch, capsys): + m = tool_loader("aikit") + monkeypatch.setattr(m, "detect_agent_bin", lambda _key: False) + monkeypatch.setattr( + m, "interactive_agent_picker", + lambda *a, **kw: pytest.fail("dry-run without keys must not open the picker"), + ) + assert m.do_uninstall([], yes=False, dry_run=True) is None + assert "No agents installed." in capsys.readouterr().out + + def test_aikit_resolve_update_cmd_openhands_reinstall(tool_loader): m = tool_loader("aikit") cmd = m.resolve_update_cmd("openhands")