From 94c72ae964db6a56a4c851dcc111ccb4ab4c3c86 Mon Sep 17 00:00:00 2001 From: Rererr <44668651+Rererr@users.noreply.github.com> Date: Thu, 24 Sep 2026 03:49:28 +0900 Subject: [PATCH] feat(config): honor a relocated Claude Code config dir via toolRoots 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/, 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 #725 Co-Authored-By: Claude Fable 5.1 --- docs/usage-guide.md | 14 + docs/usage-guide.zh-CN.md | 14 + skill-data/core/references/troubleshooting.md | 6 + skill-data/setup/references/join-member.md | 8 + src/__tests__/doctor.test.ts | 86 +++- src/__tests__/init.test.ts | 214 ++++++++- src/__tests__/local-agent-mcp.test.ts | 36 ++ .../local-agent-model-config.test.ts | 64 +++ src/__tests__/self-mode-agents.test.ts | 7 + src/__tests__/tool-roots.test.ts | 425 ++++++++++++++++++ src/__tests__/uninstall.test.ts | 43 ++ src/doctor.ts | 50 +++ src/hooks.ts | 9 +- src/init.ts | 120 ++++- src/known-agents.ts | 8 +- src/local-agent.ts | 94 +++- src/project-agent-root.ts | 3 +- src/resources/base.ts | 21 +- src/types.ts | 262 ++++++++++- src/uninstall.ts | 8 +- 20 files changed, 1436 insertions(+), 56 deletions(-) create mode 100644 src/__tests__/tool-roots.test.ts diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 58f339ae8..283c19c19 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1946,8 +1946,22 @@ projectRoot: /path/to/project # project scope only inheritUserScope: true # optional; project scope only, defaults to false coAuthorEnabled: true # optional; per-machine co-author override contributeHintEnabled: false # optional; per-machine override of sharing.contributeHint.enabled +toolRoots: # optional; per-machine tool roots (see below) + claude: ~/.claude-work ``` +#### Relocated tool roots (`toolRoots`) + +A tool that can be told to keep its configuration somewhere else — Claude Code, through `CLAUDE_CONFIG_DIR` — reads nothing that teamai writes to the team-wide default. `toolRoots` names the directory that tool actually uses, keyed by the same tool id as `toolPaths`, and every path teamai resolves for it (skills, rules, agents, `CLAUDE.md`, settings, and the user-scope MCP config) moves there with it. Other tools are untouched, and so are project-scope paths: those hang off the project root, where a per-machine root has nothing to say. Hooks are the exception that makes this worth recording — they are injected into your home directory even in project scope, so they follow `toolRoots` in both. + +`teamai init` fills it in for you: whenever `CLAUDE_CONFIG_DIR` is set, init records the directory it points at and prints it. That includes `CLAUDE_CONFIG_DIR=~/.claude`, which is not the same as leaving the variable unset — Claude Code reads `.claude.json` from inside the configured directory, so teamai writes the MCP config to `~/.claude/.claude.json` rather than `~/.claude.json`. `init` is also the only command that reads the variable, because it lives in one shell profile while teamai also runs from session hooks and other terminals; resolving it per run would make the sync target depend on who started the process. A re-init keeps a root that was recorded earlier, so running `init` from a shell without the variable does not send the sync back to the default. When a re-init does move the root, the hooks teamai injected into the previous root's `settings.json` are removed so that Claude stops syncing into the new one; the skills, rules and `CLAUDE.md` block written there are left in place and named in the output. A project-scope `init` that has no record of its own and no variable to read starts from the user-scope record, since the root is a fact about the machine and project hooks land in your home directory. To end a relocation, run `init` once with the variable set but blank (`CLAUDE_CONFIG_DIR= teamai init …`): the record is cleared and the old root released the same way. Along with the hooks, the old root loses the teamai-managed MCP servers and any gateway credentials the local agent delivered there; they are active configuration, unlike the skills and rules. + +A root has to be somewhere teamai can recognize the tool at: a directory in your home other than `~/.config` itself (`~/.claude-work`), or a `~/.config/` directory (a leading `~/` is expanded). Those are the two shapes the "is this tool installed?" check can look for; anything deeper, or outside your home directory, is refused with a warning rather than silently half-applied. + +`toolRoots` currently applies to `claude` only, and any other tool id is refused with a warning. A root is only honest for a tool whose every user-scope write goes through `toolPaths`; the other tools still write somewhere teamai resolves separately — OMP's extension directory, the Codex and Cursor co-author files, OpenCode's plugin directory — so moving their `toolPaths` entries would leave the rest behind. Copilot CLI has its own mechanism: set `COPILOT_HOME`. + +If you set or change `CLAUDE_CONFIG_DIR` after initializing, `teamai doctor` reports it: the `Claude Code root matches CLAUDE_CONFIG_DIR` check (built only when this config syncs Claude Code) compares the variable against the root this config actually syncs to and tells you to re-run `teamai init` — or, for a value teamai cannot sync to, says why. With the variable unset, the check stays out of the report. + ### Webhook notifications (`sharing.webhooks`) Notify external endpoints when team events happen. Each endpoint declares a `url`, a `type` (`json`, `feishu`, or `wecom`), and the `events` it subscribes to; `secret`, `timeout` (default `5000` ms), and `retries` (default `3`) are optional. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 218aeb2ea..8a23f9248 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1878,8 +1878,22 @@ projectRoot: /path/to/project # 仅 project scope inheritUserScope: true # 可选,仅 project scope,默认 false coAuthorEnabled: true # 可选,每机器的 co-author 覆盖 contributeHintEnabled: false # 可选,每机器覆盖 sharing.contributeHint.enabled +toolRoots: # 可选,每机器的工具根目录(见下) + claude: ~/.claude-work ``` +#### 迁移后的工具根目录(`toolRoots`) + +有的工具可以把自己的配置放到别处——Claude Code 就通过 `CLAUDE_CONFIG_DIR` 这样做——此时 teamai 按团队默认位置写入的内容它一概读不到。`toolRoots` 用与 `toolPaths` 相同的工具 id 指明该工具实际使用的目录,teamai 为它解析的所有路径(skills、rules、agents、`CLAUDE.md`、settings,以及用户级 MCP 配置)都会一并迁过去。其他工具不受影响,project scope 的路径也不受影响:那些路径挂在项目根目录下,每机器的根目录对它们没有意义。hook 是个例外,也正是值得记录 `toolRoots` 的原因——即使在 project scope,hook 也注入到 home 目录,因此两种 scope 下都跟随 `toolRoots`。 + +`teamai init` 会自动写入:只要设置了 `CLAUDE_CONFIG_DIR`,init 就记录它指向的目录并打印出来。`CLAUDE_CONFIG_DIR=~/.claude` 也算——它与不设置该变量并不等价:设置之后 Claude Code 从配置目录内部读取 `.claude.json`,因此 teamai 写的是 `~/.claude/.claude.json` 而不是 `~/.claude.json`。读取这个变量的命令也只有 `init`——它只存在于某一份 shell 配置里,而 teamai 还会从 session hook 和别的终端里运行,每次运行都去读它,同步目标就会取决于是谁启动了进程。重新执行 `init` 会保留之前记录的根目录,所以在没有该变量的 shell 里再跑一次 init,同步目标不会被悄悄改回默认位置。如果重新执行 `init` 确实换了根目录,teamai 会把此前注入到旧根目录 `settings.json` 里的 hook 移除,以免那个 Claude 继续往新目录同步;写在旧目录里的 skills、rules 和 `CLAUDE.md` 片段会原样保留,并在输出中指明位置。project scope 的 `init` 若自身没有记录、也读不到该变量,则沿用 user scope 的记录:根目录是这台机器的事实,而 project scope 的 hook 也注入到 home 目录。要结束迁移,把该变量设为空再执行一次 `init`(`CLAUDE_CONFIG_DIR= teamai init …`):记录会被清除,旧根目录按同样方式释放。除 hook 之外,旧根目录里 teamai 管理的 MCP server 和本地 agent 下发的网关凭据也会一并移除——它们是生效中的配置,不同于 skills 和 rules。 + +根目录必须是 teamai 能够识别该工具的位置:home 目录下的一层目录(`~/.claude-work`,但 `~/.config` 本身除外),或者一个 `~/.config/<名称>` 目录(开头的 `~/` 会被展开)。这两种形态正是「该工具是否已安装」这项检查能够查找的范围;更深的层级、或 home 目录之外的路径都会被拒绝并给出警告,而不是只生效一半。 + +`toolRoots` 目前只对 `claude` 生效,其他工具 id 都会被拒绝并给出警告。只有当一个工具在用户级的所有写入都经过 `toolPaths` 时,为它指定根目录才是可靠的;其余工具都还有 teamai 另行解析的写入位置——OMP 的扩展目录、Codex 与 Cursor 的 co-author 文件、OpenCode 的插件目录——只迁移它们的 `toolPaths` 会把其余部分留在原处。Copilot CLI 有自己的机制:设置 `COPILOT_HOME`。 + +如果你在初始化之后才设置或修改 `CLAUDE_CONFIG_DIR`,`teamai doctor` 会报出来:`Claude Code root matches CLAUDE_CONFIG_DIR` 这项检查(仅在当前配置会同步 Claude Code 时出现)会比对该变量与当前配置实际同步到的根目录,并提示重新执行 `teamai init`;若该值是 teamai 无法同步到的目录,则说明原因。未设置该变量时,这项检查不会出现在报告里。 + ### Webhook 通知(`sharing.webhooks`) 在团队事件发生时通知外部端点。每个 endpoint 声明 `url`、`type`(`json`、`feishu` 或 `wecom`)以及订阅的 `events`;`secret`、`timeout`(默认 `5000` 毫秒)、`retries`(默认 `3`)均为可选。 diff --git a/skill-data/core/references/troubleshooting.md b/skill-data/core/references/troubleshooting.md index 2d413a84a..839f41cbc 100644 --- a/skill-data/core/references/troubleshooting.md +++ b/skill-data/core/references/troubleshooting.md @@ -34,6 +34,12 @@ This is the #1 onboarding issue. In order: with `--scope user`. 5. **Tool has no hook surface** (e.g. Gemini CLI, JoyCode): there is no auto-sync; run `teamai pull` manually each time. +6. **Claude Code reads a different directory** (`CLAUDE_CONFIG_DIR` is set). + `teamai doctor` reports `Claude Code root matches CLAUDE_CONFIG_DIR` when the + directory the variable names is not the one this config syncs to. Re-run + `teamai init` from a shell that has the variable exported; it records the root + and moves the install. If the check says the value cannot be synced to (outside + your home, or nested deeper than `~/.config/`), fix the variable first. 6. **A command reports a broken manifest** (`Invalid roles manifest…`, `Invalid projects manifest…`, `Invalid manifests…`, or `…manifest … could not be read`). `pull` skips that scope on purpose, since syncing without the diff --git a/skill-data/setup/references/join-member.md b/skill-data/setup/references/join-member.md index 0f5f5489b..83be86a23 100644 --- a/skill-data/setup/references/join-member.md +++ b/skill-data/setup/references/join-member.md @@ -88,6 +88,14 @@ teamai init --http https://your-team-host/api --token This is a read-only consumer mode — `push` / `contribute` are not available, but skills and rules still sync. +**Claude Code kept in a different directory (`CLAUDE_CONFIG_DIR`):** `init` records +that directory (as `toolRoots.claude` in the local config) and syncs every Claude +path there, so run `init` from a shell that has the variable exported. Re-running +`init` after changing it moves the install (the old root's hooks, managed MCP +servers and delivered model credentials are removed; its skills and rules are left +and named in the output). To end the relocation, run `init` once with the variable +set but blank: `CLAUDE_CONFIG_DIR= teamai init …`. + ## Step 5 — Verify with doctor ```bash diff --git a/src/__tests__/doctor.test.ts b/src/__tests__/doctor.test.ts index 7fd28127b..221566809 100644 --- a/src/__tests__/doctor.test.ts +++ b/src/__tests__/doctor.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; import path from 'node:path'; // ── Mocks ──────────────────────────────────────────────── @@ -100,8 +100,15 @@ function buildPartialHooksContent(exclude: string[]): string { // Suppress console.log output in tests const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); +// The Claude-root check only appears when CLAUDE_CONFIG_DIR is set, and this +// suite's fixtures record no root — so a developer whose own shell relocates +// Claude Code would otherwise see every doctor test fail. The describe that +// covers the check sets the variable itself. +const originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + beforeEach(() => { vi.clearAllMocks(); + delete process.env.CLAUDE_CONFIG_DIR; mockedLoadLocalConfig.mockResolvedValue(mockLocalConfig); mockedLoadTeamConfig.mockResolvedValue(mockTeamConfig); mockedPathExists.mockResolvedValue(true); @@ -741,3 +748,80 @@ describe('buildChecks — a tool enabled but not installed', () => { expect(allPassed).toBe(false); }); }); + +describe('doctor — the recorded Claude Code root', () => { + const CHECK_NAME = 'Claude Code root matches CLAUDE_CONFIG_DIR'; + const home = process.env.HOME ?? ''; + const relocated = path.join(home, '.claude-work'); + + afterEach(() => { + if (originalClaudeConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; + }); + + async function checkFor(toolRoots?: Record) { + mockedLoadLocalConfig.mockResolvedValue({ ...mockLocalConfig, ...(toolRoots ? { toolRoots } : {}) }); + const ctx = await resolveDoctorContext(); + if (!ctx) throw new Error('expected a resolved doctor context'); + return (await buildChecks(ctx)).find((c) => c.name === CHECK_NAME); + } + + it('is not built when the config does not sync Claude Code at all', async () => { + process.env.CLAUDE_CONFIG_DIR = relocated; + mockedLoadLocalConfig.mockResolvedValue({ ...mockLocalConfig, disabledAgents: ['claude'] }); + const ctx = await resolveDoctorContext(); + expect((await buildChecks(ctx!)).find((c) => c.name === CHECK_NAME)).toBeUndefined(); + }); + + it('passes when the recorded root is the one Claude Code is told to use', async () => { + process.env.CLAUDE_CONFIG_DIR = relocated; + const check = await checkFor({ claude: relocated }); + expect(check).toBeDefined(); + expect(await check!.check()).toBe(true); + }); + + it('fails when nothing was recorded, and says how to record it', async () => { + process.env.CLAUDE_CONFIG_DIR = relocated; + const check = await checkFor(); + expect(await check!.check()).toBe(false); + expect(check!.fix).toContain(relocated); + expect(check!.fix).toContain('Re-run `teamai init`'); + }); + + it('fails when the recorded root is a different directory', async () => { + process.env.CLAUDE_CONFIG_DIR = relocated; + const check = await checkFor({ claude: path.join(home, '.claude-other') }); + expect(await check!.check()).toBe(false); + expect(check!.fix).toContain(path.join(home, '.claude-other')); + }); + + it('stays out of the report when the variable is unset', async () => { + delete process.env.CLAUDE_CONFIG_DIR; + expect(await checkFor()).toBeUndefined(); + }); + + it('runs for an explicit default root, which is not the same as no variable', async () => { + process.env.CLAUDE_CONFIG_DIR = path.join(home, '.claude'); + const unrecorded = await checkFor(); + expect(await unrecorded!.check()).toBe(false); + expect(await (await checkFor({ claude: path.join(home, '.claude') }))!.check()).toBe(true); + }); + + it('reads a root written with ~/ as the directory it expands to', async () => { + process.env.CLAUDE_CONFIG_DIR = relocated; + const check = await checkFor({ claude: '~/.claude-work' }); + expect(await check!.check()).toBe(true); + }); + + it('fails for a recorded root the sync refuses, naming where it actually writes', async () => { + // Outside HOME: applyToolRoots drops it, so the sync keeps using + // ~/.claude and the check must not call that a match. + process.env.CLAUDE_CONFIG_DIR = '/opt/claude-config'; + const check = await checkFor({ claude: '/opt/claude-config' }); + expect(await check!.check()).toBe(false); + expect(check!.fix).toContain(path.join(home, '.claude')); + // Re-running init cannot record this value, so the fix says why instead. + expect(check!.fix).toContain('outside the home directory'); + expect(check!.fix).not.toContain('to record it'); + }); +}); diff --git a/src/__tests__/init.test.ts b/src/__tests__/init.test.ts index 0fa582ea9..615389669 100644 --- a/src/__tests__/init.test.ts +++ b/src/__tests__/init.test.ts @@ -113,9 +113,19 @@ vi.mock('../config.js', async (importOriginal) => ({ resolveProjectDataHome: vi.fn(async (projectRoot: string) => `${projectRoot}/.teamai`), })); +vi.mock('../mcp-reconcile.js', async (importOriginal) => ({ + ...(await importOriginal()), + reconcileMcpForConfig: vi.fn(async () => ({ changes: [], wrote: false })), +})); +vi.mock('../local-agent.js', async (importOriginal) => ({ + ...(await importOriginal()), + releaseClaudeModelConfig: vi.fn(), +})); vi.mock('../hooks.js', () => ({ injectHooksToAllTools: vi.fn(), reconcileTeamHooksForConfig: vi.fn(), + hasTeamaiHooks: vi.fn(async () => true), + reconcileHooks: vi.fn(), })); const mockDeployBuiltinSkills = vi.fn().mockResolvedValue(0); @@ -223,7 +233,7 @@ const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as import { init } from '../init.js'; import { RepoNotFoundError, OrganizationNotFoundError, RepoCreatePermissionError } from '../providers/types.js'; import { CnbRepoNotFoundError } from '../providers/cnb/cnb-cli.js'; -import { saveLocalConfig } from '../config.js'; +import { saveLocalConfig, loadLocalConfigForScope } from '../config.js'; import fse from 'fs-extra'; describe('init', () => { @@ -830,4 +840,206 @@ describe('init', () => { ); }); }); + describe('CLAUDE_CONFIG_DIR', () => { + const relocated = path.join(HOME, '.claude-work'); + let originalConfigDir: string | undefined; + + beforeEach(() => { + originalConfigDir = process.env.CLAUDE_CONFIG_DIR; + // vi.clearAllMocks() keeps implementations, so the re-init case below + // would otherwise hand its saved config to every later test. + vi.mocked(loadLocalConfigForScope).mockResolvedValue(null); + let cloneDone = false; + pathExistsFn = (p: string) => (p === localPath ? cloneDone : false); + mockGfRepoClone.mockImplementation(() => { + cloneDone = true; + }); + questionAnswers = ['n']; + }); + + afterEach(() => { + if (originalConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = originalConfigDir; + }); + + async function savedConfig(): Promise> { + await init({ repo: 'https://git.woa.com/HyperAI/teamai-test.git', scope: 'user' }); + const call = vi.mocked(saveLocalConfig).mock.calls.at(-1); + if (!call) throw new Error('expected the local config to be saved'); + return call[0] as unknown as Record; + } + + it('records a relocated Claude Code root so later runs target it', async () => { + process.env.CLAUDE_CONFIG_DIR = relocated; + + expect(await savedConfig()).toMatchObject({ toolRoots: { claude: relocated } }); + const { log } = await import('../utils/logger.js'); + expect(vi.mocked(log.info).mock.calls.map(([m]) => String(m)).join('\n')) + .toContain(`Recorded CLAUDE_CONFIG_DIR as the Claude Code root: ${relocated}`); + }); + + it('records nothing when the variable is unset', async () => { + delete process.env.CLAUDE_CONFIG_DIR; + expect(await savedConfig()).not.toHaveProperty('toolRoots'); + }); + + it('records an explicit default root, which moves the MCP file into it', async () => { + process.env.CLAUDE_CONFIG_DIR = path.join(HOME, '.claude'); + expect(await savedConfig()).toMatchObject({ toolRoots: { claude: path.join(HOME, '.claude') } }); + }); + + it('keeps the recorded root when a re-init runs without the variable', async () => { + vi.mocked(loadLocalConfigForScope).mockResolvedValue({ + repo: { localPath: localPath, remote: 'https://git.woa.com/HyperAI/teamai-test.git' }, + username: 'testuser', + scope: 'user', + additionalRoles: [], + toolRoots: { claude: relocated }, + } as never); + delete process.env.CLAUDE_CONFIG_DIR; + + expect(await savedConfig()).toMatchObject({ toolRoots: { claude: relocated } }); + }); + + describe('re-init that moves the root', () => { + beforeEach(async () => { + // The previous root is derived from the team's toolPaths, so a team + // config has to exist for the comparison to happen at all. + const { TeamaiConfigSchema } = await import('../types.js'); + const { loadTeamConfig } = await import('../config.js'); + vi.mocked(loadTeamConfig).mockResolvedValue(TeamaiConfigSchema.parse({ team: 't', repo: 'r' })); + }); + + const previousConfig = (toolRoots?: Record) => ({ + repo: { localPath: localPath, remote: 'https://git.woa.com/HyperAI/teamai-test.git' }, + username: 'testuser', + scope: 'user', + additionalRoles: [], + ...(toolRoots ? { toolRoots } : {}), + }) as never; + + async function removedHooksFrom(): Promise { + const { reconcileHooks } = await import('../hooks.js'); + return vi.mocked(reconcileHooks).mock.calls.map(([p]) => String(p)); + } + + function settingsExistsAt(settingsPath: string): void { + const cloneProbe = pathExistsFn; + pathExistsFn = (p: string) => p === settingsPath || cloneProbe(p); + } + + it('removes the hooks left in the previous root, and says what stays', async () => { + vi.mocked(loadLocalConfigForScope).mockResolvedValue(previousConfig({ claude: relocated })); + const oldSettings = path.join(relocated, 'settings.json'); + settingsExistsAt(oldSettings); + const moved = path.join(HOME, '.claude-other'); + process.env.CLAUDE_CONFIG_DIR = moved; + + expect(await savedConfig()).toMatchObject({ toolRoots: { claude: moved } }); + expect(await removedHooksFrom()).toEqual([oldSettings]); + // The MCP servers and the delivered gateway credentials in the old root + // are active config, not inert copies: both are released as well. + const { reconcileMcpForConfig } = await import('../mcp-reconcile.js'); + expect(vi.mocked(reconcileMcpForConfig)).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ toolRoots: { claude: relocated } }), + { removeAll: true }, + ); + const { releaseClaudeModelConfig } = await import('../local-agent.js'); + expect(vi.mocked(releaseClaudeModelConfig)).toHaveBeenCalledWith(relocated); + // Team hooks are only stripped on a manifest-aware pass; a plain + // removeHooks() would leave them firing in the old root. + const { reconcileHooks } = await import('../hooks.js'); + expect(vi.mocked(reconcileHooks).mock.calls[0]?.[3]).toMatchObject({ + removeAll: true, + manifestPath: expect.stringContaining('managed-hooks'), + }); + const { log } = await import('../utils/logger.js'); + const warned = vi.mocked(log.warn).mock.calls.map(([m]) => String(m)).join('\n'); + expect(warned).toContain(`Claude Code now syncs to ${moved}`); + expect(warned).toContain(`under ${relocated} were left in place`); + }); + + it('removes the hooks from the default root when a root is recorded for the first time', async () => { + vi.mocked(loadLocalConfigForScope).mockResolvedValue(previousConfig()); + const oldSettings = path.join(HOME, '.claude', 'settings.json'); + settingsExistsAt(oldSettings); + process.env.CLAUDE_CONFIG_DIR = relocated; + + expect(await savedConfig()).toMatchObject({ toolRoots: { claude: relocated } }); + expect(await removedHooksFrom()).toEqual([oldSettings]); + }); + + it('leaves the previous root alone when the root did not move', async () => { + vi.mocked(loadLocalConfigForScope).mockResolvedValue(previousConfig({ claude: relocated })); + settingsExistsAt(path.join(relocated, 'settings.json')); + process.env.CLAUDE_CONFIG_DIR = relocated; + + expect(await savedConfig()).toMatchObject({ toolRoots: { claude: relocated } }); + expect(await removedHooksFrom()).toEqual([]); + }); + + it('clears the record when the variable is set but blank, and releases the old root', async () => { + vi.mocked(loadLocalConfigForScope).mockResolvedValue(previousConfig({ claude: relocated })); + const oldSettings = path.join(relocated, 'settings.json'); + settingsExistsAt(oldSettings); + process.env.CLAUDE_CONFIG_DIR = ''; + + expect(await savedConfig()).not.toHaveProperty('toolRoots'); + expect(await removedHooksFrom()).toEqual([oldSettings]); + const { log } = await import('../utils/logger.js'); + expect(vi.mocked(log.info).mock.calls.map(([m]) => String(m)).join('\n')) + .toContain('Cleared the recorded Claude Code root'); + }); + + it('lets a project-scope init without the variable inherit the user-scope record', async () => { + vi.mocked(loadLocalConfigForScope).mockImplementation(async (scope) => + scope === 'user' ? previousConfig({ claude: relocated }) : null); + delete process.env.CLAUDE_CONFIG_DIR; + + await init({ repo: 'https://git.woa.com/HyperAI/teamai-test.git', scope: 'project' }); + const { saveLocalConfigForScope } = await import('../config.js'); + expect(saveLocalConfigForScope).toHaveBeenCalledWith( + expect.objectContaining({ scope: 'project', toolRoots: { claude: relocated } }), + 'project', + process.cwd(), + ); + }); + + it('never creates the previous settings file just to clean it', async () => { + vi.mocked(loadLocalConfigForScope).mockResolvedValue(previousConfig({ claude: relocated })); + process.env.CLAUDE_CONFIG_DIR = path.join(HOME, '.claude-other'); + + await savedConfig(); + expect(await removedHooksFrom()).toEqual([]); + }); + }); + + it('refuses a root outside the home directory and says why', async () => { + process.env.CLAUDE_CONFIG_DIR = '/opt/claude-config'; + + expect(await savedConfig()).not.toHaveProperty('toolRoots'); + const { log } = await import('../utils/logger.js'); + expect(vi.mocked(log.warn).mock.calls.map(([m]) => String(m)).join('\n')) + .toContain('outside the home directory'); + }); + + it('refuses a root nested deeper than the installed-tool check can look', async () => { + process.env.CLAUDE_CONFIG_DIR = path.join(HOME, 'configs', 'claude'); + + expect(await savedConfig()).not.toHaveProperty('toolRoots'); + const { log } = await import('../utils/logger.js'); + expect(vi.mocked(log.warn).mock.calls.map(([m]) => String(m)).join('\n')) + .toContain('~/.config/'); + }); + + it('refuses ~/.config itself', async () => { + process.env.CLAUDE_CONFIG_DIR = path.join(HOME, '.config'); + + expect(await savedConfig()).not.toHaveProperty('toolRoots'); + const { log } = await import('../utils/logger.js'); + expect(vi.mocked(log.warn).mock.calls.map(([m]) => String(m)).join('\n')) + .toContain('~/.config itself'); + }); + }); }); diff --git a/src/__tests__/local-agent-mcp.test.ts b/src/__tests__/local-agent-mcp.test.ts index 32cfd3fb7..39a797b65 100644 --- a/src/__tests__/local-agent-mcp.test.ts +++ b/src/__tests__/local-agent-mcp.test.ts @@ -226,6 +226,42 @@ describe('local-agent: MCP install/uninstall commands', () => { ); }); + // A member who relocated Claude Code's root records it in the local config; + // the user-scope MCP file then lives inside that root (~/.claude-work/ + // .claude.json), where a `teamai pull` would also write it. + it('install_mcp follows a relocated Claude Code root', async () => { + const relocated = path.join(tmpDir, '.claude-work'); + await fse.ensureDir(path.join(relocated, 'skills')); + await fse.outputFile(path.join(tmpDir, '.teamai', 'config.yaml'), [ + 'repo:', + ` localPath: ${path.join(tmpDir, '.teamai', 'team-repo')}`, + ' remote: https://git.example.com/team/repo.git', + 'username: tester', + 'scope: user', + 'toolRoots:', + ` claude: ${relocated}`, + '', + ].join('\n')); + + const acks = await runResponse({ + cmds: [{ + id: 9002, + type: 'install_mcp', + scope: 'user', + slug: 'clawpro', + version: '1.0.0', + mcp_config: { transport: 'http', url: 'https://clawpro.example.com/mcp' }, + }], + }, 'claude'); + + expect(acks[0].status).toBe('success'); + const mcpConfig = await fse.readJson(path.join(relocated, '.claude.json')); + expect(mcpConfig.mcpServers.clawpro).toEqual(expect.objectContaining({ type: 'http' })); + // The default location is where a Claude Code with CLAUDE_CONFIG_DIR set + // never looks, so nothing may be written there. + expect(await fse.pathExists(path.join(tmpDir, '.claude.json'))).toBe(false); + }); + // ─── install_mcp: stdio transport ───────────────────────────────── it('install_mcp handles stdio transport correctly', async () => { const acks = await runResponse({ diff --git a/src/__tests__/local-agent-model-config.test.ts b/src/__tests__/local-agent-model-config.test.ts index 42ee67869..cdc65374e 100644 --- a/src/__tests__/local-agent-model-config.test.ts +++ b/src/__tests__/local-agent-model-config.test.ts @@ -606,6 +606,41 @@ describe('local-agent: apply_model_config', () => { expect((await fse.readJson(target))[0].id).toBe('deepseek-v3-0324'); }); + // The root is a per-machine setting recorded by `teamai init`, and a member + // who initialized in project scope has it in the project config — the local + // agent resolves it the way every other command does, project config first. + it('writes the Claude gateway into the root recorded by a project-scope config', async () => { + const workspace = path.join(home, 'workspace'); + const relocated = path.join(home, '.claude-work'); + await fse.ensureDir(relocated); + await fse.outputFile(path.join(workspace, '.teamai', 'config.yaml'), [ + 'repo:', + ` localPath: ${path.join(workspace, '.teamai', 'team-repo')}`, + ' remote: https://git.example.com/team/repo.git', + 'username: tester', + 'scope: project', + `projectRoot: ${workspace}`, + 'toolRoots:', + ` claude: ${relocated}`, + '', + ].join('\n')); + vi.spyOn(process, 'cwd').mockReturnValue(workspace); + const acks = stubSync({ + id: 33, + type: 'apply_model_config', + cmd: JSON.stringify(deliveredModel), + }); + + const { reportAndSyncLocalAgent } = await import('../local-agent.js'); + await reportAndSyncLocalAgent({ tool: 'claude', status: 'running' }); + + expect(acks[0]?.status).toBe('success'); + expect((await fse.readJson(path.join(relocated, 'settings.json'))).env.ANTHROPIC_CUSTOM_MODEL_OPTION) + .toBe('deepseek-v3-0324'); + expect(await fse.pathExists(path.join(relocated, 'teamai-models.json'))).toBe(true); + expect(await fse.pathExists(path.join(home, '.claude', 'settings.json'))).toBe(false); + }); + it('preserves a symlinked Claude settings file', async () => { const target = path.join(home, 'dotfiles', 'claude-settings.json'); const link = path.join(home, '.claude', 'settings.json'); @@ -1012,3 +1047,32 @@ describe('local-agent: report local model inventory', () => { expect(await reportedModels('claude')).toBeUndefined(); }); }); + +describe('releaseClaudeModelConfig', () => { + it('drops the delivered gateway env and profile from the given root and forgets them', async () => { + const { releaseClaudeModelConfig } = await import('../local-agent.js'); + const root = path.join(home, '.claude-old'); + const env = { ANTHROPIC_BASE_URL: 'https://gw.example.com', ANTHROPIC_AUTH_TOKEN: 'secret', KEEP: 'mine' }; + await fse.outputJson(path.join(root, 'settings.json'), { env }); + await fse.outputJson(path.join(root, 'teamai-models.json'), { env }); + const { entryHash } = await import('../resources/mcp-format.js'); + await fse.outputJson(path.join(home, '.teamai/local-agent/model-manifest.json'), { + claudeEnv: { ANTHROPIC_BASE_URL: entryHash(env.ANTHROPIC_BASE_URL), ANTHROPIC_AUTH_TOKEN: entryHash(env.ANTHROPIC_AUTH_TOKEN) }, + }); + + await releaseClaudeModelConfig(root); + + expect((await fse.readJson(path.join(root, 'settings.json'))).env).toEqual({ KEEP: 'mine' }); + expect(await fse.pathExists(path.join(root, 'teamai-models.json'))).toBe(false); + expect((await fse.readJson(path.join(home, '.teamai/local-agent/model-manifest.json'))).claudeEnv).toEqual({}); + }); + + it('touches nothing when no model was ever delivered', async () => { + const { releaseClaudeModelConfig } = await import('../local-agent.js'); + const root = path.join(home, '.claude-old'); + await releaseClaudeModelConfig(root); + expect(await fse.pathExists(root)).toBe(false); + // Nor is an empty manifest written just to record that nothing was there. + expect(await fse.pathExists(path.join(home, '.teamai/local-agent/model-manifest.json'))).toBe(false); + }); +}); diff --git a/src/__tests__/self-mode-agents.test.ts b/src/__tests__/self-mode-agents.test.ts index e37a3399e..16f433f6a 100644 --- a/src/__tests__/self-mode-agents.test.ts +++ b/src/__tests__/self-mode-agents.test.ts @@ -55,6 +55,13 @@ describe('detectHomeInstalledAgents', () => { expect(await detectHomeInstalledAgents()).toEqual([]); }); + it('counts a Claude Code relocated with CLAUDE_CONFIG_DIR, with no ~/.claude at all', async () => { + const relocated = path.join(home, '.claude-work'); + await fse.ensureDir(relocated); + vi.stubEnv('CLAUDE_CONFIG_DIR', relocated); + expect(await detectHomeInstalledAgents(['claude', 'codex'])).toEqual(['claude']); + }); + it('returns only the tools whose root dir exists, in candidate order', async () => { await fse.ensureDir(path.join(home, '.codex')); await fse.ensureDir(path.join(home, '.claude')); diff --git a/src/__tests__/tool-roots.test.ts b/src/__tests__/tool-roots.test.ts new file mode 100644 index 000000000..4dd2f7493 --- /dev/null +++ b/src/__tests__/tool-roots.test.ts @@ -0,0 +1,425 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import os from 'node:os'; +import path from 'node:path'; +import fse from 'fs-extra'; +import YAML from 'yaml'; + +import { + LocalConfigSchema, + TeamaiConfigSchema, + applyToolRoots, + detectClaudeConfigRoot, + resolveHookScope, + resolveToolRootDir, + scopedToolPaths, + toolInstallRoot, + type LocalConfig, + type TeamaiConfig, +} from '../types.js'; +import { log } from '../utils/logger.js'; + +const teamConfig: TeamaiConfig = TeamaiConfigSchema.parse({ team: 't', repo: 'r' }); + +function localConfig(overrides: Partial = {}): LocalConfig { + return { + repo: { localPath: '/team-repo', remote: 'git@example.com:t/r.git' }, + username: 'u', + scope: 'user', + additionalRoles: [], + ...overrides, + }; +} + +/** `toolPaths` as every hook injector resolves them: at the hook scope, not the config's. */ +function hookScopedPaths(config: LocalConfig): ReturnType { + return scopedToolPaths(teamConfig, { ...config, scope: resolveHookScope(config).scope }); +} + +describe('toolRoots — re-rooting a relocated tool', () => { + let home: string; + let originalHome: string | undefined; + + beforeEach(async () => { + originalHome = process.env.HOME; + home = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-tool-roots-')); + process.env.HOME = home; + }); + + afterEach(async () => { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + await fse.remove(home); + vi.restoreAllMocks(); + }); + + it('moves every path of the listed tool and nothing else', () => { + const paths = scopedToolPaths(teamConfig, localConfig({ + toolRoots: { claude: path.join(home, '.claude-work') }, + })); + + expect(paths.claude).toEqual({ + skills: '.claude-work/skills', + rules: '.claude-work/rules', + settings: '.claude-work/settings.json', + claudemd: '.claude-work/CLAUDE.md', + agents: '.claude-work/agents', + // The user data dir moves as a whole, so the MCP file travels inside it. + mcp: '.claude-work/.claude.json', + // Project scope is anchored on the project root, not on the member's root. + mcpProject: '.mcp.json', + }); + expect(paths.codex).toEqual(teamConfig.toolPaths.codex); + expect(paths.tclaude).toEqual(teamConfig.toolPaths.tclaude); + expect(paths.copilot).toEqual(scopedToolPaths(teamConfig, localConfig()).copilot); + }); + + it('expands a leading ~/ in the configured root', () => { + const paths = scopedToolPaths(teamConfig, localConfig({ toolRoots: { claude: '~/.claude-work' } })); + expect(paths.claude.settings).toBe('.claude-work/settings.json'); + }); + + it('accepts a ~/.config/ root, which the installed-tool gate can express', () => { + const paths = scopedToolPaths(teamConfig, localConfig({ + toolRoots: { claude: path.join(home, '.config', 'claude-work') }, + })); + expect(paths.claude.skills).toBe('.config/claude-work/skills'); + expect(toolInstallRoot('.config/claude-work/settings.json')).toBe('.config/claude-work'); + }); + + it('refuses a root nested deeper than the gate can look for', () => { + const warn = vi.spyOn(log, 'warn').mockImplementation(() => {}); + const nested = path.join(home, 'configs', 'claude'); + + const paths = scopedToolPaths(teamConfig, localConfig({ toolRoots: { claude: nested } })); + + expect(paths.claude).toEqual(teamConfig.toolPaths.claude); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain('toolRoots.claude'); + expect(warn.mock.calls[0][0]).toContain(nested); + expect(warn.mock.calls[0][0]).toContain('~/.config/'); + }); + + it('moves the MCP companion file inside a root equal to the default one', () => { + // CLAUDE_CONFIG_DIR=~/.claude is not the same as leaving it unset: Claude + // Code then reads ~/.claude/.claude.json instead of ~/.claude.json. + const paths = scopedToolPaths(teamConfig, localConfig({ + toolRoots: { claude: path.join(home, '.claude') }, + })); + expect(paths.claude).toEqual({ + ...teamConfig.toolPaths.claude, + mcp: '.claude/.claude.json', + }); + }); + + it('moves a bare file name the team declared beside the root inside the new one', () => { + // `.claude.json` is the usual case, but the promise is every user-scope + // Claude path, so a customized `settings: settings.json` follows too. + const bare = TeamaiConfigSchema.parse({ + team: 't', + repo: 'r', + toolPaths: { claude: { skills: '.claude/skills', settings: 'settings.json', mcp: '.claude.json', mcpProject: '.mcp.json' } }, + }); + const paths = scopedToolPaths(bare, localConfig({ + toolRoots: { claude: path.join(home, '.claude-work') }, + })); + expect(paths.claude.settings).toBe('.claude-work/settings.json'); + expect(paths.claude.mcp).toBe('.claude-work/.claude.json'); + }); + + it('adds no key for a field the team did not declare', () => { + const sparse = TeamaiConfigSchema.parse({ + team: 't', + repo: 'r', + toolPaths: { claude: { skills: '.claude/skills', mcpProject: '.mcp.json' } }, + }); + const paths = scopedToolPaths(sparse, localConfig({ + toolRoots: { claude: path.join(home, '.claude-work') }, + })); + expect(Object.keys(paths.claude).sort()).toEqual(['mcpProject', 'skills']); + }); + + it('moves fields the team spread over several roots, every one of them', () => { + // A customized `toolPaths.claude` need not keep every field under one + // directory. The feature promises that every user-scope Claude path moves, + // so each field's own root counts, not just the first populated one. + const spread = TeamaiConfigSchema.parse({ + team: 't', + repo: 'r', + toolPaths: { + claude: { + skills: '.claude/skills', + rules: '.claude/rules', + settings: '.claude-settings/settings.json', + agents: '.claude-agents/agents', + mcp: '.claude.json', + mcpProject: '.mcp.json', + }, + }, + }); + + const paths = scopedToolPaths(spread, localConfig({ + toolRoots: { claude: path.join(home, '.claude-work') }, + })); + + expect(paths.claude).toEqual({ + skills: '.claude-work/skills', + rules: '.claude-work/rules', + settings: '.claude-work/settings.json', + agents: '.claude-work/agents', + mcp: '.claude-work/.claude.json', + mcpProject: '.mcp.json', + }); + }); + + it('moves a user-scope MCP file that sits beside a customized root inside the new one', () => { + // CLAUDE_CONFIG_DIR makes Claude Code read .claude.json from inside the + // directory whatever the resource root was called, so the companion file + // follows even when its name does not echo that root. + const custom = TeamaiConfigSchema.parse({ + team: 't', + repo: 'r', + toolPaths: { + claude: { + skills: '.claude-custom/skills', + settings: '.claude-custom/settings.json', + mcp: '.claude.json', + mcpProject: '.mcp.json', + }, + }, + }); + + const paths = scopedToolPaths(custom, localConfig({ + toolRoots: { claude: path.join(home, '.claude-work') }, + })); + + expect(paths.claude.mcp).toBe('.claude-work/.claude.json'); + expect(paths.claude.settings).toBe('.claude-work/settings.json'); + }); + + it('takes a user-scope layout at a second root along with the move', () => { + // `toolPaths` is team-declared, so a team can give a tool a `userScope` + // block that hangs off a different root (the way OpenCode's does). The move + // has to take both roots, or half the resources stay where nothing reads + // them. Declared for claude, the one id a member may relocate. + const twoRooted = TeamaiConfigSchema.parse({ + team: 't', + repo: 'r', + toolPaths: { + claude: { + skills: '.claude/skills', + rules: '.claude/rules', + agents: '.claude/agents', + mcp: '.config/claude/claude.json', + mcpProject: '.mcp.json', + userScope: { skills: '.config/claude/skills', rules: '.config/claude/rules' }, + }, + }, + }); + + const paths = scopedToolPaths(twoRooted, localConfig({ + toolRoots: { claude: path.join(home, '.claude-work') }, + })); + + expect(paths.claude).toEqual({ + skills: '.claude-work/skills', + rules: '.claude-work/rules', + agents: '.claude-work/agents', + mcp: '.claude-work/claude.json', + // Project scope is anchored on the project root. + mcpProject: '.mcp.json', + userScope: { skills: '.claude-work/skills', rules: '.claude-work/rules' }, + }); + // Rebuilt without the fields the tool does not declare. + expect(Object.keys(paths.claude.userScope ?? {})).toEqual(['skills', 'rules']); + }); + + // Every tool except claude still writes somewhere teamai does not resolve + // through toolPaths — Codex and Cursor co-author files, OMP's extension dir, + // $COPILOT_HOME, OpenCode's plugin dir — so a root would move half a layout. + it.each(['codex', 'omp', 'cursor', 'copilot', 'opencode'])( + 'refuses to relocate %s, which teamai does not address through toolPaths alone', + (tool) => { + const warn = vi.spyOn(log, 'warn').mockImplementation(() => {}); + const withRoot = localConfig({ toolRoots: { [tool]: path.join(home, `.${tool}-work`) } }); + + const paths = scopedToolPaths(teamConfig, withRoot); + + expect(paths[tool]).toEqual(scopedToolPaths(teamConfig, localConfig())[tool]); + expect(resolveToolRootDir(tool, `.${tool}`, withRoot.toolRoots)) + .toBe(path.join(home, `.${tool}`)); + // Once per tool, however many times the paths are resolved during a pull. + scopedToolPaths(teamConfig, withRoot); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain(`toolRoots.${tool}`); + expect(warn.mock.calls[0][0]).toContain('supports claude only'); + if (tool === 'copilot') expect(warn.mock.calls[0][0]).toContain('COPILOT_HOME'); + }, + ); + + it('refuses ~/.config itself, which the installed-tool gate would read as a file', () => { + const warn = vi.spyOn(log, 'warn').mockImplementation(() => {}); + const paths = scopedToolPaths(teamConfig, localConfig({ + toolRoots: { claude: path.join(home, '.config') }, + })); + + expect(paths.claude).toEqual(teamConfig.toolPaths.claude); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain('~/.config itself'); + }); + + it('refuses a root outside the home directory and says so', () => { + const warn = vi.spyOn(log, 'warn').mockImplementation(() => {}); + const outside = path.join(os.tmpdir(), `teamai-outside-${Date.now()}`); + + const paths = scopedToolPaths(teamConfig, localConfig({ toolRoots: { claude: outside } })); + + expect(paths.claude).toEqual(teamConfig.toolPaths.claude); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain('toolRoots.claude'); + expect(warn.mock.calls[0][0]).toContain(outside); + }); + + it('ignores a tool the team declares no paths for', () => { + const paths = scopedToolPaths(teamConfig, localConfig({ + toolRoots: { 'not-a-tool': path.join(home, '.somewhere') }, + })); + expect(paths).toEqual(scopedToolPaths(teamConfig, localConfig())); + }); + + it('leaves a config without toolRoots exactly as it was', () => { + expect(applyToolRoots(teamConfig.toolPaths, undefined)).toBe(teamConfig.toolPaths); + expect(applyToolRoots(teamConfig.toolPaths, {})).toBe(teamConfig.toolPaths); + expect(scopedToolPaths(teamConfig, localConfig())).toEqual( + scopedToolPaths(teamConfig, localConfig({ toolRoots: {} })), + ); + }); + + it('keeps project-scope resource paths on the project root, while hooks follow the member root', () => { + const project = localConfig({ + scope: 'project', + projectRoot: '/work/app', + toolRoots: { claude: path.join(home, '.claude-work') }, + }); + + // Resources land under /.claude — a HOME-relative member root + // means nothing there. + expect(scopedToolPaths(teamConfig, project).claude.skills).toBe('.claude/skills'); + // Hooks for a non-self project scope are injected into HOME (resolveHookScope), + // and every injector resolves its paths at that scope. + expect(hookScopedPaths(project).claude.settings).toBe('.claude-work/settings.json'); + }); + + it('keeps self single-repo hooks on the business repo', () => { + const self = localConfig({ + scope: 'project', + projectRoot: '/work/app', + repo: { localPath: '/work/app/.teamai', remote: 'r', kind: 'self', businessRepoRoot: '/work/app' }, + toolRoots: { claude: path.join(home, '.claude-work') }, + }); + expect(hookScopedPaths(self).claude.settings).toBe('.claude/settings.json'); + }); + + it('resolves a tool root directory for writers that address it directly', () => { + expect(resolveToolRootDir('claude', '.claude', undefined)).toBe(path.join(home, '.claude')); + expect(resolveToolRootDir('claude', '.claude', { claude: '~/.claude-work' })) + .toBe(path.join(home, '.claude-work')); + }); +}); + +describe('toolRoots — local config schema', () => { + it('parses a config that predates the field', () => { + const parsed = LocalConfigSchema.parse({ + repo: { localPath: '/x', remote: '' }, + username: 'u', + }); + expect(parsed.toolRoots).toBeUndefined(); + }); + + it('round-trips through save and load', async () => { + const home = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-tool-roots-io-')); + const originalHome = process.env.HOME; + process.env.HOME = home; + try { + const { saveLocalConfig, loadLocalConfig } = await import('../config.js'); + const saved = localConfig({ toolRoots: { claude: path.join(home, '.claude-work') } }); + await fse.ensureDir(path.join(home, '.teamai')); + await saveLocalConfig(saved); + + const raw = YAML.parse(await fse.readFile(path.join(home, '.teamai', 'config.yaml'), 'utf8')); + expect(raw.toolRoots).toEqual({ claude: path.join(home, '.claude-work') }); + const loaded = await loadLocalConfig(); + expect(loaded?.toolRoots).toEqual({ claude: path.join(home, '.claude-work') }); + } finally { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + await fse.remove(home); + } + }); +}); + +describe('detectClaudeConfigRoot', () => { + let home: string; + let originalHome: string | undefined; + + beforeEach(async () => { + originalHome = process.env.HOME; + home = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-claude-env-')); + process.env.HOME = home; + }); + + afterEach(async () => { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + await fse.remove(home); + }); + + it('is null only when the variable is unset or blank', () => { + expect(detectClaudeConfigRoot({} as NodeJS.ProcessEnv)).toBeNull(); + expect(detectClaudeConfigRoot({ CLAUDE_CONFIG_DIR: ' ' } as NodeJS.ProcessEnv)).toBeNull(); + }); + + it('answers with the default root when the variable names it explicitly', () => { + // Setting the variable changes where Claude Code reads .claude.json, even + // when its value is the directory it would have used anyway. + expect(detectClaudeConfigRoot({ CLAUDE_CONFIG_DIR: '~/.claude' } as NodeJS.ProcessEnv)) + .toBe(path.join(home, '.claude')); + }); + + it('resolves a relocated root to an absolute path', () => { + expect(detectClaudeConfigRoot({ CLAUDE_CONFIG_DIR: '~/.claude-work' } as NodeJS.ProcessEnv)) + .toBe(path.join(home, '.claude-work')); + expect(detectClaudeConfigRoot( + { CLAUDE_CONFIG_DIR: `${path.join(home, '.claude-work')}/` } as NodeJS.ProcessEnv, + )).toBe(path.join(home, '.claude-work')); + }); +}); + +describe('hook injection with a relocated root', () => { + let home: string; + let originalHome: string | undefined; + + beforeEach(async () => { + originalHome = process.env.HOME; + home = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-tool-roots-hooks-')); + process.env.HOME = home; + // Only the relocated root exists: the injector writes to installed tools only. + await fse.ensureDir(path.join(home, '.claude-work')); + }); + + afterEach(async () => { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + await fse.remove(home); + }); + + it('writes settings into the member root and never creates the default one', async () => { + const { injectHooksToAllTools } = await import('../hooks.js'); + const config = localConfig({ toolRoots: { claude: path.join(home, '.claude-work') } }); + + await injectHooksToAllTools(hookScopedPaths(config), home, ['claude']); + + const settings = await fse.readJson(path.join(home, '.claude-work', 'settings.json')); + expect(JSON.stringify(settings)).toContain('teamai hook-dispatch'); + expect(await fse.pathExists(path.join(home, '.claude'))).toBe(false); + }); +}); diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index 5cc135b46..34d160e53 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -765,6 +765,49 @@ describe('uninstall', () => { expect(await fse.pathExists(path.join(projectPlugin, 'my-own-plugin.ts'))).toBe(true); }); + // A relocated Claude Code root (toolRoots) moves the HOME hook file, but the + // legacy copy was written by a CLI that knew nothing about it — + // so the two targets must be looked for at different paths. + it('removes hooks from a relocated home root and from the legacy project copy', async () => { + const projectRoot = path.join(tmpDir, 'relocated-project'); + const homeDir = path.join(tmpDir, 'home'); + const repoPath = path.join(projectRoot, '.teamai', 'team-repo'); + await fse.ensureDir(repoPath); + + const teamaiHooks = { + hooks: { + SessionStart: [{ + matcher: '*', + hooks: [{ type: 'command', command: 'teamai hook-dispatch session-start' }], + description: '[teamai] Auto-pull', + }], + }, + }; + const homeSettings = path.join(homeDir, '.claude-work', 'settings.json'); + const legacySettings = path.join(projectRoot, '.claude', 'settings.json'); + await fse.outputJson(homeSettings, teamaiHooks); + await fse.outputJson(legacySettings, teamaiHooks); + + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/zsh'); + + const teamConfig = makeTeamConfig(); + const localConfig = makeLocalConfig(projectRoot, repoPath, { + scope: 'project', + projectRoot, + toolRoots: { claude: path.join(homeDir, '.claude-work') }, + }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + + await uninstall({ force: true }); + + const cleaned = mockReconcileHooks.mock.calls.map((call) => call[0]); + expect(cleaned).toContain(homeSettings); + expect(cleaned).toContain(legacySettings); + // The un-relocated home path is not a target and must not be created. + expect(await fse.pathExists(path.join(homeDir, '.claude'))).toBe(false); + }); + it('保留用户自建的 skills', async () => { const { homeDir, repoPath } = await setupFixture(tmpDir); vi.stubEnv('HOME', homeDir); diff --git a/src/doctor.ts b/src/doctor.ts index ea1426410..28180925f 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -4,7 +4,12 @@ import { pathExists, readFileSafe } from './utils/fs.js'; import { log, setStderrOnly } from './utils/logger.js'; import type { GlobalOptions } from './types.js'; import { + CLAUDE_TOOL_ID, COPILOT_TOOL_ID, + DEFAULT_CLAUDE_ROOT, + detectClaudeConfigRoot, + resolveToolRootDir, + toolRootRejection, resolveHookScope, resolveToolBaseDir, isAgentExcluded, @@ -165,6 +170,50 @@ async function buildEnabledToolChecks(ctx: DoctorContext): Promise { return checks; } +/** + * Check that a relocated Claude Code root is the one teamai writes to. + * + * `CLAUDE_CONFIG_DIR` moves everything Claude Code reads — settings, skills, + * rules, CLAUDE.md — and teamai learns about it only when `init` records it in + * `toolRoots.claude`. Without the check, a member who sets the variable after + * initializing (or changes it) keeps getting a green report while every synced + * resource lands in a directory their Claude never opens. + * + * Skipped only when the variable is unset — then there is nothing to relocate + * and a member who never used it should not be told about a setting they do not + * have. A value equal to the default root is not that case: it still moves + * `.claude.json` inside the directory, so it has to be recorded like any other. + */ +function buildClaudeRootCheck(localConfig: LocalConfig, toolPaths: TeamaiConfig['toolPaths']): Check[] { + // Nothing to compare for a config that never writes to Claude Code. + if (!(CLAUDE_TOOL_ID in toolPaths)) return []; + const detected = detectClaudeConfigRoot(); + if (!detected) return []; + // The effective root, not the recorded string: `~/.claude-work` written by + // hand is the same directory as the expanded one, while a root the sync + // refuses (outside HOME, or nested too deep) resolves back to the default — + // so the check fails exactly when the sync would write somewhere else. + const recorded = localConfig.toolRoots?.[CLAUDE_TOOL_ID]; + const effective = resolveToolRootDir(CLAUDE_TOOL_ID, DEFAULT_CLAUDE_ROOT, localConfig.toolRoots); + // A value init refuses cannot be fixed by re-running init: say why instead. + const rejection = toolRootRejection(detected); + return [{ + name: 'Claude Code root matches CLAUDE_CONFIG_DIR', + source: 'local', + // Recording matters even when the directories agree: an unrecorded root + // leaves the MCP config at ~/.claude.json, while a Claude Code told to use + // that directory reads .claude.json from inside it. + check: async () => recorded !== undefined && effective === detected, + fix: rejection + ? `CLAUDE_CONFIG_DIR is ${detected}, which teamai cannot sync to (${rejection}); ` + + `this config syncs Claude Code to ${effective}. Point CLAUDE_CONFIG_DIR at a directory ` + + 'in your home (or ~/.config/) and re-run `teamai init`.' + : `CLAUDE_CONFIG_DIR is ${detected}; this config syncs Claude Code to ${effective}` + + `${recorded === undefined ? ' (no root recorded)' : ''}. ` + + 'Re-run `teamai init` to record it.', + }]; +} + /** * Build hook checks for tools whose settings parent directory already exists * (i.e. the tool is installed). Tools that are not installed are skipped. @@ -394,6 +443,7 @@ export async function buildChecks(ctx: DoctorContext, stage: CheckStage = 'docto fix: 'Run `teamai pull` to publish them. If they stay queued, check that you ' + 'can push to the team repo (run with --verbose to see the push error).', }, + ...buildClaudeRootCheck(localConfig, toolPaths), ...await buildEnabledToolChecks(ctx), ...await buildHookChecks(toolPaths, hookToolPaths, baseDir, localConfig), ...await buildDeliveryChecks(ctx), diff --git a/src/hooks.ts b/src/hooks.ts index f3e41e271..dceccf9fd 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -15,6 +15,7 @@ import { resolveLegacyProjectHookScope, resolveToolBaseDir, scopedToolPaths, + toolInstallRoot, } from './types.js'; import type { HookDef, TeamaiConfig, LocalConfig, Scope } from './types.js'; import { isSelfMode } from './types.js'; @@ -1429,7 +1430,7 @@ export async function injectHooksToAllTools(toolPaths: Record { for (const [tool, paths] of Object.entries(toolPaths)) { if (!isCodexTrustGatedTool(tool) || !paths.settings) continue; - const toolRoot = path.join(baseDir, paths.settings.split('/')[0]); + const toolRoot = path.join(baseDir, toolInstallRoot(paths.settings)); if (await pathExists(toolRoot)) return true; } return false; diff --git a/src/init.ts b/src/init.ts index befa082ae..faf1fd08b 100644 --- a/src/init.ts +++ b/src/init.ts @@ -2,7 +2,7 @@ import YAML from 'yaml'; import fs from 'node:fs'; import path from 'node:path'; import { saveLocalConfig, loadTeamConfig, saveLocalConfigForScope, loadLocalConfigForScope, loadStateForScope, saveStateForScope, resolveProjectDataHome } from './config.js'; -import { reconcileTeamHooksForConfig } from './hooks.js'; +import { hasTeamaiHooks, reconcileHooks, reconcileTeamHooksForConfig } from './hooks.js'; import { configureGitUser, initRepo, isGitRepo, getRemoteUrl, remotesMatch, redactGitCredentials, pullRepoFastForward } from './utils/git.js'; import { pushRepoDirectly } from './utils/git.js'; import { getProvider, detectProviderForInit, RepoNotFoundError, OrganizationNotFoundError, RepoCreatePermissionError } from './providers/index.js'; @@ -10,6 +10,12 @@ import { parseGenericGitExistingRemote } from './providers/git/repo-url.js'; import { ensureDir, writeFile, pathExists, expandHome, readFileSafe, remove } from './utils/fs.js'; import { log, spinner } from './utils/logger.js'; import { + CLAUDE_TOOL_ID, + detectClaudeConfigRoot, + resolveHookScope, + scopedToolPaths, + toolRootRejection, + type TeamaiConfig, getTeamaiHomeDir, getUserConfigPath, REPORTS_BRANCH, @@ -32,6 +38,78 @@ import { KNOWN_AGENTS, } from './known-agents.js'; +/** + * Record a relocated Claude Code configuration root into the config being + * written, so every later run targets the directory that Claude Code reads. + * + * `init` is the only command that reads `CLAUDE_CONFIG_DIR`. The variable lives + * in one shell profile, while teamai also runs from session hooks and from + * other terminals; resolving it on each run would make the sync target depend + * on who started the process. Recorded once, it is the member's own setting + * like `enabledAgents` — and `teamai doctor` reports it when the two drift. + */ +function recordClaudeConfigRoot(localConfig: LocalConfig): void { + // Unset is "not this shell's business"; set-but-blank is the explicit way to + // say the relocation is over, since nothing else can tell the two apart. + if (process.env.CLAUDE_CONFIG_DIR === '' && localConfig.toolRoots?.[CLAUDE_TOOL_ID]) { + delete localConfig.toolRoots[CLAUDE_TOOL_ID]; + if (Object.keys(localConfig.toolRoots).length === 0) delete localConfig.toolRoots; + log.info('Cleared the recorded Claude Code root (CLAUDE_CONFIG_DIR is blank); Claude Code syncs to the default root again'); + return; + } + const root = detectClaudeConfigRoot(); + if (!root) return; + const rejection = toolRootRejection(root); + if (rejection) { + log.warn(`CLAUDE_CONFIG_DIR (${root}) was not recorded: ${rejection}.`); + return; + } + localConfig.toolRoots = { ...localConfig.toolRoots, [CLAUDE_TOOL_ID]: root }; + log.info(`Recorded CLAUDE_CONFIG_DIR as the Claude Code root: ${root}`); +} + +/** + * A re-init that moves the Claude root leaves the previous root's active + * config live: hooks keep firing in the Claude that still reads it and sync + * into the new root — one install split across two directories — and the + * managed MCP servers and the gateway credentials the local agent delivered + * stay in files nothing should read any more. Strip all three before the new + * root is saved. Skills, rules and CLAUDE.md blocks teamai wrote there are + * inert copies, so they are reported, not touched. Nothing happens when the + * root did not move, and no file is created just to be cleaned. + */ +async function releasePreviousClaudeRoot( + teamConfig: TeamaiConfig | null, + previous: LocalConfig | null, + next: LocalConfig, +): Promise { + if (!previous || !teamConfig) return; + const hookScope = resolveHookScope(next); + const settingsOf = (config: LocalConfig): string | undefined => + scopedToolPaths(teamConfig, { ...config, scope: hookScope.scope })[CLAUDE_TOOL_ID]?.settings; + const before = settingsOf(previous); + if (!before || before === settingsOf(next)) return; + const oldSettings = path.join(hookScope.baseDir, before); + if (await pathExists(oldSettings) && await hasTeamaiHooks(oldSettings, CLAUDE_TOOL_ID, hookScope.manifestPath)) { + // With the manifest, so team hooks go too — removeHooks() alone keeps them. + await reconcileHooks(oldSettings, CLAUDE_TOOL_ID, [], { removeAll: true, manifestPath: hookScope.manifestPath }); + } + if (next.scope === 'user') { + // The user-scope MCP file and the gateway env are addressed through the + // previous config, so they resolve to the old root (or ~/.claude.json). + const { reconcileMcpForConfig } = await import('./mcp-reconcile.js'); + const { changes } = await reconcileMcpForConfig(teamConfig, previous, { removeAll: true }); + const removed = changes.filter((c) => c.action === 'removed').length; + if (removed > 0) log.info(`Removed ${removed} teamai-managed MCP server(s) from the previous Claude Code root`); + const { releaseClaudeModelConfig } = await import('./local-agent.js'); + await releaseClaudeModelConfig(path.dirname(oldSettings)); + } + log.warn( + `Claude Code now syncs to ${next.toolRoots?.[CLAUDE_TOOL_ID] ?? 'the default root'}; skills, rules and CLAUDE.md ` + + `that teamai wrote under ${path.dirname(oldSettings)} were left in place.`, + ); +} + /** Resolve + realpath so macOS /var → /private/var (and similar) compare equal. */ function resolveRealPath(p: string): string { const resolved = path.resolve(p); @@ -472,6 +550,19 @@ export async function initHttp( localConfig.disabledAgents = (existing?.disabledAgents ?? []).filter((t) => !requestedAgents.includes(t)); } + // Carry the member's recorded tool roots across a re-init. `init` is + // re-runnable and CLAUDE_CONFIG_DIR lives in one shell profile, so a re-init + // from a shell that does not export it must not quietly send every later sync + // back to the default root. recordClaudeConfigRoot then overwrites the claude + // entry when the variable IS set. + // A project-scope config with no record of its own starts from the user-scope + // one: the root is a fact about this machine, and project hooks land in HOME. + const carriedToolRoots = existingLocalConfig?.toolRoots + ?? (scope === 'project' ? (await loadLocalConfigForScope('user'))?.toolRoots : undefined); + if (carriedToolRoots) localConfig.toolRoots = { ...carriedToolRoots }; + recordClaudeConfigRoot(localConfig); + await releasePreviousClaudeRoot(teamConfig, existingLocalConfig, localConfig); + await ensureDir(teamaiHome); if (scope === 'project') { await saveLocalConfigForScope(localConfig, scope, projectRoot); @@ -911,14 +1002,22 @@ export async function initSelfRepo(options: GlobalOptions & { // commit their settings.json). Resolved from --agent, else HOME detection // (non-interactive), else an interactive picker. Written to enabledAgents, // which drives seedSelfModeToolDirs and hook injection alike. + const existingSelfConfig = await loadLocalConfigForScope('project', businessRepoRoot); const selectedAgents = await promptForSelfModeAgents(options); if (selectedAgents.length > 0) { - const existing = await loadLocalConfigForScope('project', businessRepoRoot); - const prev = existing?.enabledAgents ?? []; + const prev = existingSelfConfig?.enabledAgents ?? []; localConfig.enabledAgents = [...new Set([...prev, ...selectedAgents])]; - localConfig.disabledAgents = (existing?.disabledAgents ?? []).filter((t) => !selectedAgents.includes(t)); + localConfig.disabledAgents = (existingSelfConfig?.disabledAgents ?? []).filter((t) => !selectedAgents.includes(t)); } + // Carry the member's recorded tool roots across a re-init. `init` is + // re-runnable and CLAUDE_CONFIG_DIR lives in one shell profile, so a re-init + // from a shell that does not export it must not quietly send every later sync + // back to the default root. recordClaudeConfigRoot then overwrites the claude + // entry when the variable IS set. + if (existingSelfConfig?.toolRoots) localConfig.toolRoots = { ...existingSelfConfig.toolRoots }; + recordClaudeConfigRoot(localConfig); + // Step 5: write local config (into the partition via dataHome) + single-repo // gitignore. ensureDir both the knowledge dir (class B, in the repo) and the // partition (class A1 machine data). saveLocalConfigForScope writes through @@ -1591,6 +1690,19 @@ export async function init(options: GlobalOptions & { localConfig.disabledAgents = (existing?.disabledAgents ?? []).filter((t) => !requestedAgents.includes(t)); } + // Carry the member's recorded tool roots across a re-init. `init` is + // re-runnable and CLAUDE_CONFIG_DIR lives in one shell profile, so a re-init + // from a shell that does not export it must not quietly send every later sync + // back to the default root. recordClaudeConfigRoot then overwrites the claude + // entry when the variable IS set. + // A project-scope config with no record of its own starts from the user-scope + // one: the root is a fact about this machine, and project hooks land in HOME. + const carriedToolRoots = existingLocalConfig?.toolRoots + ?? (scope === 'project' ? (await loadLocalConfigForScope('user'))?.toolRoots : undefined); + if (carriedToolRoots) localConfig.toolRoots = { ...carriedToolRoots }; + recordClaudeConfigRoot(localConfig); + await releasePreviousClaudeRoot(currentConfig, existingLocalConfig, localConfig); + await ensureDir(teamaiHome); if (scope === 'project') { diff --git a/src/known-agents.ts b/src/known-agents.ts index 6de0b8344..c378cac68 100644 --- a/src/known-agents.ts +++ b/src/known-agents.ts @@ -7,6 +7,8 @@ import { resolveToolBaseDir, isAgentDisabled, scopedToolPaths, + CLAUDE_TOOL_ID, + detectClaudeConfigRoot, } from './types.js'; import { isToolInstalledForConfig } from './resources/base.js'; import type { LocalConfig, TeamaiConfig, Scope } from './types.js'; @@ -209,7 +211,11 @@ export async function detectHomeInstalledAgents( if (!skillsPath) continue; const rootSegment = skillsPath.split('/')[0]; // e.g. ".claude" if (!rootSegment) continue; - if (await pathExists(path.join(home, rootSegment))) { + // A Claude Code relocated with CLAUDE_CONFIG_DIR may have no ~/.claude at + // all; the developer still uses it. This runs before any config exists, so + // the variable is the only signal. + const relocated = id === CLAUDE_TOOL_ID ? detectClaudeConfigRoot() : null; + if (await pathExists(path.join(home, rootSegment)) || (relocated !== null && await pathExists(relocated))) { found.push(id); } } diff --git a/src/local-agent.ts b/src/local-agent.ts index 450c84d60..4b20ed112 100644 --- a/src/local-agent.ts +++ b/src/local-agent.ts @@ -55,6 +55,10 @@ import { resolveBaseDir, resolveToolBaseDir, scopedToolPaths, + applyToolRoots, + resolveToolRootDir, + CLAUDE_TOOL_ID, + DEFAULT_CLAUDE_ROOT, COPILOT_TOOL_ID, getTokenPath, TEAMAI_CLAUDEMD_START, @@ -459,9 +463,34 @@ async function saveAgentHookManifest(manifest: AgentHookManifest): Promise await writeJsonAtomic(getAgentHookManifestPath(), manifest); } +/** + * The member's per-machine tool roots, from the teamai config that governs this + * directory: the project one when there is one, else the user-scope one. + * + * The local agent carries no LocalConfig — it addresses tool roots under $HOME + * directly — but it writes the same files `teamai pull` does, so a root the + * member relocated (CLAUDE_CONFIG_DIR, recorded by `teamai init`) has to reach + * them too. No config, or no entry, leaves the paths exactly as they were. + */ +async function memberToolRoots(workspacePath?: string): Promise | undefined> { + const { detectProjectConfig, loadLocalConfig } = await import('./config.js'); + // Same resolution order every teamai command uses: the project config that + // governs this directory, then the user-scope one. `init --http --scope + // project` records the root in the project partition, which a user-scope-only + // read would never see. + const project = await detectProjectConfig(workspacePath ?? process.cwd()); + return project?.toolRoots ?? (await loadLocalConfig())?.toolRoots; +} + +/** Claude Code's user root on this machine, honoring a relocated CLAUDE_CONFIG_DIR. */ +async function claudeUserRoot(): Promise { + return resolveToolRootDir(CLAUDE_TOOL_ID, DEFAULT_CLAUDE_ROOT, await memberToolRoots()); +} + /** Resolve the current tool's settings file absolute path (user scope, $HOME base). */ -function resolveToolSettingsPath(config: LocalAgentConfig, tool: string): string { - const toolPath = createLocalAgentTeamConfig(config.endpoint).toolPaths[tool]; +async function resolveToolSettingsPath(config: LocalAgentConfig, tool: string): Promise { + const teamConfig = createLocalAgentTeamConfig(config.endpoint); + const toolPath = applyToolRoots(teamConfig.toolPaths, await memberToolRoots())[tool]; if (!toolPath?.settings) { throw new Error(`unsupported tool: ${tool} (no settings path)`); } @@ -655,12 +684,12 @@ function createLocalAgentTeamConfig(endpoint: string): TeamaiConfig { }); } -function createResourceLocalConfig( +async function createResourceLocalConfig( config: LocalAgentConfig, scope: LocalAgentScope, repoPath: string, workspacePath?: string, -): LocalConfig { +): Promise { const projectScope = scope === 'project'; return { repo: { localPath: repoPath, remote: config.endpoint }, @@ -668,6 +697,9 @@ function createResourceLocalConfig( scope: projectScope ? 'project' : 'user', projectRoot: projectScope ? workspacePath : undefined, additionalRoles: [], + // User-scope paths resolve under $HOME here, so a tool the member relocated + // must be addressed at its recorded root — the same one `teamai pull` uses. + ...(projectScope ? {} : { toolRoots: await memberToolRoots(workspacePath) }), }; } @@ -1434,7 +1466,7 @@ async function scanModelsFromDisk(tool: string, workspacePath?: string): Promise if (agentKind === 'claude' && !workspacePath) { const providers = manifest.providersByAgent?.claude ?? manifest.providers ?? {}; const settings = await readJson<{ env?: unknown }>( - path.join(getUserHome(), '.claude', 'settings.json'), + path.join(await claudeUserRoot(), 'settings.json'), ); const env = settings?.env; if (typeof env !== 'object' || env === null || Array.isArray(env)) return []; @@ -1556,7 +1588,7 @@ export async function buildReportPayload( // teamai never writes to — and silently report nothing. const scanScope = async (workspacePath?: string): Promise<{ skills: ReportedResource[]; rules: ReportedResource[] }> => { const scope: LocalAgentScope = workspacePath ? 'project' : 'user'; - const localConfig = createResourceLocalConfig(config, scope, workspacePath ?? getUserHome(), workspacePath); + const localConfig = await createResourceLocalConfig(config, scope, workspacePath ?? getUserHome(), workspacePath); const toolPath = scopedToolPaths(teamConfig, localConfig)[tool]; if (!toolPath) return { skills: [], rules: [] }; const baseDir = resolveToolBaseDir(tool, localConfig); @@ -1907,7 +1939,7 @@ async function installDownloadedResource(input: { throw new Error(`Unknown tool "${tool}": no toolPaths entry found`); } const teamConfig = { ...fullTeamConfig, toolPaths: { [tool]: toolPath } }; - const localConfig = createResourceLocalConfig(input.config, input.scope, repoPath, input.workspacePath); + const localConfig = await createResourceLocalConfig(input.config, input.scope, repoPath, input.workspacePath); // Ensure the tool root directory exists before dispatch so isToolInstalled // gate does not skip the resource when the workspace is freshly bound. // Restricted to project scope: user-scope installs use $HOME as baseDir and @@ -2004,7 +2036,7 @@ async function uninstallResource(input: { throw new Error(`Unknown tool "${tool}": no toolPaths entry found`); } const teamConfig = { ...fullTeamConfig, toolPaths: { [tool]: toolPath } }; - const localConfig = createResourceLocalConfig(input.config, input.scope, repoPath, input.workspacePath); + const localConfig = await createResourceLocalConfig(input.config, input.scope, repoPath, input.workspacePath); const manifest = await loadManifest(); const scopeManifest = getManifestScope(manifest, input.scope, input.workspacePath); @@ -2368,12 +2400,28 @@ function claudeEnvForModel(model: DeliveredModel): Record { }; } +/** + * Drop the gateway env and model profile the agent delivered into `claudeRoot`, + * and forget them in the manifest. For `teamai init` moving the Claude root: + * the credentials would otherwise stay in a profile nothing syncs any more. + * No-op when the agent never delivered a model. + */ +export async function releaseClaudeModelConfig(claudeRoot: string): Promise { + const manifest = (await readJson(getModelManifestPath())) ?? {}; + if (Object.keys(manifest.claudeEnv ?? {}).length === 0) return; + await reconcileClaudeModels([], manifest, claudeRoot); + await writeJsonAtomic(getModelManifestPath(), manifest); + log.info(`Removed the delivered Claude model config from ${claudeRoot}`); +} + async function reconcileClaudeModels( models: DeliveredModel[], manifest: ModelConfigManifest, + claudeRoot?: string, ): Promise { - const settingsPath = path.join(getUserHome(), '.claude', 'settings.json'); - const profilePath = path.join(getUserHome(), '.claude', 'teamai-models.json'); + claudeRoot ??= await claudeUserRoot(); + const settingsPath = path.join(claudeRoot, 'settings.json'); + const profilePath = path.join(claudeRoot, 'teamai-models.json'); const previousHashes = manifest.claudeEnv ?? {}; const settings = await readJsonObject(settingsPath); const rawEnv = settings.env === undefined ? {} : settings.env; @@ -2694,7 +2742,7 @@ async function runHookRuleCommand( const { removePiAgentHook } = await import('./pi-hooks.js'); await removePiAgentHook(slug); } else { - const settingsPath = resolveToolSettingsPath(config, rec.tool); + const settingsPath = await resolveToolSettingsPath(config, rec.tool); await removeAgentHook(settingsPath, rec.tool, { slug, command: rec.command }); } delete manifest[slug]; @@ -2737,7 +2785,7 @@ async function runHookRuleCommand( const { removePiAgentHook } = await import('./pi-hooks.js'); await removePiAgentHook(slug); } else { - const priorPath = resolveToolSettingsPath(config, prior.tool); + const priorPath = await resolveToolSettingsPath(config, prior.tool); await removeAgentHook(priorPath, prior.tool, { slug, command: prior.command }); } } catch (e) { @@ -2759,7 +2807,7 @@ async function runHookRuleCommand( const { applyPiAgentHook } = await import('./pi-hooks.js'); await applyPiAgentHook({ slug, event, command: cmd, matcher, timeout }); } else { - const settingsPath = resolveToolSettingsPath(config, tool); + const settingsPath = await resolveToolSettingsPath(config, tool); await applyAgentHook(settingsPath, tool, { slug, event, command: cmd, matcher, timeout }); } manifest[slug] = { tool, event, command: cmd, matcher, timeout }; @@ -2818,7 +2866,11 @@ async function installMcpServer( const def = mcpConfigToDef(slug, command.mcp_config); const fullTeamConfig = createLocalAgentTeamConfig(config.endpoint); - const toolPath = fullTeamConfig.toolPaths[tool]; + // Resolved through the scope seam, so the user-scope MCP file follows a root + // the member relocated (`toolRoots`) the way `teamai pull` writes it. Project + // scope returns `mcpProject` unchanged — it belongs to the workspace. + const localConfig = await createResourceLocalConfig(config, scope, getUserHome(), workspacePath); + const toolPath = scopedToolPaths(fullTeamConfig, localConfig)[tool]; if (!toolPath) { throw new Error(`install_mcp: unknown tool "${tool}"`); } @@ -2837,7 +2889,6 @@ async function installMcpServer( throw new Error(`install_mcp: tool "${tool}" does not support ${def.transport} transport`); } - const localConfig = createResourceLocalConfig(config, scope, getUserHome(), workspacePath); const baseDir = resolveToolBaseDir(tool, localConfig); const targetFile = path.join(baseDir, mcpRel); @@ -2900,7 +2951,9 @@ async function uninstallMcpServer( workspacePath?: string, ): Promise { const fullTeamConfig = createLocalAgentTeamConfig(config.endpoint); - const toolPath = fullTeamConfig.toolPaths[tool]; + // Removal has to look where the install wrote: same scope seam, same root. + const localConfig = await createResourceLocalConfig(config, scope, getUserHome(), workspacePath); + const toolPath = scopedToolPaths(fullTeamConfig, localConfig)[tool]; if (!toolPath) return; const projectScope = scope === 'project'; @@ -2910,7 +2963,6 @@ async function uninstallMcpServer( const format = detectMcpFormat(tool); if (!format) return; - const localConfig = createResourceLocalConfig(config, scope, getUserHome(), workspacePath); const baseDir = resolveToolBaseDir(tool, localConfig); const targetFile = path.join(baseDir, mcpRel); @@ -3277,7 +3329,11 @@ export async function initLocalAgentHttp(options: { const teamConfig = createLocalAgentTeamConfig(endpoint); // The local agent is always user-scope and always rooted at HOME, so resolve // the user-scope paths (Qoder CN's user config lives under ~/.qoder-cn). - await injectHooksToAllTools(scopedToolPaths(teamConfig, { scope: 'user' }), getUserHome(), options.filterAgents); + await injectHooksToAllTools( + scopedToolPaths(teamConfig, { scope: 'user', toolRoots: await memberToolRoots() }), + getUserHome(), + options.filterAgents, + ); log.success(`HTTP local agent initialized at ${getConfigPath()}`); } @@ -3376,7 +3432,7 @@ export async function removeAllAgentHooks(): Promise { const { removePiAgentHook } = await import('./pi-hooks.js'); await removePiAgentHook(slug); } else { - const settingsPath = resolveToolSettingsPath(config, rec.tool); + const settingsPath = await resolveToolSettingsPath(config, rec.tool); await removeAgentHook(settingsPath, rec.tool, { slug, command: rec.command }); } } catch (e) { diff --git a/src/project-agent-root.ts b/src/project-agent-root.ts index fbd32a4fd..fc9f2ee76 100644 --- a/src/project-agent-root.ts +++ b/src/project-agent-root.ts @@ -2,8 +2,7 @@ import path from 'node:path'; import { detectProjectConfig, loadTeamConfig } from './config.js'; import { KNOWN_AGENTS } from './known-agents.js'; -import { toolInstallRoot } from './resources/base.js'; -import { isAgentDisabled, resolveBaseDir, scopedToolPaths } from './types.js'; +import { isAgentDisabled, resolveBaseDir, scopedToolPaths, toolInstallRoot } from './types.js'; import { ensureDir } from './utils/fs.js'; import { log } from './utils/logger.js'; diff --git a/src/resources/base.ts b/src/resources/base.ts index dec681aed..4a6ed04ff 100644 --- a/src/resources/base.ts +++ b/src/resources/base.ts @@ -1,30 +1,11 @@ import path from 'node:path'; -import { COPILOT_TOOL_ID, getCopilotHome, resolveToolBaseDir } from '../types.js'; +import { COPILOT_TOOL_ID, getCopilotHome, resolveToolBaseDir, toolInstallRoot } from '../types.js'; import type { ResourceType, ResourceItem, ResourceDiff, DeliveryTarget, TeamaiConfig, LocalConfig } from '../types.js'; import { readFileSafe, writeFile, ensureDir, pathExists } from '../utils/fs.js'; import { getUserHome } from '../utils/home.js'; const TOMBSTONE_FILE = '.removed'; -/** - * The directory whose existence marks a tool as "installed" for a given - * resource path. The tool root is normally the first path segment - * (`.claude/skills` → `.claude`, `.openclaw/workspace/AGENTS.md` → `.openclaw`). - * - * The one exception is OpenCode's user scope, whose paths live under - * `.config/opencode/...`: there the first segment (`.config`) is a directory - * nearly every user has, so it would wrongly report OpenCode as installed. - * For a `.config//...` path the root is the first two segments - * (`.config/opencode`) instead. - */ -export function toolInstallRoot(toolPath: string): string { - const segments = toolPath.split('/'); - if (segments[0] === '.config' && segments.length > 1) { - return `${segments[0]}/${segments[1]}`; - } - return segments[0] ?? toolPath; -} - /** Detect an installed tool while respecting tool-specific user roots. */ export async function isToolInstalledForConfig( tool: string, diff --git a/src/types.ts b/src/types.ts index cce8db937..9d0ac30f3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import path from 'node:path'; import { createHash } from 'node:crypto'; import { getUserHome, expandHome } from './utils/home.js'; +import { log } from './utils/logger.js'; const DEFAULT_COPILOT_HOME = '.copilot'; const COPILOT_USER_MCP_CONFIG = 'mcp-config.json'; @@ -569,6 +570,15 @@ export const LocalConfigSchema = z.object({ coAuthorEnabled: z.boolean().optional(), /** When set, only inject hooks into these agents. Additive across multiple init --agent runs. */ enabledAgents: z.array(z.string()).optional(), + /** + * Per-machine relocation of a tool's user-scope root, keyed by the same tool + * id as `toolPaths` (`claude: ~/.claude-work`). A tool that can be told to + * keep its configuration elsewhere — Claude Code's `CLAUDE_CONFIG_DIR` — + * reads nothing teamai writes to the team-wide default, and `teamai init` + * records that variable here so every later run targets the right root. + * The value must resolve inside HOME; `~/` is expanded. + */ + toolRoots: z.record(z.string(), z.string()).optional(), /** Tools explicitly excluded from all teamai sync (set by `uninstall --agent`). Removed again by `init --agent`. */ disabledAgents: z.array(z.string()).optional(), /** @@ -1718,6 +1728,87 @@ export function getCopilotHome(env: NodeJS.ProcessEnv = process.env): string { return configured ? path.resolve(configured) : path.join(getUserHome(), DEFAULT_COPILOT_HOME); } +export const CLAUDE_TOOL_ID = 'claude'; + +/** The `toolPaths.claude` root segment Claude Code uses when it is not relocated. */ +export const DEFAULT_CLAUDE_ROOT = '.claude'; + +/** True when `dir` resolves to something inside the user's home directory. */ +function isUnderUserHome(dir: string): boolean { + const rel = path.relative(getUserHome(), path.resolve(dir)); + return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel); +} + +/** + * Root shapes the installed-tool gate can express: a single directory in HOME + * (`.claude-work`), or `.config/` — the two forms `toolInstallRoot` + * recognises. Anything deeper (`configs/claude`) would leave every gate keying + * on the first segment alone, so an unrelated `~/configs` would report the tool + * as installed and teamai would write into a directory that does not exist. + */ +function isAddressableRootSegment(segment: string): boolean { + const segments = segment.split('/'); + // `.config` alone is not one of them: `toolInstallRoot('.config/settings.json')` + // reads it as the two-segment OpenCode-style root, so the gate would look for + // the settings FILE as the tool's directory and never find it. + if (segments.length === 1) return segments[0] !== '.config'; + return segments.length === 2 && segments[0] === '.config'; +} + +/** + * Tools a member may relocate. An allowlist, not a list of known offenders: a + * root is only honest for a tool whose every user-scope write goes through + * `toolPaths`, and most tools keep at least one path teamai resolves elsewhere + * (OMP's extension dir, Codex and Cursor co-author files, Copilot's + * `$COPILOT_HOME`, OpenCode's plugin dir), which a partial move would split in + * half. Claude Code qualifies today — hooks, skills, rules, agents, CLAUDE.md, + * MCP, model sync and co-author all resolve through `toolPaths`, and the one + * remaining fixed `.claude` path is `legacyHooksNeedReinject`, a read-only + * probe for a pre-dispatch migration. `toolRoots` itself stays a generic record, + * so a tool joins this set as soon as its writes have been audited. + */ +const TOOL_ROOTS_SUPPORTED: ReadonlySet = new Set([CLAUDE_TOOL_ID]); + +/** + * Why `dir` cannot serve as a tool root, as a sentence fragment for a warning — + * or null when it can. One place decides, so `teamai init` refuses to record + * exactly the roots `applyToolRoots` would refuse to apply. + */ +export function toolRootRejection(dir: string): string | null { + const resolved = path.resolve(expandHome(dir)); + if (!isUnderUserHome(resolved)) { + return `it is outside the home directory ${getUserHome()}, and every tool path is resolved relative to it`; + } + const segment = path.relative(getUserHome(), resolved).split(path.sep).join('/'); + if (!isAddressableRootSegment(segment)) { + return 'a tool root has to be a directory in the home directory other than ' + + '~/.config itself (~/.claude-work), or a ~/.config/ directory, ' + + 'because that is what the "is this tool installed?" check can look for'; + } + return null; +} + +/** + * The Claude Code configuration root `CLAUDE_CONFIG_DIR` asks for, or null when + * the variable is unset or blank. + * + * A value equal to the default `~/.claude` is still an answer, not an absence: + * Claude Code reads `.claude.json` from INSIDE the configured directory + * whenever the variable is set, so `~/.claude/.claude.json` rather than + * `~/.claude.json` — a different file from the one an unset variable means. + * + * Read in exactly two commands: `teamai init` records the answer into + * `toolRoots.claude`, and `teamai doctor` reports a recorded value that no + * longer matches. Everything else reads the recorded value, so a teamai run + * from a shell that happens not to export the variable (a hook, a cron, a + * different terminal) still writes where that Claude Code reads. + */ +export function detectClaudeConfigRoot(env: NodeJS.ProcessEnv = process.env): string | null { + const configured = env.CLAUDE_CONFIG_DIR?.trim(); + if (!configured) return null; + return path.resolve(expandHome(configured)); +} + /** Base directory for one tool's resources in the active scope. */ export function resolveToolBaseDir(tool: string, localConfig: LocalConfig): string { if (tool === COPILOT_TOOL_ID && localConfig.scope === 'user') return getCopilotHome(); @@ -1751,6 +1842,153 @@ export function isAgentExcluded( return localConfig.enabledAgents ? !localConfig.enabledAgents.includes(tool) : false; } +/** + * The directory whose existence marks a tool as "installed" for a given + * resource path. The tool root is normally the first path segment + * (`.claude/skills` → `.claude`, `.openclaw/workspace/AGENTS.md` → `.openclaw`). + * + * The one exception is OpenCode's user scope, whose paths live under + * `.config/opencode/...`: there the first segment (`.config`) is a directory + * nearly every user has, so it would wrongly report OpenCode as installed. + * For a `.config//...` path the root is the first two segments + * (`.config/opencode`) instead. + */ +export function toolInstallRoot(toolPath: string): string { + const segments = toolPath.split('/'); + if (segments[0] === '.config' && segments.length > 1) { + return `${segments[0]}/${segments[1]}`; + } + return segments[0] ?? toolPath; +} + +/** Path fields of ToolPathsSchema that live under the tool's own user root. */ +const TOOL_ROOT_FIELDS = ['skills', 'rules', 'settings', 'hooks', 'claudemd', 'agents', 'mcp'] as const; + +/** `userScope` path fields, which carry the same resources at a user-scope root. */ +const USER_SCOPE_ROOT_FIELDS = ['skills', 'rules', 'agents', 'hooks', 'claudemd'] as const; + +/** Warned roots, so one bad entry does not repeat on every scoped lookup of a pull. */ +const warnedToolRoots = new Set(); + +/** + * A configured root as a HOME-relative segment, or null when it cannot be used. + * + * An unusable entry is warned about and dropped rather than thrown on: the rest + * of the sync is still correct, and failing a whole pull over one member's typo + * would be worse than telling them about it. + */ +function toolRootSegment(tool: string, configured: string): string | null { + if (!TOOL_ROOTS_SUPPORTED.has(tool)) { + if (!warnedToolRoots.has(tool)) { + warnedToolRoots.add(tool); + log.warn( + `Ignoring toolRoots.${tool}: toolRoots currently supports ${CLAUDE_TOOL_ID} only — ` + + `${tool} has writes teamai does not resolve through toolPaths.` + + (tool === COPILOT_TOOL_ID ? ' Copilot CLI is relocated with COPILOT_HOME instead.' : ''), + ); + } + return null; + } + const resolved = path.resolve(expandHome(configured)); + const rejection = toolRootRejection(resolved); + if (rejection) { + const key = `${tool}:${resolved}`; + if (!warnedToolRoots.has(key)) { + warnedToolRoots.add(key); + log.warn(`Ignoring toolRoots.${tool} (${resolved}): ${rejection}.`); + } + return null; + } + return path.relative(getUserHome(), resolved).split(path.sep).join('/'); +} + +/** + * Move one tool's paths to `newRoot`. `oldRoots` holds every root the tool's + * paths hang off, user-scope ones included (OpenCode keeps its user resources + * under `.config/opencode`, and its user MCP file with them). A field is moved + * when it hangs off one of those roots, so relocating the tool takes its whole + * layout along. + */ +function relocateToolPaths( + paths: z.infer, + oldRoots: ReadonlySet, + newRoot: string, +): z.infer { + // A bare file name (`.claude.json` beside `.claude`) has no root to match: + // it travels INSIDE the new one. Claude Code reads .claude.json from within + // CLAUDE_CONFIG_DIR whenever that variable is set, which is also how it lays + // itself out under tclaude's customUserDataDir (`.tclaude/.claude.json`), and + // the same holds for any other file the team declares beside the root. This + // is why a root EQUAL to the default still changes something and is worth + // recording. + const moved = (value: string | undefined): string | undefined => { + if (value === undefined) return value; + if (!value.includes('/')) return `${newRoot}/${value}`; + const root = toolInstallRoot(value); + return oldRoots.has(root) ? newRoot + value.slice(root.length) : value; + }; + + // `mcpProject` is absent on purpose: it is only ever read in project scope, + // where paths resolve against the project root and a member's HOME-relative + // root says nothing. + const out: z.infer = { ...paths }; + for (const field of TOOL_ROOT_FIELDS) { + if (paths[field] !== undefined) out[field] = moved(paths[field]); + } + if (paths.userScope) { + // Rebuilt field by field, copying only what was there: a consumer that asks + // which user-scope paths a tool declares reads the keys, and an explicit + // `undefined` would answer "it declares one" for a path that does not exist. + const userScope: NonNullable['userScope']> = {}; + for (const field of USER_SCOPE_ROOT_FIELDS) { + const value = paths.userScope[field]; + if (value !== undefined) userScope[field] = moved(value); + } + out.userScope = userScope; + } + return out; +} + +/** + * Apply a member's `toolRoots` to a `toolPaths` map: for each listed tool, every + * path under that tool's declared root is re-rooted at the configured one. + * + * The team's `toolPaths` cannot answer this: it is shared by everyone, while a + * relocated root (Claude Code's `CLAUDE_CONFIG_DIR`) is a property of one + * machine. Tools the member did not list, and paths outside the tool's own root, + * are returned untouched. + */ +export function applyToolRoots( + toolPaths: Record>, + toolRoots?: Record, +): Record> { + if (!toolRoots || Object.keys(toolRoots).length === 0) return toolPaths; + let out: Record> | undefined; + for (const [tool, configured] of Object.entries(toolRoots)) { + const paths = toolPaths[tool]; + if (!paths) continue; + const newRoot = toolRootSegment(tool, configured); + if (!newRoot) continue; + // Every root the tool's paths hang off, not just the first one: a team that + // customized `toolPaths.claude` field by field may have spread them over + // several. A bare file name (`.claude.json`) is not under a root. + const oldRoots = new Set(); + for (const field of TOOL_ROOT_FIELDS) { + const value = paths[field]; + if (value?.includes('/')) oldRoots.add(toolInstallRoot(value)); + } + for (const field of USER_SCOPE_ROOT_FIELDS) { + const value = paths.userScope?.[field]; + if (value?.includes('/')) oldRoots.add(toolInstallRoot(value)); + } + // Not skipped when the root is unchanged: a bare file name (the MCP + // companion) still moves inside it (see relocateToolPaths). + out ??= { ...toolPaths }; + out[tool] = relocateToolPaths(paths, oldRoots, newRoot); + } + return out ?? toolPaths; +} + /** * Return `teamConfig.toolPaths` with per-scope path overrides applied. * @@ -1766,14 +2004,19 @@ export function isAgentExcluded( * * MCP is untouched here: its two scopes are already distinct fields * (`mcp` / `mcpProject`), resolved separately in the reconcile engine. + * + * User scope also applies the member's `toolRoots` (applyToolRoots). Project + * scope must not: there the paths hang off the project root, which a + * HOME-relative member root has nothing to say about. */ export function scopedToolPaths( teamConfig: TeamaiConfig, - localConfig: { scope?: Scope }, + localConfig: { scope?: Scope; toolRoots?: Record }, ): Record> { if (localConfig.scope !== 'user') return teamConfig.toolPaths; + const rooted = applyToolRoots(teamConfig.toolPaths, localConfig.toolRoots); const out: Record> = {}; - for (const [tool, paths] of Object.entries(teamConfig.toolPaths)) { + for (const [tool, paths] of Object.entries(rooted)) { const us = paths.userScope; if (!us) { out[tool] = paths; @@ -2034,6 +2277,21 @@ export function resolveHookScope( }; } +/** + * Absolute user-scope root directory of a tool (`.claude` → `~/.claude`), + * honoring a member's `toolRoots`. For the few writers that address a tool's + * root directly instead of through a `toolPaths` entry. + */ +export function resolveToolRootDir( + tool: string, + defaultRoot: string, + toolRoots?: Record, +): string { + const configured = toolRoots?.[tool]; + const segment = configured ? toolRootSegment(tool, configured) : null; + return path.join(getUserHome(), segment ?? defaultRoot); +} + /** * The legacy `` hook location a pre-#370 CLI wrote to for a * non-self project scope, whose hooks now live in HOME (`resolveHookScope`). diff --git a/src/uninstall.ts b/src/uninstall.ts index c0c06cd8a..785f4943d 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -366,9 +366,13 @@ async function discoverToolResources( // project scope, per #370) — plus any legacy copy. Scan every // target and tag each match with the manifest that recorded its team hooks, // so removal strips the right entries at each location. The file name comes - // from the same scope decision (`hookSettingsPath`), not from `toolPath`. - const settingsRel = hookSettingsPath ?? toolPath.settings; + // from the same scope decision (`hookSettingsPath`), not from `toolPath` — + // except for the legacy copy, written into by a CLI that knew + // nothing about a member's relocated root, so it sits at the team path. for (const { baseDir: hookBaseDir, manifestPath } of hookTargets) { + const settingsRel = path.resolve(hookBaseDir) === path.resolve(getUserHome()) + ? (hookSettingsPath ?? toolPath.settings) + : toolPath.settings; const settingsPath = path.join(hookBaseDir, settingsRel); if (await pathExists(settingsPath) && (await hasTeamaiHooks(settingsPath, tool, manifestPath)