From c4e3489c84d9e7c68b4d0cde2f9b5a1406a2b0d8 Mon Sep 17 00:00:00 2001 From: daoiqi Date: Fri, 21 Aug 2026 01:54:09 +0800 Subject: [PATCH 1/2] feat(opencode): sync skills/rules/subagents/MCP into OpenCode config (both scopes) Add OpenCode as a supported AI coding tool. teamai now syncs all four resource types into OpenCode's native config in both user and project scopes: - Skills: copied to .opencode/skills (project) or ~/.config/opencode/skills (user); OpenCode reads these natively. - Subagents: rendered to .opencode/agents/*.md with OpenCode frontmatter (description + mode:subagent + optional model/tool_extras); name comes from the filename, tools are omitted. - Rules: copied to the rules dir and activated via the opencode.json `instructions` glob (reconciled with key-level surgery); the glob is deactivated when the team's last rule is removed upstream. - MCP: written under the `mcp` top-level key (not mcpServers) with OpenCode's local/remote shapes; stdio + http transports, sse skipped. User scope diverges from project scope (~/.config/opencode/... vs /.opencode/...), handled by a new `userScope` field on ToolPathsSchema and a scopedToolPaths() choke-point. The install-probe now uses path.dirname so a multi-segment path like .config/opencode/skills resolves to the tool root rather than the near-universal .config. Also fixes a pull orchestration bug: pullAllRules is now always called (even with an empty rule set) so the OpenCode instructions glob is deactivated when the team removes its last rule. Personal rule files are still preserved via the existing tombstone mechanism. Docs (README + usage-guide, both languages) updated with OpenCode. --- README.md | 2 +- README.zh-CN.md | 2 +- docs/usage-guide.md | 17 +++- docs/usage-guide.zh-CN.md | 17 +++- src/__tests__/agent-format.test.ts | 98 +++++++++++++++++++ src/__tests__/mcp-reconcile.test.ts | 134 ++++++++++++++++++++++++++ src/__tests__/opencode-config.test.ts | 98 +++++++++++++++++++ src/__tests__/rules.test.ts | 78 +++++++++++++++ src/mcp-reconcile.ts | 24 +++-- src/pull.ts | 16 ++- src/resources/agent-format.ts | 77 ++++++++++++++- src/resources/agents.ts | 14 ++- src/resources/base.ts | 8 +- src/resources/mcp-format.ts | 42 +++++++- src/resources/opencode-config.ts | 113 ++++++++++++++++++++++ src/resources/rules.ts | 60 ++++++++++-- src/resources/skills.ts | 8 +- src/types.ts | 65 +++++++++++++ 18 files changed, 836 insertions(+), 37 deletions(-) create mode 100644 src/__tests__/opencode-config.test.ts create mode 100644 src/resources/opencode-config.ts 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..1831c58b --- /dev/null +++ b/src/__tests__/opencode-config.test.ts @@ -0,0 +1,98 @@ +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]); + }); +}); 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/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..a6034953 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -392,15 +392,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; } 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..1709c209 100644 --- a/src/resources/base.ts +++ b/src/resources/base.ts @@ -66,7 +66,13 @@ export abstract class ResourceHandler { */ static async isToolInstalled(toolPath: string, baseDir?: string): Promise { const base = baseDir ?? getUserHome(); - const toolRoot = path.join(base, toolPath.split('/')[0]); + // The tool root is the resource dir's parent: `.claude/skills` → `.claude`, + // and `.config/opencode/skills` → `.config/opencode`. Using split('/')[0] + // instead would report `.config` for OpenCode's user scope — a directory + // nearly every user has — and wrongly treat OpenCode as installed. dirname + // is identical to the old behavior for every 2-segment `./`. + const dir = path.dirname(toolPath); + const toolRoot = dir === '.' ? path.join(base, toolPath) : path.join(base, dir); return pathExists(toolRoot); } diff --git a/src/resources/mcp-format.ts b/src/resources/mcp-format.ts index 8c70f63f..f65a2863 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,33 @@ 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. + e.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..18090819 --- /dev/null +++ b/src/resources/opencode-config.ts @@ -0,0 +1,113 @@ +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; + } + } + + const current = Array.isArray(data.instructions) + ? (data.instructions as unknown[]).filter((v): v is string => typeof v === 'string') + : []; + const has = current.includes(glob); + + if (present && has) return false; + if (!present && !has) return false; + + let next: string[]; + if (present) { + next = [...current, glob]; + } else { + next = current.filter((g) => g !== glob); + } + + // Key-level surgery: drop `instructions` entirely when it would be empty and + // there were no non-string entries to preserve, otherwise write it back. + if (next.length === 0 && (!Array.isArray(data.instructions) || (data.instructions as unknown[]).every((v) => typeof v === 'string'))) { + delete data.instructions; + } else { + // Preserve any non-string entries the user may have had by re-appending them. + const nonStrings = Array.isArray(data.instructions) + ? (data.instructions as unknown[]).filter((v) => typeof v !== 'string') + : []; + data.instructions = [...next, ...nonStrings]; + } + + 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/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'; From 33eb08bd30322013ce2672c2ded00197f51c862d Mon Sep 17 00:00:00 2001 From: jeffyxu Date: Mon, 24 Aug 2026 15:46:33 +0800 Subject: [PATCH 2/2] fix(opencode): route all toolPaths iteration through scopedToolPaths for user scope The PR added scopedToolPaths() but only wired it into the resource handlers. Every orchestration/cleanup/deploy site still iterated the raw toolPaths, so OpenCode's user scope (~/.config/opencode/...) was written to but never cleaned up or fully deployed. Route the remaining sites through scopedToolPaths so the correct per-scope path is always used: - pull.ts: tombstone cleanup, excluded-skill cleanup, inactive-namespace cleanup, existing-name detection, culture/claudemd/recall injection - source.ts: source-skill deploy and removal - builtin-{skills,agents,rules}.ts: built-in resource deployment - recall-toggle.ts: recall artifact deploy/removal - uninstall.ts: per-tool resource discovery - pre-push-sync.ts: rules/skills sync-to-local (localConfig was unused) - known-agents.ts: getEffectiveAgents + detectInstalledAgents now scope-aware Also fix a regression and two edge cases: - base.ts isToolInstalled: the path.dirname change reported .openclaw/workspace as the install root for openclaw's 3-segment claudemd path, wrongly treating a .openclaw-without-workspace user as not-installed. Introduce toolInstallRoot() which takes the first path segment (restoring the old openclaw behavior) except for .config//... where it takes the first two (the OpenCode fix). Shared with known-agents detection. - opencode-config.ts reconcileOpencodeInstructions: no longer relocates non-string instructions entries to the end of the array on every pull, which could silently change instruction precedence. Operates on the array in place. - mcp-format.ts renderOpencode: guard against a missing stdio command so a malformed def never writes an empty-string executable into opencode.json. Verified end-to-end with a real user-scope pull against a fresh OpenCode-only HOME: skills deploy to ~/.config/opencode/skills, and a tombstoned skill is now cleaned up there (baseline build leaves it behind, fixed build removes it). Co-Authored-By: Claude Fable 5 --- src/__tests__/opencode-config.test.ts | 25 +++++++++ src/__tests__/skip-uninstalled-tools.test.ts | 57 ++++++++++++++++++++ src/builtin-agents.ts | 4 +- src/builtin-rules.ts | 4 +- src/builtin-skills.ts | 4 +- src/known-agents.ts | 23 ++++---- src/pull.ts | 15 +++--- src/recall-toggle.ts | 5 +- src/resources/base.ts | 27 +++++++--- src/resources/mcp-format.ts | 4 +- src/resources/opencode-config.ts | 31 +++++------ src/source.ts | 12 ++--- src/uninstall.ts | 3 +- src/utils/pre-push-sync.ts | 10 ++-- 14 files changed, 162 insertions(+), 62 deletions(-) diff --git a/src/__tests__/opencode-config.test.ts b/src/__tests__/opencode-config.test.ts index 1831c58b..00f5a29b 100644 --- a/src/__tests__/opencode-config.test.ts +++ b/src/__tests__/opencode-config.test.ts @@ -95,4 +95,29 @@ describe('reconcileOpencodeInstructions', () => { 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__/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/pull.ts b/src/pull.ts index a6034953..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; @@ -533,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; @@ -563,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; @@ -695,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; @@ -726,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; @@ -972,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/base.ts b/src/resources/base.ts index 1709c209..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,13 +85,7 @@ export abstract class ResourceHandler { */ static async isToolInstalled(toolPath: string, baseDir?: string): Promise { const base = baseDir ?? getUserHome(); - // The tool root is the resource dir's parent: `.claude/skills` → `.claude`, - // and `.config/opencode/skills` → `.config/opencode`. Using split('/')[0] - // instead would report `.config` for OpenCode's user scope — a directory - // nearly every user has — and wrongly treat OpenCode as installed. dirname - // is identical to the old behavior for every 2-segment `./`. - const dir = path.dirname(toolPath); - const toolRoot = dir === '.' ? path.join(base, toolPath) : path.join(base, dir); + 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 f65a2863..c3db0865 100644 --- a/src/resources/mcp-format.ts +++ b/src/resources/mcp-format.ts @@ -238,7 +238,9 @@ function renderOpencode(def: McpServerDef): McpJsonEntry { if (def.transport === 'stdio') { e.type = 'local'; // OpenCode folds the executable and its args into one `command` array. - e.command = [def.command ?? '', ...(def.args ?? [])]; + // 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'; diff --git a/src/resources/opencode-config.ts b/src/resources/opencode-config.ts index 18090819..71d39d3d 100644 --- a/src/resources/opencode-config.ts +++ b/src/resources/opencode-config.ts @@ -80,31 +80,26 @@ export async function reconcileOpencodeInstructions( } } - const current = Array.isArray(data.instructions) - ? (data.instructions as unknown[]).filter((v): v is string => typeof v === 'string') - : []; - const has = current.includes(glob); + // 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; - let next: string[]; - if (present) { - next = [...current, glob]; - } else { - next = current.filter((g) => g !== glob); - } + 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 and - // there were no non-string entries to preserve, otherwise write it back. - if (next.length === 0 && (!Array.isArray(data.instructions) || (data.instructions as unknown[]).every((v) => typeof v === 'string'))) { + // 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 { - // Preserve any non-string entries the user may have had by re-appending them. - const nonStrings = Array.isArray(data.instructions) - ? (data.instructions as unknown[]).filter((v) => typeof v !== 'string') - : []; - data.instructions = [...next, ...nonStrings]; + data.instructions = next; } await writeJsonAtomic(configFileAbs, data); 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/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;