@softspark/ai-toolkit 2.12.0 → 3.0.0
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/CHANGELOG.md +38 -0
- package/README.md +18 -8
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/skills/hook-creator/SKILL.md +84 -8
- package/app/skills/skill-creator/SKILL.md +8 -4
- package/benchmarks/ecosystem-doctor-snapshot.json +395 -0
- package/kb/history/completed/deep-coverage-v3-20260423.md +160 -0
- package/kb/history/completed/ecosystem-deep-sweep-20260423.md +273 -0
- package/kb/procedures/ecosystem-sync-sop.md +255 -0
- package/kb/procedures/maintenance-sop.md +13 -2
- package/kb/procedures/release-preparation-sop.md +83 -10
- package/kb/reference/global-install-model.md +15 -2
- package/kb/reference/supported-tools-registry.md +229 -0
- package/llms-full.txt +1052 -14
- package/llms.txt +4 -0
- package/manifest.json +1 -1
- package/package.json +4 -1
- package/scripts/ecosystem_doctor.py +348 -0
- package/scripts/ecosystem_tools.json +500 -0
- package/scripts/generate_aider_conf.py +26 -1
- package/scripts/generate_antigravity.py +77 -8
- package/scripts/generate_augment_agents.py +161 -0
- package/scripts/generate_augment_commands.py +160 -0
- package/scripts/generate_augment_hooks.py +162 -0
- package/scripts/generate_augment_skills.py +98 -0
- package/scripts/generate_cline_rules.py +96 -9
- package/scripts/generate_codex_hooks.py +13 -2
- package/scripts/generate_codex_skills.py +195 -0
- package/scripts/generate_copilot.py +296 -18
- package/scripts/generate_cursor_agents.py +144 -0
- package/scripts/generate_cursor_hooks.py +155 -0
- package/scripts/generate_cursor_mdc.py +20 -8
- package/scripts/generate_gemini_commands.py +158 -0
- package/scripts/generate_gemini_hooks.py +159 -0
- package/scripts/generate_gemini_skills.py +98 -0
- package/scripts/generate_roo_modes.py +42 -1
- package/scripts/generate_windsurf_hooks.py +143 -0
- package/scripts/generate_windsurf_rules.py +162 -10
- package/scripts/install.py +11 -2
- package/scripts/install_steps/ai_tools.py +120 -5
- package/scripts/validate.py +20 -3
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Generate ``.gemini/commands/ai-toolkit-*.toml`` files for Gemini CLI.
|
|
3
|
+
|
|
4
|
+
User-invocable skills become Gemini custom slash commands. Knowledge skills
|
|
5
|
+
(``user-invocable: false``) are excluded — they load automatically via
|
|
6
|
+
``GEMINI.md`` context instead of ``/`` invocation.
|
|
7
|
+
|
|
8
|
+
Per github.com/google-gemini/gemini-cli, Gemini custom commands are TOML
|
|
9
|
+
files with at minimum:
|
|
10
|
+
|
|
11
|
+
description = "<one-line summary shown in the palette>"
|
|
12
|
+
prompt = \"\"\"
|
|
13
|
+
<multi-line prompt body>
|
|
14
|
+
\"\"\"
|
|
15
|
+
|
|
16
|
+
Argument substitution uses the ``{{args}}`` placeholder. We translate the
|
|
17
|
+
Claude Code ``$ARGUMENTS`` token that appears in some skill bodies into
|
|
18
|
+
``{{args}}`` so the same skill works across both runtimes.
|
|
19
|
+
|
|
20
|
+
Files are prefixed ``ai-toolkit-`` so install/uninstall can identify ours
|
|
21
|
+
without touching user-authored Gemini commands.
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
python3 scripts/generate_gemini_commands.py [target-dir]
|
|
25
|
+
|
|
26
|
+
Writes files to ``target-dir/.gemini/commands/``.
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import sys
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
34
|
+
from emission import skills_dir
|
|
35
|
+
from frontmatter import frontmatter_field
|
|
36
|
+
|
|
37
|
+
COMMAND_PREFIX = "ai-toolkit-"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _skill_body(skill_file: Path) -> str:
|
|
41
|
+
"""Return the markdown body of a skill (content after frontmatter)."""
|
|
42
|
+
text = skill_file.read_text(encoding="utf-8")
|
|
43
|
+
if not text.startswith("---"):
|
|
44
|
+
return text
|
|
45
|
+
parts = text.split("---", 2)
|
|
46
|
+
return parts[2].lstrip("\n") if len(parts) >= 3 else text
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _translate_placeholders(body: str) -> str:
|
|
50
|
+
"""Rewrite Claude-style ``$ARGUMENTS`` placeholders to Gemini ``{{args}}``."""
|
|
51
|
+
return body.replace("$ARGUMENTS", "{{args}}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _render_gemini_command(skill_file: Path) -> str:
|
|
55
|
+
"""Render a single Gemini TOML command file from a user-invocable skill.
|
|
56
|
+
|
|
57
|
+
Bodies are emitted as TOML multi-line **literal** strings (``'''...'''``)
|
|
58
|
+
so embedded regex such as ``\\s``, ``\\d``, ``\\n`` are passed through
|
|
59
|
+
verbatim. Literal strings accept no escape sequences, which is exactly
|
|
60
|
+
what we want for prompt fidelity.
|
|
61
|
+
"""
|
|
62
|
+
description = frontmatter_field(skill_file, "description")
|
|
63
|
+
body = _skill_body(skill_file).rstrip()
|
|
64
|
+
body = _translate_placeholders(body)
|
|
65
|
+
|
|
66
|
+
if "'''" in body:
|
|
67
|
+
raise ValueError(
|
|
68
|
+
f"{skill_file}: body contains ''' which would terminate a TOML "
|
|
69
|
+
f"multi-line literal string. Rewrite the skill to avoid that "
|
|
70
|
+
f"sequence or switch this generator to basic strings for it."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
safe_desc = (description or "").replace('"', '\\"')
|
|
74
|
+
|
|
75
|
+
lines: list[str] = []
|
|
76
|
+
lines.append(f'description = "{safe_desc}"')
|
|
77
|
+
lines.append("prompt = '''")
|
|
78
|
+
if body:
|
|
79
|
+
lines.append(body)
|
|
80
|
+
lines.append("'''")
|
|
81
|
+
lines.append("")
|
|
82
|
+
return "\n".join(lines)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _is_user_invocable(skill_file: Path) -> bool:
|
|
86
|
+
"""Return True if the skill should be exposed as a ``/`` command."""
|
|
87
|
+
invocable = frontmatter_field(skill_file, "user-invocable")
|
|
88
|
+
if invocable:
|
|
89
|
+
return invocable.lower() not in ("false", "0", "no")
|
|
90
|
+
disable_model = frontmatter_field(skill_file, "disable-model-invocation")
|
|
91
|
+
if disable_model and disable_model.lower() in ("true", "1", "yes"):
|
|
92
|
+
return True
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _cleanup_stale(commands_out: Path) -> int:
|
|
97
|
+
"""Remove stale ai-toolkit-* command files whose source no longer exists.
|
|
98
|
+
|
|
99
|
+
Also removes commands whose source skill is no longer user-invocable.
|
|
100
|
+
Only touches files prefixed ``ai-toolkit-`` so user-authored commands
|
|
101
|
+
are preserved.
|
|
102
|
+
"""
|
|
103
|
+
if not commands_out.is_dir():
|
|
104
|
+
return 0
|
|
105
|
+
removed = 0
|
|
106
|
+
for f in sorted(commands_out.glob(f"{COMMAND_PREFIX}*.toml")):
|
|
107
|
+
source_name = f.stem[len(COMMAND_PREFIX):]
|
|
108
|
+
source = skills_dir / source_name / "SKILL.md"
|
|
109
|
+
if not source.is_file() or not _is_user_invocable(source):
|
|
110
|
+
f.unlink()
|
|
111
|
+
removed += 1
|
|
112
|
+
return removed
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def generate(
|
|
116
|
+
target_dir: Path, config_root: Path | None = None
|
|
117
|
+
) -> tuple[int, int]:
|
|
118
|
+
"""Write Gemini TOML command files and return (written, removed_stale).
|
|
119
|
+
|
|
120
|
+
By default writes to ``target_dir/.gemini/commands/`` (project-local).
|
|
121
|
+
Pass ``config_root=~/.gemini`` for the global layout.
|
|
122
|
+
"""
|
|
123
|
+
base = config_root if config_root is not None else target_dir / ".gemini"
|
|
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}.toml"
|
|
140
|
+
out_path.write_text(_render_gemini_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: .gemini/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,159 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Generate .gemini/settings.json hooks block for Gemini CLI.
|
|
3
|
+
|
|
4
|
+
Merges ai-toolkit hook entries into `<target>/.gemini/settings.json`. User
|
|
5
|
+
settings (everything outside the `hooks` key or any hook entry not tagged
|
|
6
|
+
`_source: ai-toolkit`) are preserved byte-for-byte.
|
|
7
|
+
|
|
8
|
+
Gemini CLI hook events (per docs/hooks/reference.md, google-gemini/gemini-cli):
|
|
9
|
+
BeforeTool, AfterTool, BeforeAgent, AfterAgent, BeforeModel,
|
|
10
|
+
BeforeToolSelection, AfterModel, SessionStart, SessionEnd,
|
|
11
|
+
Notification, PreCompress
|
|
12
|
+
|
|
13
|
+
The ai-toolkit registry nominally includes `Stop`, but the upstream Gemini CLI
|
|
14
|
+
does not implement it; `AfterAgent` is the closest equivalent (fires once per
|
|
15
|
+
turn after the model's final response) and we wire our `save-session.sh` /
|
|
16
|
+
`quality-check.sh` there.
|
|
17
|
+
|
|
18
|
+
Hook scripts are shared with Claude Code / Codex and live under
|
|
19
|
+
`~/.softspark/ai-toolkit/hooks/`. This generator does NOT duplicate shell code.
|
|
20
|
+
|
|
21
|
+
Usage:
|
|
22
|
+
python3 scripts/generate_gemini_hooks.py [target-dir]
|
|
23
|
+
|
|
24
|
+
Writes `<target-dir>/.gemini/settings.json` (merge-safe, idempotent).
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import sys
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
HOOKS_PREFIX = '"$HOME/.softspark/ai-toolkit/hooks/'
|
|
33
|
+
SOURCE_TAG = "ai-toolkit"
|
|
34
|
+
|
|
35
|
+
# Event -> list of (matcher, script) pairs. Matcher semantics:
|
|
36
|
+
# - Tool events (BeforeTool/AfterTool): regex over tool_name
|
|
37
|
+
# (built-in tools: run_shell_command, write_file, edit, replace, read_file...)
|
|
38
|
+
# - Agent/Model/Session events: matcher is ignored upstream but harmless to omit
|
|
39
|
+
GEMINI_HOOKS: dict[str, list[tuple[str, str]]] = {
|
|
40
|
+
"SessionStart": [
|
|
41
|
+
("", "session-start.sh"),
|
|
42
|
+
("", "mcp-health.sh"),
|
|
43
|
+
("", "session-context.sh"),
|
|
44
|
+
],
|
|
45
|
+
"BeforeTool": [
|
|
46
|
+
# run_shell_command covers Bash-equivalent destructive patterns
|
|
47
|
+
("run_shell_command", "guard-destructive.sh"),
|
|
48
|
+
("run_shell_command", "commit-quality.sh"),
|
|
49
|
+
# File-touching tools — guard paths + config
|
|
50
|
+
("write_file|edit|replace|read_file", "guard-path.sh"),
|
|
51
|
+
("write_file|edit|replace", "guard-config.sh"),
|
|
52
|
+
],
|
|
53
|
+
"AfterTool": [
|
|
54
|
+
("write_file|edit|replace", "post-tool-use.sh"),
|
|
55
|
+
("run_shell_command|write_file|edit|replace", "governance-capture.sh"),
|
|
56
|
+
],
|
|
57
|
+
"BeforeAgent": [
|
|
58
|
+
("", "user-prompt-submit.sh"),
|
|
59
|
+
("", "track-usage.sh"),
|
|
60
|
+
],
|
|
61
|
+
"AfterAgent": [
|
|
62
|
+
# Closest equivalent to Claude's Stop event. Gemini has no native Stop.
|
|
63
|
+
("", "quality-check.sh"),
|
|
64
|
+
("", "save-session.sh"),
|
|
65
|
+
],
|
|
66
|
+
"BeforeModel": [
|
|
67
|
+
# Light-weight observability slot. Reuses the session-context probe.
|
|
68
|
+
("", "session-context.sh"),
|
|
69
|
+
],
|
|
70
|
+
"SessionEnd": [
|
|
71
|
+
("", "session-end.sh"),
|
|
72
|
+
],
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def build_hook_entry(matcher: str, script: str) -> dict:
|
|
77
|
+
"""Build a single Gemini hook entry (merge-safe shape)."""
|
|
78
|
+
entry: dict = {
|
|
79
|
+
"_source": SOURCE_TAG,
|
|
80
|
+
"hooks": [
|
|
81
|
+
{
|
|
82
|
+
"type": "command",
|
|
83
|
+
"command": f"{HOOKS_PREFIX}{script}\"",
|
|
84
|
+
}
|
|
85
|
+
],
|
|
86
|
+
}
|
|
87
|
+
if matcher:
|
|
88
|
+
entry["matcher"] = matcher
|
|
89
|
+
return entry
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def build_toolkit_hooks() -> dict[str, list[dict]]:
|
|
93
|
+
"""Build the toolkit's hook entries grouped by event."""
|
|
94
|
+
result: dict[str, list[dict]] = {}
|
|
95
|
+
for event, entries in GEMINI_HOOKS.items():
|
|
96
|
+
result[event] = [build_hook_entry(m, s) for m, s in entries]
|
|
97
|
+
return result
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _is_toolkit_entry(entry: dict) -> bool:
|
|
101
|
+
return isinstance(entry, dict) and entry.get("_source") == SOURCE_TAG
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def strip_toolkit_hooks(hooks: dict) -> dict:
|
|
105
|
+
"""Drop any existing ai-toolkit-tagged entries, keep everything else."""
|
|
106
|
+
kept: dict = {}
|
|
107
|
+
for event, entries in hooks.items():
|
|
108
|
+
if not isinstance(entries, list):
|
|
109
|
+
kept[event] = entries
|
|
110
|
+
continue
|
|
111
|
+
survivors = [e for e in entries if not _is_toolkit_entry(e)]
|
|
112
|
+
if survivors:
|
|
113
|
+
kept[event] = survivors
|
|
114
|
+
return kept
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def merge_hooks(existing_hooks: dict, toolkit_hooks: dict) -> dict:
|
|
118
|
+
"""Strip old toolkit entries, then append the current batch."""
|
|
119
|
+
merged = strip_toolkit_hooks(existing_hooks)
|
|
120
|
+
for event, entries in toolkit_hooks.items():
|
|
121
|
+
merged.setdefault(event, []).extend(entries)
|
|
122
|
+
return merged
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def generate(target_dir: Path) -> Path:
|
|
126
|
+
"""Write `<target_dir>/.gemini/settings.json` and return its path."""
|
|
127
|
+
gemini_dir = target_dir / ".gemini"
|
|
128
|
+
gemini_dir.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
settings_path = gemini_dir / "settings.json"
|
|
130
|
+
|
|
131
|
+
settings: dict = {}
|
|
132
|
+
if settings_path.is_file():
|
|
133
|
+
try:
|
|
134
|
+
with open(settings_path, encoding="utf-8") as f:
|
|
135
|
+
settings = json.load(f)
|
|
136
|
+
if not isinstance(settings, dict):
|
|
137
|
+
settings = {}
|
|
138
|
+
except (json.JSONDecodeError, OSError):
|
|
139
|
+
settings = {}
|
|
140
|
+
|
|
141
|
+
existing_hooks = settings.get("hooks") if isinstance(settings.get("hooks"), dict) else {}
|
|
142
|
+
settings["hooks"] = merge_hooks(existing_hooks or {}, build_toolkit_hooks())
|
|
143
|
+
|
|
144
|
+
with open(settings_path, "w", encoding="utf-8") as f:
|
|
145
|
+
json.dump(settings, f, indent=4, ensure_ascii=False, sort_keys=True)
|
|
146
|
+
f.write("\n")
|
|
147
|
+
return settings_path
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def main() -> None:
|
|
151
|
+
target = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd()
|
|
152
|
+
path = generate(target)
|
|
153
|
+
total = sum(len(v) for v in GEMINI_HOOKS.values())
|
|
154
|
+
print(f"Generated: {path.relative_to(target) if path.is_relative_to(target) else path} "
|
|
155
|
+
f"({total} hooks across {len(GEMINI_HOOKS)} events)")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
if __name__ == "__main__":
|
|
159
|
+
main()
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Generate a Gemini CLI skill pointer under ``.gemini/skills/``.
|
|
3
|
+
|
|
4
|
+
Gemini supports the Agent Skills standard via
|
|
5
|
+
``.gemini/skills/<skill-name>/SKILL.md``. Rather than duplicating the full
|
|
6
|
+
ai-toolkit skill catalogue into Gemini's skills directory (which would
|
|
7
|
+
produce 99 near-duplicate files on every install), we emit a single
|
|
8
|
+
**pointer skill** that teaches Gemini to resolve real skills from the
|
|
9
|
+
canonical Claude Code locations:
|
|
10
|
+
|
|
11
|
+
* ``.claude/skills/<name>/SKILL.md`` (project-local install)
|
|
12
|
+
* ``~/.claude/skills/<name>/SKILL.md`` (global install)
|
|
13
|
+
|
|
14
|
+
This mirrors the pattern used by ``generate_antigravity.py``.
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
python3 scripts/generate_gemini_skills.py [target-dir]
|
|
18
|
+
|
|
19
|
+
Writes ``<target-dir>/.gemini/skills/ai-toolkit-skill-catalogue/SKILL.md``.
|
|
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 emit_skills_bullets
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Skill pointer — a single SKILL.md that directs Gemini to our catalogue
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
POINTER_SKILL_NAME = "ai-toolkit-skill-catalogue"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _pointer_skill_md() -> str:
|
|
38
|
+
"""Build the SKILL.md body for the Gemini pointer skill."""
|
|
39
|
+
body = (
|
|
40
|
+
"# AI Toolkit Skill Catalogue\n\n"
|
|
41
|
+
"This workspace uses the ai-toolkit. Real skills are installed "
|
|
42
|
+
"alongside Claude Code at `.claude/skills/` (project install) or "
|
|
43
|
+
"`~/.claude/skills/` (global install). If the ai-toolkit installer "
|
|
44
|
+
"has run, every skill below already exists as an agent-invocable "
|
|
45
|
+
"SKILL.md on disk.\n\n"
|
|
46
|
+
"## When to use this skill\n\n"
|
|
47
|
+
"- You are asked to run a task that maps to one of the catalogued "
|
|
48
|
+
"skills below.\n"
|
|
49
|
+
"- You want to discover which skills the user has installed before "
|
|
50
|
+
"reinventing the wheel.\n\n"
|
|
51
|
+
"## How to invoke a catalogue entry\n\n"
|
|
52
|
+
"1. Match the user's task to a skill name in the catalogue.\n"
|
|
53
|
+
"2. Read the skill's SKILL.md from `.claude/skills/<name>/SKILL.md` "
|
|
54
|
+
"or `~/.claude/skills/<name>/SKILL.md` (whichever exists).\n"
|
|
55
|
+
"3. Follow its Rules, Gotchas, and When NOT to Use sections.\n\n"
|
|
56
|
+
"## Catalogue (installed skills)\n\n"
|
|
57
|
+
f"{emit_skills_bullets()}\n"
|
|
58
|
+
)
|
|
59
|
+
return (
|
|
60
|
+
"---\n"
|
|
61
|
+
f"name: {POINTER_SKILL_NAME}\n"
|
|
62
|
+
"description: Index of ai-toolkit skills installed at .claude/skills/"
|
|
63
|
+
" or ~/.claude/skills/. Read this first when the user's request "
|
|
64
|
+
"matches a named skill.\n"
|
|
65
|
+
"---\n"
|
|
66
|
+
f"{body}"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _write_skill_pointer(target_dir: Path) -> None:
|
|
71
|
+
"""Write the pointer SKILL.md under .gemini/skills/<name>/."""
|
|
72
|
+
skill_dir = target_dir / ".gemini" / "skills" / POINTER_SKILL_NAME
|
|
73
|
+
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
(skill_dir / "SKILL.md").write_text(_pointer_skill_md(), encoding="utf-8")
|
|
75
|
+
print(f" Generated: .gemini/skills/{POINTER_SKILL_NAME}/SKILL.md")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
# Main
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def generate(target_dir: Path, *, emit_skill_pointer: bool = True) -> None:
|
|
83
|
+
"""Write ``.gemini/skills/ai-toolkit-skill-catalogue/SKILL.md``.
|
|
84
|
+
|
|
85
|
+
``emit_skill_pointer`` controls whether the pointer SKILL.md is written.
|
|
86
|
+
Set to ``False`` to leave Gemini's skills directory untouched.
|
|
87
|
+
"""
|
|
88
|
+
if emit_skill_pointer:
|
|
89
|
+
_write_skill_pointer(target_dir)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def main() -> None:
|
|
93
|
+
target = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd()
|
|
94
|
+
generate(target)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
main()
|
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Generate
|
|
2
|
+
"""Generate ``.roomodes`` JSON from ``app/agents/*.md``.
|
|
3
|
+
|
|
4
|
+
Roo Code's custom mode format supports these top-level properties:
|
|
5
|
+
|
|
6
|
+
* ``slug`` — internal identifier, used for mode-specific rule dirs
|
|
7
|
+
* ``name`` — display name shown in the UI
|
|
8
|
+
* ``description`` — short one-line summary shown below the name in the
|
|
9
|
+
mode selector (required for the redesigned selector)
|
|
10
|
+
* ``roleDefinition`` — detailed expertise text placed at the start of
|
|
11
|
+
the system prompt
|
|
12
|
+
* ``whenToUse`` — optional guidance consumed by the Orchestrator mode
|
|
13
|
+
and mode-switch tool to pick the right mode for a given task
|
|
14
|
+
* ``groups`` — list of allowed tool groups
|
|
15
|
+
|
|
16
|
+
Previously this script emitted only ``slug``, ``name``, ``roleDefinition``,
|
|
17
|
+
and ``groups``. Per the Roo Code docs (features/custom-modes), both
|
|
18
|
+
``description`` and ``whenToUse`` are now first-class fields: the
|
|
19
|
+
description field is what the UI renders under the mode name, while
|
|
20
|
+
``roleDefinition`` should carry the deeper persona text.
|
|
3
21
|
|
|
4
22
|
Usage: ./scripts/generate_roo_modes.py > .roomodes
|
|
5
23
|
"""
|
|
@@ -43,6 +61,25 @@ def _read_body(filepath: Path) -> str:
|
|
|
43
61
|
return "\n".join(lines)
|
|
44
62
|
|
|
45
63
|
|
|
64
|
+
def _first_sentence(description: str, *, limit: int = 140) -> str:
|
|
65
|
+
"""Extract a short summary for the ``whenToUse`` hint.
|
|
66
|
+
|
|
67
|
+
Roo's orchestrator uses ``whenToUse`` to pick between modes, so a
|
|
68
|
+
terse action-oriented sentence is more useful than the full agent
|
|
69
|
+
description. Falls back to the first ~140 chars if no period is
|
|
70
|
+
found before the limit.
|
|
71
|
+
"""
|
|
72
|
+
if not description:
|
|
73
|
+
return ""
|
|
74
|
+
text = description.strip().replace("\n", " ")
|
|
75
|
+
dot = text.find(". ")
|
|
76
|
+
if 0 < dot <= limit:
|
|
77
|
+
return text[:dot + 1]
|
|
78
|
+
if len(text) <= limit:
|
|
79
|
+
return text
|
|
80
|
+
return text[:limit].rsplit(" ", 1)[0] + "..."
|
|
81
|
+
|
|
82
|
+
|
|
46
83
|
def main() -> None:
|
|
47
84
|
first = True
|
|
48
85
|
sys.stdout.write('{\n "customModes": [\n')
|
|
@@ -60,6 +97,7 @@ def main() -> None:
|
|
|
60
97
|
|
|
61
98
|
role_def = _read_body(agent_file)
|
|
62
99
|
role_text = f"{description}\n\n{role_def}"
|
|
100
|
+
when_to_use = _first_sentence(description)
|
|
63
101
|
|
|
64
102
|
if first:
|
|
65
103
|
first = False
|
|
@@ -69,7 +107,10 @@ def main() -> None:
|
|
|
69
107
|
sys.stdout.write(" {\n")
|
|
70
108
|
sys.stdout.write(f' "slug": "{_json_escape(slug)}",\n')
|
|
71
109
|
sys.stdout.write(f' "name": "{_json_escape(name)}",\n')
|
|
110
|
+
sys.stdout.write(f' "description": "{_json_escape(description)}",\n')
|
|
72
111
|
sys.stdout.write(f' "roleDefinition": "{_json_escape(role_text)}",\n')
|
|
112
|
+
if when_to_use:
|
|
113
|
+
sys.stdout.write(f' "whenToUse": "{_json_escape(when_to_use)}",\n')
|
|
73
114
|
sys.stdout.write(' "groups": ["read", "edit", "command", "mcp"]\n')
|
|
74
115
|
sys.stdout.write(" }")
|
|
75
116
|
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Generate .windsurf/hooks.json for Windsurf Cascade.
|
|
3
|
+
|
|
4
|
+
Writes `<target>/.windsurf/hooks.json`. Existing user hook entries are
|
|
5
|
+
preserved; only entries tagged `_source: ai-toolkit` are replaced on
|
|
6
|
+
regeneration. Windsurf merges system / user / workspace hooks at runtime, so
|
|
7
|
+
this file is safe to live alongside `~/.codeium/windsurf/hooks.json`.
|
|
8
|
+
|
|
9
|
+
Windsurf Cascade events (per docs.windsurf.com/windsurf/cascade/hooks.md):
|
|
10
|
+
pre_read_code, post_read_code, pre_write_code, post_write_code,
|
|
11
|
+
pre_run_command, post_run_command, pre_mcp_tool_use, post_mcp_tool_use,
|
|
12
|
+
pre_user_prompt, post_cascade_response,
|
|
13
|
+
post_cascade_response_with_transcript, post_setup_worktree
|
|
14
|
+
(12 total).
|
|
15
|
+
|
|
16
|
+
Pre-hooks can block via exit code 2 (see `guard-destructive.sh`). Each entry
|
|
17
|
+
takes `command` (macOS/Linux) + optional `powershell` (Windows). We only emit
|
|
18
|
+
`command` because our hook scripts are bash-only.
|
|
19
|
+
|
|
20
|
+
Usage:
|
|
21
|
+
python3 scripts/generate_windsurf_hooks.py [target-dir]
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import sys
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
HOOKS_PREFIX = '"$HOME/.softspark/ai-toolkit/hooks/'
|
|
30
|
+
SOURCE_TAG = "ai-toolkit"
|
|
31
|
+
|
|
32
|
+
# Event -> list of script names.
|
|
33
|
+
WINDSURF_HOOKS: dict[str, list[str]] = {
|
|
34
|
+
"pre_read_code": [
|
|
35
|
+
"guard-path.sh",
|
|
36
|
+
],
|
|
37
|
+
"pre_write_code": [
|
|
38
|
+
"guard-path.sh",
|
|
39
|
+
"guard-config.sh",
|
|
40
|
+
],
|
|
41
|
+
"post_write_code": [
|
|
42
|
+
"post-tool-use.sh",
|
|
43
|
+
"governance-capture.sh",
|
|
44
|
+
],
|
|
45
|
+
"pre_run_command": [
|
|
46
|
+
"guard-destructive.sh",
|
|
47
|
+
"commit-quality.sh",
|
|
48
|
+
],
|
|
49
|
+
"post_run_command": [
|
|
50
|
+
"governance-capture.sh",
|
|
51
|
+
],
|
|
52
|
+
"pre_mcp_tool_use": [
|
|
53
|
+
"guard-config.sh",
|
|
54
|
+
],
|
|
55
|
+
"pre_user_prompt": [
|
|
56
|
+
"user-prompt-submit.sh",
|
|
57
|
+
"track-usage.sh",
|
|
58
|
+
],
|
|
59
|
+
"post_cascade_response": [
|
|
60
|
+
"quality-check.sh",
|
|
61
|
+
"save-session.sh",
|
|
62
|
+
],
|
|
63
|
+
"post_setup_worktree": [
|
|
64
|
+
"session-context.sh",
|
|
65
|
+
],
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def build_hook_entry(script: str) -> dict:
|
|
70
|
+
"""Build a single Windsurf hook entry.
|
|
71
|
+
|
|
72
|
+
Uses `bash -c` semantics via the raw command (Windsurf already runs the
|
|
73
|
+
macOS/Linux `command` via `bash -c`).
|
|
74
|
+
"""
|
|
75
|
+
return {
|
|
76
|
+
"_source": SOURCE_TAG,
|
|
77
|
+
"command": f"{HOOKS_PREFIX}{script}\"",
|
|
78
|
+
"show_output": False,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def build_toolkit_hooks() -> dict[str, list[dict]]:
|
|
83
|
+
return {event: [build_hook_entry(s) for s in scripts]
|
|
84
|
+
for event, scripts in WINDSURF_HOOKS.items()}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _is_toolkit_entry(entry: dict) -> bool:
|
|
88
|
+
return isinstance(entry, dict) and entry.get("_source") == SOURCE_TAG
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def strip_toolkit_hooks(hooks: dict) -> dict:
|
|
92
|
+
kept: dict = {}
|
|
93
|
+
for event, entries in hooks.items():
|
|
94
|
+
if not isinstance(entries, list):
|
|
95
|
+
kept[event] = entries
|
|
96
|
+
continue
|
|
97
|
+
survivors = [e for e in entries if not _is_toolkit_entry(e)]
|
|
98
|
+
if survivors:
|
|
99
|
+
kept[event] = survivors
|
|
100
|
+
return kept
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def merge_hooks(existing: dict, toolkit: dict) -> dict:
|
|
104
|
+
merged = strip_toolkit_hooks(existing)
|
|
105
|
+
for event, entries in toolkit.items():
|
|
106
|
+
merged.setdefault(event, []).extend(entries)
|
|
107
|
+
return merged
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def generate(target_dir: Path) -> Path:
|
|
111
|
+
ws_dir = target_dir / ".windsurf"
|
|
112
|
+
ws_dir.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
path = ws_dir / "hooks.json"
|
|
114
|
+
|
|
115
|
+
doc: dict = {}
|
|
116
|
+
if path.is_file():
|
|
117
|
+
try:
|
|
118
|
+
with open(path, encoding="utf-8") as f:
|
|
119
|
+
doc = json.load(f)
|
|
120
|
+
if not isinstance(doc, dict):
|
|
121
|
+
doc = {}
|
|
122
|
+
except (json.JSONDecodeError, OSError):
|
|
123
|
+
doc = {}
|
|
124
|
+
|
|
125
|
+
existing_hooks = doc.get("hooks") if isinstance(doc.get("hooks"), dict) else {}
|
|
126
|
+
doc["hooks"] = merge_hooks(existing_hooks or {}, build_toolkit_hooks())
|
|
127
|
+
|
|
128
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
129
|
+
json.dump(doc, f, indent=4, ensure_ascii=False, sort_keys=True)
|
|
130
|
+
f.write("\n")
|
|
131
|
+
return path
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def main() -> None:
|
|
135
|
+
target = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd()
|
|
136
|
+
path = generate(target)
|
|
137
|
+
total = sum(len(v) for v in WINDSURF_HOOKS.values())
|
|
138
|
+
print(f"Generated: {path.relative_to(target) if path.is_relative_to(target) else path} "
|
|
139
|
+
f"({total} hooks across {len(WINDSURF_HOOKS)} events)")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
if __name__ == "__main__":
|
|
143
|
+
main()
|