@softspark/ai-toolkit 2.5.0 → 2.6.2
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.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +44 -0
- package/README.md +8 -9
- package/app/.claude-plugin/plugin.json +3 -2
- package/app/ARCHITECTURE.md +11 -0
- package/app/skills/hipaa-validate/SKILL.md +39 -23
- package/app/skills/hipaa-validate/scripts/hipaa_scan.py +64 -7
- package/bin/ai-toolkit.js +20 -5
- package/kb/procedures/release-preparation-sop.md +6 -3
- package/kb/reference/opencode-compatibility.md +161 -0
- package/llms-full.txt +174 -4
- package/llms.txt +1 -0
- package/manifest.json +1 -1
- package/package.json +6 -3
- package/scripts/eject.py +5 -1
- package/scripts/generate_opencode.py +117 -0
- package/scripts/generate_opencode_agents.py +126 -0
- package/scripts/generate_opencode_commands.py +158 -0
- package/scripts/generate_opencode_json.py +133 -0
- package/scripts/generate_opencode_plugin.py +169 -0
- package/scripts/install_steps/ai_tools.py +117 -1
- package/scripts/install_steps/install_state.py +1 -1
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Generate .opencode/agents/*.md files for opencode (https://opencode.ai).
|
|
3
|
+
|
|
4
|
+
Each ai-toolkit agent becomes an opencode subagent with frontmatter:
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
description: "..."
|
|
8
|
+
mode: subagent
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
<body from agent file>
|
|
12
|
+
|
|
13
|
+
Generated files are prefixed ``ai-toolkit-`` so they never collide with
|
|
14
|
+
user-authored opencode agents and can be cleanly removed on uninstall.
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
python3 scripts/generate_opencode_agents.py [target-dir]
|
|
18
|
+
|
|
19
|
+
Writes files to target-dir/.opencode/agents/.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
27
|
+
from emission import agents_dir
|
|
28
|
+
from frontmatter import frontmatter_field
|
|
29
|
+
|
|
30
|
+
AGENT_PREFIX = "ai-toolkit-"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _agent_body(agent_file: Path) -> str:
|
|
34
|
+
"""Return the markdown body of an agent file (content after frontmatter)."""
|
|
35
|
+
text = agent_file.read_text(encoding="utf-8")
|
|
36
|
+
if not text.startswith("---"):
|
|
37
|
+
return text.strip() + "\n"
|
|
38
|
+
# Skip first frontmatter block
|
|
39
|
+
parts = text.split("---", 2)
|
|
40
|
+
if len(parts) < 3:
|
|
41
|
+
return text.strip() + "\n"
|
|
42
|
+
return parts[2].lstrip("\n")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _render_opencode_agent(agent_file: Path) -> str:
|
|
46
|
+
"""Render a single opencode subagent .md file from an ai-toolkit agent."""
|
|
47
|
+
name = frontmatter_field(agent_file, "name")
|
|
48
|
+
description = frontmatter_field(agent_file, "description")
|
|
49
|
+
model = frontmatter_field(agent_file, "model")
|
|
50
|
+
color = frontmatter_field(agent_file, "color")
|
|
51
|
+
|
|
52
|
+
# Escape description for YAML quoted string
|
|
53
|
+
safe_desc = description.replace('"', "'")
|
|
54
|
+
|
|
55
|
+
lines: list[str] = ["---"]
|
|
56
|
+
lines.append(f'description: "{safe_desc}"')
|
|
57
|
+
lines.append("mode: subagent")
|
|
58
|
+
# opencode requires `provider/model-id` for the `model` field. ai-toolkit
|
|
59
|
+
# only stores a short alias (opus/sonnet/haiku) which is not mappable
|
|
60
|
+
# without assuming a provider, so we deliberately omit it — opencode falls
|
|
61
|
+
# back to the user's `default_agent` / top-level `model` config.
|
|
62
|
+
_ = model # intentionally unused
|
|
63
|
+
if color:
|
|
64
|
+
lines.append(f"color: {color}")
|
|
65
|
+
lines.append("---")
|
|
66
|
+
lines.append("")
|
|
67
|
+
body = _agent_body(agent_file).rstrip()
|
|
68
|
+
if body:
|
|
69
|
+
lines.append(body)
|
|
70
|
+
lines.append("")
|
|
71
|
+
return "\n".join(lines)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _cleanup_stale(agents_out: Path) -> int:
|
|
75
|
+
"""Remove stale ai-toolkit-* agent files whose source no longer exists."""
|
|
76
|
+
if not agents_out.is_dir():
|
|
77
|
+
return 0
|
|
78
|
+
removed = 0
|
|
79
|
+
for f in sorted(agents_out.glob(f"{AGENT_PREFIX}*.md")):
|
|
80
|
+
source_name = f.stem[len(AGENT_PREFIX):]
|
|
81
|
+
source = agents_dir / f"{source_name}.md"
|
|
82
|
+
if not source.is_file():
|
|
83
|
+
f.unlink()
|
|
84
|
+
removed += 1
|
|
85
|
+
return removed
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def generate(
|
|
89
|
+
target_dir: Path, config_root: Path | None = None
|
|
90
|
+
) -> tuple[int, int]:
|
|
91
|
+
"""Write opencode agent files and return (written, removed_stale).
|
|
92
|
+
|
|
93
|
+
By default writes to ``target_dir/.opencode/agents/`` (project-local).
|
|
94
|
+
Pass ``config_root=~/.config/opencode`` for the global layout, which
|
|
95
|
+
lives directly under ``agents/`` (no ``.opencode/`` prefix).
|
|
96
|
+
"""
|
|
97
|
+
base = config_root if config_root is not None else target_dir / ".opencode"
|
|
98
|
+
agents_out = base / "agents"
|
|
99
|
+
agents_out.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
|
|
101
|
+
written = 0
|
|
102
|
+
for agent_file in sorted(agents_dir.glob("*.md")):
|
|
103
|
+
name = frontmatter_field(agent_file, "name")
|
|
104
|
+
description = frontmatter_field(agent_file, "description")
|
|
105
|
+
if not name or not description:
|
|
106
|
+
continue
|
|
107
|
+
out_path = agents_out / f"{AGENT_PREFIX}{name}.md"
|
|
108
|
+
out_path.write_text(_render_opencode_agent(agent_file), encoding="utf-8")
|
|
109
|
+
written += 1
|
|
110
|
+
|
|
111
|
+
removed = _cleanup_stale(agents_out)
|
|
112
|
+
return written, removed
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def main() -> None:
|
|
116
|
+
target = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd()
|
|
117
|
+
written, removed = generate(target)
|
|
118
|
+
msg = f"Generated: .opencode/agents/ ({written} agents"
|
|
119
|
+
if removed:
|
|
120
|
+
msg += f", {removed} stale removed"
|
|
121
|
+
msg += ")"
|
|
122
|
+
print(msg)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
if __name__ == "__main__":
|
|
126
|
+
main()
|
|
@@ -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()
|