@softspark/ai-toolkit 2.5.0 → 2.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env python3
2
+ """Generate .opencode/commands/*.md files for opencode (https://opencode.ai).
3
+
4
+ User-invocable skills become opencode slash commands. Knowledge skills
5
+ (``user-invocable: false``) are excluded — they load automatically via
6
+ AGENTS.md context instead of `/` invocation.
7
+
8
+ Generated commands use the required ``template`` frontmatter field.
9
+ Files are prefixed ``ai-toolkit-`` for clean uninstall.
10
+
11
+ opencode commands: https://opencode.ai/docs/commands/
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
19
+ from codex_skill_adapter import build_codex_skill_text, is_codex_adapted_skill
20
+ from emission import skills_dir
21
+ from frontmatter import frontmatter_field
22
+
23
+ COMMAND_PREFIX = "ai-toolkit-"
24
+
25
+
26
+ def _skill_body(skill_file: Path) -> str:
27
+ """Return the markdown body of a skill (content after frontmatter).
28
+
29
+ For skills that rely on Claude-only orchestration primitives, route
30
+ through the Codex adapter so `Agent`/`TeamCreate`/`TaskCreate` get
31
+ rewritten to opencode-compatible delegation guidance. The adapter's
32
+ output is compatible with opencode's subagent model (``spawn_agent``
33
+ conventions, plan tracking) because both lack Claude primitives.
34
+ """
35
+ if is_codex_adapted_skill(skill_file):
36
+ adapted = build_codex_skill_text(skill_file)
37
+ # Strip the adapted frontmatter — we emit our own below
38
+ parts = adapted.split("---", 2)
39
+ return parts[2].lstrip("\n") if len(parts) >= 3 else adapted
40
+
41
+ text = skill_file.read_text(encoding="utf-8")
42
+ if not text.startswith("---"):
43
+ return text
44
+ parts = text.split("---", 2)
45
+ return parts[2].lstrip("\n") if len(parts) >= 3 else text
46
+
47
+
48
+ def _render_opencode_command(skill_file: Path) -> str:
49
+ """Render a single opencode command .md file from a user-invocable skill."""
50
+ name = frontmatter_field(skill_file, "name")
51
+ description = frontmatter_field(skill_file, "description")
52
+ agent_field = frontmatter_field(skill_file, "agent")
53
+ # opencode does NOT read "body" — the prompt is entirely in the `template`
54
+ # frontmatter field. We embed the SKILL.md body as the template using a
55
+ # YAML block scalar (``|``) which preserves newlines without escaping.
56
+ body = _skill_body(skill_file).rstrip()
57
+
58
+ lines: list[str] = ["---"]
59
+ if description:
60
+ safe_desc = description.replace('"', "'")
61
+ lines.append(f'description: "{safe_desc}"')
62
+ if agent_field:
63
+ # opencode accepts an `agent` frontmatter field pointing at a subagent
64
+ lines.append(f"agent: {_map_agent_name(agent_field)}")
65
+ lines.append("template: |")
66
+ for tpl_line in body.splitlines() or [""]:
67
+ lines.append(f" {tpl_line}" if tpl_line else " ")
68
+ lines.append("---")
69
+ lines.append("")
70
+ return "\n".join(lines)
71
+
72
+
73
+ def _map_agent_name(value: str) -> str:
74
+ """Map ai-toolkit agent names to opencode subagent names.
75
+
76
+ Our opencode agents are installed with the ``ai-toolkit-`` prefix by
77
+ ``generate_opencode_agents.py``, so rewrite here for consistency.
78
+ """
79
+ value = value.strip()
80
+ if not value:
81
+ return value
82
+ if value.startswith(COMMAND_PREFIX):
83
+ return value
84
+ return f"{COMMAND_PREFIX}{value}"
85
+
86
+
87
+ def _is_user_invocable(skill_file: Path) -> bool:
88
+ """Return True if the skill should be exposed as a `/` command."""
89
+ invocable = frontmatter_field(skill_file, "user-invocable")
90
+ if invocable:
91
+ return invocable.lower() not in ("false", "0", "no")
92
+ # Absence + `disable-model-invocation: true` = task skill (user-invocable)
93
+ disable_model = frontmatter_field(skill_file, "disable-model-invocation")
94
+ if disable_model and disable_model.lower() in ("true", "1", "yes"):
95
+ return True
96
+ # Default: NOT invocable — avoids exposing knowledge skills as commands
97
+ return False
98
+
99
+
100
+ def _cleanup_stale(commands_out: Path) -> int:
101
+ """Remove stale ai-toolkit-* command files whose source no longer exists or is no longer invocable."""
102
+ if not commands_out.is_dir():
103
+ return 0
104
+ removed = 0
105
+ for f in sorted(commands_out.glob(f"{COMMAND_PREFIX}*.md")):
106
+ source_name = f.stem[len(COMMAND_PREFIX):]
107
+ source = skills_dir / source_name / "SKILL.md"
108
+ if not source.is_file() or not _is_user_invocable(source):
109
+ f.unlink()
110
+ removed += 1
111
+ return removed
112
+
113
+
114
+ def generate(
115
+ target_dir: Path, config_root: Path | None = None
116
+ ) -> tuple[int, int]:
117
+ """Write opencode command files and return (written, removed_stale).
118
+
119
+ By default writes to ``target_dir/.opencode/commands/`` (project-local).
120
+ Pass ``config_root=~/.config/opencode`` for the global layout, which
121
+ lives directly under ``commands/`` (no ``.opencode/`` prefix).
122
+ """
123
+ base = config_root if config_root is not None else target_dir / ".opencode"
124
+ commands_out = base / "commands"
125
+ commands_out.mkdir(parents=True, exist_ok=True)
126
+
127
+ written = 0
128
+ for skill_dir in sorted(skills_dir.iterdir()):
129
+ if skill_dir.name.startswith("_"):
130
+ continue
131
+ skill_file = skill_dir / "SKILL.md"
132
+ if not skill_file.is_file():
133
+ continue
134
+ if not _is_user_invocable(skill_file):
135
+ continue
136
+ name = frontmatter_field(skill_file, "name")
137
+ if not name:
138
+ continue
139
+ out_path = commands_out / f"{COMMAND_PREFIX}{name}.md"
140
+ out_path.write_text(_render_opencode_command(skill_file), encoding="utf-8")
141
+ written += 1
142
+
143
+ removed = _cleanup_stale(commands_out)
144
+ return written, removed
145
+
146
+
147
+ def main() -> None:
148
+ target = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd()
149
+ written, removed = generate(target)
150
+ msg = f"Generated: .opencode/commands/ ({written} commands"
151
+ if removed:
152
+ msg += f", {removed} stale removed"
153
+ msg += ")"
154
+ print(msg)
155
+
156
+
157
+ if __name__ == "__main__":
158
+ main()
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env python3
2
+ """Merge MCP servers from .mcp.json into opencode.json for opencode.
3
+
4
+ opencode MCP config: https://opencode.ai/docs/mcp-servers/
5
+
6
+ Schema per server:
7
+ Local: {"type": "local", "command": [...], "enabled": true, "environment": {...}}
8
+ Remote: {"type": "remote", "url": "...", "enabled": true, "headers": {...}}
9
+
10
+ Our canonical source is .mcp.json (Claude-style) with:
11
+ {"mcpServers": {"name": {"command": "...", "args": [...], "env": {...}}}}
12
+ or: {"name": {"url": "..."}} for remote.
13
+
14
+ We translate into opencode's shape and merge under the ``mcp`` key.
15
+ All non-``mcp`` keys are preserved verbatim — idempotent on re-run.
16
+
17
+ Usage:
18
+ python3 scripts/generate_opencode_json.py [target-dir]
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import copy
23
+ import json
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ SCHEMA_URL = "https://opencode.ai/config.json"
28
+
29
+
30
+ def _load_json(path: Path) -> dict:
31
+ if not path.is_file():
32
+ return {}
33
+ with open(path, encoding="utf-8") as f:
34
+ data = json.load(f)
35
+ if not isinstance(data, dict):
36
+ raise ValueError(f"{path} must contain a JSON object")
37
+ return data
38
+
39
+
40
+ def _write_json(path: Path, data: dict) -> None:
41
+ path.parent.mkdir(parents=True, exist_ok=True)
42
+ with open(path, "w", encoding="utf-8") as f:
43
+ json.dump(data, f, indent=2)
44
+ f.write("\n")
45
+
46
+
47
+ def _translate_server(name: str, server: dict) -> dict:
48
+ """Translate a Claude-style server entry into opencode's shape."""
49
+ out: dict = {}
50
+ if "url" in server:
51
+ out["type"] = "remote"
52
+ out["url"] = server["url"]
53
+ if "headers" in server and isinstance(server["headers"], dict):
54
+ out["headers"] = copy.deepcopy(server["headers"])
55
+ elif "command" in server:
56
+ out["type"] = "local"
57
+ cmd = server["command"]
58
+ args = server.get("args") or []
59
+ if isinstance(cmd, list):
60
+ out["command"] = list(cmd) + list(args)
61
+ else:
62
+ out["command"] = [cmd, *args]
63
+ env = server.get("env") or server.get("environment")
64
+ if isinstance(env, dict) and env:
65
+ out["environment"] = copy.deepcopy(env)
66
+ else:
67
+ # Unknown shape — preserve as-is so opencode can report the error
68
+ out = copy.deepcopy(server)
69
+ out.setdefault("type", "local")
70
+
71
+ out.setdefault("enabled", True)
72
+ if "timeout" in server:
73
+ out["timeout"] = server["timeout"]
74
+ return out
75
+
76
+
77
+ def _read_mcp_servers(project_dir: Path) -> dict:
78
+ """Read canonical .mcp.json and return the servers map.
79
+
80
+ Returns {} when no .mcp.json exists (non-fatal).
81
+ """
82
+ config = project_dir / ".mcp.json"
83
+ if not config.is_file():
84
+ return {}
85
+ data = _load_json(config)
86
+ servers = data.get("mcpServers", {})
87
+ if not isinstance(servers, dict):
88
+ return {}
89
+ return servers
90
+
91
+
92
+ def merge_into_opencode_json(
93
+ target_dir: Path, output_path: Path | None = None
94
+ ) -> tuple[Path, int]:
95
+ """Merge .mcp.json servers into an opencode.json file.
96
+
97
+ Reads ``target_dir/.mcp.json`` as the canonical source. By default
98
+ writes to ``target_dir/opencode.json`` (project-local). Pass
99
+ ``output_path=~/.config/opencode/opencode.json`` for the global layout.
100
+
101
+ Returns (path, server_count). If no .mcp.json exists, still ensures
102
+ the output has the $schema key set (creates a minimal file).
103
+ """
104
+ servers = _read_mcp_servers(target_dir)
105
+ path = output_path if output_path is not None else target_dir / "opencode.json"
106
+ data = _load_json(path)
107
+ data.setdefault("$schema", SCHEMA_URL)
108
+
109
+ if servers:
110
+ bucket = data.setdefault("mcp", {})
111
+ if not isinstance(bucket, dict):
112
+ raise ValueError(f"{path} has invalid 'mcp' data (expected object)")
113
+ for name, entry in servers.items():
114
+ if not isinstance(entry, dict):
115
+ continue
116
+ bucket[name] = _translate_server(name, entry)
117
+
118
+ _write_json(path, data)
119
+ return path, len(servers)
120
+
121
+
122
+ def main() -> None:
123
+ target = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd()
124
+ path, count = merge_into_opencode_json(target)
125
+ rel = path.relative_to(target) if path.is_relative_to(target) else path
126
+ if count:
127
+ print(f"Generated: {rel} ({count} MCP server(s) merged)")
128
+ else:
129
+ print(f"Generated: {rel} (no MCP servers; .mcp.json not found)")
130
+
131
+
132
+ if __name__ == "__main__":
133
+ main()
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env python3
2
+ """Generate the ai-toolkit opencode plugin at ``.opencode/plugins/ai-toolkit-hooks.js``.
3
+
4
+ opencode plugins are JS/TS modules with a NAMED export that receive a
5
+ context (``$``, ``project``, ``client``, ``directory``, ``worktree``) and
6
+ return a hooks object. Docs: https://opencode.ai/docs/plugins/
7
+
8
+ This generator emits a single-file plugin that bridges ai-toolkit's
9
+ shared Bash hooks (``$HOME/.softspark/ai-toolkit/hooks/*.sh``) to
10
+ opencode lifecycle events. Coverage map:
11
+
12
+ opencode event -> ai-toolkit Bash hook(s)
13
+ ---------------------------------------------------------------
14
+ session.created -> session-start.sh + session-context.sh + mcp-health.sh
15
+ session.compacted -> pre-compact.sh + pre-compact-save.sh
16
+ session.deleted -> session-end.sh + save-session.sh
17
+ message.updated -> user-prompt-submit.sh + track-usage.sh
18
+ message.part.updated -> user-prompt-submit.sh + track-usage.sh
19
+ tool.execute.before (bash) -> guard-destructive.sh + commit-quality.sh
20
+ tool.execute.after -> post-tool-use.sh
21
+ permission.asked -> guard-destructive.sh
22
+ command.executed -> post-tool-use.sh
23
+
24
+ Security: hooks are invoked via ``$`` with the script path bound to a
25
+ JS constant (no opencode-payload interpolation into the command). Event
26
+ payloads are passed on stdin as JSON. Non-zero exit codes are logged to
27
+ stderr; ``exit 2`` is preserved as a block signal for PreToolUse guards.
28
+
29
+ Usage:
30
+ python3 scripts/generate_opencode_plugin.py [target-dir] [--config-root PATH]
31
+ """
32
+ from __future__ import annotations
33
+
34
+ import argparse
35
+ import sys
36
+ from pathlib import Path
37
+
38
+ PLUGIN_BODY = r"""// ai-toolkit opencode plugin — bridges shared Bash hooks to opencode events.
39
+ // Auto-generated by ai-toolkit. Do not edit by hand; re-run to update:
40
+ // ai-toolkit opencode-plugin
41
+ // Docs: https://opencode.ai/docs/plugins/
42
+ //
43
+ // Hooks live at $HOME/.softspark/ai-toolkit/hooks/*.sh and are shared with
44
+ // Claude Code and Codex CLI. This plugin invokes them via Bun's `$` shell
45
+ // with the script path bound as a JS constant — no string interpolation of
46
+ // opencode event payloads into the shell, so event data cannot inject
47
+ // shell metacharacters. Payloads are passed on stdin as JSON.
48
+
49
+ const HOOKS_DIR = `${process.env.HOME}/.softspark/ai-toolkit/hooks`;
50
+
51
+ /** Invoke a Bash hook with a JSON payload on stdin. */
52
+ async function runHook($, script, payload) {
53
+ const scriptPath = `${HOOKS_DIR}/${script}`;
54
+ try {
55
+ const input = JSON.stringify(payload ?? {});
56
+ const proc = $`bash ${scriptPath}`.env({
57
+ ...process.env,
58
+ AI_TOOLKIT_EVENT: payload?.event || "unknown",
59
+ });
60
+ proc.stdin.write(input);
61
+ proc.stdin.end();
62
+ const result = await proc.quiet().nothrow();
63
+ if (result.exitCode !== 0 && result.exitCode !== 2) {
64
+ // Exit 2 is the toolkit's "block" signal — pass through to opencode as a guard.
65
+ process.stderr.write(
66
+ `[ai-toolkit] ${script} exited ${result.exitCode}\n${result.stderr.toString()}`
67
+ );
68
+ }
69
+ return result.exitCode;
70
+ } catch (err) {
71
+ process.stderr.write(`[ai-toolkit] failed to run ${script}: ${err.message}\n`);
72
+ return 0;
73
+ }
74
+ }
75
+
76
+ export const AiToolkitHooks = async ({ $, project, directory, worktree }) => ({
77
+ event: async ({ event }) => {
78
+ const payload = { event: event.type, project, directory, worktree };
79
+ switch (event.type) {
80
+ case "session.created":
81
+ await runHook($, "session-start.sh", payload);
82
+ await runHook($, "session-context.sh", payload);
83
+ await runHook($, "mcp-health.sh", payload);
84
+ break;
85
+ case "session.compacted":
86
+ await runHook($, "pre-compact.sh", payload);
87
+ await runHook($, "pre-compact-save.sh", payload);
88
+ break;
89
+ case "session.deleted":
90
+ await runHook($, "session-end.sh", payload);
91
+ await runHook($, "save-session.sh", payload);
92
+ break;
93
+ case "message.updated":
94
+ case "message.part.updated":
95
+ await runHook($, "user-prompt-submit.sh", payload);
96
+ await runHook($, "track-usage.sh", payload);
97
+ break;
98
+ case "permission.asked":
99
+ await runHook($, "guard-destructive.sh", payload);
100
+ break;
101
+ case "command.executed":
102
+ await runHook($, "post-tool-use.sh", payload);
103
+ break;
104
+ }
105
+ },
106
+
107
+ "tool.execute.before": async (input, output) => {
108
+ const payload = {
109
+ event: "tool.execute.before",
110
+ tool: input?.tool,
111
+ args: output?.args,
112
+ project,
113
+ };
114
+ if (input?.tool === "bash") {
115
+ await runHook($, "guard-destructive.sh", payload);
116
+ await runHook($, "commit-quality.sh", payload);
117
+ }
118
+ },
119
+
120
+ "tool.execute.after": async (input, output) => {
121
+ const payload = {
122
+ event: "tool.execute.after",
123
+ tool: input?.tool,
124
+ result: output,
125
+ project,
126
+ };
127
+ await runHook($, "post-tool-use.sh", payload);
128
+ },
129
+ });
130
+ """
131
+
132
+
133
+ def generate(target_dir: Path, config_root: Path | None = None) -> Path:
134
+ """Write the opencode plugin file and return its path.
135
+
136
+ ``config_root`` lets the caller override the default project-local
137
+ layout. When omitted, writes to ``target_dir/.opencode/plugins/``.
138
+ Pass ``config_root=~/.config/opencode`` to lay down the plugin
139
+ globally at ``~/.config/opencode/plugins/ai-toolkit-hooks.js``.
140
+ """
141
+ base = config_root if config_root is not None else target_dir / ".opencode"
142
+ plugins_dir = base / "plugins"
143
+ plugins_dir.mkdir(parents=True, exist_ok=True)
144
+ path = plugins_dir / "ai-toolkit-hooks.js"
145
+ path.write_text(PLUGIN_BODY, encoding="utf-8")
146
+ return path
147
+
148
+
149
+ def main() -> None:
150
+ parser = argparse.ArgumentParser(description="Generate opencode plugin")
151
+ parser.add_argument("target", nargs="?", default=".", help="Target directory")
152
+ parser.add_argument(
153
+ "--config-root",
154
+ type=Path,
155
+ default=None,
156
+ help="Override base (e.g. ~/.config/opencode for global install).",
157
+ )
158
+ args = parser.parse_args()
159
+ target = Path(args.target)
160
+ path = generate(target, config_root=args.config_root)
161
+ try:
162
+ rel = path.relative_to(target)
163
+ except ValueError:
164
+ rel = path
165
+ print(f"Generated: {rel}")
166
+
167
+
168
+ if __name__ == "__main__":
169
+ main()
@@ -92,6 +92,14 @@ def install_ai_tools(target_dir: Path, rules_dir: Path,
92
92
  _install_codex_global(target_dir, rules_dir)
93
93
  installed.append("codex")
94
94
 
95
+ if "opencode" in eds:
96
+ if dry_run:
97
+ print(" Would inject: ~/.config/opencode/{AGENTS.md, agents/, "
98
+ "commands/, plugins/ai-toolkit-hooks.js, opencode.json}")
99
+ else:
100
+ _install_opencode_global(target_dir, rules_dir)
101
+ installed.append("opencode")
102
+
95
103
  print()
96
104
  print(f" Available: {', '.join(GLOBAL_CAPABLE_EDITORS)}")
97
105
  print(" Note: Copilot, Cline, Roo Code, Aider, Antigravity have no global config -- use 'ai-toolkit install --local' per project")
@@ -128,6 +136,51 @@ def _install_codex_global(target_dir: Path, rules_dir: Path) -> None:
128
136
  _install_codex_skills(target_dir)
129
137
 
130
138
 
139
+ def _install_opencode_global(target_dir: Path, rules_dir: Path) -> None:
140
+ """Install opencode at the global level (~/.config/opencode/).
141
+
142
+ Creates:
143
+ - ~/.config/opencode/AGENTS.md (marker injection with rules)
144
+ - ~/.config/opencode/agents/ai-toolkit-*.md (subagents)
145
+ - ~/.config/opencode/commands/ai-toolkit-*.md (slash commands)
146
+ - ~/.config/opencode/plugins/ai-toolkit-hooks.js (hook bridge)
147
+ - ~/.config/opencode/opencode.json (MCP merge, preserves user keys)
148
+ """
149
+ opencode_home = target_dir / ".config" / "opencode"
150
+ inject_with_rules(
151
+ "generate_opencode.py",
152
+ opencode_home / "AGENTS.md",
153
+ rules_dir,
154
+ )
155
+
156
+ from generate_opencode_agents import generate as gen_opencode_agents
157
+ written, removed = gen_opencode_agents(target_dir, config_root=opencode_home)
158
+ msg = f" Created: ~/.config/opencode/agents/ ({written} agents"
159
+ if removed:
160
+ msg += f", {removed} stale removed"
161
+ msg += ")"
162
+ print(msg)
163
+
164
+ from generate_opencode_commands import generate as gen_opencode_commands
165
+ written, removed = gen_opencode_commands(target_dir, config_root=opencode_home)
166
+ msg = f" Created: ~/.config/opencode/commands/ ({written} commands"
167
+ if removed:
168
+ msg += f", {removed} stale removed"
169
+ msg += ")"
170
+ print(msg)
171
+
172
+ from generate_opencode_plugin import generate as gen_opencode_plugin
173
+ gen_opencode_plugin(target_dir, config_root=opencode_home)
174
+ print(" Created: ~/.config/opencode/plugins/ai-toolkit-hooks.js")
175
+
176
+ from generate_opencode_json import merge_into_opencode_json
177
+ _, count = merge_into_opencode_json(
178
+ target_dir, output_path=opencode_home / "opencode.json"
179
+ )
180
+ suffix = f" ({count} MCP server(s) merged)" if count else " (no MCP servers)"
181
+ print(f" Created: ~/.config/opencode/opencode.json{suffix}")
182
+
183
+
131
184
  def inject_with_rules(
132
185
  generator_script: str,
133
186
  target_file: Path,
@@ -194,7 +247,7 @@ def run_script(script_name: str, *args: str, capture: bool = False) -> str:
194
247
  # All known editor identifiers for --editors flag
195
248
  ALL_EDITORS = [
196
249
  "copilot", "cursor", "windsurf", "cline", "roo",
197
- "aider", "augment", "antigravity", "codex", "gemini",
250
+ "aider", "augment", "antigravity", "codex", "gemini", "opencode",
198
251
  ]
199
252
 
200
253
  # Map of project files/dirs → editor names for auto-detection
@@ -213,7 +266,15 @@ _EDITOR_MARKERS: dict[str, str] = {
213
266
  ".agent/rules": "antigravity",
214
267
  ".agents/skills": "codex",
215
268
  ".codex": "codex",
269
+ # NOTE: AGENTS.md alone is ambiguous (Codex + opencode both read it);
270
+ # prefer the dedicated .opencode/ and opencode.json markers when
271
+ # disambiguating. If only AGENTS.md is present, Codex takes precedence
272
+ # to preserve v2.4.x behavior.
216
273
  "AGENTS.md": "codex",
274
+ "opencode.json": "opencode",
275
+ ".opencode": "opencode",
276
+ ".opencode/agents": "opencode",
277
+ ".opencode/commands": "opencode",
217
278
  }
218
279
 
219
280
 
@@ -485,6 +546,7 @@ def _install_local_dry_run(reset: bool, editors: list[str] | None = None) -> Non
485
546
  "aider": " Would generate: .aider.conf.yml + CONVENTIONS.md",
486
547
  "augment": " Would generate: .augment/rules/ai-toolkit-*.md",
487
548
  "antigravity": " Would generate: .agent/rules/ + .agent/workflows/",
549
+ "opencode": " Would generate: AGENTS.md + .opencode/{agents,commands,plugins}/ + opencode.json",
488
550
  }
489
551
  for ed, msg in _EDITOR_DRY_RUN.items():
490
552
  if ed in eds:
@@ -511,6 +573,24 @@ def _reset_local_configs(cwd: Path) -> None:
511
573
  p.unlink()
512
574
  print(f" Removed: {rel}")
513
575
 
576
+ # opencode: remove only ai-toolkit-prefixed generated files so the
577
+ # user's own .opencode/agents/ or commands/ entries are preserved.
578
+ # opencode.json is left alone — it may contain user MCP servers and other
579
+ # project settings; reinstall re-merges our MCP entries idempotently.
580
+ opencode_plugin = cwd / ".opencode" / "plugins" / "ai-toolkit-hooks.js"
581
+ if opencode_plugin.is_file():
582
+ opencode_plugin.unlink()
583
+ print(" Removed: .opencode/plugins/ai-toolkit-hooks.js")
584
+ for sub in ("agents", "commands"):
585
+ sub_dir = cwd / ".opencode" / sub
586
+ if sub_dir.is_dir():
587
+ removed_any = False
588
+ for f in sorted(sub_dir.glob("ai-toolkit-*.md")):
589
+ f.unlink()
590
+ removed_any = True
591
+ if removed_any:
592
+ print(f" Removed: .opencode/{sub}/ai-toolkit-*.md")
593
+
514
594
 
515
595
  def _create_local_claude_md(cwd: Path, reset: bool) -> None:
516
596
  claude_local = cwd / "CLAUDE.md"
@@ -699,6 +779,42 @@ def _create_local_ai_tool_configs(cwd: Path, rules_dir: Path,
699
779
  # .agents/skills/ — filtered symlinks (Codex-compatible skills only)
700
780
  _install_codex_skills(cwd)
701
781
 
782
+ if "opencode" in eds:
783
+ # AGENTS.md — shared with Codex via marker injection (opencode reads same file)
784
+ # Use a dedicated section tag so Codex and opencode don't clobber each other.
785
+ inject_with_rules(
786
+ "generate_opencode.py",
787
+ cwd / "AGENTS.md",
788
+ rules_dir,
789
+ )
790
+ # .opencode/agents/ — native subagents
791
+ from generate_opencode_agents import generate as gen_opencode_agents
792
+ written, removed = gen_opencode_agents(cwd)
793
+ msg = f" Created: .opencode/agents/ ({written} agents"
794
+ if removed:
795
+ msg += f", {removed} stale removed"
796
+ msg += ")"
797
+ print(msg)
798
+ # .opencode/commands/ — native slash commands
799
+ from generate_opencode_commands import generate as gen_opencode_commands
800
+ written, removed = gen_opencode_commands(cwd)
801
+ msg = f" Created: .opencode/commands/ ({written} commands"
802
+ if removed:
803
+ msg += f", {removed} stale removed"
804
+ msg += ")"
805
+ print(msg)
806
+ # .opencode/plugins/ai-toolkit-hooks.js — lifecycle hook bridge
807
+ from generate_opencode_plugin import generate as gen_opencode_plugin
808
+ gen_opencode_plugin(cwd)
809
+ print(" Created: .opencode/plugins/ai-toolkit-hooks.js")
810
+ # opencode.json — merge MCP servers from .mcp.json (preserves user keys)
811
+ from generate_opencode_json import merge_into_opencode_json
812
+ _, mcp_count = merge_into_opencode_json(cwd)
813
+ if mcp_count:
814
+ print(f" Updated: opencode.json ({mcp_count} MCP server(s) from .mcp.json)")
815
+ else:
816
+ print(" Updated: opencode.json ($schema set)")
817
+
702
818
  synced_paths = sync_project_mcp_to_editors(cwd, sorted(eds))
703
819
  for path in synced_paths:
704
820
  rel = path.relative_to(cwd)
@@ -89,7 +89,7 @@ def remove_mcp_template(name: str) -> None:
89
89
  DEFAULT_GLOBAL_EDITORS: list[str] = []
90
90
 
91
91
  # All editors that support global install (opt-in via --editors)
92
- GLOBAL_CAPABLE_EDITORS = ["augment", "codex", "cursor", "gemini", "windsurf"]
92
+ GLOBAL_CAPABLE_EDITORS = ["augment", "codex", "cursor", "gemini", "opencode", "windsurf"]
93
93
 
94
94
 
95
95
  def get_global_editors() -> list[str]: