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)