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
2 changes: 2 additions & 0 deletions docs/usage-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -1960,6 +1960,8 @@ A tool that can be told to keep its configuration somewhere else — Claude Code

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.

`import --from-claude` and skill-use tracking read the recorded root as well, so a relocated Claude Code's rules are importable and its skills count as installed.

`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.
Expand Down
2 changes: 2 additions & 0 deletions docs/usage-guide.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -1892,6 +1892,8 @@ toolRoots: # 可选,每机器的工具根目录(见下

根目录必须是 teamai 能够识别该工具的位置:home 目录下的一层目录(`~/.claude-work`,但 `~/.config` 本身除外),或者一个 `~/.config/<名称>` 目录(开头的 `~/` 会被展开)。这两种形态正是「该工具是否已安装」这项检查能够查找的范围;更深的层级、或 home 目录之外的路径都会被拒绝并给出警告,而不是只生效一半。

`import --from-claude` 和 skill 使用统计同样读取记录的根目录:迁移后的 Claude Code 的 rules 可以导入,其 skills 也算作已安装。

`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 无法同步到的目录,则说明原因。未设置该变量时,这项检查不会出现在报告里。
Expand Down
2 changes: 1 addition & 1 deletion skill-data/core/references/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ Generated: do not edit by hand. Regenerate with

- `teamai import` — Import knowledge from local directories, remote repos, organizations, MRs, or iWiki
- `--dir <path>` — Extract code knowledge from a local directory (same as --from-repo but no clone)
- `--from-claude` (hidden) — Scan Claude/Cursor rule directories (~/.claude/rules, ~/.cursor/rules)
- `--from-claude` (hidden) — Scan Claude/Cursor rule directories (the Claude root's rules/ — ~/.claude or the recorded toolRoots.claude — and ~/.cursor/rules)
- `--from-mr <url>` — Extract learning from merged MR/PR and trigger incremental teamwiki update
- `--from-iwiki <space-id-or-url>` — Import documents from iWiki Space ID or page URL (requires TAI_PAT_TOKEN)
- `--resume` (hidden) — Resume an interrupted import session
Expand Down
44 changes: 44 additions & 0 deletions src/__tests__/import-local-relocated-root.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
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';

vi.mock('../utils/logger.js', () => ({
log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));

describe('import --from-claude with a relocated Claude Code root', () => {
let home: string;
let originalHome: string | undefined;

beforeEach(async () => {
originalHome = process.env.HOME;
home = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-import-root-'));
process.env.HOME = home;
});

afterEach(async () => {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
await fse.remove(home);
});

it('scans the rules under the recorded toolRoots.claude, not ~/.claude', async () => {
await fse.outputFile(path.join(home, '.teamai', 'config.yaml'), YAML.stringify({
repo: { localPath: path.join(home, '.teamai', 'team-repo'), remote: 'https://example.test/t/r.git' },
username: 'u',
scope: 'user',
additionalRoles: [],
toolRoots: { claude: path.join(home, '.claude-work') },
}));
await fse.outputFile(path.join(home, '.claude-work', 'rules', 'relocated.md'), '# relocated rule');
await fse.outputFile(path.join(home, '.claude', 'rules', 'stale.md'), '# stale rule');

const { scanCandidates } = await import('../import-local.js');
const found = (await scanCandidates({ fromClaude: true })).map((c) => c.path);

expect(found).toContain(path.join(home, '.claude-work', 'rules', 'relocated.md'));
expect(found).not.toContain(path.join(home, '.claude', 'rules', 'stale.md'));
});
});
4 changes: 3 additions & 1 deletion src/__tests__/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -993,7 +993,9 @@ describe('init', () => {
// 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(),
// Claude's file only: the reconciler walks every tool of the config
// it is handed, and the other tools' servers did not move.
expect.objectContaining({ toolPaths: { claude: expect.anything() } }),
expect.objectContaining({ toolRoots: { claude: relocated } }),
{ removeAll: true },
);
Expand Down
24 changes: 24 additions & 0 deletions src/__tests__/mcp-reconcile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,30 @@ servers:
});
});

