From 583861150048329e22bf019f57eb5cd4dc478736 Mon Sep 17 00:00:00 2001 From: legend91019 <15853153143@163.com> Date: Sun, 18 Oct 2026 17:49:01 +0800 Subject: [PATCH 1/4] fix(config): merge custom toolPaths tool by tool over defaults (#1185) --- src/__tests__/tool-roots.test.ts | 11 + src/__tests__/types.test.ts | 59 ++++ src/types.ts | 538 ++++++++++++++++--------------- 3 files changed, 346 insertions(+), 262 deletions(-) diff --git a/src/__tests__/tool-roots.test.ts b/src/__tests__/tool-roots.test.ts index 51cfcc3ca..0a8b15d6b 100644 --- a/src/__tests__/tool-roots.test.ts +++ b/src/__tests__/tool-roots.test.ts @@ -188,6 +188,17 @@ describe('toolRoots — re-rooting a relocated tool', () => { expect(Object.keys(paths.claude).sort()).toEqual(['mcpProject', 'skills']); }); + it('keeps other tools at default paths when a single tool declares sparse paths (#1185)', () => { + const sparse = TeamaiConfigSchema.parse({ + team: 't', + repo: 'r', + toolPaths: { claude: { skills: '.claude/skills', mcpProject: '.mcp.json' } }, + }); + const paths = scopedToolPaths(sparse, localConfig({})); + expect(paths.codebuddy.mcpProject).toBe('.mcp.json'); + expect(paths.cursor.skills).toBe('.cursor/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, diff --git a/src/__tests__/types.test.ts b/src/__tests__/types.test.ts index f4cf3850f..5df47ee53 100644 --- a/src/__tests__/types.test.ts +++ b/src/__tests__/types.test.ts @@ -9,6 +9,7 @@ import { StateSchema, LocalConfigSchema, resolveLegacyProjectHookScope, + DEFAULT_TOOL_PATHS, } from '../types.js'; import type { LocalConfig } from '../types.js'; @@ -147,6 +148,64 @@ describe('TeamaiConfigSchema', () => { }); }); + it('should export DEFAULT_TOOL_PATHS matching default parse', () => { + const result = TeamaiConfigSchema.parse({ + team: 'test-team', + repo: 'https://git.woa.com/test/repo.git', + }); + expect(result.toolPaths).toEqual(DEFAULT_TOOL_PATHS); + expect(DEFAULT_TOOL_PATHS.codebuddy.mcpProject).toBe('.mcp.json'); + expect(DEFAULT_TOOL_PATHS.claude.mcpProject).toBe('.mcp.json'); + }); + + it('merges custom toolPaths over defaults tool by tool (#1185)', () => { + // When a team config provides custom toolPaths for one tool, + // that tool is overridden while other built-in tools retain their defaults + const result = TeamaiConfigSchema.parse({ + team: 'test-team', + repo: 'https://git.woa.com/test/repo.git', + toolPaths: { + codebuddy: { + skills: '.codebuddy/skills', + rules: '.codebuddy/rules', + mcpProject: '.mcp.json', + }, + }, + }); + + // The specified tool has the declared paths + expect(result.toolPaths.codebuddy).toEqual({ + skills: '.codebuddy/skills', + rules: '.codebuddy/rules', + mcpProject: '.mcp.json', + }); + + // Other built-in tools remain intact with their default paths + expect(result.toolPaths.claude).toEqual(DEFAULT_TOOL_PATHS.claude); + expect(result.toolPaths.cursor).toEqual(DEFAULT_TOOL_PATHS.cursor); + expect(result.toolPaths.codex).toEqual(DEFAULT_TOOL_PATHS.codex); + expect(result.toolPaths.trae).toEqual(DEFAULT_TOOL_PATHS.trae); + }); + + it('retains default toolPaths when custom tool adds a third-party agent', () => { + const result = TeamaiConfigSchema.parse({ + team: 'test-team', + repo: 'https://git.woa.com/test/repo.git', + toolPaths: { + 'custom-agent': { + skills: '.custom/skills', + rules: '.custom/rules', + }, + }, + }); + + expect(result.toolPaths['custom-agent']).toEqual({ + skills: '.custom/skills', + rules: '.custom/rules', + }); + expect(result.toolPaths.codebuddy).toEqual(DEFAULT_TOOL_PATHS.codebuddy); + expect(result.toolPaths.claude).toEqual(DEFAULT_TOOL_PATHS.claude); + }); }); describe('TeamaiConfigSchema reviewers', () => { diff --git a/src/types.ts b/src/types.ts index be832ed0f..d985aa16e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -391,6 +391,273 @@ const TeamScriptSchema = z.object({ path: z.string().min(1), }); +/** + * Default tool paths for built-in tools. + * MCP paths are only set for tools whose config location has been verified. + * Tools left without `mcp` are skipped by MCP sync rather than guessed at, so a + * wrong guess can never create a junk config file on a user's machine. + */ +export const DEFAULT_TOOL_PATHS: Record> = { + claude: { skills: '.claude/skills', rules: '.claude/rules', settings: '.claude/settings.json', claudemd: '.claude/CLAUDE.md', agents: '.claude/agents', mcp: '.claude.json', mcpProject: '.mcp.json' }, + // Codex reads no rules directory: `.codex/rules` holds its exec-policy + // `*.rules` files. In user scope teamai's blocks go to ~/.codex/AGENTS.md, + // which only Codex reads. In project scope they come from the session-start + // hook, since the project AGENTS.md is the owners' file and other tools + // read it too (#938, #945); so the entry has no project `claudemd`. + // Codex reads the project MCP config only in a trusted project (#954). + codex: { + skills: '.codex/skills', + settings: '.codex/hooks.json', + agents: '.codex/agents', + mcp: '.codex/config.toml', + mcpProject: '.codex/config.toml', + userScope: { claudemd: '.codex/AGENTS.md' }, + }, + // codex-internal and tcodex run the same Codex from their own home root, so + // they take the codex shape. Their user-scope AGENTS.md locations + // (~/.codex-internal/AGENTS.md, ~/.tcodex/AGENTS.md) are assumed from that, + // not verified against either build. + 'codex-internal': { + skills: '.codex-internal/skills', + settings: '.codex-internal/hooks.json', + agents: '.codex-internal/agents', + userScope: { claudemd: '.codex-internal/AGENTS.md' }, + }, + 'claude-internal': { skills: '.claude-internal/skills', rules: '.claude-internal/rules', settings: '.claude-internal/settings.json', claudemd: '.claude-internal/CLAUDE.md', agents: '.claude-internal/agents' }, + // tclaude ships Claude Code with `customUserDataDir: .tclaude`, which + // relocates the whole user data dir — so its MCP file is + // ~/.tclaude/.claude.json, not ~/.tclaude.json. No mcpProject: project scope + // for the Claude family is /.mcp.json, which the `claude` target + // already writes and tclaude reads from the same location. + tclaude: { skills: '.tclaude/skills', rules: '.tclaude/rules', settings: '.tclaude/settings.json', claudemd: '.tclaude/CLAUDE.md', agents: '.tclaude/agents', mcp: '.tclaude/.claude.json' }, + // Same shape as codex; see codex-internal above. + tcodex: { + skills: '.tcodex/skills', + settings: '.tcodex/hooks.json', + agents: '.tcodex/agents', + userScope: { claudemd: '.tcodex/AGENTS.md' }, + }, + cursor: { skills: '.cursor/skills', rules: '.cursor/rules', settings: '.cursor/hooks.json', agents: '.cursor/agents', mcp: '.cursor/mcp.json', mcpProject: '.cursor/mcp.json' }, + // GitHub Copilot CLI keeps project customizations under .github and moves + // the complete user customization root when COPILOT_HOME is set. Agents use + // the official .agent.md format. Hooks and MCP use standalone files; + // settings.json is deliberately never managed. + copilot: { + skills: '.github/skills', + rules: '.github/instructions', + agents: '.github/agents', + hooks: '.github/hooks/teamai.json', + claudemd: '.github/copilot-instructions.md', + mcp: COPILOT_USER_MCP_CONFIG, + mcpProject: COPILOT_PROJECT_MCP_CONFIG, + userScope: { + skills: 'skills', + rules: 'instructions', + agents: 'agents', + hooks: 'hooks/teamai.json', + claudemd: 'copilot-instructions.md', + }, + }, + // JoyCode: skills, rules (.mdc), and subagents are synced to .joycode/. + // JoyCode currently does not provide a lifecycle hooks system or startup + // adapter, so it intentionally has no `settings` path. Hook reconciliation + // skips JoyCode cleanly without generating ghost files; users must sync + // manually via `teamai pull`. In user scope it reads no rules directory: + // its team rules are a block in ~/.joycode/rules.txt (#946). + joycode: { skills: '.joycode/skills', rules: '.joycode/rules', agents: '.joycode/agents', userScope: { rules: null } }, + qoder: { + skills: '.qoder/skills', + rules: '.qoder/rules', + settings: '.qoder/settings.json', + agents: '.qoder/agents', + mcp: '.qoder/settings.json', + mcpProject: '.qoder/settings.json', + }, + // Qoder CN is a separate distribution whose *user*-scope directory is + // ~/.qoder-cn instead of ~/.qoder, so it needs its own entry rather than + // sharing `qoder`. It reads the same Claude-compatible resource formats. + // + // Only the user scope differs. Top-level fields are PROJECT-scope paths and + // the `userScope` block below carries the user-scope overrides, so the + // top-level entries stay identical to `qoder`: + // ASSUMPTION: Qoder CN's project-scope layout is assumed shared with Qoder + // (`/.qoder/`), i.e. the CN build differs from the international + // build only in its user directory, not in its per-repo directory. This + // could not be verified from this repository — it is a third-party product + // layout. If a CN project actually keeps its resources in `/.qoder-cn/`, + // the top-level fields below are wrong and must move to `.qoder-cn/`. + // MCP stays two distinct fields: `mcp` is the user-scope file, `mcpProject` + // the project-scope one. + 'qoder-cn': { + skills: '.qoder/skills', + rules: '.qoder/rules', + settings: '.qoder/settings.json', + agents: '.qoder/agents', + mcp: '.qoder-cn/settings.json', + mcpProject: '.qoder/settings.json', + userScope: { + skills: '.qoder-cn/skills', + rules: '.qoder-cn/rules', + settings: '.qoder-cn/settings.json', + agents: '.qoder-cn/agents', + }, + }, + // Kiro: skills, steering (rules), and custom agents sync to .kiro/. Kiro CLI + // 2.x stores lifecycle hooks inside each .kiro/agents/*.json config. The + // Kiro agent renderer therefore embeds TeamAI's session-start dispatch as + // `hooks.agentSpawn`; there is no standalone `settings` hook surface. + // MCP uses the dedicated, mcpServers-only .kiro/settings/mcp.json: + // https://kiro.dev/docs/mcp/configuration/ + kiro: { + skills: '.kiro/skills', + rules: '.kiro/steering', + agents: '.kiro/agents', + mcp: '.kiro/settings/mcp.json', + mcpProject: '.kiro/settings/mcp.json', + }, + // Trae (trae.ai) is a VS Code-family IDE. Per its docs and its bundled + // code (verified against the installed app): project skills live in + // /.trae/skills//SKILL.md, project rules in .trae/rules/*.md + // (frontmatter `alwaysApply` / `globs`, globs comma-separated, parsed by + // lines — see trae-rule.ts), and project MCP in .trae/mcp.json holding a + // Claude-shaped `mcpServers` object map. User rules are NOT ~/.trae/rules + // but ~/.trae/user_rules (the default file is user_rules.md), so + // userScope.rules carries that name. User-level MCP has no + // teamai-writable cross-platform file: the IDE keeps it next to its + // user settings (~/Library/Application Support/Trae/User/mcp.json on + // macOS, %APPDATA%/… on Windows), so only `mcpProject` is set. Trae has + // no settings-based hook surface and no subagents directory, so those + // keys stay absent (like JoyCode, users sync via `teamai pull`). + trae: { + skills: '.trae/skills', + rules: '.trae/rules', + mcpProject: '.trae/mcp.json', + userScope: { rules: '.trae/user_rules' }, + }, + // Trae CN (domestic.trae.cn) shares every project path with the + // international build (verified on installed apps: only the user + // directory differs, ~/.trae-cn). Both map the same .trae/mcp.json, and + // one shared ownership record covers the pair + // (MCP_MANIFEST_KEY_ALIAS), so whichever build a member runs, its + // target writes, updates and cleans the file. + 'trae-cn': { + skills: '.trae/skills', + rules: '.trae/rules', + mcpProject: '.trae/mcp.json', + userScope: { + skills: '.trae-cn/skills', + rules: '.trae-cn/user_rules', + }, + }, + // ZCode: user-level config lives at ~/.zcode/cli/config.json (a shared file + // that also carries plugin state — reconcile must merge, never replace). + // Hooks are Claude-shaped but nested under `hooks.events` and gated by + // `hooks.enabled` (config-file hooks are disabled by default; the writer + // must force it on). Subagents deploy to ~/.zcode/agents/ as Claude-style + // Markdown (the CLI also reads /.zcode/agents/ per workspace). + // User-scope MCP mirrors Claude's shape (`mcpServers` key) in + // ~/.agents/mcp.json; project scope writes `mcp.servers` inside + // .zcode/config.json (a different key), which the Claude writer cannot + // emit — so no mcpProject. ZCode has no user-level rules dir convention. + zcode: { skills: '.zcode/skills', agents: '.zcode/agents', settings: '.zcode/cli/config.json', mcp: '.agents/mcp.json' }, + // Oh My Pi (OMP): the config root is ~/.omp on every platform (no %APPDATA% + // on Windows); user-scope resources live in the agent dir ~/.omp/agent/, a + // different prefix from the project /.omp/, hence userScope. Rules are + // plain .md, instructions land in AGENTS.md, and MCP uses the Claude-shaped + // {"mcpServers": …} mcp.json. OMP runs lifecycle hooks as in-process TS + // extensions rather than a settings hook list, so there is no `settings` + // path — the adapter in omp-hooks.ts writes the single user-root extension + // (~/.omp/agent/extensions/teamai-hooks.ts). Profiles (OMP_PROFILE / + // PI_CODING_AGENT_DIR / PI_CONFIG_DIR) move the agent dir and are not + // supported for hooks. + omp: { + skills: '.omp/skills', + rules: '.omp/rules', + claudemd: '.omp/AGENTS.md', + agents: '.omp/agents', + mcp: '.omp/agent/mcp.json', + mcpProject: '.omp/mcp.json', + userScope: { + skills: '.omp/agent/skills', + rules: '.omp/agent/rules', + claudemd: '.omp/agent/AGENTS.md', + agents: '.omp/agent/agents', + }, + }, + // Pi Coding Agent: skills/rules/extensions live under the agent root. Pi + // discovers global context from ~/.pi/agent/AGENTS.md and project context + // from AGENTS.md/CLAUDE.md walking up the workspace tree. Hooks are + // TypeScript extensions rather than a settings hook list, so the adapter + // keeps one user extension and forwards the active cwd to hook-dispatch. + // Profile overrides (PI_CODING_AGENT_DIR / PI_CONFIG_DIR) that relocate + // the agent dir are not supported for hooks or MCP, same as the OMP adapter; + // model profiles do read PI_CODING_AGENT_DIR. Pi reads no rules + // directory: its user team rules are a block in ~/.pi/agent/AGENTS.md, and + // in a project teamai's extension adds them to the system prompt (#946). + pi: { + mcp: '.pi/agent/mcp.json', + mcpProject: '.pi/mcp.json', + skills: '.pi/skills', + claudemd: 'AGENTS.md', + userScope: { + skills: '.pi/agent/skills', + claudemd: '.pi/agent/AGENTS.md', + }, + }, + codebuddy: { skills: '.codebuddy/skills', rules: '.codebuddy/rules', settings: '.codebuddy/settings.json', claudemd: '.codebuddy/CODEBUDDY.md', agents: '.codebuddy/agents', mcp: '.codebuddy/mcp.json', mcpProject: '.mcp.json' }, + // OpenClaw reads no rules directory: in user scope its team rules are a + // block in the workspace AGENTS.md, and a project gets none (#946). + openclaw: { skills: '.openclaw/skills', claudemd: '.openclaw/workspace/AGENTS.md' }, + hermes: { skills: '.hermes/skills', claudemd: 'AGENTS.md' }, + // DeepSeek Harness: skills synced to ~/.dsh/skills, which its skill-filesystem + // provider scans as user-dsh root (rank 400). dsh discovers both directory + // bundles (/SKILL.md) and flat Markdown files there natively. + dsh: { skills: '.dsh/skills' }, + // WorkBuddy runs CodeBuddy's engine: in a project it reads CodeBuddy's + // .codebuddy/rules, which the two share (one copy), and in user scope its + // own ~/.workbuddy/rules (#946). Its install probe stays .workbuddy + // (`isToolInstalledForConfig`), whatever directory its rules land in. + workbuddy: { + skills: '.workbuddy/skills', + rules: '.codebuddy/rules', + settings: '.workbuddy/settings.json', + claudemd: 'AGENTS.md', + agents: '.workbuddy/agents', + mcp: '.workbuddy/mcp.json', + mcpProject: '.workbuddy/mcp.json', + userScope: { rules: '.workbuddy/rules' }, + }, + // OpenCode reads project config from /.opencode/ but user config from + // ~/.config/opencode/ — a different prefix, hence userScope. Skills are also + // read natively from .claude/skills, but we write .opencode/skills so an + // OpenCode-only user (no Claude) still gets them. Rules land in .opencode/rules + // but must be activated via the `instructions` glob in opencode.json (OpenCode + // does not auto-scan a rules dir). MCP shares opencode.json under the `mcp` key. + opencode: { + skills: '.opencode/skills', + rules: '.opencode/rules', + agents: '.opencode/agents', + mcp: '.config/opencode/opencode.json', + mcpProject: 'opencode.json', + userScope: { skills: '.config/opencode/skills', rules: '.config/opencode/rules', agents: '.config/opencode/agents' }, + }, + // Muse Code (Meta, binary `muse`) keeps its user config in ~/.config/muse + // and reads user skills from ~/.config/muse/skills, ahead of the + // ~/.claude/skills and ~/.agents/skills it also scans. Hooks take Codex's + // shape: user hooks in the `hooks` block of ~/.config/muse/settings.json, + // which runs in every workspace, project hooks in /.muse/hooks.json, + // which runs only in a trusted one. + muse: { + skills: '.agents/skills', + settings: '.muse/hooks.json', + // User MCP servers sit in the same settings.json. A project's come from + // the /.mcp.json the claude target writes, as for tclaude. + mcp: '.config/muse/settings.json', + agents: '.agents/agents', + userScope: { skills: '.config/muse/skills', settings: '.config/muse/settings.json', agents: '.config/muse/agents' }, + }, +}; + export const TeamaiConfigBaseSchema = z.object({ team: z.string(), description: z.string().default(''), @@ -456,268 +723,15 @@ export const TeamaiConfigBaseSchema = z.object({ postDeploy: TeamScriptSchema.optional(), }).optional(), // MCP paths are only set for tools whose config location has been verified. - // Tools left without `mcp` are skipped by MCP sync rather than guessed at, so a - // wrong guess can never create a junk config file on a user's machine. - toolPaths: z.record(z.string(), ToolPathsSchema).default({ - claude: { skills: '.claude/skills', rules: '.claude/rules', settings: '.claude/settings.json', claudemd: '.claude/CLAUDE.md', agents: '.claude/agents', mcp: '.claude.json', mcpProject: '.mcp.json' }, - // Codex reads no rules directory: `.codex/rules` holds its exec-policy - // `*.rules` files. In user scope teamai's blocks go to ~/.codex/AGENTS.md, - // which only Codex reads. In project scope they come from the session-start - // hook, since the project AGENTS.md is the owners' file and other tools - // read it too (#938, #945); so the entry has no project `claudemd`. - // Codex reads the project MCP config only in a trusted project (#954). - codex: { - skills: '.codex/skills', - settings: '.codex/hooks.json', - agents: '.codex/agents', - mcp: '.codex/config.toml', - mcpProject: '.codex/config.toml', - userScope: { claudemd: '.codex/AGENTS.md' }, - }, - // codex-internal and tcodex run the same Codex from their own home root, so - // they take the codex shape. Their user-scope AGENTS.md locations - // (~/.codex-internal/AGENTS.md, ~/.tcodex/AGENTS.md) are assumed from that, - // not verified against either build. - 'codex-internal': { - skills: '.codex-internal/skills', - settings: '.codex-internal/hooks.json', - agents: '.codex-internal/agents', - userScope: { claudemd: '.codex-internal/AGENTS.md' }, - }, - 'claude-internal': { skills: '.claude-internal/skills', rules: '.claude-internal/rules', settings: '.claude-internal/settings.json', claudemd: '.claude-internal/CLAUDE.md', agents: '.claude-internal/agents' }, - // tclaude ships Claude Code with `customUserDataDir: .tclaude`, which - // relocates the whole user data dir — so its MCP file is - // ~/.tclaude/.claude.json, not ~/.tclaude.json. No mcpProject: project scope - // for the Claude family is /.mcp.json, which the `claude` target - // already writes and tclaude reads from the same location. - tclaude: { skills: '.tclaude/skills', rules: '.tclaude/rules', settings: '.tclaude/settings.json', claudemd: '.tclaude/CLAUDE.md', agents: '.tclaude/agents', mcp: '.tclaude/.claude.json' }, - // Same shape as codex; see codex-internal above. - tcodex: { - skills: '.tcodex/skills', - settings: '.tcodex/hooks.json', - agents: '.tcodex/agents', - userScope: { claudemd: '.tcodex/AGENTS.md' }, - }, - cursor: { skills: '.cursor/skills', rules: '.cursor/rules', settings: '.cursor/hooks.json', agents: '.cursor/agents', mcp: '.cursor/mcp.json', mcpProject: '.cursor/mcp.json' }, - // GitHub Copilot CLI keeps project customizations under .github and moves - // the complete user customization root when COPILOT_HOME is set. Agents use - // the official .agent.md format. Hooks and MCP use standalone files; - // settings.json is deliberately never managed. - copilot: { - skills: '.github/skills', - rules: '.github/instructions', - agents: '.github/agents', - hooks: '.github/hooks/teamai.json', - claudemd: '.github/copilot-instructions.md', - mcp: COPILOT_USER_MCP_CONFIG, - mcpProject: COPILOT_PROJECT_MCP_CONFIG, - userScope: { - skills: 'skills', - rules: 'instructions', - agents: 'agents', - hooks: 'hooks/teamai.json', - claudemd: 'copilot-instructions.md', - }, - }, - // JoyCode: skills, rules (.mdc), and subagents are synced to .joycode/. - // JoyCode currently does not provide a lifecycle hooks system or startup - // adapter, so it intentionally has no `settings` path. Hook reconciliation - // skips JoyCode cleanly without generating ghost files; users must sync - // manually via `teamai pull`. In user scope it reads no rules directory: - // its team rules are a block in ~/.joycode/rules.txt (#946). - joycode: { skills: '.joycode/skills', rules: '.joycode/rules', agents: '.joycode/agents', userScope: { rules: null } }, - qoder: { - skills: '.qoder/skills', - rules: '.qoder/rules', - settings: '.qoder/settings.json', - agents: '.qoder/agents', - mcp: '.qoder/settings.json', - mcpProject: '.qoder/settings.json', - }, - // Qoder CN is a separate distribution whose *user*-scope directory is - // ~/.qoder-cn instead of ~/.qoder, so it needs its own entry rather than - // sharing `qoder`. It reads the same Claude-compatible resource formats. - // - // Only the user scope differs. Top-level fields are PROJECT-scope paths and - // the `userScope` block below carries the user-scope overrides, so the - // top-level entries stay identical to `qoder`: - // ASSUMPTION: Qoder CN's project-scope layout is assumed shared with Qoder - // (`/.qoder/`), i.e. the CN build differs from the international - // build only in its user directory, not in its per-repo directory. This - // could not be verified from this repository — it is a third-party product - // layout. If a CN project actually keeps its resources in `/.qoder-cn/`, - // the top-level fields below are wrong and must move to `.qoder-cn/`. - // MCP stays two distinct fields: `mcp` is the user-scope file, `mcpProject` - // the project-scope one. - 'qoder-cn': { - skills: '.qoder/skills', - rules: '.qoder/rules', - settings: '.qoder/settings.json', - agents: '.qoder/agents', - mcp: '.qoder-cn/settings.json', - mcpProject: '.qoder/settings.json', - userScope: { - skills: '.qoder-cn/skills', - rules: '.qoder-cn/rules', - settings: '.qoder-cn/settings.json', - agents: '.qoder-cn/agents', - }, - }, - // Kiro: skills, steering (rules), and custom agents sync to .kiro/. Kiro CLI - // 2.x stores lifecycle hooks inside each .kiro/agents/*.json config. The - // Kiro agent renderer therefore embeds TeamAI's session-start dispatch as - // `hooks.agentSpawn`; there is no standalone `settings` hook surface. - // MCP uses the dedicated, mcpServers-only .kiro/settings/mcp.json: - // https://kiro.dev/docs/mcp/configuration/ - kiro: { - skills: '.kiro/skills', - rules: '.kiro/steering', - agents: '.kiro/agents', - mcp: '.kiro/settings/mcp.json', - mcpProject: '.kiro/settings/mcp.json', - }, - // Trae (trae.ai) is a VS Code-family IDE. Per its docs and its bundled - // code (verified against the installed app): project skills live in - // /.trae/skills//SKILL.md, project rules in .trae/rules/*.md - // (frontmatter `alwaysApply` / `globs`, globs comma-separated, parsed by - // lines — see trae-rule.ts), and project MCP in .trae/mcp.json holding a - // Claude-shaped `mcpServers` object map. User rules are NOT ~/.trae/rules - // but ~/.trae/user_rules (the default file is user_rules.md), so - // userScope.rules carries that name. User-level MCP has no - // teamai-writable cross-platform file: the IDE keeps it next to its - // user settings (~/Library/Application Support/Trae/User/mcp.json on - // macOS, %APPDATA%/… on Windows), so only `mcpProject` is set. Trae has - // no settings-based hook surface and no subagents directory, so those - // keys stay absent (like JoyCode, users sync via `teamai pull`). - trae: { - skills: '.trae/skills', - rules: '.trae/rules', - mcpProject: '.trae/mcp.json', - userScope: { rules: '.trae/user_rules' }, - }, - // Trae CN (domestic.trae.cn) shares every project path with the - // international build (verified on installed apps: only the user - // directory differs, ~/.trae-cn). Both map the same .trae/mcp.json, and - // one shared ownership record covers the pair - // (MCP_MANIFEST_KEY_ALIAS), so whichever build a member runs, its - // target writes, updates and cleans the file. - 'trae-cn': { - skills: '.trae/skills', - rules: '.trae/rules', - mcpProject: '.trae/mcp.json', - userScope: { - skills: '.trae-cn/skills', - rules: '.trae-cn/user_rules', - }, - }, - // ZCode: user-level config lives at ~/.zcode/cli/config.json (a shared file - // that also carries plugin state — reconcile must merge, never replace). - // Hooks are Claude-shaped but nested under `hooks.events` and gated by - // `hooks.enabled` (config-file hooks are disabled by default; the writer - // must force it on). Subagents deploy to ~/.zcode/agents/ as Claude-style - // Markdown (the CLI also reads /.zcode/agents/ per workspace). - // User-scope MCP mirrors Claude's shape (`mcpServers` key) in - // ~/.agents/mcp.json; project scope writes `mcp.servers` inside - // .zcode/config.json (a different key), which the Claude writer cannot - // emit — so no mcpProject. ZCode has no user-level rules dir convention. - zcode: { skills: '.zcode/skills', agents: '.zcode/agents', settings: '.zcode/cli/config.json', mcp: '.agents/mcp.json' }, - // Oh My Pi (OMP): the config root is ~/.omp on every platform (no %APPDATA% - // on Windows); user-scope resources live in the agent dir ~/.omp/agent/, a - // different prefix from the project /.omp/, hence userScope. Rules are - // plain .md, instructions land in AGENTS.md, and MCP uses the Claude-shaped - // {"mcpServers": …} mcp.json. OMP runs lifecycle hooks as in-process TS - // extensions rather than a settings hook list, so there is no `settings` - // path — the adapter in omp-hooks.ts writes the single user-root extension - // (~/.omp/agent/extensions/teamai-hooks.ts). Profiles (OMP_PROFILE / - // PI_CODING_AGENT_DIR / PI_CONFIG_DIR) move the agent dir and are not - // supported for hooks. - omp: { - skills: '.omp/skills', - rules: '.omp/rules', - claudemd: '.omp/AGENTS.md', - agents: '.omp/agents', - mcp: '.omp/agent/mcp.json', - mcpProject: '.omp/mcp.json', - userScope: { - skills: '.omp/agent/skills', - rules: '.omp/agent/rules', - claudemd: '.omp/agent/AGENTS.md', - agents: '.omp/agent/agents', - }, - }, - // Pi Coding Agent: skills/rules/extensions live under the agent root. Pi - // discovers global context from ~/.pi/agent/AGENTS.md and project context - // from AGENTS.md/CLAUDE.md walking up the workspace tree. Hooks are - // TypeScript extensions rather than a settings hook list, so the adapter - // keeps one user extension and forwards the active cwd to hook-dispatch. - // Profile overrides (PI_CODING_AGENT_DIR / PI_CONFIG_DIR) that relocate - // the agent dir are not supported for hooks or MCP, same as the OMP adapter; - // model profiles do read PI_CODING_AGENT_DIR. Pi reads no rules - // directory: its user team rules are a block in ~/.pi/agent/AGENTS.md, and - // in a project teamai's extension adds them to the system prompt (#946). - pi: { - mcp: '.pi/agent/mcp.json', - mcpProject: '.pi/mcp.json', - skills: '.pi/skills', - claudemd: 'AGENTS.md', - userScope: { - skills: '.pi/agent/skills', - claudemd: '.pi/agent/AGENTS.md', - }, - }, - codebuddy: { skills: '.codebuddy/skills', rules: '.codebuddy/rules', settings: '.codebuddy/settings.json', claudemd: '.codebuddy/CODEBUDDY.md', agents: '.codebuddy/agents', mcp: '.codebuddy/mcp.json', mcpProject: '.mcp.json' }, - // OpenClaw reads no rules directory: in user scope its team rules are a - // block in the workspace AGENTS.md, and a project gets none (#946). - openclaw: { skills: '.openclaw/skills', claudemd: '.openclaw/workspace/AGENTS.md' }, - hermes: { skills: '.hermes/skills', claudemd: 'AGENTS.md' }, - // DeepSeek Harness: skills synced to ~/.dsh/skills, which its skill-filesystem - // provider scans as user-dsh root (rank 400). dsh discovers both directory - // bundles (/SKILL.md) and flat Markdown files there natively. - dsh: { skills: '.dsh/skills' }, - // WorkBuddy runs CodeBuddy's engine: in a project it reads CodeBuddy's - // .codebuddy/rules, which the two share (one copy), and in user scope its - // own ~/.workbuddy/rules (#946). Its install probe stays .workbuddy - // (`isToolInstalledForConfig`), whatever directory its rules land in. - workbuddy: { - skills: '.workbuddy/skills', - rules: '.codebuddy/rules', - settings: '.workbuddy/settings.json', - claudemd: 'AGENTS.md', - agents: '.workbuddy/agents', - mcp: '.workbuddy/mcp.json', - mcpProject: '.workbuddy/mcp.json', - userScope: { rules: '.workbuddy/rules' }, - }, - // OpenCode reads project config from /.opencode/ but user config from - // ~/.config/opencode/ — a different prefix, hence userScope. Skills are also - // read natively from .claude/skills, but we write .opencode/skills so an - // OpenCode-only user (no Claude) still gets them. Rules land in .opencode/rules - // but must be activated via the `instructions` glob in opencode.json (OpenCode - // does not auto-scan a rules dir). MCP shares opencode.json under the `mcp` key. - opencode: { - skills: '.opencode/skills', - rules: '.opencode/rules', - agents: '.opencode/agents', - mcp: '.config/opencode/opencode.json', - mcpProject: 'opencode.json', - userScope: { skills: '.config/opencode/skills', rules: '.config/opencode/rules', agents: '.config/opencode/agents' }, - }, - // Muse Code (Meta, binary `muse`) keeps its user config in ~/.config/muse - // and reads user skills from ~/.config/muse/skills, ahead of the - // ~/.claude/skills and ~/.agents/skills it also scans. Hooks take Codex's - // shape: user hooks in the `hooks` block of ~/.config/muse/settings.json, - // which runs in every workspace, project hooks in /.muse/hooks.json, - // which runs only in a trusted one. - muse: { - skills: '.agents/skills', - settings: '.muse/hooks.json', - // User MCP servers sit in the same settings.json. A project's come from - // the /.mcp.json the claude target writes, as for tclaude. - mcp: '.config/muse/settings.json', - agents: '.agents/agents', - userScope: { skills: '.config/muse/skills', settings: '.config/muse/settings.json', agents: '.config/muse/agents' }, - }, - }), + // A team's `toolPaths` section merges over the built-in defaults tool by tool (#1185): + // each tool named in the team config overrides that tool's default entry whole, + // while built-in tools not named by the team retain their defaults. + toolPaths: z.record(z.string(), ToolPathsSchema) + .default({}) + .transform((custom): Record> => ({ + ...DEFAULT_TOOL_PATHS, + ...custom, + })), }); export const TeamaiConfigSchema = TeamaiConfigBaseSchema.superRefine((config, ctx) => { From 7a7cc4ff73d1b58520d5e6031edc4cb5e0d74503 Mon Sep 17 00:00:00 2001 From: legend91019 <15853153143@163.com> Date: Mon, 19 Oct 2026 10:55:07 +0800 Subject: [PATCH 2/4] test(e2e): cover sparse toolPaths merge against built CLI (#1185) --- .../e2e/toolpaths-sparse-merge-1185.test.ts | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 src/__tests__/e2e/toolpaths-sparse-merge-1185.test.ts diff --git a/src/__tests__/e2e/toolpaths-sparse-merge-1185.test.ts b/src/__tests__/e2e/toolpaths-sparse-merge-1185.test.ts new file mode 100644 index 000000000..aa4badf13 --- /dev/null +++ b/src/__tests__/e2e/toolpaths-sparse-merge-1185.test.ts @@ -0,0 +1,174 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { execFileSync, spawn } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// ─── Sparse toolPaths merge over defaults (issue #1185) ───────────────────── +// +// When a team specifies a custom or partial `toolPaths` mapping in teamai.yaml +// (e.g. only defining a single in-house tool like `zcode`), TeamAI must merge +// those entries tool-by-tool over DEFAULT_TOOL_PATHS. Built-in tools like Claude, +// Cursor, Codex, and CodeBuddy must retain their default delivery paths rather +// than having their configurations wiped out. + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..', '..', '..'); +const CLI = path.join(ROOT, 'dist', 'index.js'); + +const FAKE_URL = 'https://git.example.com/team/toolpaths-merge.git'; + +const GIT_ENV = { + GIT_AUTHOR_NAME: 'TeamAI CI', + GIT_AUTHOR_EMAIL: 'ci@teamai.test', + GIT_COMMITTER_NAME: 'TeamAI CI', + GIT_COMMITTER_EMAIL: 'ci@teamai.test', +}; + +function git(args: string[], cwd: string, home?: string): string { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + env: { + ...process.env, + ...GIT_ENV, + ...(home ? { HOME: home, USERPROFILE: home, GIT_CONFIG_GLOBAL: path.join(home, '.gitconfig') } : {}), + }, + }).trim(); +} + +function runCLI(args: string[], cwd: string, home: string): Promise<{ code: number | null; output: string }> { + return new Promise((resolve) => { + const child = spawn(process.execPath, [CLI, ...args], { + cwd, + env: { + ...process.env, + ...GIT_ENV, + HOME: home, + USERPROFILE: home, + GIT_CONFIG_GLOBAL: path.join(home, '.gitconfig'), + FORCE_COLOR: '0', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let output = ''; + child.stdout.on('data', (data: Buffer) => { output += data.toString(); }); + child.stderr.on('data', (data: Buffer) => { output += data.toString(); }); + child.on('close', (code) => resolve({ code, output })); + }); +} + +describe('sparse toolPaths merge over built-in defaults (issue #1185)', () => { + let sandbox: string; + let home: string; + let seed: string; + let remote: string; + + beforeAll(async () => { + if (!fs.existsSync(CLI)) { + throw new Error(`CLI binary not found at ${CLI}. Run "npm run build" first.`); + } + sandbox = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-toolpaths-merge-e2e-'))); + home = path.join(sandbox, 'home'); + seed = path.join(sandbox, 'seed'); + remote = path.join(sandbox, 'team.git'); + + fs.mkdirSync(home, { recursive: true }); + fs.mkdirSync(path.join(seed, 'manifest'), { recursive: true }); + fs.mkdirSync(path.join(seed, 'skills', 'common', 'hello-skill'), { recursive: true }); + + // Seed teamai.yaml with ONLY a single custom tool defined in toolPaths + fs.writeFileSync(path.join(seed, 'teamai.yaml'), [ + 'team: sparse-toolpaths-e2e', + `repo: ${FAKE_URL}`, + 'provider: git', + 'reviewers: []', + 'toolPaths:', + ' zcode:', + ' skills: .zcode/skills', + ' rules: .zcode/rules', + '', + ].join('\n')); + + fs.writeFileSync(path.join(seed, 'manifest', 'roles.yaml'), [ + 'version: 1', + 'roles:', + ' - id: dev', + ' resources:', + ' knowledge: []', + ' skills: [common]', + '', + ].join('\n')); + + fs.writeFileSync(path.join(seed, 'skills', 'common', 'hello-skill', 'SKILL.md'), [ + '---', + 'name: hello-skill', + 'description: Hello world test skill', + '---', + '# Hello Skill', + ].join('\n')); + + git(['init', '-q', '-b', 'main'], seed); + git(['add', '-A'], seed); + git(['commit', '-q', '-m', 'seed team repository'], seed); + git(['clone', '-q', '--bare', seed, remote], sandbox); + + const remotePosix = remote.replace(/\\/g, '/'); + git(['config', '--global', `url.${remotePosix}/.insteadOf`, `${FAKE_URL}/`], sandbox, home); + git(['config', '--global', `url.${remotePosix}.insteadOf`, FAKE_URL], sandbox, home); + }); + + afterAll(() => { + if (sandbox) { + fs.rmSync(sandbox, { recursive: true, force: true }); + } + }); + + it('delivers skills to default Claude path even when teamai.yaml only declared zcode', async () => { + const ws1 = path.join(sandbox, 'workspace1'); + fs.mkdirSync(ws1, { recursive: true }); + git(['init', '-q', '-b', 'main'], ws1, home); + + const initRes = await runCLI( + ['init', FAKE_URL, '--provider', 'git', '--scope', 'workspace', '--role', 'dev', '--agent', 'claude', '--force'], + ws1, + home, + ); + expect(initRes.code, initRes.output).toBe(0); + + const pullRes = await runCLI(['pull'], ws1, home); + expect(pullRes.code, pullRes.output).toBe(0); + + // Default tool Claude must retain its .claude/skills delivery path + const claudeSkillPath = path.join(ws1, '.claude', 'skills', 'hello-skill', 'SKILL.md'); + expect(fs.existsSync(claudeSkillPath)).toBe(true); + expect(fs.readFileSync(claudeSkillPath, 'utf8')).toContain('# Hello Skill'); + }); + + it('also supports custom tool zcode alongside the default tools', async () => { + const ws2 = path.join(sandbox, 'workspace2'); + fs.mkdirSync(ws2, { recursive: true }); + git(['init', '-q', '-b', 'main'], ws2, home); + + // Initialize with both default tool claude and custom tool zcode + const initRes = await runCLI( + ['init', FAKE_URL, '--provider', 'git', '--scope', 'workspace', '--role', 'dev', '--agent', 'claude,zcode', '--force'], + ws2, + home, + ); + expect(initRes.code, initRes.output).toBe(0); + + const pullRes = await runCLI(['pull'], ws2, home); + expect(pullRes.code, pullRes.output).toBe(0); + + // Both Claude (default) and zcode (custom) receive the skill + const claudeSkillPath = path.join(ws2, '.claude', 'skills', 'hello-skill', 'SKILL.md'); + const zcodeSkillPath = path.join(ws2, '.zcode', 'skills', 'hello-skill', 'SKILL.md'); + + expect(fs.existsSync(claudeSkillPath)).toBe(true); + expect(fs.existsSync(zcodeSkillPath)).toBe(true); + expect(fs.readFileSync(claudeSkillPath, 'utf8')).toContain('# Hello Skill'); + expect(fs.readFileSync(zcodeSkillPath, 'utf8')).toContain('# Hello Skill'); + }); +}); From 42b57333433d7fb332f10c0b5715166313e54717 Mon Sep 17 00:00:00 2001 From: legend91019 <15853153143@163.com> Date: Mon, 19 Oct 2026 18:26:58 +0800 Subject: [PATCH 3/4] test(source): keep Codex-only fixtures explicit (#1185) --- src/__tests__/source.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/__tests__/source.test.ts b/src/__tests__/source.test.ts index 6a2c063f6..89fc9fe40 100644 --- a/src/__tests__/source.test.ts +++ b/src/__tests__/source.test.ts @@ -379,7 +379,7 @@ describe('source', () => { it('allows a nested identity change when the old and new destinations are disjoint', async () => { const repo = 'https://source.test/platform/repo.git'; teamConfig.sources = [{ name: 'platform', repo }]; - teamConfig.toolPaths = { codex: { skills: '.codex/skills' } }; + teamConfig.toolPaths = { claude: {}, codex: { skills: '.codex/skills' } }; const YAML = (await import('yaml')).default; await fse.writeFile(path.join(localConfig.repo.localPath, 'teamai.yaml'), YAML.stringify(teamConfig)); await fse.ensureDir(path.join(homeDir, '.codex/skills')); @@ -727,7 +727,7 @@ describe('source', () => { it('does not claim a public skill when no configured tool is installed', async () => { const repo = 'https://source.test/platform/repo.git'; teamConfig.sources = [{ name: 'platform', repo }]; - teamConfig.toolPaths = { codex: { skills: '.codex/skills' } }; + teamConfig.toolPaths = { claude: {}, codex: { skills: '.codex/skills' } }; const YAML = (await import('yaml')).default; await fse.writeFile(path.join(localConfig.repo.localPath, 'teamai.yaml'), YAML.stringify(teamConfig)); await fse.outputFile(path.join(fixtureSourceRepoDir(), 'skills/shared-skill/SKILL.md'), '# Source'); @@ -895,7 +895,7 @@ describe('source', () => { const previousUrl = 'https://source.test/original/repo.git'; const repo = changedRepository ? 'https://source.test/replacement/repo.git' : previousUrl; teamConfig.sources = [{ name: 'platform', repo }]; - teamConfig.toolPaths = { codex: { skills: '.codex/skills' } }; + teamConfig.toolPaths = { claude: {}, codex: { skills: '.codex/skills' } }; const YAML = (await import('yaml')).default; await fse.writeFile(path.join(localConfig.repo.localPath, 'teamai.yaml'), YAML.stringify(teamConfig)); await fse.ensureDir(path.join(homeDir, '.codex/skills')); @@ -1000,7 +1000,7 @@ describe('source', () => { it('keeps the member\'s shared Codex skill that appears later, delivering to the recorded path (#993)', async () => { teamConfig.sources = [{ name: 'platform', repo: 'git@git.woa.com:platform/repo.git' }]; - teamConfig.toolPaths = { codex: { skills: '.codex/skills' } }; + teamConfig.toolPaths = { claude: {}, codex: { skills: '.codex/skills' } }; const YAML = (await import('yaml')).default; await fse.writeFile(path.join(localConfig.repo.localPath, 'teamai.yaml'), YAML.stringify(teamConfig)); From bc912284342133e190e3a804c1462ea8b91c1385 Mon Sep 17 00:00:00 2001 From: legend91019 <15853153143@163.com> Date: Mon, 19 Oct 2026 20:20:17 +0800 Subject: [PATCH 4/4] test(source): isolate retarget ownership fixtures (#1185) --- src/__tests__/source.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/__tests__/source.test.ts b/src/__tests__/source.test.ts index 89fc9fe40..01c2f7604 100644 --- a/src/__tests__/source.test.ts +++ b/src/__tests__/source.test.ts @@ -924,7 +924,11 @@ describe('source', () => { expect(await fse.readFile(newPath, 'utf8')).toBe('# Replacement content'); if (hasOtherOwner) { expect(await fse.readFile(path.join(homeDir, oldPath, 'SKILL.md'), 'utf8')).toBe('# Original content'); - const otherTeam = { ...teamConfig, sources: [{ name: 'platform', repo: previousUrl }], toolPaths: { claude: { skills: '.claude/skills' } } }; + const otherTeam = { + ...teamConfig, + sources: [{ name: 'platform', repo: previousUrl }], + toolPaths: { claude: { skills: '.claude/skills' }, codex: {} }, + }; await fse.outputFile(path.join(otherConfig.repo.localPath, 'teamai.yaml'), YAML.stringify(otherTeam)); const otherRepo = path.join(homeDir, '.teamai', 'source-repos', previousId, 'repo'); await fse.outputFile(path.join(otherRepo, 'skills/shared-skill/SKILL.md'), '# Original refreshed'); @@ -936,7 +940,9 @@ describe('source', () => { // Released claims must no longer prevent another producer using the old destination. const incomingUrl = 'https://source.test/independent/repo.git'; await fse.outputFile(path.join(otherConfig.repo.localPath, 'teamai.yaml'), YAML.stringify({ - ...teamConfig, sources: [{ name: 'independent', repo: incomingUrl }], toolPaths: { claude: { skills: '.claude/skills' } }, + ...teamConfig, + sources: [{ name: 'independent', repo: incomingUrl }], + toolPaths: { claude: { skills: '.claude/skills' }, codex: {} }, })); const incomingRepo = path.join(homeDir, '.teamai', 'source-repos', createHash('sha256').update(incomingUrl).digest('hex'), 'repo'); await fse.outputFile(path.join(incomingRepo, 'skills/shared-skill/SKILL.md'), '# Independent source');