Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 工具。

一个人也能用,团队用更强。

Expand Down
17 changes: 14 additions & 3 deletions docs/usage-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down Expand Up @@ -498,14 +498,15 @@ Where each tool's servers land:
| cursor | `~/.cursor/mcp.json` | `<project>/.cursor/mcp.json` |
| codebuddy / workbuddy | `~/.<tool>/mcp.json` | `<project>/.<tool>/mcp.json` |
| codex | `~/.codex/config.toml` | not supported |
| opencode | `~/.config/opencode/opencode.json` | `<project>/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.

Expand Down Expand Up @@ -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 `<project>/.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
Expand Down
17 changes: 14 additions & 3 deletions docs/usage-guide.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 编程工具中。

---

Expand Down Expand Up @@ -496,14 +496,15 @@ servers:
| cursor | `~/.cursor/mcp.json` | `<project>/.cursor/mcp.json` |
| codebuddy / workbuddy | `~/.<tool>/mcp.json` | `<project>/.<tool>/mcp.json` |
| codex | `~/.codex/config.toml` | 不支持 |
| opencode | `~/.config/opencode/opencode.json` | `<project>/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` 标为待批准,需在交互式会话中确认一次。

Expand Down Expand Up @@ -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/` 下,项目配置在 `<project>/.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
Expand Down
98 changes: 98 additions & 0 deletions src/__tests__/agent-format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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', () => {
Expand Down
134 changes: 134 additions & 0 deletions src/__tests__/mcp-reconcile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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 = [
Expand Down
Loading
Loading