diff --git a/README.md b/README.md index d45676fc..4b86f832 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Make every AI coding agent work by the same harness. -Git-native management of skills, rules, and docs across Claude Code / Codex / CodeBuddy / WorkBuddy and more. +Git-native management of skills, rules, and docs across Claude Code / Codex / CodeBuddy / WorkBuddy / OpenCode and more. For you or your whole team. diff --git a/README.zh-CN.md b/README.zh-CN.md index abb07608..1a97aec2 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -16,7 +16,7 @@ 面向 AI 智能体的团队 Harness 分发工具。 -通过 Git 统一管理 skills、rules、docs,驾驭 Claude Code / Codex / CodeBuddy / WorkBuddy 等多种 AI 工具。 +通过 Git 统一管理 skills、rules、docs,驾驭 Claude Code / Codex / CodeBuddy / WorkBuddy / OpenCode 等多种 AI 工具。 一个人也能用,团队用更强。 diff --git a/docs/usage-guide.md b/docs/usage-guide.md index c4a4c98b..2148c8b2 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -4,7 +4,7 @@ > **teamai-cli** — a shared AI experience framework for teams > -> Helps teams centrally manage and share Skills, Rules, Docs, and Env resources, automatically syncing them to AI coding tools like Claude Code, CodeBuddy, Cursor, Codex, Gemini CLI, and Windsurf. +> Helps teams centrally manage and share Skills, Rules, Docs, and Env resources, automatically syncing them to AI coding tools like Claude Code, CodeBuddy, Cursor, Codex, OpenCode, Gemini CLI, and Windsurf. --- @@ -498,14 +498,15 @@ Where each tool's servers land: | cursor | `~/.cursor/mcp.json` | `/.cursor/mcp.json` | | codebuddy / workbuddy | `~/./mcp.json` | `/./mcp.json` | | codex | `~/.codex/config.toml` | not supported | +| opencode | `~/.config/opencode/opencode.json` | `/opencode.json` | -Codex supports `stdio` and `http`; `sse` is skipped. Ownership is tracked in `~/.teamai/managed-mcp.json` — hand-added servers are left alone; name collisions skip unless `--force`. +Codex supports `stdio` and `http`; `sse` is skipped. OpenCode supports `stdio` (written as its `type:"local"` shape) and `http` (`type:"remote"`); `sse` is skipped, and its servers live under the `mcp` key of the shared `opencode.json`. Ownership is tracked in `~/.teamai/managed-mcp.json` — hand-added servers are left alone; name collisions skip unless `--force`. **Secrets.** Write `${VAR}`, never a literal, in `mcp.yaml`. Values resolve from the environment, then from `env/env.yaml` → `~/.teamai/env`. Unresolved variables skip the server with a hint. teamai **resolves every `${VAR}` to its value and writes it verbatim** into each tool's config (new files are created `0600`). It does not rely on any tool's own env-var expansion: that expansion is fragile — most decisively, IDEs launched from the GUI (Dock/Launchpad) never inherit your shell's exported variables, so a `${VAR}` placeholder expands to empty and the server 401s. Resolving to plaintext makes the token present no matter how the tool is started. -> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.cursor/mcp.json`, `.codebuddy/mcp.json`, `.codex/config.toml`) then contain the literal secret — add them to `.gitignore` and never commit them. +> ⚠️ **The resolved token lands on disk.** Project-scope MCP configs (`.mcp.json`, `.cursor/mcp.json`, `.codebuddy/mcp.json`, `.codex/config.toml`, `opencode.json`) then contain the literal secret — add them to `.gitignore` and never commit them. Claude Code may show project `.mcp.json` servers as pending approval until you accept them once in an interactive session. @@ -936,6 +937,16 @@ team-repo/ `teamai pull` copies these into each Tier-1 tool's `agents/` directory (e.g. `~/.claude/agents/`). The CLI's built-in `teamai-recall.md` is deployed alongside team agents but is not uploaded by `teamai push`. +### OpenCode + +[OpenCode](https://opencode.ai) is supported as a first-class tool. Because its config layout differs from the Claude family, teamai handles a few things specially: + +- **Scopes.** OpenCode's user config lives under `~/.config/opencode/` while its project config lives under `/.opencode/` — a different prefix from every other tool. teamai writes to the correct one per `--scope`, and only ever touches OpenCode files when OpenCode is actually installed for that scope (it never creates `~/.config/opencode/` for a non-user). +- **Skills** land in `.opencode/skills/` (project) or `~/.config/opencode/skills/` (user). OpenCode also reads `.claude/skills` natively, but teamai writes the OpenCode path too so an OpenCode-only user still gets them. +- **Subagents** are rendered into OpenCode's own `agents/*.md` format: frontmatter carries `description` + `mode: subagent` (plus `model` and any `tool_extras.opencode` fields such as `temperature`); the agent name comes from the filename. OpenCode does **not** read `.claude/agents`, so this native copy is required. +- **Rules** are copied into `.opencode/rules/` (or `~/.config/opencode/rules/`), but OpenCode does not auto-scan a rules directory — the files are inert until referenced. teamai therefore adds a `rules/*.md` glob to the `instructions` array in `opencode.json` and removes it again when the team's last rule goes away, editing only that one key and leaving your own `instructions` entries untouched. +- **MCP** servers live under the `mcp` key of the shared `opencode.json` (see the MCP section above). + ### Miscellaneous ```bash diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 1d1d49e6..f1be1963 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -4,7 +4,7 @@ > **@tencent/teamai-cli** — 团队 AI 经验共享框架 > -> 帮助团队统一管理和共享 Skills、Rules、Docs、Env 等资源,自动同步到 Claude Code、CodeBuddy、Cursor、Codex、Gemini CLI、Windsurf 等 AI 编程工具中。 +> 帮助团队统一管理和共享 Skills、Rules、Docs、Env 等资源,自动同步到 Claude Code、CodeBuddy、Cursor、Codex、OpenCode、Gemini CLI、Windsurf 等 AI 编程工具中。 --- @@ -496,14 +496,15 @@ servers: | cursor | `~/.cursor/mcp.json` | `/.cursor/mcp.json` | | codebuddy / workbuddy | `~/./mcp.json` | `/./mcp.json` | | codex | `~/.codex/config.toml` | 不支持 | +| opencode | `~/.config/opencode/opencode.json` | `/opencode.json` | -Codex 支持 `stdio` 与 `http`,`sse` 会被跳过。归属记录在 `~/.teamai/managed-mcp.json`——手动添加的 server 不动;与手写同名则跳过,除非 `--force`。 +Codex 支持 `stdio` 与 `http`,`sse` 会被跳过。OpenCode 支持 `stdio`(写成其 `type:"local"` 形态)与 `http`(`type:"remote"`),`sse` 会被跳过,其 server 位于共享 `opencode.json` 的 `mcp` 键下。归属记录在 `~/.teamai/managed-mcp.json`——手动添加的 server 不动;与手写同名则跳过,除非 `--force`。 **密钥**:在 `mcp.yaml` 里写 `${VAR}`,不要写明文。取值优先来自环境变量,其次是 `env/env.yaml` → `~/.teamai/env`。变量无法解析则跳过并提示。 teamai 会**把每个 `${VAR}` 解析成取值后原样写入**各工具的配置文件(新建文件权限为 `0600`)。它不依赖任何工具自身的环境变量展开——因为那种展开很脆弱:最典型的是,以 GUI 方式(Dock/Launchpad)启动的 IDE 不会继承你 shell 中 `export` 的变量,`${VAR}` 占位符会展开为空、导致服务端 401。解析成明文可以保证无论工具如何启动,token 都在。 -> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.cursor/mcp.json`、`.codebuddy/mcp.json`、`.codex/config.toml`)因此含有明文密钥——请把它们加入 `.gitignore`,切勿提交。 +> ⚠️ **解析后的 token 会落盘。** 项目级 MCP 配置(`.mcp.json`、`.cursor/mcp.json`、`.codebuddy/mcp.json`、`.codex/config.toml`、`opencode.json`)因此含有明文密钥——请把它们加入 `.gitignore`,切勿提交。 Claude Code 可能把来自仓库的 `.mcp.json` 标为待批准,需在交互式会话中确认一次。 @@ -931,6 +932,16 @@ team-repo/ `teamai pull` 会将它们复制到每个 Tier-1 工具的 `agents/` 目录(如 `~/.claude/agents/`)。CLI 内置的 `teamai-recall.md` 与团队 agents 并列部署,但不会被 `teamai push` 上传。 +### OpenCode + +[OpenCode](https://opencode.ai) 已作为一等工具支持。由于它的配置布局与 Claude 系不同,teamai 对以下几点做了特殊处理: + +- **作用域。** OpenCode 的用户配置在 `~/.config/opencode/` 下,项目配置在 `/.opencode/` 下——前缀与其他所有工具都不同。teamai 会按 `--scope` 写入正确的位置,且仅在该作用域确实安装了 OpenCode 时才碰它的文件(绝不会为未使用 OpenCode 的用户创建 `~/.config/opencode/`)。 +- **Skills** 落在 `.opencode/skills/`(项目)或 `~/.config/opencode/skills/`(用户)。OpenCode 也原生读取 `.claude/skills`,但 teamai 仍会写 OpenCode 路径,好让只用 OpenCode 的用户也能拿到。 +- **Subagents** 会被渲染成 OpenCode 自己的 `agents/*.md` 格式:frontmatter 带 `description` + `mode: subagent`(以及 `model` 和 `tool_extras.opencode` 中的字段,如 `temperature`);agent 名取自文件名。OpenCode **不**读取 `.claude/agents`,因此这份原生副本是必需的。 +- **Rules** 会被复制到 `.opencode/rules/`(或 `~/.config/opencode/rules/`),但 OpenCode 不会自动扫描 rules 目录——文件在被引用前是惰性的。因此 teamai 会往 `opencode.json` 的 `instructions` 数组里加一条 `rules/*.md` glob,并在团队最后一条 rule 消失时再把它移除,且只编辑这一个键、不动你自己的 `instructions` 条目。 +- **MCP** server 位于共享 `opencode.json` 的 `mcp` 键下(详见上文 MCP 章节)。 + ### 其他 ```bash diff --git a/src/__tests__/agent-format.test.ts b/src/__tests__/agent-format.test.ts index 86162765..ef9547f7 100644 --- a/src/__tests__/agent-format.test.ts +++ b/src/__tests__/agent-format.test.ts @@ -28,10 +28,13 @@ import { renderForCodex, renderForCodexInternal, renderForCursor, + renderForOpencode, reverseFromClaude, reverseFromCodebuddy, reverseFromCodex, reverseFromCursor, + reverseFromOpencode, + renderForTool, mergeReverseResults, } from '../resources/agent-format.js'; import type { AgentSpec, ToolName, ParseResult } from '../resources/agent-format.js'; @@ -362,6 +365,101 @@ describe('reverseFromCursor', () => { }); }); +// ─── renderForOpencode ─────────────────────────────────────────────────────── + +describe('renderForOpencode', () => { + it('emits description + mode:subagent and omits name', () => { + const spec = makeSpec(); + const { ext, content } = renderForOpencode(spec); + expect(ext).toBe('.md'); + expect(content).toContain('description: A test agent for unit tests'); + expect(content).toContain('mode: subagent'); + // OpenCode derives the name from the filename — it must NOT be in frontmatter. + expect(content).not.toMatch(/^name:/m); + expect(content).toContain('You are a helpful assistant.'); + }); + + it('includes model when present', () => { + const spec = makeSpec({ model: 'anthropic/claude-sonnet-4' }); + const { content } = renderForOpencode(spec); + expect(content).toContain('model: anthropic/claude-sonnet-4'); + }); + + it('does NOT emit the deprecated tools field', () => { + const spec = makeSpec({ tools: ['read', 'write'] }); + const { content } = renderForOpencode(spec); + expect(content).not.toMatch(/^tools:/m); + }); + + it('flattens tool_extras.opencode (permission/temperature) into frontmatter', () => { + const spec = makeSpec({ tool_extras: { opencode: { temperature: 0.1, permission: { edit: 'deny' } } } }); + const { content } = renderForOpencode(spec); + expect(content).toContain('temperature: 0.1'); + expect(content).toContain('edit: deny'); + }); + + it('renderForTool dispatches opencode to renderForOpencode', () => { + const spec = makeSpec(); + const viaTool = renderForTool(spec, 'opencode'); + const direct = renderForOpencode(spec); + expect(viaTool).toEqual(direct); + }); +}); + +// ─── reverseFromOpencode ───────────────────────────────────────────────────── + +describe('reverseFromOpencode', () => { + it('derives name from filename and reads description', () => { + const content = `---\ndescription: OpenCode helper\nmode: subagent\n---\nInstructions here\n`; + const result = reverseFromOpencode('/agents/oc-agent.md', content); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.spec.name).toBe('oc-agent'); + expect(result.spec.description).toBe('OpenCode helper'); + }); + + it('collects mode/permission into tool_extras.opencode', () => { + const content = `---\ndescription: b\nmode: subagent\ntemperature: 0.2\n---\nBody\n`; + const result = reverseFromOpencode('/agents/a.md', content); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.spec.tool_extras?.['opencode']).toEqual({ mode: 'subagent', temperature: 0.2 }); + }); + + it('moves model into the common field, not extras', () => { + const content = `---\ndescription: b\nmodel: anthropic/claude-sonnet-4\n---\nBody\n`; + const result = reverseFromOpencode('/agents/a.md', content); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.spec.model).toBe('anthropic/claude-sonnet-4'); + expect(result.spec.tool_extras?.['opencode']).toBeUndefined(); + }); + + it('returns error on missing description', () => { + const content = `---\nmode: subagent\n---\nBody\n`; + const result = reverseFromOpencode('/agents/a.md', content); + expect(result.ok).toBe(false); + }); + + it('returns error on empty body', () => { + const content = `---\ndescription: b\n---\n\n`; + const result = reverseFromOpencode('/agents/a.md', content); + expect(result.ok).toBe(false); + }); + + it('round-trips render → reverse preserving common fields', () => { + const spec = makeSpec({ model: 'anthropic/claude-sonnet-4' }); + const { content } = renderForOpencode(spec); + const result = reverseFromOpencode('/agents/test-agent.md', content); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.spec.name).toBe('test-agent'); + expect(result.spec.description).toBe(spec.description); + expect(result.spec.instructions).toBe(spec.instructions); + expect(result.spec.model).toBe('anthropic/claude-sonnet-4'); + }); +}); + // ─── mergeReverseResults ───────────────────────────────────────────────────── describe('mergeReverseResults', () => { diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index 969c86e9..b1c286d8 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -495,6 +495,140 @@ servers: }); }); +describe('MCP reconcile — OpenCode', () => { + let tmpDir: string; + let homeDir: string; + let repoPath: string; + let teamConfig: TeamaiConfig; + let localConfig: LocalConfig; + + const OPENCODE_TOOL_PATHS = { + 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' }, + }, + }; + + async function writeMcpYaml(body: string): Promise { + await fse.ensureDir(path.join(repoPath, 'mcp')); + await fse.writeFile(path.join(repoPath, 'mcp', 'mcp.yaml'), body); + } + + beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-mcp-oc-test-')); + homeDir = path.join(tmpDir, 'home'); + repoPath = path.join(tmpDir, 'team-repo'); + // OpenCode "installed" at user scope lives under ~/.config/opencode. + await fse.ensureDir(path.join(homeDir, '.config', 'opencode', 'skills')); + await fse.ensureDir(path.join(homeDir, '.teamai')); + vi.stubEnv('HOME', homeDir); + + teamConfig = { + team: 't', description: '', repo: 'r', provider: 'tgit', reviewers: [], + sharing: { + skills: {}, rules: { enforced: [] }, docs: { localDir: '~/.teamai/docs' }, + env: { injectShellProfile: false }, mcp: { autoApply: true, allowedCommands: [], allowedHosts: [] }, + }, + toolPaths: OPENCODE_TOOL_PATHS, + } as unknown as TeamaiConfig; + + localConfig = { + repo: { localPath: repoPath, remote: 'r' }, + username: 'u', scope: 'user', additionalRoles: [], + } as unknown as LocalConfig; + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await fse.remove(tmpDir); + }); + + const ocConfig = () => path.join(homeDir, '.config', 'opencode', 'opencode.json'); + + it('writes servers under the `mcp` key, not `mcpServers`, in local shape', async () => { + await writeMcpYaml(` +servers: + - name: local-srv + transport: stdio + command: my-server + args: ["--port", "3000"] + env: + FOO: bar +`); + await reconcileMcpForConfig(teamConfig, localConfig); + + const doc = await fse.readJson(ocConfig()); + expect(doc.mcpServers).toBeUndefined(); + expect(doc.mcp['local-srv']).toEqual({ + type: 'local', + command: ['my-server', '--port', '3000'], + environment: { FOO: 'bar' }, + enabled: true, + }); + }); + + it('renders a remote (http) server with url + headers', async () => { + await writeMcpYaml(` +servers: + - name: remote-srv + transport: http + url: https://example.com/mcp + headers: + Authorization: Bearer tok +`); + await reconcileMcpForConfig(teamConfig, localConfig); + + const doc = await fse.readJson(ocConfig()); + expect(doc.mcp['remote-srv']).toEqual({ + type: 'remote', + url: 'https://example.com/mcp', + headers: { Authorization: 'Bearer tok' }, + enabled: true, + }); + }); + + it('preserves unrelated keys (instructions) and the user\'s own mcp entries', async () => { + await fse.ensureDir(path.dirname(ocConfig())); + await fse.writeJson(ocConfig(), { + $schema: 'https://opencode.ai/config.json', + instructions: ['.opencode/rules/*.md'], + mcp: { mine: { type: 'local', command: ['x'], enabled: true } }, + }); + await writeMcpYaml(` +servers: + - name: team-srv + transport: http + url: https://team.example/mcp +`); + await reconcileMcpForConfig(teamConfig, localConfig); + + const doc = await fse.readJson(ocConfig()); + expect(doc.$schema).toBe('https://opencode.ai/config.json'); + expect(doc.instructions).toEqual(['.opencode/rules/*.md']); + expect(doc.mcp.mine).toEqual({ type: 'local', command: ['x'], enabled: true }); + expect(doc.mcp['team-srv'].type).toBe('remote'); + }); + + it('removes a dropped team server from the mcp key on a later run', async () => { + await writeMcpYaml(` +servers: + - name: temp + transport: http + url: https://example.com/mcp +`); + await reconcileMcpForConfig(teamConfig, localConfig); + expect((await fse.readJson(ocConfig())).mcp.temp).toBeDefined(); + + await writeMcpYaml(`servers: []\n`); + await reconcileMcpForConfig(teamConfig, localConfig); + expect((await fse.readJson(ocConfig())).mcp.temp).toBeUndefined(); + }); +}); + describe('spliceCodexBlock', () => { it('replaces a block and its nested env sub-table, leaving neighbours intact', () => { const src = [ diff --git a/src/__tests__/opencode-config.test.ts b/src/__tests__/opencode-config.test.ts new file mode 100644 index 00000000..00f5a29b --- /dev/null +++ b/src/__tests__/opencode-config.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import path from 'node:path'; +import os from 'node:os'; +import fse from 'fs-extra'; + +vi.mock('../utils/logger.js', () => ({ + log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), dim: vi.fn() }, +})); + +import { reconcileOpencodeInstructions, opencodeRulesGlob } from '../resources/opencode-config.js'; + +describe('opencodeRulesGlob', () => { + it('project scope: config at root, rules under .opencode/rules', () => { + const glob = opencodeRulesGlob('/repo/opencode.json', '/repo/.opencode/rules'); + expect(glob).toBe('.opencode/rules/*.md'); + }); + + it('user scope: both under ~/.config/opencode', () => { + const glob = opencodeRulesGlob('/home/u/.config/opencode/opencode.json', '/home/u/.config/opencode/rules'); + expect(glob).toBe('rules/*.md'); + }); +}); + +describe('reconcileOpencodeInstructions', () => { + let tmpDir: string; + let configFile: string; + const GLOB = '.opencode/rules/*.md'; + + beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-oc-cfg-')); + configFile = path.join(tmpDir, 'opencode.json'); + }); + + afterEach(async () => { + await fse.remove(tmpDir); + }); + + it('creates opencode.json with just the glob when absent and present=true', async () => { + const wrote = await reconcileOpencodeInstructions(configFile, GLOB, true); + expect(wrote).toBe(true); + expect(await fse.readJson(configFile)).toEqual({ instructions: [GLOB] }); + }); + + it('does nothing when absent and present=false', async () => { + const wrote = await reconcileOpencodeInstructions(configFile, GLOB, false); + expect(wrote).toBe(false); + expect(await fse.pathExists(configFile)).toBe(false); + }); + + it('adds the glob to an existing instructions array, preserving other entries and keys', async () => { + await fse.writeJson(configFile, { + $schema: 'https://opencode.ai/config.json', + instructions: ['CONTRIBUTING.md'], + mcp: { srv: { type: 'local', command: ['x'], enabled: true } }, + }); + const wrote = await reconcileOpencodeInstructions(configFile, GLOB, true); + expect(wrote).toBe(true); + const doc = await fse.readJson(configFile); + expect(doc.instructions).toEqual(['CONTRIBUTING.md', GLOB]); + expect(doc.$schema).toBe('https://opencode.ai/config.json'); + expect(doc.mcp.srv).toBeDefined(); + }); + + it('is idempotent — adding an already-present glob does not write', async () => { + await fse.writeJson(configFile, { instructions: [GLOB] }); + const wrote = await reconcileOpencodeInstructions(configFile, GLOB, true); + expect(wrote).toBe(false); + }); + + it('removes only our glob, keeping the user\'s own instructions', async () => { + await fse.writeJson(configFile, { instructions: ['CONTRIBUTING.md', GLOB] }); + const wrote = await reconcileOpencodeInstructions(configFile, GLOB, false); + expect(wrote).toBe(true); + expect((await fse.readJson(configFile)).instructions).toEqual(['CONTRIBUTING.md']); + }); + + it('drops the instructions key entirely when removing the last (all-string) entry', async () => { + await fse.writeJson(configFile, { instructions: [GLOB], mcp: {} }); + await reconcileOpencodeInstructions(configFile, GLOB, false); + const doc = await fse.readJson(configFile); + expect(doc.instructions).toBeUndefined(); + expect(doc.mcp).toBeDefined(); + }); + + it('leaves a malformed (non-object) config untouched and returns false', async () => { + await fse.writeFile(configFile, '["not", "an", "object"]'); + const wrote = await reconcileOpencodeInstructions(configFile, GLOB, true); + expect(wrote).toBe(false); + expect(await fse.readFile(configFile, 'utf-8')).toBe('["not", "an", "object"]'); + }); + + it('treats an empty file as an empty object and adds the glob', async () => { + await fse.writeFile(configFile, ''); + const wrote = await reconcileOpencodeInstructions(configFile, GLOB, true); + expect(wrote).toBe(true); + expect((await fse.readJson(configFile)).instructions).toEqual([GLOB]); + }); + + it('preserves the position of non-string entries when adding the glob', async () => { + const objEntry = { path: 'dynamic.md', enabled: true }; + await fse.writeJson(configFile, { instructions: [objEntry, 'user.md'] }); + const wrote = await reconcileOpencodeInstructions(configFile, GLOB, true); + expect(wrote).toBe(true); + // The object entry must NOT be relocated to the end — only our glob is appended. + expect((await fse.readJson(configFile)).instructions).toEqual([objEntry, 'user.md', GLOB]); + }); + + it('preserves the position of non-string entries when removing the glob', async () => { + const objEntry = { path: 'dynamic.md', enabled: true }; + await fse.writeJson(configFile, { instructions: [objEntry, 'user.md', GLOB] }); + const wrote = await reconcileOpencodeInstructions(configFile, GLOB, false); + expect(wrote).toBe(true); + expect((await fse.readJson(configFile)).instructions).toEqual([objEntry, 'user.md']); + }); + + it('keeps a non-string entry even when it is the only survivor after removing the glob', async () => { + const objEntry = { path: 'dynamic.md' }; + await fse.writeJson(configFile, { instructions: [objEntry, GLOB] }); + const wrote = await reconcileOpencodeInstructions(configFile, GLOB, false); + expect(wrote).toBe(true); + expect((await fse.readJson(configFile)).instructions).toEqual([objEntry]); + }); +}); diff --git a/src/__tests__/rules.test.ts b/src/__tests__/rules.test.ts index fce4146b..eb8ce99d 100644 --- a/src/__tests__/rules.test.ts +++ b/src/__tests__/rules.test.ts @@ -585,3 +585,81 @@ scope: 'user', expect(await fse.pathExists(path.join(localRulesDir, 'old-user-rule.md'))).toBe(false); }); }); + +describe('RulesHandler.pullAllRules — OpenCode instructions activation', () => { + let tmpDir: string; + let homeDir: string; + let handler: RulesHandler; + let teamConfig: TeamaiConfig; + let localConfig: LocalConfig; + + beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-rules-oc-')); + homeDir = path.join(tmpDir, 'home'); + const repoPath = path.join(tmpDir, 'team-repo'); + await fse.ensureDir(path.join(repoPath, 'rules')); + // OpenCode installed at user scope: ~/.config/opencode present. + await fse.ensureDir(path.join(homeDir, '.config', 'opencode', 'skills')); + vi.stubEnv('HOME', homeDir); + + handler = new RulesHandler(); + teamConfig = { + team: 'test', description: '', repo: 'r', provider: 'tgit' as const, reviewers: [], + sharing: { skills: {}, rules: { enforced: [] }, docs: { localDir: '' }, env: { injectShellProfile: true } }, + toolPaths: { + 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' }, + }, + }, + } as unknown as TeamaiConfig; + + localConfig = { + repo: { localPath: repoPath, remote: 'r' }, + username: 'u', additionalRoles: [], scope: 'user', + } as unknown as LocalConfig; + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await fse.remove(tmpDir); + }); + + const ocConfig = () => path.join(homeDir, '.config', 'opencode', 'opencode.json'); + const ocRules = () => path.join(homeDir, '.config', 'opencode', 'rules'); + + it('copies rule files to ~/.config/opencode/rules and adds the instructions glob', async () => { + await fse.writeFile(path.join(localConfig.repo.localPath, 'rules', 'team-rule.md'), 'team content'); + + await handler.pullAllRules(teamConfig, localConfig); + + // File landed under the user-scope OpenCode rules dir. + expect(await fse.pathExists(path.join(ocRules(), 'team-rule.md'))).toBe(true); + // opencode.json now references the teamai glob (user scope → 'rules/*.md'). + const doc = await fse.readJson(ocConfig()); + expect(doc.instructions).toContain('rules/*.md'); + }); + + it('removes the instructions glob when the team has no rules left', async () => { + // First: one rule → glob present. + await fse.writeFile(path.join(localConfig.repo.localPath, 'rules', 'r.md'), 'x'); + await handler.pullAllRules(teamConfig, localConfig); + expect((await fse.readJson(ocConfig())).instructions).toContain('rules/*.md'); + + // Then: remove the team rule and re-pull → glob gone. + await fse.remove(path.join(localConfig.repo.localPath, 'rules', 'r.md')); + await handler.pullAllRules(teamConfig, localConfig); + const doc = await fse.readJson(ocConfig()); + expect(doc.instructions ?? []).not.toContain('rules/*.md'); + }); + + it('does not create opencode.json when OpenCode is not installed', async () => { + // Remove the install marker. + await fse.remove(path.join(homeDir, '.config', 'opencode')); + await fse.writeFile(path.join(localConfig.repo.localPath, 'rules', 'team-rule.md'), 'content'); + + await handler.pullAllRules(teamConfig, localConfig); + expect(await fse.pathExists(ocConfig())).toBe(false); + }); +}); diff --git a/src/__tests__/skip-uninstalled-tools.test.ts b/src/__tests__/skip-uninstalled-tools.test.ts index 4c588bf1..96b24228 100644 --- a/src/__tests__/skip-uninstalled-tools.test.ts +++ b/src/__tests__/skip-uninstalled-tools.test.ts @@ -75,6 +75,21 @@ describe('ResourceHandler.isToolInstalled', () => { await fse.ensureDir(path.join(homeDir, '.codex-internal')); expect(await ResourceHandler.isToolInstalled('.codex-internal/skills')).toBe(true); }); + + it('uses .config/opencode (not .config) as the OpenCode user-scope root', async () => { + // A bare .config dir must NOT count as OpenCode installed. + await fse.ensureDir(path.join(homeDir, '.config')); + expect(await ResourceHandler.isToolInstalled('.config/opencode/skills')).toBe(false); + await fse.ensureDir(path.join(homeDir, '.config/opencode')); + expect(await ResourceHandler.isToolInstalled('.config/opencode/skills')).toBe(true); + }); + + it('uses the first segment as the root for openclaw 3-segment claudemd paths', async () => { + // .openclaw/workspace/AGENTS.md → root is .openclaw, not .openclaw/workspace. + expect(await ResourceHandler.isToolInstalled('.openclaw/workspace/AGENTS.md')).toBe(false); + await fse.ensureDir(path.join(homeDir, '.openclaw')); + expect(await ResourceHandler.isToolInstalled('.openclaw/workspace/AGENTS.md')).toBe(true); + }); }); describe('SkillsHandler.pullItem — skip uninstalled tools', () => { @@ -473,4 +488,46 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { expect(await fse.pathExists(path.join(skillDir, 'references/methodology/phase0-collection.md'))).toBe(true); expect(await fse.pathExists(path.join(skillDir, 'scripts/scan_repo.py'))).toBe(true); }); + + it('deploys built-in skills to OpenCode user scope under .config/opencode/skills', async () => { + const { deployBuiltinSkills } = await import('../builtin-skills.js'); + + // OpenCode-only user: config lives at ~/.config/opencode, no ~/.opencode. + await fse.ensureDir(path.join(homeDir, '.config/opencode')); + + const teamConfig = { + team: 'test', + description: '', + repo: 'https://git.woa.com/test/repo.git', + provider: 'tgit' as const, + reviewers: [], + sharing: { + skills: {}, + rules: { enforced: [] }, + docs: { localDir: '' }, + env: { injectShellProfile: true }, + }, + toolPaths: { + opencode: { + skills: '.opencode/skills', + userScope: { skills: '.config/opencode/skills' }, + }, + }, + }; + + const localConfig = { + repo: { localPath: path.join(tmpDir, 'repo'), remote: 'https://git.woa.com/test/repo.git' }, + username: 'testuser', + updatePolicy: 'auto' as const, + additionalRoles: [], + scope: 'user' as const, + }; + + const deployed = await deployBuiltinSkills(teamConfig, localConfig); + + expect(deployed).toBeGreaterThan(0); + // Written to the user-scope path, NOT the project-scope .opencode/skills. + expect(await fse.pathExists(path.join(homeDir, '.config/opencode/skills/team-wiki-codebase/SKILL.md'))).toBe(true); + expect(await fse.pathExists(path.join(homeDir, '.opencode'))).toBe(false); + }); }); diff --git a/src/builtin-agents.ts b/src/builtin-agents.ts index 239621de..4d82a608 100644 --- a/src/builtin-agents.ts +++ b/src/builtin-agents.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import { ensureDir, pathExists, copyFile } from './utils/fs.js'; import { log } from './utils/logger.js'; import type { TeamaiConfig, LocalConfig } from './types.js'; -import { resolveBaseDir, isAgentDisabled } from './types.js'; +import { resolveBaseDir, isAgentDisabled, scopedToolPaths } from './types.js'; import { ResourceHandler } from './resources/base.js'; import { getUserHome } from './utils/home.js'; @@ -79,7 +79,7 @@ export async function deployBuiltinAgents( const baseDir = localConfig ? resolveBaseDir(localConfig) : getUserHome(); let deployed = 0; - for (const [tool, toolPath] of Object.entries(teamConfig.toolPaths)) { + for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig ?? {}))) { if (!toolPath.agents) { log.debug(`Skipping built-in agent deployment for ${tool}: no agents path`); continue; diff --git a/src/builtin-rules.ts b/src/builtin-rules.ts index 1b41fbf5..d7c533ae 100644 --- a/src/builtin-rules.ts +++ b/src/builtin-rules.ts @@ -3,7 +3,7 @@ import { ensureDir, writeFile, pathExists } from './utils/fs.js'; import { log } from './utils/logger.js'; import { ResourceHandler } from './resources/base.js'; import type { TeamaiConfig, LocalConfig } from './types.js'; -import { resolveBaseDir, isAgentDisabled } from './types.js'; +import { resolveBaseDir, isAgentDisabled, scopedToolPaths } from './types.js'; import fs from 'node:fs/promises'; import { getUserHome } from './utils/home.js'; @@ -56,7 +56,7 @@ export async function deployBuiltinRules( { name: 'teamai-recall', content: TEAMAI_RECALL_RULE_CONTENT }, ].filter(r => !(options?.skipRecall && r.name === 'teamai-recall')); - for (const [tool, toolPath] of Object.entries(teamConfig.toolPaths)) { + for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig ?? {}))) { if (!toolPath.rules) continue; // Skip tools that are not installed diff --git a/src/builtin-skills.ts b/src/builtin-skills.ts index 203d7f4c..9046ebd2 100644 --- a/src/builtin-skills.ts +++ b/src/builtin-skills.ts @@ -5,7 +5,7 @@ import fse from 'fs-extra'; import { pathExists } from './utils/fs.js'; import { log } from './utils/logger.js'; import type { TeamaiConfig, LocalConfig } from './types.js'; -import { resolveBaseDir, isAgentDisabled } from './types.js'; +import { resolveBaseDir, isAgentDisabled, scopedToolPaths } from './types.js'; import { ResourceHandler } from './resources/base.js'; import { ensureSkillFrontmatter } from './resources/skills.js'; import { getUserHome } from './utils/home.js'; @@ -101,7 +101,7 @@ export async function deployBuiltinSkills(teamConfig: TeamaiConfig, localConfig? const baseDir = localConfig ? resolveBaseDir(localConfig) : getUserHome(); let deployed = 0; - for (const [tool, toolPath] of Object.entries(teamConfig.toolPaths)) { + for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig ?? {}))) { if (!toolPath.skills) continue; // Skip tools that are not installed diff --git a/src/known-agents.ts b/src/known-agents.ts index c773779c..e2801dc4 100644 --- a/src/known-agents.ts +++ b/src/known-agents.ts @@ -1,7 +1,8 @@ import path from 'node:path'; import { pathExists, ensureDir } from './utils/fs.js'; -import { resolveBaseDir, isAgentDisabled } from './types.js'; -import type { LocalConfig, TeamaiConfig } from './types.js'; +import { resolveBaseDir, isAgentDisabled, scopedToolPaths } from './types.js'; +import { toolInstallRoot } from './resources/base.js'; +import type { LocalConfig, TeamaiConfig, Scope } from './types.js'; import { getUserHome } from './utils/home.js'; /** @@ -193,14 +194,18 @@ export async function detectHomeInstalledAgents( return found; } -export function getEffectiveAgents(teamConfig: TeamaiConfig): KnownAgent[] { +export function getEffectiveAgents( + teamConfig: TeamaiConfig, + localConfig?: { scope?: Scope }, +): KnownAgent[] { const byId = new Map(); for (const agent of KNOWN_AGENTS) { byId.set(agent.id, { ...agent }); } - for (const [id, paths] of Object.entries(teamConfig.toolPaths)) { + const toolPaths = scopedToolPaths(teamConfig, localConfig ?? {}); + for (const [id, paths] of Object.entries(toolPaths)) { if (!paths.skills) continue; const existing = byId.get(id); if (existing) { @@ -228,18 +233,18 @@ export function getEffectiveAgents(teamConfig: TeamaiConfig): KnownAgent[] { */ export async function detectInstalledAgents(localConfig: LocalConfig, teamConfig: TeamaiConfig): Promise { const baseDir = resolveBaseDir(localConfig); - const agents = getEffectiveAgents(teamConfig); + const agents = getEffectiveAgents(teamConfig, localConfig); + const scoped = scopedToolPaths(teamConfig, localConfig); const fromTeamConfig = new Set( - Object.entries(teamConfig.toolPaths) + Object.entries(scoped) .filter(([, paths]) => paths.skills) .map(([id]) => id), ); const results: ResolvedAgent[] = []; for (const agent of agents) { - const segments = agent.skillsPath.split('/'); - const rootSegment = segments[0] ?? ''; - const rootPath = `${baseDir}/${rootSegment}`; + const rootSegment = toolInstallRoot(agent.skillsPath); + const rootPath = path.join(baseDir, rootSegment); const installed = rootSegment ? await pathExists(rootPath) : false; results.push({ ...agent, diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index 124bde7d..eb2eede4 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -12,6 +12,7 @@ import { getEnvBackupPath, managedMcpManifestPath, resolveBaseDir, + scopedToolPaths, } from './types.js'; import { detectMcpFormat, @@ -22,6 +23,7 @@ import { resolvePlaceholders, referencedVars, entryHash, + MCP_SERVER_KEY, type McpFormat, } from './resources/mcp-format.js'; import { parseTeamMcpServers } from './resources/mcp.js'; @@ -188,7 +190,9 @@ export async function resolveMcpTargets( const projectScope = localConfig.scope === 'project'; const targets: McpTarget[] = []; - for (const [tool, paths] of Object.entries(teamConfig.toolPaths)) { + // Skills/settings/agents probe paths must reflect the active scope: OpenCode's + // user-scope resources live under ~/.config/opencode, not ~/.opencode. + for (const [tool, paths] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { const format = detectMcpFormat(tool); if (!format) continue; @@ -202,7 +206,11 @@ export async function resolveMcpTargets( const probe = paths.skills ?? paths.settings ?? paths.agents; if (!probe) continue; - const toolRoot = path.join(baseDir, probe.split('/')[0]); + // Probe the tool's root dir (the resource dir's parent), so a multi-segment + // path like `.config/opencode/skills` resolves to `.config/opencode` rather + // than the near-universal `.config`. Matches ResourceHandler.isToolInstalled. + const probeDir = path.dirname(probe); + const toolRoot = probeDir === '.' ? path.join(baseDir, probe) : path.join(baseDir, probeDir); if (!await pathExists(toolRoot)) { log.debug(`Skipping MCP sync for ${tool}: tool not installed`); continue; @@ -225,7 +233,7 @@ interface JsonDoc { * parsed — we abandon the injection rather than risk clobbering a file we do * not understand (it may hold the user's OAuth session). */ -async function readJsonDoc(file: string): Promise { +async function readJsonDoc(file: string, serverKey: string): Promise { if (!await pathExists(file)) return { data: {}, servers: {} }; const raw = await readFileSafe(file); if (raw === null) return null; @@ -233,7 +241,7 @@ async function readJsonDoc(file: string): Promise { try { const data = JSON.parse(raw) as Record; if (typeof data !== 'object' || data === null || Array.isArray(data)) return null; - const servers = (data.mcpServers as Record) ?? {}; + const servers = (data[serverKey] as Record) ?? {}; if (typeof servers !== 'object' || servers === null || Array.isArray(servers)) return null; return { data, servers: { ...servers } }; } catch { @@ -410,7 +418,8 @@ async function applyJson( changes: McpChange[], options: McpReconcileOptions, ): Promise { - const doc = await readJsonDoc(target.file); + const serverKey = MCP_SERVER_KEY[target.format as Exclude]; + const doc = await readJsonDoc(target.file, serverKey); if (!doc) { log.warn(`Could not parse ${target.file} — skipping MCP injection for ${target.tool}`); return false; @@ -449,7 +458,10 @@ async function applyJson( if (!dirty || options.dryRun) return false; // Key-level surgery: every unrelated top-level key is carried over untouched. - doc.data.mcpServers = doc.servers; + // Some tools (OpenCode) key the server map under `mcp`, not `mcpServers`; + // writing the wrong key would strip the servers and, worse, leave a phantom + // empty `mcpServers` in a file the tool never reads under that name. + doc.data[serverKey] = doc.servers; await writeJsonAtomic(target.file, doc.data); return true; } diff --git a/src/pull.ts b/src/pull.ts index 0059d5f6..2b13b938 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -24,6 +24,7 @@ import { getTeamaiHome, isRecallEnabled, isAgentDisabled, + scopedToolPaths, } from './types.js'; import type { CultureFrontmatter } from './types.js'; import { loadRolesManifest, resolveRoleResourceNamespaces, type ResourceNamespaces } from './roles.js'; @@ -203,7 +204,7 @@ export async function cleanupInactiveNamespaceSkills( ): Promise { const baseDir = resolveBaseDir(localConfig); - for (const [tool, toolPath] of Object.entries(teamConfig.toolPaths)) { + for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { if (isAgentDisabled(localConfig, tool)) continue; if (!toolPath.skills) continue; if (!await ResourceHandler.isToolInstalled(toolPath.skills, baseDir)) continue; @@ -237,7 +238,7 @@ async function getExistingLocalNames( if (type === 'skills') { // Check the first installed tool's skills directory - for (const [_tool, toolPath] of Object.entries(teamConfig.toolPaths)) { + for (const [_tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { if (!toolPath.skills) continue; const skillsDir = path.join(baseDir, toolPath.skills); if (!await pathExists(skillsDir)) continue; @@ -392,15 +393,21 @@ async function pullForScope( const knowledgeNs = roleContext ? roleContext.activeNamespaces.knowledge : null; const roleFiltered = filterRulesByKnowledgeNamespaces(allItems, knowledgeNs); const { included: items, skipped } = filterByTags(roleFiltered, tagsConfig, subscribedTags, 'rules'); - if (items.length > 0) { - if (options.dryRun) { + if (options.dryRun) { + if (items.length > 0) { log.info(`[${scopeLabel}] [dry-run] Would sync ${items.length} rule(s)${skipped.length > 0 ? ` (skipped ${skipped.length} by tags)` : ''}`); - } else { - await rulesHandler.pullAllRules(freshConfig, localConfig, items); + } + } else { + // Always call pullAllRules, even with an empty set: it also cleans up + // stale local rule files and deactivates the OpenCode instructions glob + // when the team's last rule is removed. Guarding on items.length > 0 + // would leak those artifacts on the machine after upstream deletion. + await rulesHandler.pullAllRules(freshConfig, localConfig, items); + if (items.length > 0) { log.success(`[${scopeLabel}] Synced ${items.length} rule(s)${skipped.length > 0 ? ` (skipped ${skipped.length} by tags)` : ''}`); } - totalSynced += items.length; } + totalSynced += items.length; continue; } @@ -527,7 +534,7 @@ async function pullForScope( const tombstones = await handler.readTombstones(localConfig); if (tombstones.size === 0) continue; - for (const [tool, toolPath] of Object.entries(freshConfig.toolPaths)) { + for (const [tool, toolPath] of Object.entries(scopedToolPaths(freshConfig, localConfig))) { const dir = toolPath[toolPathField]; if (!dir) continue; if (!await ResourceHandler.isToolInstalled(dir, baseDir)) continue; @@ -557,7 +564,7 @@ async function pullForScope( if (!options.dryRun && desiredSkillNames && knownRepoSkillNames) { const baseDir = resolveBaseDir(localConfig); - for (const [tool, toolPath] of Object.entries(freshConfig.toolPaths)) { + for (const [tool, toolPath] of Object.entries(scopedToolPaths(freshConfig, localConfig))) { if (isAgentDisabled(localConfig, tool)) continue; if (!toolPath.skills) continue; if (!await ResourceHandler.isToolInstalled(toolPath.skills, baseDir)) continue; @@ -689,7 +696,7 @@ async function pullForScope( const compiled = compileCulture(cultureContent); if (compiled) { const baseDir = resolveBaseDir(localConfig); - for (const [tool, toolPath] of Object.entries(freshConfig.toolPaths)) { + for (const [tool, toolPath] of Object.entries(scopedToolPaths(freshConfig, localConfig))) { if (isAgentDisabled(localConfig, tool)) continue; if (!toolPath.claudemd) continue; if (toolPath.rules && !await ResourceHandler.isToolInstalled(toolPath.rules, baseDir)) continue; @@ -720,7 +727,7 @@ async function pullForScope( const compiled = compileClaudemd(claudemdContents); if (compiled) { const baseDir = resolveBaseDir(localConfig); - for (const [tool, toolPath] of Object.entries(freshConfig.toolPaths)) { + for (const [tool, toolPath] of Object.entries(scopedToolPaths(freshConfig, localConfig))) { if (isAgentDisabled(localConfig, tool)) continue; if (!toolPath.claudemd) continue; if (toolPath.rules && !await ResourceHandler.isToolInstalled(toolPath.rules, baseDir)) continue; @@ -966,7 +973,7 @@ export async function injectRecallBlockIntoTools( const baseDir = resolveBaseDir(localConfig); const recallBlock = compileRecallRulesBlock(); let injected = 0; - for (const [tool, toolPath] of Object.entries(config.toolPaths)) { + for (const [tool, toolPath] of Object.entries(scopedToolPaths(config, localConfig))) { if (isAgentDisabled(localConfig, tool)) continue; if (!toolPath.claudemd || !toolPath.agents) continue; if (!await ResourceHandler.isToolInstalled(toolPath.agents, baseDir)) continue; diff --git a/src/recall-toggle.ts b/src/recall-toggle.ts index 1c4c255d..76529580 100644 --- a/src/recall-toggle.ts +++ b/src/recall-toggle.ts @@ -7,6 +7,7 @@ import { RECALL_DEPENDENT_SKILLS } from './builtin-skills.js'; import { resolveBaseDir, isRecallEnabled, + scopedToolPaths, TEAMAI_RECALL_RULES_START, TEAMAI_RECALL_RULES_END, type GlobalOptions, @@ -17,7 +18,7 @@ import { async function removeRecallArtifacts(teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise { const baseDir = resolveBaseDir(localConfig); - for (const [tool, toolPath] of Object.entries(teamConfig.toolPaths)) { + for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { // Remove recall rule file if (toolPath.rules) { const ruleFile = path.join(baseDir, toolPath.rules, 'teamai-recall.md'); @@ -85,7 +86,7 @@ async function deployRecallArtifacts(teamConfig: TeamaiConfig, localConfig: Loca const baseDir = resolveBaseDir(localConfig); const recallBlock = compileRecallRulesBlock(); - for (const [tool, toolPath] of Object.entries(teamConfig.toolPaths)) { + for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { if (!toolPath.claudemd || !toolPath.agents) continue; if (!await ResourceHandler.isToolInstalled(toolPath.agents, baseDir)) continue; diff --git a/src/resources/agent-format.ts b/src/resources/agent-format.ts index abf701a9..42208327 100644 --- a/src/resources/agent-format.ts +++ b/src/resources/agent-format.ts @@ -5,7 +5,7 @@ import { stringify as stringifyToml, parse as parseToml } from 'smol-toml'; // ─── Tool name type ────────────────────────────────────────────────────────── -export type ToolName = 'claude' | 'claude-internal' | 'tclaude' | 'codebuddy' | 'codex' | 'codex-internal' | 'tcodex' | 'cursor'; +export type ToolName = 'claude' | 'claude-internal' | 'tclaude' | 'codebuddy' | 'codex' | 'codex-internal' | 'tcodex' | 'cursor' | 'opencode'; export const ALL_SUPPORTED_TOOLS: ToolName[] = [ 'claude', @@ -16,6 +16,7 @@ export const ALL_SUPPORTED_TOOLS: ToolName[] = [ 'codex-internal', 'tcodex', 'cursor', + 'opencode', ]; // ─── Intermediate format ───────────────────────────────────────────────────── @@ -50,6 +51,7 @@ export interface AgentSpec { 'codex-internal'?: Record; tcodex?: Record; cursor?: Record; + opencode?: Record; }; /** * Which tools this agent should be deployed to. @@ -194,6 +196,34 @@ export function renderForCursor(spec: AgentSpec): RenderResult { return { ext: '.md', content }; } +/** + * Render an AgentSpec for OpenCode. + * Output: YAML frontmatter (.md). OpenCode derives the agent name from the + * filename, so `name` is intentionally omitted from frontmatter. `mode` defaults + * to `subagent` (teamai only syncs subagents). OpenCode's `tools` field is + * deprecated in favor of `permission`, so the common `tools` list is not emitted; + * a team that needs per-tool permissions carries them in tool_extras.opencode + * (e.g. `permission: { edit: deny }`), which is flattened into the frontmatter. + */ +export function renderForOpencode(spec: AgentSpec): RenderResult { + const frontmatterData: Record = { + description: spec.description, + mode: 'subagent', + }; + if (spec.model !== undefined) { + frontmatterData['model'] = spec.model; + } + // Flatten tool_extras.opencode into frontmatter (mode/permission/temperature/…). + const extras = spec.tool_extras?.['opencode']; + if (extras) { + for (const [key, value] of Object.entries(extras)) { + frontmatterData[key] = value; + } + } + const content = matter.stringify(spec.instructions, frontmatterData); + return { ext: '.md', content }; +} + // ─── Internal render helpers ───────────────────────────────────────────────── /** @@ -252,6 +282,9 @@ export type ReverseResult = const COMMON_CLAUDE_FIELDS = new Set(['name', 'description', 'model', 'tools']); const COMMON_CURSOR_FIELDS = new Set(['agent_id', 'description', 'model', 'tools']); const COMMON_CODEX_FIELDS = new Set(['name', 'description', 'developer_instructions', 'model']); +// `mode` is not carried to the AgentSpec root — it is an OpenCode-only concept +// (teamai always renders `subagent`), so it round-trips through tool_extras.opencode. +const COMMON_OPENCODE_FIELDS = new Set(['description', 'model']); /** * Reverse a Claude-format .md file into an AgentSpec. @@ -391,6 +424,47 @@ export function reverseFromCursor(filePath: string, content: string): ReverseRes return { ok: true, spec }; } +/** + * Reverse an OpenCode-format .md file into an AgentSpec. + * The agent name is derived from the filename (OpenCode has no `name` in + * frontmatter). Non-common frontmatter fields (mode, permission, temperature, …) + * are collected into tool_extras.opencode. + */ +export function reverseFromOpencode(filePath: string, content: string): ReverseResult { + let parsed: matter.GrayMatterFile; + try { + parsed = matter(content); + } catch (err) { + return { ok: false, reason: `parse error: ${(err as Error).message}` }; + } + + const fm = parsed.data as Record; + const body = parsed.content.trim(); + + const name = path.basename(filePath, '.md'); + if (!name) return { ok: false, reason: 'missing agent name (empty filename)' }; + if (!fm['description']) return { ok: false, reason: 'missing field description' }; + if (!body) return { ok: false, reason: 'missing field instructions (empty body)' }; + + // Collect non-common frontmatter fields as tool_extras + const extras: Record = {}; + for (const [key, value] of Object.entries(fm)) { + if (!COMMON_OPENCODE_FIELDS.has(key)) { + extras[key] = value; + } + } + + const spec: AgentSpec = { + name, + description: fm['description'] as string, + instructions: body, + }; + if (fm['model'] !== undefined) spec.model = fm['model'] as string; + if (Object.keys(extras).length > 0) spec.tool_extras = { opencode: extras }; + + return { ok: true, spec }; +} + // ─── Merge multi-tool reverse results ─────────────────────────────────────── /** Conflict details when merging results from multiple tools. */ @@ -499,5 +573,6 @@ export function renderForTool(spec: AgentSpec, tool: ToolName): RenderResult { case 'codex-internal': return renderForCodexInternal(spec); case 'tcodex': return renderForCodex(spec); case 'cursor': return renderForCursor(spec); + case 'opencode': return renderForOpencode(spec); } } diff --git a/src/resources/agents.ts b/src/resources/agents.ts index 9ad2db5d..4f6929c5 100644 --- a/src/resources/agents.ts +++ b/src/resources/agents.ts @@ -3,7 +3,7 @@ import { ResourceHandler } from './base.js'; import type { ResourceItem, ResourceItemStatus, TeamaiConfig, LocalConfig } from '../types.js'; import { listFiles, pathExists, copyFile, ensureDir, remove, fileContentEqual, getFileMtime, writeFile, readFileSafe } from '../utils/fs.js'; import { log } from '../utils/logger.js'; -import { resolveBaseDir, isAgentDisabled, isSelfMode } from '../types.js'; +import { resolveBaseDir, isAgentDisabled, isSelfMode, scopedToolPaths } from '../types.js'; import { BUILTIN_AGENT_NAMES } from '../builtin-agents.js'; import { parseAgentYaml, @@ -13,6 +13,7 @@ import { reverseFromCodebuddy, reverseFromCodex, reverseFromCursor, + reverseFromOpencode, mergeReverseResults, ALL_SUPPORTED_TOOLS, } from './agent-format.js'; @@ -96,7 +97,7 @@ export class AgentsHandler extends ResourceHandler { // Collect all local agent files grouped by stem const grouped = new Map>(); // stem → (tool → filePath) - for (const [tool, toolPath] of Object.entries(teamConfig.toolPaths)) { + for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { if (!toolPath.agents) continue; const agentsDir = path.join(baseDir, toolPath.agents); if (!await pathExists(agentsDir)) continue; @@ -331,9 +332,10 @@ export class AgentsHandler extends ResourceHandler { spec = parseResult.spec; const targets = spec.targets ?? ALL_SUPPORTED_TOOLS; + const scoped = scopedToolPaths(teamConfig, localConfig); for (const tool of targets) { - const toolPath = teamConfig.toolPaths[tool]; + const toolPath = scoped[tool]; if (!toolPath?.agents) { log.debug(`Skipping agent sync for ${tool}: no agents path configured`); continue; @@ -378,7 +380,7 @@ export class AgentsHandler extends ResourceHandler { await this.addTombstone(name, localConfig); - for (const [tool, toolPath] of Object.entries(teamConfig.toolPaths)) { + for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { if (!toolPath.agents) continue; // Try removing both .md and .toml variants for (const ext of ['.md', '.toml'] as const) { @@ -407,7 +409,7 @@ export class AgentsHandler extends ResourceHandler { ): Promise { const legacyTools = new Set(['claude', 'claude-internal', 'tclaude', 'codebuddy']); - for (const [tool, toolPath] of Object.entries(teamConfig.toolPaths)) { + for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { if (!legacyTools.has(tool)) continue; if (!toolPath.agents) { log.debug(`Skipping legacy agent sync for ${tool}: no agents path configured`); @@ -468,5 +470,7 @@ function reverseByTool(tool: ToolName, filePath: string, content: string): Rever return reverseFromCodex(filePath, content); case 'cursor': return reverseFromCursor(filePath, content); + case 'opencode': + return reverseFromOpencode(filePath, content); } } diff --git a/src/resources/base.ts b/src/resources/base.ts index aab626b6..ec96e6ad 100644 --- a/src/resources/base.ts +++ b/src/resources/base.ts @@ -5,6 +5,25 @@ import { getUserHome } from '../utils/home.js'; const TOMBSTONE_FILE = '.removed'; +/** + * The directory whose existence marks a tool as "installed" for a given + * resource path. The tool root is normally the first path segment + * (`.claude/skills` → `.claude`, `.openclaw/workspace/AGENTS.md` → `.openclaw`). + * + * The one exception is OpenCode's user scope, whose paths live under + * `.config/opencode/...`: there the first segment (`.config`) is a directory + * nearly every user has, so it would wrongly report OpenCode as installed. + * For a `.config//...` path the root is the first two segments + * (`.config/opencode`) instead. + */ +export function toolInstallRoot(toolPath: string): string { + const segments = toolPath.split('/'); + if (segments[0] === '.config' && segments.length > 1) { + return `${segments[0]}/${segments[1]}`; + } + return segments[0] ?? toolPath; +} + /** * Abstract base class for resource handlers. * Each resource type (skills, rules, docs, env, agents, hooks, mcp) implements this. @@ -66,7 +85,7 @@ export abstract class ResourceHandler { */ static async isToolInstalled(toolPath: string, baseDir?: string): Promise { const base = baseDir ?? getUserHome(); - const toolRoot = path.join(base, toolPath.split('/')[0]); + const toolRoot = path.join(base, toolInstallRoot(toolPath)); return pathExists(toolRoot); } diff --git a/src/resources/mcp-format.ts b/src/resources/mcp-format.ts index 8c70f63f..c3db0865 100644 --- a/src/resources/mcp-format.ts +++ b/src/resources/mcp-format.ts @@ -13,21 +13,35 @@ import type { McpServerDef, McpTransport } from '../types.js'; // Keeping the differences here — rather than in the reconcile engine — is the // same split agents uses between agent-format.ts and its handler. -export type McpFormat = 'claude' | 'cursor' | 'buddy' | 'codex'; +export type McpFormat = 'claude' | 'cursor' | 'buddy' | 'codex' | 'opencode'; const CLAUDE_TOOLS = new Set(['claude', 'claude-internal', 'tclaude']); const CURSOR_TOOLS = new Set(['cursor']); const CODEX_TOOLS = new Set(['codex', 'codex-internal', 'tcodex']); const BUDDY_TOOLS = new Set(['codebuddy', 'workbuddy']); +const OPENCODE_TOOLS = new Set(['opencode']); export function detectMcpFormat(tool: string): McpFormat | null { if (CLAUDE_TOOLS.has(tool)) return 'claude'; if (CURSOR_TOOLS.has(tool)) return 'cursor'; if (CODEX_TOOLS.has(tool)) return 'codex'; if (BUDDY_TOOLS.has(tool)) return 'buddy'; + if (OPENCODE_TOOLS.has(tool)) return 'opencode'; return null; } +/** + * Top-level JSON key each format stores its server map under. Claude/cursor/buddy + * all use `mcpServers`; OpenCode uses `mcp`. Codex is TOML (handled separately) and + * has no entry here. + */ +export const MCP_SERVER_KEY: Record, string> = { + claude: 'mcpServers', + cursor: 'mcpServers', + buddy: 'mcpServers', + opencode: 'mcp', +}; + /** Transports each format can actually express. */ const SUPPORTED_TRANSPORTS: Record> = { claude: new Set(['stdio', 'http', 'sse']), @@ -36,6 +50,9 @@ const SUPPORTED_TRANSPORTS: Record> = { // Codex speaks streamable HTTP (`url` + header keys) as well as stdio, but has // no SSE transport, so only that one is skipped. codex: new Set(['stdio', 'http']), + // OpenCode splits transports into `type: local` (stdio) and `type: remote` + // (streamable HTTP). It has no SSE transport. + opencode: new Set(['stdio', 'http']), }; export function supportsTransport(format: McpFormat, transport: McpTransport): boolean { @@ -210,10 +227,35 @@ function renderBuddy(def: McpServerDef): McpJsonEntry { return e; } +/** + * OpenCode's shape is unlike the others: transport is expressed as + * `type: "local"` (stdio) or `type: "remote"` (http), a local server's command + * and args are a single `command` array, env is `environment` (not `env`), and + * every server carries `enabled: true`. + */ +function renderOpencode(def: McpServerDef): McpJsonEntry { + const e: McpJsonEntry = {}; + if (def.transport === 'stdio') { + e.type = 'local'; + // OpenCode folds the executable and its args into one `command` array. + // The schema guarantees a stdio def has `command`; guard anyway so a + // malformed def never writes an empty-string executable into opencode.json. + e.command = [...(def.command ? [def.command] : []), ...(def.args ?? [])]; + if (def.env && Object.keys(def.env).length) e.environment = def.env; + } else { + e.type = 'remote'; + e.url = def.url; + if (def.headers && Object.keys(def.headers).length) e.headers = def.headers; + } + e.enabled = true; + return e; +} + /** Render the JSON-shaped entry for a format. Codex is handled separately (TOML). */ export function renderJsonEntry(format: Exclude, def: McpServerDef): McpJsonEntry { if (format === 'claude') return renderClaude(def); if (format === 'cursor') return renderCursor(def); + if (format === 'opencode') return renderOpencode(def); return renderBuddy(def); } diff --git a/src/resources/opencode-config.ts b/src/resources/opencode-config.ts new file mode 100644 index 00000000..71d39d3d --- /dev/null +++ b/src/resources/opencode-config.ts @@ -0,0 +1,108 @@ +import path from 'node:path'; +import { readFileSafe, writeJsonAtomic, pathExists } from '../utils/fs.js'; +import { log } from '../utils/logger.js'; + +// ─── OpenCode config activation ────────────────────────────── +// +// Unlike every other tool teamai targets, OpenCode does not auto-scan a rules +// directory. Rule .md files copied into `.opencode/rules/` are inert until they +// are referenced from the `instructions` array in `opencode.json`. This module +// maintains exactly one teamai-managed glob in that array by key-level surgery: +// it reads the JSON, adds or removes only our glob, and writes every other +// top-level key (including `mcp`, which the MCP reconcile engine owns) back +// untouched. It never rewrites the user's own `instructions` entries. +// +// The same opencode.json is shared with the MCP `mcp` key, so both writers must +// be surgical — a regenerate-from-scratch here would clobber injected servers. + +/** + * The glob OpenCode should use to load teamai-managed rules, expressed relative + * to the directory that holds opencode.json. + * + * OpenCode resolves relative `instructions` paths against the config file's own + * directory. In project scope, opencode.json sits at the repo root and rules at + * `/.opencode/rules`, giving `.opencode/rules/*.md` (the same shape as the + * documented `.cursor/rules/*.md` example). In user scope, both live under + * `~/.config/opencode/`, giving a clean `rules/*.md`. + */ +export function opencodeRulesGlob(configFileAbs: string, rulesDirAbs: string): string { + const rel = path.relative(path.dirname(configFileAbs), rulesDirAbs); + // Always use forward slashes: opencode.json globs are POSIX-style. + const relPosix = rel.split(path.sep).join('/'); + return `${relPosix}/*.md`; +} + +/** + * Ensure `opencode.json` references (or stops referencing) the teamai rules glob. + * + * @param configFileAbs Absolute path to the opencode.json to edit. + * @param glob The instructions glob to add/remove (see opencodeRulesGlob). + * @param present true = the glob should be in `instructions`; false = removed. + * @returns true if the file was written. + * + * When `present` is true and the file does not exist, it is created with just the + * `instructions` array — teamai owns nothing else in it. When `present` is false + * and the file does not exist, nothing happens. A file that exists but cannot be + * parsed as a JSON object is left strictly alone (it may hold config we do not + * understand), and the function returns false. + */ +export async function reconcileOpencodeInstructions( + configFileAbs: string, + glob: string, + present: boolean, +): Promise { + const exists = await pathExists(configFileAbs); + + if (!exists) { + if (!present) return false; + await writeJsonAtomic(configFileAbs, { instructions: [glob] }); + log.debug(`Created ${configFileAbs} with teamai rules instructions glob`); + return true; + } + + const raw = await readFileSafe(configFileAbs); + if (raw === null) return false; + + let data: Record; + if (raw.trim() === '') { + data = {}; + } else { + try { + const parsed = JSON.parse(raw) as unknown; + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + log.warn(`Could not parse ${configFileAbs} as a JSON object — skipping OpenCode rules activation`); + return false; + } + data = parsed as Record; + } catch { + log.warn(`Could not parse ${configFileAbs} — skipping OpenCode rules activation`); + return false; + } + } + + // Operate on the array in place so the relative order of the user's own + // entries — string globs and any non-string entries alike — is preserved. + // (OpenCode may treat instruction order as precedence, so reordering the + // user's entries on every pull would silently change their config.) + const original = Array.isArray(data.instructions) ? [...(data.instructions as unknown[])] : []; + const has = original.includes(glob); + + if (present && has) return false; + if (!present && !has) return false; + + const next = present + ? [...original, glob] // append our glob without touching existing order + : original.filter((g) => g !== glob); // remove only our glob, everything else stays put + + // Key-level surgery: drop `instructions` entirely when it would be empty, + // otherwise write the reconciled array back. + if (next.length === 0) { + delete data.instructions; + } else { + data.instructions = next; + } + + await writeJsonAtomic(configFileAbs, data); + log.debug(`${present ? 'Added' : 'Removed'} teamai rules glob in ${configFileAbs}`); + return true; +} diff --git a/src/resources/rules.ts b/src/resources/rules.ts index 709adaf0..a6f9800f 100644 --- a/src/resources/rules.ts +++ b/src/resources/rules.ts @@ -3,7 +3,7 @@ import { ResourceHandler } from './base.js'; import type { ResourceItem, ResourceItemStatus, TeamaiConfig, LocalConfig } from '../types.js'; import { listFilesRecursive, pathExists, copyFile, ensureDir, remove, fileContentEqual, getFileMtime, listDirs, readFileSafe, writeFile } from '../utils/fs.js'; import { log } from '../utils/logger.js'; -import { TEAMAI_RULES_START, TEAMAI_RULES_END, resolveBaseDir, isAgentDisabled } from '../types.js'; +import { TEAMAI_RULES_START, TEAMAI_RULES_END, resolveBaseDir, isAgentDisabled, scopedToolPaths } from '../types.js'; import { EXCLUDED_RULE_NAMES } from '../builtin-rules.js'; export class RulesHandler extends ResourceHandler { @@ -31,7 +31,7 @@ export class RulesHandler extends ResourceHandler { const candidates = new Map(); // Scan each tool's rules/ directory (recursively) - for (const [_tool, toolPath] of Object.entries(teamConfig.toolPaths)) { + for (const [_tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { const rulesPath = toolPath.rules; if (!rulesPath) continue; const rulesDir = path.join(resolveBaseDir(localConfig), rulesPath); @@ -119,7 +119,7 @@ export class RulesHandler extends ResourceHandler { */ async pullItem(item: ResourceItem, teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise { const baseDir = resolveBaseDir(localConfig); - for (const [tool, toolPath] of Object.entries(teamConfig.toolPaths)) { + for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { if (isAgentDisabled(localConfig, tool)) continue; if (!toolPath.rules) continue; @@ -160,7 +160,7 @@ export class RulesHandler extends ResourceHandler { await this.addTombstone(name, localConfig); // Remove from each tool's rules directory - for (const [tool, toolPath] of Object.entries(teamConfig.toolPaths)) { + for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { if (!toolPath.rules) continue; const filePath = path.join(baseDir, toolPath.rules, fileName); if (await pathExists(filePath)) { @@ -203,6 +203,18 @@ export class RulesHandler extends ResourceHandler { } } + // OpenCode does not auto-scan a rules directory: the .md files are inert + // until referenced from `instructions` in opencode.json. Activate (or, when + // there are no team rules, deactivate) that glob. Runs before the empty-set + // early return so removing the last rule also removes the glob. + await this.activateOpencodeInstructions(teamConfig, localConfig, rules.length > 0); + + // Empty set = the team has no rules right now. We deliberately do NOT run the + // aggressive stale-file cleanup below in that case, because it would treat a + // user's own personal rule files as stale and delete them. Explicit team + // removals are handled by the tombstone cleanup in pull.ts instead. The + // OpenCode glob deactivation above still runs, so the (now unmanaged) rules + // stop being auto-loaded. if (rules.length === 0) return; // 1. Distribute rule files to each tool's rules/ directory @@ -213,7 +225,7 @@ export class RulesHandler extends ResourceHandler { // 1.5. Clean up stale local rule files not present in team repo const teamRuleFiles = new Set(rules.map((r) => `${r.name}.md`)); const baseDir = resolveBaseDir(localConfig); - for (const [tool, toolPath] of Object.entries(teamConfig.toolPaths)) { + for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { if (!toolPath.rules) continue; if (!await ResourceHandler.isToolInstalled(toolPath.rules, baseDir)) continue; @@ -238,7 +250,7 @@ export class RulesHandler extends ResourceHandler { } // 2. Remove legacy rules section from CLAUDE.md (no longer injected) - for (const [, toolPath] of Object.entries(teamConfig.toolPaths)) { + for (const [, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { if (!toolPath.claudemd) continue; const claudeMdPath = path.join(baseDir, toolPath.claudemd); try { @@ -262,6 +274,42 @@ export class RulesHandler extends ResourceHandler { } } + /** + * Add or remove the teamai rules glob in OpenCode's opencode.json `instructions` + * array, so copied rule files are actually loaded. No-op for any tool other than + * opencode, when opencode is disabled, or when opencode is not installed (we + * never create an opencode.json for a user who doesn't use OpenCode). + */ + private async activateOpencodeInstructions( + teamConfig: TeamaiConfig, + localConfig: LocalConfig, + present: boolean, + ): Promise { + if (isAgentDisabled(localConfig, 'opencode')) return; + const scoped = scopedToolPaths(teamConfig, localConfig); + const paths = scoped['opencode']; + if (!paths?.rules) return; + + const baseDir = resolveBaseDir(localConfig); + // Only touch opencode.json when OpenCode is actually installed for this scope. + if (!await ResourceHandler.isToolInstalled(paths.rules, baseDir)) return; + + // The config file mirrors the MCP scope fields: /opencode.json in + // project scope, ~/.config/opencode/opencode.json in user scope. + const configRel = localConfig.scope === 'project' ? paths.mcpProject : paths.mcp; + if (!configRel) return; + const configFileAbs = path.join(baseDir, configRel); + const rulesDirAbs = path.join(baseDir, paths.rules); + + const { reconcileOpencodeInstructions, opencodeRulesGlob } = await import('./opencode-config.js'); + const glob = opencodeRulesGlob(configFileAbs, rulesDirAbs); + try { + await reconcileOpencodeInstructions(configFileAbs, glob, present); + } catch (e) { + log.warn(`Failed to update OpenCode instructions in ${configFileAbs}: ${(e as Error).message}`); + } + } + /** * Recursively remove empty subdirectories under a given directory. */ diff --git a/src/resources/skills.ts b/src/resources/skills.ts index bc2f68cf..78bced73 100644 --- a/src/resources/skills.ts +++ b/src/resources/skills.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import { ResourceHandler } from './base.js'; import type { ResourceItem, ResourceItemStatus, TeamaiConfig, LocalConfig } from '../types.js'; -import { resolveBaseDir, getPushignorePath, isAgentDisabled } from '../types.js'; +import { resolveBaseDir, getPushignorePath, isAgentDisabled, scopedToolPaths } from '../types.js'; import { listDirs, pathExists, copyDir, remove, dirTeamSubsetEqual, getDirLatestMtime, readFileSafe, writeFile } from '../utils/fs.js'; import { log } from '../utils/logger.js'; import { BUILTIN_SKILL_NAMES } from '../builtin-skills.js'; @@ -301,7 +301,7 @@ export class SkillsHandler extends ResourceHandler { const candidates = new Map(); // Scan each tool's skills directory - for (const [_tool, toolPath] of Object.entries(teamConfig.toolPaths)) { + for (const [_tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { if (!toolPath.skills) continue; const skillsDir = path.join(resolveBaseDir(localConfig), toolPath.skills); if (!await pathExists(skillsDir)) continue; @@ -431,7 +431,7 @@ export class SkillsHandler extends ResourceHandler { async pullItem(item: ResourceItem, teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise { const baseDir = resolveBaseDir(localConfig); - for (const [tool, toolPath] of Object.entries(teamConfig.toolPaths)) { + for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { if (isAgentDisabled(localConfig, tool)) continue; if (!toolPath.skills) continue; @@ -490,7 +490,7 @@ export class SkillsHandler extends ResourceHandler { await this.addTombstone(name, localConfig); // Remove from each tool's skills directory - for (const [tool, toolPath] of Object.entries(teamConfig.toolPaths)) { + for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { if (!toolPath.skills) continue; let skillDir: string; if (tool === 'openclaw') { diff --git a/src/source.ts b/src/source.ts index 2a3872cd..a96462b6 100644 --- a/src/source.ts +++ b/src/source.ts @@ -27,7 +27,7 @@ import type { SourceInstallManifest, GlobalOptions, } from './types.js'; -import { resolveBaseDir, SOURCE_PULL_TTL_MS } from './types.js'; +import { resolveBaseDir, scopedToolPaths, SOURCE_PULL_TTL_MS } from './types.js'; // ─── Source repo management ────────────────────────────── @@ -467,7 +467,7 @@ async function pullSingleSource( } // Deploy to each tool's skills directory - for (const [_tool, toolPath] of Object.entries(teamConfig.toolPaths)) { + for (const [_tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { if (!toolPath.skills) continue; if (!await ResourceHandler.isToolInstalled(toolPath.skills, baseDir)) continue; @@ -488,7 +488,7 @@ async function pullSingleSource( const deployedSet = new Set(deployed); for (const oldSkill of oldInstalled) { if (!deployedSet.has(oldSkill) && !localTeamSkills.has(oldSkill)) { - await removeSkillFromToolPaths(oldSkill, teamConfig, baseDir); + await removeSkillFromToolPaths(oldSkill, teamConfig, localConfig, baseDir); log.debug(`[source:${source.name}] Removed "${oldSkill}" (no longer public)`); } } @@ -610,8 +610,8 @@ async function getLocalTeamSkillNames(teamConfig: TeamaiConfig, localConfig: Loc /** * Remove a skill from all tool paths. */ -async function removeSkillFromToolPaths(skillName: string, teamConfig: TeamaiConfig, baseDir: string): Promise { - for (const [_tool, toolPath] of Object.entries(teamConfig.toolPaths)) { +async function removeSkillFromToolPaths(skillName: string, teamConfig: TeamaiConfig, localConfig: LocalConfig, baseDir: string): Promise { + for (const [_tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { if (!toolPath.skills) continue; const skillDir = path.join(baseDir, toolPath.skills, skillName); if (await pathExists(skillDir)) { @@ -629,7 +629,7 @@ async function cleanupSourceSkills(sourceName: string, teamConfig: TeamaiConfig, const baseDir = resolveBaseDir(localConfig); for (const skillName of manifest.installedSkills) { - await removeSkillFromToolPaths(skillName, teamConfig, baseDir); + await removeSkillFromToolPaths(skillName, teamConfig, localConfig, baseDir); } } diff --git a/src/types.ts b/src/types.ts index 85fcfcdd..e714d197 100644 --- a/src/types.ts +++ b/src/types.ts @@ -19,6 +19,21 @@ export const ToolPathsSchema = z.object({ * cannot share a value: user scope is ~/.claude.json but project scope is * /.mcp.json, breaking the usual `./` convention. */ mcpProject: z.string().optional(), + /** + * User-scope path overrides for skills/rules/agents. Most tools store their + * user-scope resources at the same `./` relative path as their + * project-scope ones, so this is omitted. OpenCode is the exception: its + * project-scope config lives at `/.opencode/...` but its user-scope config + * lives at `~/.config/opencode/...`, a different prefix entirely. When set and the + * active scope is `user`, these values replace the base skills/rules/agents paths. + */ + userScope: z + .object({ + skills: z.string().optional(), + rules: z.string().optional(), + agents: z.string().optional(), + }) + .optional(), }); // ─── Scope ────────────────────────────────────────────── @@ -195,6 +210,20 @@ export const TeamaiConfigSchema = z.object({ openclaw: { skills: '.openclaw/skills', rules: '.openclaw/rules', claudemd: '.openclaw/workspace/AGENTS.md' }, hermes: { skills: '.hermes/skills', claudemd: 'AGENTS.md' }, workbuddy: { skills: '.workbuddy/skills', rules: '.workbuddy/rules', settings: '.workbuddy/settings.json', claudemd: 'AGENTS.md', mcp: '.workbuddy/mcp.json', mcpProject: '.workbuddy/mcp.json' }, + // 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' }, + }, }), }); @@ -1019,6 +1048,42 @@ export function isAgentDisabled(localConfig: { disabledAgents?: string[] }, tool return localConfig.disabledAgents?.includes(tool) ?? false; } +/** + * Return `teamConfig.toolPaths` with per-scope path overrides applied. + * + * Almost every tool keeps its user-scope and project-scope resources at the same + * `./` relative path, so this is the identity map for them. The + * one exception is OpenCode, whose user-scope config lives under + * `~/.config/opencode/` (a different prefix from its project `/.opencode/`); + * its `userScope` block carries those paths and is spliced in only when the active + * scope is `user`. Callers that iterate `toolPaths` for skills/rules/agents should + * iterate the result of this function instead, so the correct scope path is used. + * + * MCP is untouched here: its two scopes are already distinct fields + * (`mcp` / `mcpProject`), resolved separately in the reconcile engine. + */ +export function scopedToolPaths( + teamConfig: TeamaiConfig, + localConfig: { scope?: Scope }, +): Record> { + if (localConfig.scope !== 'user') return teamConfig.toolPaths; + const out: Record> = {}; + for (const [tool, paths] of Object.entries(teamConfig.toolPaths)) { + const us = paths.userScope; + if (!us) { + out[tool] = paths; + continue; + } + out[tool] = { + ...paths, + ...(us.skills !== undefined ? { skills: us.skills } : {}), + ...(us.rules !== undefined ? { rules: us.rules } : {}), + ...(us.agents !== undefined ? { agents: us.agents } : {}), + }; + } + return out; +} + /** True when the local config is single-repo mode (the business repo is the team repo). */ export function isSelfMode(localConfig: { repo: { kind?: string } }): boolean { return localConfig.repo.kind === 'self'; diff --git a/src/uninstall.ts b/src/uninstall.ts index c52cdaca..d6ab0785 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -17,6 +17,7 @@ import { getManagedHooksPath, managedMcpManifestPath, resolveBaseDir, + scopedToolPaths, type GlobalOptions, type TeamaiConfig, type LocalConfig, @@ -301,7 +302,7 @@ async function buildRemovalPlan( // Discover per-tool resources const managedHooksPath = getManagedHooksPath(localConfig.scope, localConfig.projectRoot); const perTool = new Map(); - for (const [tool, toolPath] of Object.entries(teamConfig.toolPaths)) { + for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { perTool.set( tool, await discoverToolResources(tool, toolPath, baseDir, teamSkillNames, teamRuleNames, managedHooksPath), diff --git a/src/utils/pre-push-sync.ts b/src/utils/pre-push-sync.ts index aa6626e9..d56e508e 100644 --- a/src/utils/pre-push-sync.ts +++ b/src/utils/pre-push-sync.ts @@ -1,6 +1,6 @@ import path from 'node:path'; import type { TeamaiConfig, LocalConfig } from '../types.js'; -import { resolveBaseDir } from '../types.js'; +import { resolveBaseDir, scopedToolPaths } from '../types.js'; import { listFilesRecursive, listDirs, @@ -55,7 +55,7 @@ export async function syncTeamUpdatesToLocal( */ async function syncRulesToLocal( teamConfig: TeamaiConfig, - _localConfig: LocalConfig, + localConfig: LocalConfig, repoPath: string, baseDir: string, lastPullRev: string, @@ -63,7 +63,7 @@ async function syncRulesToLocal( const teamRulesDir = path.join(repoPath, 'rules'); if (!await pathExists(teamRulesDir)) return; - for (const [tool, toolPath] of Object.entries(teamConfig.toolPaths)) { + for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { if (!toolPath.rules) continue; if (!await ResourceHandler.isToolInstalled(toolPath.rules, baseDir)) continue; @@ -103,7 +103,7 @@ async function syncRulesToLocal( */ async function syncSkillsToLocal( teamConfig: TeamaiConfig, - _localConfig: LocalConfig, + localConfig: LocalConfig, repoPath: string, baseDir: string, lastPullRev: string, @@ -132,7 +132,7 @@ async function syncSkillsToLocal( const CONTRIBUTORS_FILE = 'CONTRIBUTORS'; - for (const [tool, toolPath] of Object.entries(teamConfig.toolPaths)) { + for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { if (!toolPath.skills) continue; if (!await ResourceHandler.isToolInstalled(toolPath.skills, baseDir)) continue;