diff --git a/docs/usage-guide.md b/docs/usage-guide.md index ccfbab62e..cfd76b34d 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -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/` 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. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index a953cdb7d..462475e0b 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -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 无法同步到的目录,则说明原因。未设置该变量时,这项检查不会出现在报告里。 diff --git a/skill-data/core/references/commands.md b/skill-data/core/references/commands.md index 4360733b0..1bfbeed3b 100644 --- a/skill-data/core/references/commands.md +++ b/skill-data/core/references/commands.md @@ -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 ` — 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 ` — Extract learning from merged MR/PR and trigger incremental teamwiki update - `--from-iwiki ` — Import documents from iWiki Space ID or page URL (requires TAI_PAT_TOKEN) - `--resume` (hidden) — Resume an interrupted import session diff --git a/src/__tests__/import-local-relocated-root.test.ts b/src/__tests__/import-local-relocated-root.test.ts new file mode 100644 index 000000000..f879e834e --- /dev/null +++ b/src/__tests__/import-local-relocated-root.test.ts @@ -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')); + }); +}); diff --git a/src/__tests__/init.test.ts b/src/__tests__/init.test.ts index 3180ae18b..ab89bda7e 100644 --- a/src/__tests__/init.test.ts +++ b/src/__tests__/init.test.ts @@ -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 }, ); diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index 4b6b039eb..6fc3bbe21 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -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: diff --git a/src/__tests__/usage-tracking.test.ts b/src/__tests__/usage-tracking.test.ts index 21f392f48..fafc3192d 100644 --- a/src/__tests__/usage-tracking.test.ts +++ b/src/__tests__/usage-tracking.test.ts @@ -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'); @@ -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({ diff --git a/src/config.ts b/src/config.ts index eedd6a259..fa8a26a78 100644 --- a/src/config.ts +++ b/src/config.ts @@ -351,6 +351,22 @@ export async function resolveConfigForDir(dir?: string): Promise | 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 diff --git a/src/import-local.ts b/src/import-local.ts index 34af5de2a..426e86c25 100644 --- a/src/import-local.ts +++ b/src/import-local.ts @@ -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)。 * @@ -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) { diff --git a/src/index.ts b/src/index.ts index 88c534ffc..ce8606439 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1024,7 +1024,7 @@ program .command('import') .description('Import knowledge from local directories, remote repos, organizations, MRs, or iWiki') .option('--dir ', '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 ', 'Extract learning from merged MR/PR and trigger incremental teamwiki update') .option('--from-iwiki ', 'Import documents from iWiki Space ID or page URL (requires TAI_PAT_TOKEN)') .addOption(new Option('--resume', 'Resume an interrupted import session').hideHelp()) diff --git a/src/init.ts b/src/init.ts index aa07b117e..0431f195a 100644 --- a/src/init.ts +++ b/src/init.ts @@ -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'); diff --git a/src/local-agent.ts b/src/local-agent.ts index 137d05004..b185090c8 100644 --- a/src/local-agent.ts +++ b/src/local-agent.ts @@ -473,13 +473,8 @@ async function saveAgentHookManifest(manifest: AgentHookManifest): Promise * 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; + 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. */ diff --git a/src/usage-tracker.ts b/src/usage-tracker.ts index 3cd14bb66..7d7dfe162 100644 --- a/src/usage-tracker.ts +++ b/src/usage-tracker.ts @@ -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: `/usage.jsonl`, so each scope reports @@ -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 { +export async function skillExistsOnDisk(skillName: string, toolRoots?: Record): Promise { 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'), ]; @@ -506,8 +514,13 @@ export async function trackSlashCommand(toolArg?: string): Promise { 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]; @@ -519,7 +532,7 @@ export async function trackSlashCommand(toolArg?: string): Promise { // 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; }