Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/usage-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>` 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.
Expand Down
14 changes: 14 additions & 0 deletions docs/usage-guide.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)均为可选。
Expand Down
6 changes: 6 additions & 0 deletions skill-data/core/references/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>`), 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
Expand Down
8 changes: 8 additions & 0 deletions skill-data/setup/references/join-member.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,14 @@ teamai init --http https://your-team-host/api --token <api-key>
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
Expand Down
86 changes: 85 additions & 1 deletion src/__tests__/doctor.test.ts
Original file line number Diff line number Diff line change
@@ -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 ────────────────────────────────────────────────
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<string, string>) {
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');
});
});
Loading
Loading