it('removeAll on a config narrowed to one tool leaves the other tools\' servers and manifest rows alone', async () => {
// `teamai init` releases only Claude's MCP file when the Claude root moves;
// it hands the reconciler a team config whose toolPaths hold Claude alone.
await writeMcpYaml(`
servers:
- name: team-server
transport: http
url: https://example.com/mcp
`);
await reconcileMcpForConfig(teamConfig, localConfig);
const cursorFile = path.join(homeDir, TOOL_PATHS.cursor.mcp!);
expect((await fse.readJson(cursorFile)).mcpServers['team-server']).toBeDefined();

const claudeOnly = { ...teamConfig, toolPaths: { claude: TOOL_PATHS.claude } } as TeamaiConfig;
const { changes } = await reconcileMcpForConfig(claudeOnly, localConfig, { removeAll: true });

expect(changes.map((c) => `${c.tool}:${c.action}`)).toEqual(['claude:removed']);
expect((await fse.readJson(path.join(homeDir, '.claude.json'))).mcpServers?.['team-server']).toBeUndefined();
expect((await fse.readJson(cursorFile)).mcpServers['team-server']).toBeDefined();
const manifest = await fse.readJson(path.join(homeDir, '.teamai', 'managed-mcp.json'));
expect(Object.keys(manifest).some((k) => k.startsWith('cursor'))).toBe(true);
expect(Object.keys(manifest).some((k) => k.startsWith('claude'))).toBe(false);
});

