Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
141 changes: 121 additions & 20 deletions aikit
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand All @@ -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'.",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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):

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: aikit uninstall --dry-run with no agent keys still opens the interactive picker (the if not agent_keys: branch below), so "prompts nothing" holds only when keys are passed. Fine if intended (the picker selects what to preview) — if so, consider hinting in the --dry-run help text that keys skip the picker, or auto-previewing all installed agents in dry-run mode.

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]))
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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


Expand Down
2 changes: 1 addition & 1 deletion docs/aikit.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Loading
Loading