Files
chainbase-labs__agentkey/scripts/dev-smoke.sh
T
不白 83363014c4 feat(installer): unify skill + MCP agent registration (16 agents) (#41)
## Summary

- Drive both `npx skills add -a` and `@agentkey/mcp --auth-login --only`
from a single detected-agent list. MCP registration now follows the same
per-host auto-detection that skill install already does, expanding MCP
auto-registration from 3 clients to **16**.
- Fix the longstanding `claude-code` marker bug: it included Claude
Desktop's config dir, causing skills CLI to target a nonexistent Claude
Code on Desktop-only machines. `claude-desktop` is now its own id
(MCP-only) — passed to `--auth-login --only` but never to `skills add`.
- Detect Claude Desktop via `/Applications/Claude.app` /
`%LOCALAPPDATA%\AnthropicClaude` so "installed but never launched" still
registers (Linux still requires the config dir).
- New `scripts/dev-smoke.sh` — sandboxed 4-phase regression suite, 42
assertions, ~10s, never touches real `$HOME`. Run before any PR touching
install/uninstall scripts.

## Depends on


[chainbase-labs/AgentKey-Server#9](https://github.com/chainbase-labs/AgentKey-Server/pull/9)
— adds `--only <ids>` to `@agentkey/mcp --auth-login`. Older CLI
versions silently ignore the flag, so this PR is forward-compatible
either way.

## Uninstaller (the bigger gap before this)

The previous uninstaller only cleaned 3 config paths and only knew the
`mcpServers.<name>` JSON shape. With 13 new agents using 4 different
schema dialects, that left AgentKey configured everywhere after
uninstall.

- Expanded MCP cleanup to **14 JSON paths + codex TOML**, covering all
16 auto-registered agents
- Schema-agnostic JSON scrub: walks the tree and drops dict keys whose
name EXACTLY matches our server names. Handles `mcpServers.<name>` /
`mcp.<name>` / `amp.mcpServers.<name>` / `projects.X.mcpServers.<name>`
in one pass
- Codex TOML splice via awk / PowerShell (no parser dep) — drops
`[mcp_servers.agentkey]` + legacy quoted block, preserves sibling
sections
- `droid mcp remove` + `openclaw mcp unset` for CLI-registered agents
- **Exact-match** server names (not substring) so user keys like
`agentkey-helper` are preserved (regression test included in dev-smoke)

## Bugs fixed during review

| Where | Bug |
|---|---|
| install.sh:487 | Unbound `$TARGETS` variable (renamed during refactor)
— `set -u` would have made this fatal |
| install.ps1 | `$SkillTargets.Count` used where `$AllTargets.Count` was
meant — diverged from install.sh behavior |
| install.sh | `--only claude-desktop` ran `skills add -a` with no
filter, defeating the user's `--only` intent. Now correctly skips the
skill step |
| install.sh helpers | Leaked-scope loop vars (`_ids`, `_id`) — declared
`local -a` |

## Test plan

- [x] `scripts/dev-smoke.sh` — 42 passing / 0 failing (Phase 1 unit
tests + Phase 2 installer + Phase 3 writer schemas + Phase 4 uninstaller
w/ false-positive guard)
- [x] `bash -n` clean for install.sh + uninstall.sh
- [x] `--list-agents` correctly lists `claude-desktop` as a separate id
- [x] `--only claude-desktop --skip-mcp --yes` walks the new "MCP-only,
skip skill" branch
- [x] Auto-detect path tested via `bash -x` trace under `set -u` (no
unbound-variable explosion)
- [x] Uninstaller decoy fixtures: `agentkey-helper`, `other-svr`,
`[mcp_servers.other]`, `[unrelated_section]` all preserved after scrub
- [ ] Windows: `install.ps1` / `uninstall.ps1` syntax-checked but not
runtime-tested (no Windows box handy — happy to test if reviewer has
one)

---------

Co-authored-by: Bruce <bruce@checkabc.me>
2026-05-20 10:07:11 +08:00

486 lines
20 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
#
# dev-smoke.sh — sandboxed regression suite for installer + uninstaller + MCP
# writer strategies. Nothing in this script touches your real $HOME — every
# phase creates its own mktemp -d sandbox and tears it down on exit.
#
# Run before opening a PR that changes:
# - scripts/install.sh / install.ps1
# - scripts/uninstall.sh / uninstall.ps1
# - AgentKey-Server/cli/src/lib/mcp-clients.ts
# - The AGENT_REGISTRY contract in general
#
# Usage:
# scripts/dev-smoke.sh # run all phases
# scripts/dev-smoke.sh 1 # run only phase 1
# scripts/dev-smoke.sh 2 4 # run phases 2 and 4
# AGENTKEY_CLI_SRC=/path/to/cli scripts/dev-smoke.sh # override
#
# Phases:
# 1. Unit tests — npm test in cli/ (registry + skill-meta)
# 2. Installer sandbox — --list-agents, --only edge cases (no real npx work)
# 3. Writers sandbox — call every AGENT_REGISTRY writer; assert schemas
# 4. Uninstaller sandbox — scrub fakes; assert decoys preserved
# Keep `-e` off so one failing assertion does not stop the smoke run before the
# remaining phases report their own failures.
set -uo pipefail
# ── Locate repos ──────────────────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SKILL_REPO="$(dirname "$SCRIPT_DIR")"
# Find the sibling AgentKey-Server/cli repo (the npm-published @agentkey/cli
# source). Try in order:
# 1. $AGENTKEY_CLI_SRC env var (manual override)
# 2. Direct sibling of skill repo (main worktree case)
# 3. Climb up from a git worktree (3 levels up from .claude/worktrees/<name>)
# 4. Fall back to a generic sibling path so the error message is actionable
_find_cli_src() {
local cand
for cand in \
"${AGENTKEY_CLI_SRC:-}" \
"$SKILL_REPO/../AgentKey-Server/cli" \
"$SKILL_REPO/../../../../AgentKey-Server/cli"
do
[ -z "$cand" ] && continue
if [ -d "$cand" ] && [ -f "$cand/package.json" ]; then
(cd "$cand" && pwd); return
fi
done
# Nothing found — return the first candidate so the error message
# tells the user what we looked for.
printf '%s\n' "${AGENTKEY_CLI_SRC:-$SKILL_REPO/../AgentKey-Server/cli}"
}
CLI_SRC="$(_find_cli_src)"
# ── UI ────────────────────────────────────────────────────────────────────
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
GREEN=$'\033[38;2;0;220;150m'; RED=$'\033[38;2;230;57;70m'
YELLOW=$'\033[38;2;255;176;32m'; CYAN=$'\033[38;2;0;200;180m'
DIM=$'\033[38;2;110;118;132m'; BOLD=$'\033[1m'; NC=$'\033[0m'
else
GREEN=''; RED=''; YELLOW=''; CYAN=''; DIM=''; BOLD=''; NC=''
fi
PASS=0; FAIL=0; SKIP=0
FAIL_REASONS=()
ok() { PASS=$((PASS+1)); printf " ${GREEN}${NC} %s\n" "$*"; }
fail() { FAIL=$((FAIL+1)); FAIL_REASONS+=("$*"); printf " ${RED}${NC} %s\n" "$*"; }
skip() { SKIP=$((SKIP+1)); printf " ${DIM}-${NC} %s\n" "$*"; }
info() { printf " ${DIM}${NC} %s\n" "$*"; }
phase() { printf "\n${CYAN}${BOLD}── %s ──${NC}\n" "$*"; }
assert_contains() {
local name="$1" needle="$2" haystack="$3"
if printf '%s' "$haystack" | grep -qF -- "$needle"; then
ok "$name"
else
fail "$name (expected to contain '$needle')"
fi
}
assert_not_contains() {
local name="$1" needle="$2" haystack="$3"
if printf '%s' "$haystack" | grep -qF -- "$needle"; then
fail "$name (must NOT contain '$needle')"
else
ok "$name"
fi
}
assert_file_has() {
local name="$1" path="$2" needle="$3"
if [ -f "$path" ] && grep -qF -- "$needle" "$path"; then
ok "$name"
else
fail "$name (file $path missing or doesn't contain '$needle')"
fi
}
# Sandbox helper: each phase calls `sandbox_init` to get a fresh tmpdir and
# `sandbox_clean` to tear it down. Trapped so Ctrl-C still cleans up.
SANDBOX=""
sandbox_init() {
SANDBOX="$(mktemp -d -t agentkey-smoke.XXXXXX)"
info "sandbox: $SANDBOX"
}
sandbox_clean() {
[ -n "$SANDBOX" ] && [ -d "$SANDBOX" ] && rm -rf "$SANDBOX"
SANDBOX=""
}
trap 'sandbox_clean' EXIT INT TERM
# ──────────────────────────────────────────────────────────────────────────
# Phase 1: Unit tests
# ──────────────────────────────────────────────────────────────────────────
phase_1() {
phase "Phase 1: Unit tests (cli)"
if [ ! -d "$CLI_SRC" ]; then
skip "@agentkey/cli source not found at $CLI_SRC — set AGENTKEY_CLI_SRC to override"
return
fi
# Subshells under nvm often fall back to an older default Node, even when
# the parent shell has a current one. `node:test` (used by tsx --test)
# needs Node 18+. Detect explicitly and skip with a hint rather than
# surfacing a cryptic "bad option: --test" error.
local node_major
node_major="$(cd "$CLI_SRC" && node --version 2>/dev/null | sed -nE 's/^v([0-9]+).*/\1/p')"
if [ -z "$node_major" ]; then
skip "node not found in cli subshell"
return
fi
if [ "$node_major" -lt 18 ] 2>/dev/null; then
skip "subshell Node is v$node_major (need >=18). Try: \`nvm alias default 20\` or \`nvm use 20\` then re-run."
return
fi
info "running: cd $CLI_SRC && npm test (node v$node_major)"
local out
if out="$(cd "$CLI_SRC" && npm test 2>&1)"; then
local count
count="$(printf '%s' "$out" | sed -nE 's/.*tests ([0-9]+).*/\1/p' | head -1)"
if printf '%s' "$out" | grep -qE 'fail (0|0$)'; then
ok "all $count tests pass"
else
fail "npm test reported failures"
printf '%s\n' "$out" | tail -10
fi
else
fail "npm test exited non-zero"
if printf '%s' "$out" | grep -q 'bad option: --test'; then
info "hint: a child process picked up an older Node. Check \`which node\` inside $CLI_SRC."
fi
printf '%s\n' "$out" | tail -15
fi
}
# ──────────────────────────────────────────────────────────────────────────
# Phase 2: Installer sandbox — exercise install.sh logic without network
# ──────────────────────────────────────────────────────────────────────────
phase_2() {
phase "Phase 2: Installer sandbox"
sandbox_init
# Seed enough markers so detect_agents() finds a varied set.
mkdir -p "$SANDBOX/.cursor" \
"$SANDBOX/.codex" \
"$SANDBOX/.gemini" \
"$SANDBOX/.qwen" \
"$SANDBOX/Library/Application Support/Claude"
touch "$SANDBOX/.claude.json"
# Test 1: --list-agents output shape
info "test: --list-agents"
local listed
listed="$(HOME="$SANDBOX" bash "$SKILL_REPO/scripts/install.sh" --list-agents 2>&1)"
assert_contains "lists claude-code" "claude-code" "$listed"
assert_contains "lists claude-desktop" "claude-desktop" "$listed"
assert_contains "lists cursor" "cursor" "$listed"
assert_contains "lists codex" "codex" "$listed"
assert_contains "lists gemini-cli" "gemini-cli" "$listed"
assert_contains "lists qwen-code" "qwen-code" "$listed"
# Test 2: --only claude-desktop must skip the skill step (MCP-only edge case)
info "test: --only claude-desktop --skip-mcp --yes (edge case)"
local out
out="$(HOME="$SANDBOX" bash "$SKILL_REPO/scripts/install.sh" \
--only claude-desktop --skip-mcp --yes 2>&1)"
assert_contains "skill step skipped for MCP-only ids" \
"MCP-only (no skill install path)" "$out"
assert_contains "MCP step skipped via --skip-mcp" "Skipped (--skip-mcp)" "$out"
assert_not_contains "no skills add invocation" "Running: npx" "$out"
# Test 3: auto-detect path doesn't crash under set -u (the $TARGETS bug repro)
info "test: auto-detect with --skip-skill --skip-mcp --yes"
out="$(HOME="$SANDBOX" bash "$SKILL_REPO/scripts/install.sh" \
--skip-skill --skip-mcp --yes 2>&1)"
assert_contains "auto-detect runs" "Detected agents on this host" "$out"
assert_contains "claude-desktop detected" "claude-desktop" "$out"
assert_not_contains "no unbound var error" "unbound variable" "$out"
sandbox_clean
}
# ──────────────────────────────────────────────────────────────────────────
# Phase 3: Writers sandbox — exercise every AGENT_REGISTRY writer
# ──────────────────────────────────────────────────────────────────────────
phase_3() {
phase "Phase 3: MCP writer schemas"
if [ ! -d "$CLI_SRC" ]; then
skip "MCP server not at $CLI_SRC — skipping writer tests"
return
fi
# Ensure dist/ is fresh.
info "rebuilding cli dist/..."
if ! (cd "$CLI_SRC" && npm run build >/dev/null 2>&1); then
fail "npm run build failed"
return
fi
sandbox_init
# Run every file-write strategy (skip CLI-only ones since those would
# need real `droid` / `openclaw` binaries on PATH).
info "running writers for every JSON / TOML agent..."
local runner="$SANDBOX/runner.mjs"
cat > "$runner" <<'EOF'
import { pathToFileURL } from "node:url";
const cliModule = process.env.AGENTKEY_CLI_MODULE;
if (!cliModule) {
throw new Error("AGENTKEY_CLI_MODULE is required");
}
const { AGENT_REGISTRY, writeAgentConfig } = await import(pathToFileURL(cliModule).href);
const SKIP_IDS = new Set(["claude-code", "droid", "openclaw"]);
const ctx = { apiKey: "ak_test_smoke", baseUrl: "https://api.agentkey.app" };
let written = 0, failed = 0;
for (const spec of AGENT_REGISTRY) {
if (SKIP_IDS.has(spec.id)) { console.log("SKIP " + spec.id); continue; }
const r = await writeAgentConfig(spec.id, ctx);
if (r.ok) { written++; console.log("OK " + spec.id + " -> " + r.detail); }
else { failed++; console.log("ERR " + spec.id + " -> " + r.error); }
}
console.log("---");
console.log("written=" + written + " failed=" + failed);
EOF
local writer_out
writer_out="$(AGENTKEY_CLI_MODULE="$CLI_SRC/dist/lib/mcp-clients.js" HOME="$SANDBOX" node "$runner" 2>&1)"
if printf '%s' "$writer_out" | grep -q '^ERR '; then
fail "some writers reported errors:"
printf '%s\n' "$writer_out" | grep '^ERR '
else
ok "every writer reported success"
fi
# Schema assertions on the files that landed.
info "asserting per-agent schemas..."
# Claude Desktop / Cursor / Gemini / Windsurf / Warp / Qwen / iFlow /
# Kimi / Kiro all use the same {mcpServers: {agentkey: {command, args, env}}}
# shape — spot-check a couple.
assert_file_has "claude-desktop mcpServers.agentkey" \
"$SANDBOX/Library/Application Support/Claude/claude_desktop_config.json" \
'"agentkey"'
assert_file_has "cursor command=npx" \
"$SANDBOX/.cursor/mcp.json" '"command": "npx"'
assert_file_has "gemini-cli env API_KEY" \
"$SANDBOX/.gemini/settings.json" '"AGENTKEY_API_KEY": "ak_test_smoke"'
# OpenCode — the schema differs: array command + 'environment' (not 'env'),
# under top-level 'mcp' (not 'mcpServers').
local oc="$SANDBOX/.config/opencode/opencode.json"
assert_file_has "opencode uses 'mcp' key" "$oc" '"mcp":'
assert_file_has "opencode command is array" "$oc" '"command": ['
assert_file_has "opencode uses 'environment' key" "$oc" '"environment":'
if grep -q '"mcpServers"' "$oc" 2>/dev/null; then
fail "opencode must NOT use mcpServers key"
else
ok "opencode does not leak mcpServers"
fi
# Amp — flat dotted key, not nested.
local amp="$SANDBOX/.config/amp/settings.json"
assert_file_has "amp uses flat 'amp.mcpServers' key" "$amp" '"amp.mcpServers":'
# Crush — mcp.<name> with type:stdio.
local crush="$SANDBOX/.config/crush/crush.json"
assert_file_has "crush uses 'mcp' key" "$crush" '"mcp":'
assert_file_has "crush has type:stdio" "$crush" '"type": "stdio"'
# Codex — TOML.
local codex="$SANDBOX/.codex/config.toml"
assert_file_has "codex header [mcp_servers.agentkey]" "$codex" '[mcp_servers.agentkey]'
assert_file_has "codex env table" "$codex" 'AGENTKEY_API_KEY = "ak_test_smoke"'
# Idempotency check — re-run all writers and make sure nothing duplicates.
info "idempotency: re-running every writer..."
AGENTKEY_CLI_MODULE="$CLI_SRC/dist/lib/mcp-clients.js" HOME="$SANDBOX" node "$runner" >/dev/null 2>&1
local agentkey_count
agentkey_count="$(grep -c '\[mcp_servers\.agentkey\]' "$codex" 2>/dev/null)"
if [ "$agentkey_count" = "1" ]; then
ok "codex stanza appears exactly once after double-write"
else
fail "codex agentkey stanza count after double-write: $agentkey_count (expected 1)"
fi
sandbox_clean
}
# ──────────────────────────────────────────────────────────────────────────
# Phase 4: Uninstaller sandbox — assert decoys are preserved
# ──────────────────────────────────────────────────────────────────────────
phase_4() {
phase "Phase 4: Uninstaller sandbox"
sandbox_init
# ── Decoy fixtures ────────────────────────────────────────────────────
# Each fixture mixes a legit agentkey entry with one we MUST NOT touch.
mkdir -p "$SANDBOX/.cursor" \
"$SANDBOX/.config/opencode" \
"$SANDBOX/.config/amp" \
"$SANDBOX/.config/crush" \
"$SANDBOX/.codex" \
"$SANDBOX/.gemini"
# Cursor: standard mcpServers + a decoy user key containing "agentkey".
cat > "$SANDBOX/.cursor/mcp.json" <<'EOF'
{
"mcpServers": {
"agentkey": { "command": "npx", "args": ["-y", "@agentkey/cli"] },
"agentkey-helper": { "command": "x" },
"other-svr": { "command": "y" }
}
}
EOF
# OpenCode: mcp.<name> dialect.
cat > "$SANDBOX/.config/opencode/opencode.json" <<'EOF'
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"agentkey": { "type": "local", "command": ["npx", "-y", "@agentkey/cli"] },
"user-svr": { "type": "local", "command": ["x"] }
}
}
EOF
# Amp: flat dotted key.
cat > "$SANDBOX/.config/amp/settings.json" <<'EOF'
{
"amp.mcpServers": {
"agentkey": { "command": "npx" },
"my-svr": { "command": "y" }
}
}
EOF
# Crush: mcp.<name> + type:stdio.
cat > "$SANDBOX/.config/crush/crush.json" <<'EOF'
{
"mcp": {
"agentkey": { "type": "stdio", "command": "npx" },
"keep": { "type": "stdio", "command": "z" }
}
}
EOF
# Codex TOML: agentkey block + legacy quoted block + unrelated sections.
cat > "$SANDBOX/.codex/config.toml" <<'EOF'
model = "gpt-5.1"
[mcp_servers.other]
command = "other"
args = []
[mcp_servers.agentkey]
command = "npx"
args = ["-y", "@agentkey/cli"]
env = { AGENTKEY_API_KEY = "ak_xxx" }
[mcp_servers."agentkey.app AgentKey"]
command = "legacy"
[unrelated_section]
key = "val"
EOF
# Gemini: claude-code per-project shape simulator (nested).
cat > "$SANDBOX/.claude.json" <<'EOF'
{
"mcpServers": { "agentkey": { "command": "npx" } },
"projects": {
"/path/a": {
"mcpServers": {
"agentkey": { "command": "npx" },
"user-server": { "command": "keep" }
}
}
}
}
EOF
# ── Run the uninstaller in the sandbox ────────────────────────────────
info "running uninstall.sh --skip-skill-remove --force-in-repo"
HOME="$SANDBOX" bash "$SKILL_REPO/scripts/uninstall.sh" \
--skip-skill-remove --force-in-repo >/dev/null 2>&1 || true
# ── Assertions: agentkey gone, decoys preserved ───────────────────────
info "checking: agentkey entries scrubbed"
assert_not_contains "cursor: agentkey removed" '"agentkey":' "$(cat "$SANDBOX/.cursor/mcp.json")"
assert_not_contains "opencode: agentkey removed" '"agentkey":' "$(cat "$SANDBOX/.config/opencode/opencode.json")"
assert_not_contains "amp: agentkey removed" '"agentkey":' "$(cat "$SANDBOX/.config/amp/settings.json")"
assert_not_contains "crush: agentkey removed" '"agentkey":' "$(cat "$SANDBOX/.config/crush/crush.json")"
assert_not_contains "codex: agentkey block removed" \
'[mcp_servers.agentkey]' "$(cat "$SANDBOX/.codex/config.toml")"
assert_not_contains "codex: legacy block removed" \
'agentkey.app AgentKey' "$(cat "$SANDBOX/.codex/config.toml")"
info "checking: decoy user entries preserved"
assert_contains "cursor: agentkey-helper kept (false-positive guard)" \
"agentkey-helper" "$(cat "$SANDBOX/.cursor/mcp.json")"
assert_contains "cursor: other-svr kept" \
"other-svr" "$(cat "$SANDBOX/.cursor/mcp.json")"
assert_contains "opencode: user-svr kept" \
"user-svr" "$(cat "$SANDBOX/.config/opencode/opencode.json")"
assert_contains "amp: my-svr kept" \
"my-svr" "$(cat "$SANDBOX/.config/amp/settings.json")"
assert_contains "crush: keep kept" \
'"keep"' "$(cat "$SANDBOX/.config/crush/crush.json")"
assert_contains "codex: [mcp_servers.other] kept" \
"[mcp_servers.other]" "$(cat "$SANDBOX/.codex/config.toml")"
assert_contains "codex: [unrelated_section] kept" \
"[unrelated_section]" "$(cat "$SANDBOX/.codex/config.toml")"
assert_contains "codex: top-level model= kept" \
'model = "gpt-5.1"' "$(cat "$SANDBOX/.codex/config.toml")"
assert_contains "claude.json: per-project user-server kept" \
"user-server" "$(cat "$SANDBOX/.claude.json")"
sandbox_clean
}
# ──────────────────────────────────────────────────────────────────────────
# Driver
# ──────────────────────────────────────────────────────────────────────────
main() {
local selected=("$@")
if [ ${#selected[@]} -eq 0 ]; then
selected=(1 2 3 4)
fi
printf "${BOLD}AgentKey dev-smoke${NC}\n"
printf " ${DIM}skill repo:${NC} %s\n" "$SKILL_REPO"
printf " ${DIM}cli:${NC} %s\n" "$CLI_SRC"
for n in "${selected[@]}"; do
case "$n" in
1) phase_1 ;;
2) phase_2 ;;
3) phase_3 ;;
4) phase_4 ;;
*) echo "Unknown phase: $n (valid: 1-4)" >&2 ;;
esac
done
# ── Summary ───────────────────────────────────────────────────────────
printf "\n${BOLD}Summary${NC}\n"
printf " ${GREEN}pass:${NC} %d ${RED}fail:${NC} %d ${DIM}skip:${NC} %d\n" \
"$PASS" "$FAIL" "$SKIP"
if [ "$FAIL" -gt 0 ]; then
printf "\n${RED}${BOLD}Failures:${NC}\n"
for r in "${FAIL_REASONS[@]}"; do
printf " ${RED}-${NC} %s\n" "$r"
done
exit 1
fi
printf "\n${GREEN}${BOLD}All green.${NC} Ready to PR.\n"
exit 0
}
main "$@"