it('is idempotent — a second run does not rewrite the file', async () => {
await writeMcpYaml(`
servers:
Expand Down
77 changes: 77 additions & 0 deletions src/__tests__/usage-tracking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,15 @@ describe('isValidSkillName', () => {
});
});

describe('skillExistsOnDisk — relocated Claude Code root', () => {
it('finds a skill installed only under the root the governing config records', async () => {
const relocated = path.join(tmpDir, '.claude-work');
await fse.outputFile(path.join(relocated, 'skills', 'relocated-only', 'SKILL.md'), '# s');
await expect(skillExistsOnDisk('relocated-only', { claude: relocated })).resolves.toBe(true);
await expect(skillExistsOnDisk('relocated-only')).resolves.toBe(false);
});
});

describe('skillExistsOnDisk — Copilot', () => {
it('finds user skills under a custom COPILOT_HOME', async () => {
const copilotHome = path.join(tmpDir, 'copilot-home');
Expand Down Expand Up @@ -988,6 +997,74 @@ describe('trackSlashCommand', () => {
expect(events[0].tool).toBe('claude');
});

it('tracks a skill installed only under the root recorded by the hook directory\'s config', async () => {
// The hook reports its cwd; the project config there records a relocated
// Claude root, and the skill lives only under that root.
const YAML = (await import('yaml')).default;
const workspace = path.join(tmpDir, 'workspace');
const relocated = path.join(tmpDir, '.claude-work');
await fse.outputFile(path.join(workspace, '.teamai', 'config.yaml'), YAML.stringify({
repo: { localPath: path.join(workspace, '.teamai', 'team-repo'), remote: 'https://example.test/acme/team.git' },
username: 'tester',
scope: 'project',
projectRoot: workspace,
toolRoots: { claude: relocated },
}));
await fse.outputFile(path.join(relocated, 'skills', 'relocated-only', 'SKILL.md'), '# s');
const hookData = JSON.stringify({
prompt: '/relocated-only go',
cwd: workspace,
session_id: 'sess-reloc',
hook_event_name: 'UserPromptSubmit',
});
const restore = mockStdin(hookData);
try {
await trackSlashCommand();
} finally {
restore();
}

const events = await readUsageEvents({ ...userScope(), scope: 'project', projectRoot: workspace, dataHome: path.join(workspace, '.teamai') } as LocalConfig);
expect(events.map((e) => e.skill)).toEqual(['relocated-only']);
});

it('follows the user-scope record when the hook directory\'s project config has none', async () => {
const YAML = (await import('yaml')).default;
const workspace = path.join(tmpDir, 'workspace');
const relocated = path.join(tmpDir, '.claude-work');
await fse.outputFile(path.join(tmpDir, '.teamai', 'config.yaml'), YAML.stringify({
...userScope(),
toolRoots: { claude: relocated },
}));
await fse.outputFile(path.join(workspace, '.teamai', 'config.yaml'), YAML.stringify({
repo: { localPath: path.join(workspace, '.teamai', 'team-repo'), remote: 'https://example.test/acme/team.git' },
username: 'tester',
scope: 'project',
projectRoot: workspace,
}));
await fse.outputFile(path.join(relocated, 'skills', 'user-rooted', 'SKILL.md'), '# s');
const restore = mockStdin(JSON.stringify({ prompt: '/user-rooted', cwd: workspace, session_id: 's', hook_event_name: 'UserPromptSubmit' }));
try {
await trackSlashCommand();
} finally {
restore();
}

const events = await readUsageEvents({ ...userScope(), scope: 'project', projectRoot: workspace, dataHome: path.join(workspace, '.teamai') } as LocalConfig);
expect(events.map((e) => e.skill)).toEqual(['user-rooted']);
});

it('still records when the hook reports a directory that no longer exists', async () => {
await createFakeSkill('gone-worktree');
const restore = mockStdin(JSON.stringify({ prompt: '/gone-worktree', cwd: path.join(tmpDir, 'deleted-worktree'), session_id: 's', hook_event_name: 'UserPromptSubmit' }));
try {
await trackSlashCommand();
} finally {
restore();
}
expect((await readUsageEvents(userScope())).map((e) => e.skill)).toEqual(['gone-worktree']);
});

it('tracks slash command with colon-namespaced skill', async () => {
await createFakeSkill('gstack:tdd');
const hookData = JSON.stringify({
Expand Down
16 changes: 16 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,22 @@ export async function resolveConfigForDir(dir?: string): Promise<LocalConfig | n
return unreadable ? null : loadLocalConfig();
}

/**
* The member's per-machine tool roots as seen from `dir`: the project config
* governing it when it records one, else the user-scope record. A project
* config without a record follows user scope on purpose — the same rule
* `init` applies when it fills a project config in — because the root is a
* fact about the machine, and a user-scope `init` may have recorded it after
* the project was set up. Readers that hold no resolved config (the local
* agent, import, usage tracking) go through here.
*/
export async function resolveMemberToolRoots(dir?: string): Promise<Record<string, string> | undefined> {
// A hook can report a directory that no longer exists (a deleted worktree);
// git probing there throws, so it means user scope — as resolveConfigForDir.
const project = dir === undefined || await pathExists(dir) ? await detectProjectConfig(dir) : null;
return project?.toolRoots ?? (await loadLocalConfig())?.toolRoots;
}

/**
* Told about a project-scope config file that exists but cannot be used, which
* detection otherwise skips: `null` means "no project config here" to every
Expand Down
8 changes: 6 additions & 2 deletions src/import-local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ async function persistSession(session: ImportSession, sessionPath: string): Prom
*
* 支持两种模式:
* - dir 模式:扫描指定目录下的 .md/.txt 文件(跳过隐藏文件和 >50KB 文件)
* - fromClaude 模式:扫描 ~/.claude/rules/ 和 ~/.cursor/rules/ 下的 .md 文件
* - fromClaude mode: scans the .md files under the Claude root's rules/ (default ~/.claude, following toolRoots) and ~/.cursor/rules/
*
* rawContent 只取前 3000 字符(用于 AI 分类,节省 token)。
*
Expand Down Expand Up @@ -244,8 +244,12 @@ export async function scanCandidates(opts: {
}

if (opts.fromClaude) {
// Claude's rules follow a relocated root (CLAUDE_CONFIG_DIR); Cursor's do not move.
const { resolveMemberToolRoots } = await import('./config.js');
const { resolveToolRootDir, CLAUDE_TOOL_ID, DEFAULT_CLAUDE_ROOT } = await import('./types.js');
const claudeRoot = resolveToolRootDir(CLAUDE_TOOL_ID, DEFAULT_CLAUDE_ROOT, await resolveMemberToolRoots());
const rulesBaseDirs = [
expandHome('~/.claude/rules'),
path.join(claudeRoot, 'rules'),
expandHome('~/.cursor/rules'),
];
for (const baseDir of rulesBaseDirs) {
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1024,7 +1024,7 @@ program
.command('import')
.description('Import knowledge from local directories, remote repos, organizations, MRs, or iWiki')
.option('--dir <path>', 'Extract code knowledge from a local directory (same as --from-repo but no clone)')
.addOption(new Option('--from-claude', 'Scan Claude/Cursor rule directories (~/.claude/rules, ~/.cursor/rules)').hideHelp())
.addOption(new Option('--from-claude', 'Scan Claude/Cursor rule directories (the Claude root\'s rules/ — ~/.claude or the recorded toolRoots.claude — and ~/.cursor/rules)').hideHelp())
.option('--from-mr <url>', 'Extract learning from merged MR/PR and trigger incremental teamwiki update')
.option('--from-iwiki <space-id-or-url>', 'Import documents from iWiki Space ID or page URL (requires TAI_PAT_TOKEN)')
.addOption(new Option('--resume', 'Resume an interrupted import session').hideHelp())
Expand Down
5 changes: 4 additions & 1 deletion src/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,10 @@ async function releasePreviousClaudeRoot(
// 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 });
// Only Claude's file: the reconciler walks every MCP-capable tool of the
// config it is handed, and the other tools' servers did not move.
const claudeOnly = { ...teamConfig, toolPaths: { [CLAUDE_TOOL_ID]: teamConfig.toolPaths[CLAUDE_TOOL_ID] } };
const { changes } = await reconcileMcpForConfig(claudeOnly, 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');
Expand Down
9 changes: 2 additions & 7 deletions src/local-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,13 +473,8 @@ async function saveAgentHookManifest(manifest: AgentHookManifest): Promise<void>
* them too. No config, or no entry, leaves the paths exactly as they were.
*/
async function memberToolRoots(workspacePath?: string): Promise<Record<string, string> | 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;
const { resolveMemberToolRoots } = await import('./config.js');
return resolveMemberToolRoots(workspacePath ?? process.cwd());
}

/** Claude Code's user root on this machine, honoring a relocated CLAUDE_CONFIG_DIR. */
Expand Down
21 changes: 17 additions & 4 deletions src/usage-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,14 @@ import {
SKILL_NAME_REGEX,
type LocalConfig,
type UsageEvent,
resolveToolRootDir,
CLAUDE_TOOL_ID,
DEFAULT_CLAUDE_ROOT,
} from './types.js';
import { ensureDir, readJson, writeJson, pathExists } from './utils/fs.js';
import { getUserHome } from './utils/home.js';
import { resolveHookCwd } from './utils/hook-cwd.js';
import { resolveConfigForDir } from './config.js';
import { resolveConfigForDir, resolveMemberToolRoots } from './config.js';

/**
* The usage JSONL of one scope: `<dataHome>/usage.jsonl`, so each scope reports
Expand Down Expand Up @@ -186,9 +189,14 @@ const PROJECT_SKILL_DIRS = [...SKILL_DIRS, '.github/skills'];
*
* Performance: Checks a bounded list of user and project directories with one stat() each.
*/
export async function skillExistsOnDisk(skillName: string): Promise<boolean> {
export async function skillExistsOnDisk(skillName: string, toolRoots?: Record<string, string>): Promise<boolean> {
const home = getUserHome();
// A Claude Code relocated with CLAUDE_CONFIG_DIR keeps its skills under the
// recorded root, which the static list cannot know; the caller resolves it
// from the hook's directory (resolveMemberToolRoots).
const claudeRoot = resolveToolRootDir(CLAUDE_TOOL_ID, DEFAULT_CLAUDE_ROOT, toolRoots);
const userSkillDirs = [
path.join(claudeRoot, 'skills'),
...SKILL_DIRS.map((dir) => path.join(home, dir)),
path.join(getCopilotHome(), 'skills'),
];
Expand Down Expand Up @@ -506,8 +514,13 @@ export async function trackSlashCommand(toolArg?: string): Promise<void> {
return;
}

const config = await resolveConfigForDir(resolveHookCwd(hookData));
const hookCwd = resolveHookCwd(hookData);
const config = await resolveConfigForDir(hookCwd);
if (!config) return;
// The same root resolution import and the local agent use, from the hook's
// directory: a project set up before a user-scope relocation has no record
// of its own and follows user scope.
const toolRoots = await resolveMemberToolRoots(hookCwd);

for (const match of matches) {
const skillName = match[1];
Expand All @@ -519,7 +532,7 @@ export async function trackSlashCommand(toolArg?: string): Promise<void> {

// Verify the skill actually exists on disk to avoid tracking phantom skills
// (e.g. user typing "/data" which is not a real skill)
if (!await skillExistsOnDisk(skillName)) {
if (!await skillExistsOnDisk(skillName, toolRoots)) {
log.debug(`Slash command "/${skillName}" is not a known skill — skipping tracking`);
continue;
}
Expand Down
Loading