feat(config): honor a relocated Claude Code config dir (CLAUDE_CONFIG_DIR) via toolRoots - #728
Conversation
|
Findings
The PR description contains a detailed test plan and real-CLI end-to-end verification record, so no testing-description finding is needed. |
aa644a5 to
2c58bd6
Compare
|
Thanks — addressed in the rebased push.
A second review pass on the rebased branch turned up five more things, fixed in the same push: the old-root cleanup is manifest-aware (team hooks are stripped too, not only built-in ones); a project-scope The rebase also folds the branch onto #695/#733: the |
|
Findings
Earlier Findings
The PR description includes a detailed test plan and real-CLI end-to-end record, so no testing-description finding is needed. |
Claude Code can move its whole user config directory with CLAUDE_CONFIG_DIR, but teamai resolved every Claude path from the team-wide toolPaths (.claude/...), so hooks, skills and rules were written to ~/.claude, which that Claude Code never reads, and doctor stayed green. Add a member-level `toolRoots` key to the local config. In user scope `scopedToolPaths` re-roots every path of the listed tool; a new `hookToolPaths` does the same for writes that land in HOME regardless of scope (hook injection/removal/listing, doctor's hook checks, the local agent). `teamai init` records CLAUDE_CONFIG_DIR into `toolRoots.claude` and keeps it across a re-init; `teamai doctor` reports when the variable and the effective root disagree. An explicit CLAUDE_CONFIG_DIR=~/.claude is recorded too: Claude Code then reads .claude.json from inside the directory, so the MCP companion file moves inside the root even when the root is unchanged. Accepted roots are one directory in HOME or .config/<name>, the shapes `toolInstallRoot` can express; the hook gates in hooks.ts now use it so hook and resource gates agree. Only `claude` is accepted for now: it is the one tool whose every user-scope write goes through toolPaths. Closes Tencent#725 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2c58bd6 to
94c72ae
Compare
|
Thanks — second round addressed in the rebased push.
|
|
Findings
Earlier Findings
|
|
Thanks — this landed at the second-pass state, so the third pass is being taken as follow-ups.
|
…he recorded root in import and skill tracking (#775) A re-init that moved the Claude Code root handed the full team config to reconcileMcpForConfig({ removeAll }), which walks every MCP-capable tool, so Codex, Cursor and the rest lost their teamai-managed servers until the next pull. The release now narrows the team config to Claude. import --from-claude scanned ~/.claude/rules and skill-use tracking only knew the static ~/.claude/skills; both now resolve the recorded root. The resolution (project config governing the directory, else user scope) moves into resolveMemberToolRoots so the local agent, import and tracking agree; tracking resolves it from the hook's reported directory. The helper checks that the directory exists before probing, as resolveConfigForDir does, so a hook from a deleted worktree still records — this also stops the local agent from throwing on a missing workspace path. Follow-up to #728 (third review pass, findings 1 and 5). Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Summary
Add
toolRootsto the member-level local config so a relocated Claude Code config directory (CLAUDE_CONFIG_DIR) is honored: every Claude path teamai resolves in user scope, plus the HOME-bound hook injection that project scope also performs, moves to that directory.teamai initrecordsCLAUDE_CONFIG_DIRintotoolRoots.claudeautomatically, andteamai doctorreports when the variable and the recorded root disagree. Without this, a member using a second Claude Code profile gets hooks, skills and rules written into~/.claude/, which that Claude never reads, whiledoctorstays green.Type of Change
Test Plan
npx tsc --noEmitpassesnpx vitest runpasses (the only failures are the 3shell-profile.test.tsWindows cases, which fail identically on a pristinemaincheckout on macOS;lock-atomic.test.tshas a pre-existing timing flake that reproduces on pristinemaintoo)New/updated tests:
tool-roots.test.ts(new, 28 cases: re-rooting every path field of the listed tool only,~/expansion,.config/<name>roots, refusal of outside-HOME / deeper-nested / bare.configroots with a warning, refusal of every tool id other thanclaude, every root a customizedtoolPaths.claudespreads its fields over, no phantom key for a field the team did not declare, a bare file name declared beside the root (settings: settings.json) moving inside the new one, MCP companion file moving inside the root even when the root equals the default or the resource root is customized, project-scope resource paths untouched while HOME hook paths follow the root, self single-repo hooks untouched, schema round-trip,detectClaudeConfigRoot, real hook injection into<HOME>/.claude-work/settings.jsonwithout creating<HOME>/.claude),init.test.ts(+12: records the root, records an explicit default, keeps the root across a re-init without the variable, refuses outside-HOME / nested / bare.config; a re-init that moves the root removes the hooks from the previous root — recorded or the default one — and releases its managed MCP servers and delivered model config, leaves an unmoved root alone, never creates the old settings file just to clean it, and clears the record on a set-but-blank variable; a project-scope init without the variable inherits the user-scope record),doctor.test.ts(+8: not built when the config does not sync Claude Code, pass/fail/absent cases,~/-written value, refused value — asserting the reason is named and re-running init is not prescribed —, unrecorded value),self-mode-agents.test.ts(+1: a relocated Claude Code with no~/.claudeis detected as installed),uninstall.test.ts(+1: removes hooks from the relocated HOME root and from the legacy<project>/.claudecopy),local-agent-mcp.test.ts(+1),local-agent-model-config.test.ts(+3: model sync into the relocated root;releaseClaudeModelConfigdrops the delivered gateway env and profile from a given root and forgets them, and touches nothing — not even the manifest — when no model was delivered).Mutation checks: reverting
applyToolRootsinscopedToolPathsfails 4 tests; reverting the allowlist fails 5; deriving only the first root fails 1; restoring the name gate on the MCP companion fails 1; dropping the previous-root cleanup ininitfails 2; dropping its exists/has-hooks guard fails 1; dropping the manifest from that removal (team hooks would survive) fails 1; dropping the user-scope fallback for project scope fails 1; dropping the refusal reason indoctorfails 1; ignoring the variable indetectHomeInstalledAgentsfails 1; assigning undeclared fields inrelocateToolPathsfails 1; ignoring a blank variable fails 1; skipping the MCP release fails 1; skipping the model-config release fails 1; building the doctor check for a config that excludes Claude fails 1; leaving bare file names in place fails 5; writing an empty model manifest on a no-op release fails 1; reverting the per-target hook path inuninstallfails 1; reverting the MCP path ininstallMcpServerfails 1; revertingdetectProjectConfiginmemberToolRootsfails 1.Real-CLI end-to-end (built
dist/index.js, isolatedHOME, local bare repo through the genericgitprovider; never touching a real~/.teamai):CLAUDE_CONFIG_DIR=$HOME/.claude-work, user scope:initprintsRecorded CLAUDE_CONFIG_DIR as the Claude Code root: …/.claude-work;pulllands rules, skills, hooks (settings.json) and the user-scope MCP file (.claude.json) under.claude-work/;$HOME/.claudeand$HOME/.claude.jsonare never created;doctorpasses, including the newClaude Code root matches CLAUDE_CONFIG_DIRcheck.initfrom a shell without the variable keepstoolRoots.claude.initthat moves the root (unset →~/.claude-a, then~/.claude-a→~/.claude-b): the previous root'ssettings.jsonloses its teamai hooks — built-in (6 → 0) and the team hook fromhooks/hooks.yaml(1 → 0) — the new one gets them,toolRoots.claudefollows; the team MCP server thatpullhad landed in the old MCP file (~/.claude.json, then~/.claude-a/.claude.json) is removed (1 → 0 each time); the skills/rules left behind are named in the output. Re-initwith the same root removes nothing.CLAUDE_CONFIG_DIR= teamai init(set but blank) clears the record, releases~/.claude-band re-arms~/.claude(hooks 0 → 6).doctorpasses under the recorded root and fails with the re-run hint when the variable points elsewhere; with a value outside HOME it names the reason instead of prescribing a re-run.~/.claude-b: the project config inheritstoolRoots.claude, its hooks land in~/.claude-b/settings.json(not~/.claude).CLAUDE_CONFIG_DIR=$HOME/.claude(explicit default): recorded; MCP goes to$HOME/.claude/.claude.json, not$HOME/.claude.json.CLAUDE_CONFIG_DIR=$HOME/.config/claude-work: accepted; everything lands under it.CLAUDE_CONFIG_DIR=$HOME/configs/claudeand$HOME/.config:initrefuses with the reason; nothing recorded.doctorwith the variable pointing elsewhere:✖ … CLAUDE_CONFIG_DIR is …/.claude-elsewhere; this config syncs Claude Code to …/.claude-work. Re-run \teamai init` to record it.` With the variable unset the check is not reported.$HOME/.claude-work/settings.json, resources stay under<project>/.claude/.uninstallin project scope with a legacy<project>/.claude/settings.jsonpresent: hooks removed from both files.toolRoots.codex/.omp/.copilot: warned and ignored; no*-workdirectories created.~/.claudepresent: notoolRootsrecorded, all writes at the default locations,doctorgreen.teamai push(resolves throughscopedToolPaths, so it reads the relocated root by construction).Related Issues
Closes #725
Notes for Reviewers
scopedToolPaths()at user scope. Every hook writer already resolves its paths at the hook scope (scopedToolPaths(teamConfig, { ...localConfig, scope: hookScope }), feat(agents): support Qoder CN via its .qoder-cn user directory #695/fix(doctor): probe the Copilot hooks file where inject writes it #733), so hooks injected into HOME from a project-scope config pick the root up with no further change; the local agent's HOME writers callapplyToolRootson their synthetic team config. Project-scope resource paths are deliberately untouched:CLAUDE_CONFIG_DIRrelocates the user config, not<project>/.claude/. Self single-repo hooks stay in the business repo.pull/doctor/uninstallfrom another shell would not, splitting one install across two directories.initis the only command that readsCLAUDE_CONFIG_DIR;doctoronly compares.initrecords a root different from the one the previous config resolved to (recorded, or the default), the active configuration in the previous root is released first: the teamai hooks in itssettings.json(a manifest-aware pass, so team hooks go too), the teamai-managed MCP servers in its MCP file (reconcileMcpForConfigwithremoveAll, addressed through the previous config so it resolves to the old file), and the gateway env / model profile the local agent delivered there (releaseClaudeModelConfig, which also forgets them in the model manifest, so the agent re-delivers into the new root). Otherwise that Claude keeps firing the hooks, starting the servers and using the credentials — one install split across two directories. Skills, rules and theCLAUDE.mdblock under the old root are inert copies; they are left where they are and named in the output rather than deleted byinit. No file is created just to be cleaned. Server-pushed agent hooks in the old root are removed with the rest; the local agent's hook manifest is not rewritten, so they come back with the agent's next push.CLAUDE_CONFIG_DIR= teamai init …clearstoolRoots.claudeand releases the old root the same way. Documented in the usage guide and in thesetupskill.initwith no record of its own and no variable to read starts from the user-scopetoolRoots. Records stay per scope: a later user-scope move does not rewrite project records, butdoctor(with the variable set) reports the drift for that project.doctorbuilds the root check only for a config that syncs Claude Code (Claude declared intoolPathsand not excluded throughenabledAgents/disabledAgents); there is nothing to compare otherwise.relocateToolPathstreats any path without a/(.claude.json, or a customizedsettings: settings.json) as a file beside the root and moves it inside the new one — the MCP companion is the usual case, not a special one.CLAUDE_CONFIG_DIR=~/.claude, Claude Code reads.claude.jsonfrom inside that directory (docs), so the value is recorded and the MCP companion file moves inside the root even when the root is unchanged.doctoralso fails when the variable is set but nothing is recorded, for the same reason..configitself) or.config/<name>: exactly whattoolInstallRoot()can express, so the installed-tool gate keeps meaning "the root exists". Deeper roots would let an unrelated~/configscount as installed. The foursettings.split('/')[0]gates inhooks.tsnow usetoolInstallRoot()so hook and resource gates agree on.config/<name>roots.claudeonly, by allowlist. A root is only honest for a tool whose every user-scope write goes throughtoolPaths. Claude qualifies (hooks, skills, rules, agents, CLAUDE.md, MCP, model sync, co-author); most others still have a fixed path (OMP's extension dir, Codex/Cursor co-author files, OpenCode's plugin dir, Copilot's$COPILOT_HOME). Other tool ids are refused with a warning. The key stays a generic record so a tool can be added once its writes are audited.toolInstallRoot()fromresources/base.tstotypes.ts(needed there; avoids a circular import).detectHomeInstalledAgents(self-mode picker, clone-time self-heal) now also counts Claude as installed whenCLAUDE_CONFIG_DIRnames an existing directory; it runs before any config exists, so the variable is the only signal there. Reading it outsideinitis limited to this detection.legacyHooksNeedReinjectinpull.tsprobes the fixed~/.claude/settings.jsonbefore config is loaded. On a machine that still carries pre-dispatch-format hooks there while syncing to a relocated root, the migration re-injects into the relocated root and re-fires on each pull; a re-init on this branch clears that file, so the case is confined to installs that never re-init.memberToolRootsin the local agent resolves the governing config per call (adetectProjectConfigeach time); the writers that call it are per hook / per resource, not per tick.docs/usage-guide.mdanddocs/usage-guide.zh-CN.md(config reference sample + a "Relocated tool roots" subsection);skill-data/setup/references/join-member.md(init withCLAUDE_CONFIG_DIR, moving and ending a relocation) andskill-data/core/references/troubleshooting.md(the doctor check and what to do). No command or flag changed, socommands.mdis untouched. READMEs do not enumerate local-config keys, so they are unchanged.🤖 Generated with Claude Code