feat(uninstall): support --agent <tool> to uninstall a single tool (#230) - #244
Conversation
70a04f5 to
b48269d
Compare
jeff-r2026
left a comment
There was a problem hiding this comment.
Reviewed the full diff and traced the behavior through the pull sync paths. The refactor itself is clean — extracting discoverToolResources() is the right seam, and intersecting the managed-hooks manifest with actual file content in hasTeamaiHooks (so a stale manifest entry can't produce a false positive) is a nice catch. CI is green and the new test cases cover the interesting boundaries.
One functional gap is worth deciding on before merge, plus a few smaller items.
1. A single-tool uninstall does not survive the next pull (verified by running it)
--agent X exists primarily for "other tools are still on this machine, only remove X". On exactly that path, the uninstall is undone by the next pull.
I ran the real pull() and uninstall() against a fixture (only git fetch and the logger were mocked), with claude + codex both installed and enabledAgents unset (i.e. the user never used init --agent):
[STEP1] after pull — claude settings has hooks: true
{"SessionStart":[{"matcher":"*","hooks":[{"command":"bash -lc \"teamai hook-dispatch session-start --tool claude ...
[STEP2] after `uninstall --agent claude`
claude skill removed: true
claude hooks: {"SessionStart":[],"Stop":[],"PostToolUse":[],"UserPromptSubmit":[]}
~/.teamai kept: true <- correct, codex is still present
[STEP3] after the next pull
claude skill restored: true
claude hooks restored: {"SessionStart":[{"matcher":"*","hooks":[{"command":"bash -lc \"teamai hook-dispatch
session-start --tool claude ...","description":"[teamai] Hook dispatch session..."
Step 2 is exactly right — the skill is gone, hooks are stripped, shared resources are kept. The problem is step 3. Two independent reasons:
- Skills / rules / agents / CLAUDE.md sync gates only on whether the tool's root directory exists (
ResourceHandler.isToolInstalled,src/resources/base.ts).~/.claudestill exists after the uninstall — it is the user's own tool directory — so everything gets written back. reconcileHooksAllScopes(src/pull.ts) re-injects built-in + team hooks on every pull, filtered only bylocalConfig.enabledAgents.init --agentappends toenabledAgents(src/init.ts), butuninstall --agentnever removes the tool from it. And whenenabledAgentsis unset there is no filter at all, so the tool is re-hooked regardless.
And the user does not have to run pull for this to happen — a remaining tool's SessionStart hook triggers it silently:
// src/hook-handlers.ts
{ event: 'session-start', matcher: '*', handler: pullHandler, timeoutMs: PULL_TIMEOUT_MS },So after uninstall --agent claude, opening one codex session brings claude's teamai resources back.
Options, roughly in order of how well they match the documented intent:
- Persist the exclusion: drop the tool from
enabledAgents, and add something likedisabledAgentsthat the skills/rules/agents sync also honors. This is what actually makesuninstall --agentsymmetric withinit --agent. - Keep the current behavior but print an explicit warning after a non-last-tool uninstall and document that the removal is not durable.
- Scope the flag down to "uninstall the last tool" only.
2. An unknown tool name still exits 0
log.error(`未知工具 "${opts.agent}"。可用工具: ${tools.join(', ')}`);
return;log.error only prints; it does not set an exit code. So teamai uninstall --agent clade --force looks successful to a script or CI job. The --agent validation in status.ts sets process.exitCode = 2 — worth matching here.
3. hasTeamaiHooks also changes the full-uninstall path
Gating hookFiles on hasTeamaiHooks applies to the no---agent path too. I checked that this cannot under-remove: the isManaged predicate inside each reconcile*Format is a subset of what hasTeamaiHooks matches (claude: isBuiltinClaudeEntry || isTeamClaudeEntry || manifest hit; the cursor and codex branches are identical to their reconcile counterparts). The only thing lost is the "clean up empty camelCase keys left by a previous incorrect injection" pass no longer running on files that hold no teamai hooks. Low impact, but it is an incidental behavior change that the PR description does not mention — worth a line there or in the changelog.
Related, and visible in step 2 above: after the uninstall, claude's settings.json keeps {"SessionStart":[],"Stop":[],"PostToolUse":[],"UserPromptSubmit":[]}. That is pre-existing reconcileHooks(removeAll) behavior, not something this PR introduced, but it becomes user-visible here because the settings file is now meant to stay on the user's machine after a targeted uninstall — they think claude is clean while teamai-created empty event keys remain. The existing empty-key cleanup only covers the camelCase spellings, so it does not catch these.
4. The docs describe one fewer condition than the code
The docs say shared resources are removed "only when the target is the last tool still using teamai", but the code requires targetHasResources && !othersHaveResources — the target must also have resources of its own. So a user who has env/docs installed but no resources landed in the tool directory yet gets "没有需要卸载的内容" from uninstall --agent claude, while a plain uninstall would remove the shared resources. Either document the extra condition or drop it. Case-insensitive matching is tested but also undocumented. (README only carries a command table without options, so no README change needed there.)
Nits
- The three new user-facing strings are Chinese (
⚠ 仅卸载工具: …,未知工具 "…",未检测到有效配置…), whileCLAUDE.mdrequires English for CLI output. The file is already entirely Chinese so it is locally consistent, but--helpis English and the runtime output is not — worth cleaning up, here or in a follow-up for the whole file. - The test name
(P2#2 regression)references internal review numbering; a descriptive name would age better. discoverToolResourcesruns for all configured tools even when--agentis given. That is necessary to computeothersHaveResources, but it could short-circuit as soon as one non-target tool is found to have resources.
Item 1 is the decision to make; item 2 is a one-liner. 3-5 are description/docs/style.
Address PR Tencent#244 review. `uninstall --agent X` was undone by the next pull (or another tool's session-start hook): resource sync gates only on tool-dir existence and hooks re-inject filtered by enabledAgents, which the uninstall never updated. - Add localConfig.disabledAgents + isAgentDisabled() and honor it on every sync write-back: hooks (reconcileTeamHooksForConfig + auto-migrate inject), the pull.ts resource/CLAUDE.md loops, resources/{skills,rules, agents} pullItem, and builtin-{agents,rules,skills} deploy. - uninstall --agent records the tool in disabledAgents (and prunes it from an existing enabledAgents whitelist) when shared ~/.teamai survives; a last-tool uninstall deletes the home so nothing is persisted. Leave an absent whitelist undefined rather than collapsing it to [] (which the hook path reads as "whitelist nothing"). - init --agent clears the exclusion again (symmetry). - Unknown/--agent-without-config paths set process.exitCode = 2. - English-ify the new user-facing strings. - Docs: document the extra shared-removal condition, case-insensitive matching, and the durable-exclusion guarantee (both languages). - Tests: persistence + whitelist-pruning + exit-code assertions; a pullItem-honors-disabledAgents gate test. --other=OSS project, no TAPD ticket Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…encent#230) Mirror `init --agent`: `uninstall --agent <tool>` removes only that tool's teamai resources (hooks, CLAUDE.md block, skills, rules, built-in agents). Shared resources (env block, docs, ~/.teamai) are removed only when the target itself has teamai resources AND is the last tool still using teamai; otherwise they are kept. Targeting a tool with no teamai resources is a no-op for shared resources. Add hooks.hasTeamaiHooks() so a tool whose settings file remains on disk but has had its teamai hooks stripped is no longer counted as "still using teamai" — fixes shared resources not being removed on the true last tool. Manifest records only count when the recorded command still exists in the settings file, so a stale managed-hooks entry cannot cause a false positive. Unknown tool name aborts without deleting anything and lists available tools. Tool name matching is case-insensitive. Behavior without --agent is unchanged (full uninstall). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address PR Tencent#244 review. `uninstall --agent X` was undone by the next pull (or another tool's session-start hook): resource sync gates only on tool-dir existence and hooks re-inject filtered by enabledAgents, which the uninstall never updated. - Add localConfig.disabledAgents + isAgentDisabled() and honor it on every sync write-back: hooks (reconcileTeamHooksForConfig + auto-migrate inject), the pull.ts resource/CLAUDE.md loops, resources/{skills,rules, agents} pullItem, and builtin-{agents,rules,skills} deploy. - uninstall --agent records the tool in disabledAgents (and prunes it from an existing enabledAgents whitelist) when shared ~/.teamai survives; a last-tool uninstall deletes the home so nothing is persisted. Leave an absent whitelist undefined rather than collapsing it to [] (which the hook path reads as "whitelist nothing"). - init --agent clears the exclusion again (symmetry). - Unknown/--agent-without-config paths set process.exitCode = 2. - English-ify the new user-facing strings. - Docs: document the extra shared-removal condition, case-insensitive matching, and the durable-exclusion guarantee (both languages). - Tests: persistence + whitelist-pruning + exit-code assertions; a pullItem-honors-disabledAgents gate test. --other=OSS project, no TAPD ticket Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
115382a to
4fd162c
Compare
Summary
Closes #230. Adds
--agent <tool>toteamai uninstall, mirroringinit --agent, so a user can uninstall a single AI tool's teamai resources instead of everything on the machine — and makes that removal durable across pulls.teamai-recall).~/.teamai/): removed only when the target itself has teamai resources AND is the last tool still using teamai; otherwise kept for the remaining tools.disabledAgentsso the nextpull(or another tool's session-start hook) does not resurrect it.init --agentclears the exclusion again — symmetric.--agentwithout config → aborts without deleting anything, lists the available tools, and exits with a non-zero status.--agent→ behavior is exactly as before (full uninstall).Implementation
src/uninstall.ts: extract per-tool discovery intodiscoverToolResources();buildRemovalPlan(…, agentFilter?)merges only the selected tool(s) and computesincludeShared(false when other tools still hold teamai resources). On a non-last-tool uninstall (shared~/.teamaisurvives), the tool is recorded indisabledAgentsand pruned from any existingenabledAgentswhitelist. Unknown-tool and no-config--agentpaths setprocess.exitCode = 2.src/types.ts: newdisabledAgentsfield +isAgentDisabled()predicate.disabledAgentson every sync write-back so an excluded tool is never re-hydrated: hooks (reconcileTeamHooksForConfigeffective filter + auto-migrate inject inpull.ts), thepull.tsresource/CLAUDE.md loops (tombstone, culture, claudemd, recall, cleanup),resources/{skills,rules,agents}pullItem, andbuiltin-{agents,rules,skills}deploy.src/init.ts:init --agentremoves the tool fromdisabledAgents(reverses the exclusion).src/hooks.ts:hasTeamaiHooks(settingsPath, tool, manifestPath?)— true iff a settings file actually contains a teamai-managed hook (built-in or team), across claude/codex/cursor formats + managed-hooks manifest. Discovery gates the hook file on this instead of mere file existence.src/index.ts: register the--agent <name>option.Note on
hasTeamaiHooksand the full-uninstall pathGating
hookFilesonhasTeamaiHooksalso applies to the no---agentpath. It cannot under-remove (eachreconcile*Format'sisManagedpredicate is a subset of whathasTeamaiHooksmatches); the only behavior lost is the empty-camelCase-key cleanup pass no longer running on files that hold no teamai hooks. Low impact.Note on the
enabledAgentswhitelistWhen no whitelist existed (
enabledAgentsunset = "all tools"), the uninstall leaves it undefined rather than collapsing it to[]— the hook path reads[]as "whitelist nothing", which would otherwise stop hook sync for the remaining tools too. Covered by a regression test + the e2e round-trip below.Test Plan
npx tsc --noEmitcleannpx vitest run— full suite green (1791 tests).uninstall.test.ts: single-tool full removal, keep-shared when another tool present, unknown-tool error +exitCode === 2, no-config+--agentguard, last-tool-with-stripped-hooks regression, disabledAgents persisted on non-last-tool uninstall, enabledAgents whitelist pruned (not collapsed) when it exists.skills.test.ts:pullItemskips a disabled tool but still syncs the others.dist/index.js, isolated$HOMEwith claude+codex tools):uninstall --agent claude(codex present) → claude skill/rule/hooks removed,.teamai+ codex kept;disabledAgents: [claude]written,enabledAgentsleft unset ✅uninstall --agent codex(now last tool) → codex skill and.teamairemoved ✅ (regression fixed)uninstall --agent <unknown>→ English error + available-tools list, nothing deleted,exit 2✅pull → uninstall --agent claude → new team-repo commit → pullwith codex configured with hooks: claude's skill/rule/hooks stay gone, codex's hooks and newly-synced rule are kept (i.e. the excluded tool is not resurrected, and sync for remaining tools is unaffected) ✅uninstall(no--agent) → full removal of both tools + shared, unchanged ✅docs/usage-guide.md+.zh-CN.md): the extra shared-removal condition, case-insensitive tool matching, and the durable-exclusion guarantee.🤖 Generated with Claude Code