@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +18 -8
  3. package/app/.claude-plugin/plugin.json +1 -1
  4. package/app/skills/hook-creator/SKILL.md +84 -8
  5. package/app/skills/skill-creator/SKILL.md +8 -4
  6. package/benchmarks/ecosystem-doctor-snapshot.json +395 -0
  7. package/kb/history/completed/deep-coverage-v3-20260423.md +160 -0
  8. package/kb/history/completed/ecosystem-deep-sweep-20260423.md +273 -0
  9. package/kb/procedures/ecosystem-sync-sop.md +255 -0
  10. package/kb/procedures/maintenance-sop.md +13 -2
  11. package/kb/procedures/release-preparation-sop.md +83 -10
  12. package/kb/reference/global-install-model.md +15 -2
  13. package/kb/reference/supported-tools-registry.md +229 -0
  14. package/llms-full.txt +1052 -14
  15. package/llms.txt +4 -0
  16. package/manifest.json +1 -1
  17. package/package.json +4 -1
  18. package/scripts/ecosystem_doctor.py +348 -0
  19. package/scripts/ecosystem_tools.json +500 -0
  20. package/scripts/generate_aider_conf.py +26 -1
  21. package/scripts/generate_antigravity.py +77 -8
  22. package/scripts/generate_augment_agents.py +161 -0
  23. package/scripts/generate_augment_commands.py +160 -0
  24. package/scripts/generate_augment_hooks.py +162 -0
  25. package/scripts/generate_augment_skills.py +98 -0
  26. package/scripts/generate_cline_rules.py +96 -9
  27. package/scripts/generate_codex_hooks.py +13 -2
  28. package/scripts/generate_codex_skills.py +195 -0
  29. package/scripts/generate_copilot.py +296 -18
  30. package/scripts/generate_cursor_agents.py +144 -0
  31. package/scripts/generate_cursor_hooks.py +155 -0
  32. package/scripts/generate_cursor_mdc.py +20 -8
  33. package/scripts/generate_gemini_commands.py +158 -0
  34. package/scripts/generate_gemini_hooks.py +159 -0
  35. package/scripts/generate_gemini_skills.py +98 -0
  36. package/scripts/generate_roo_modes.py +42 -1
  37. package/scripts/generate_windsurf_hooks.py +143 -0
  38. package/scripts/generate_windsurf_rules.py +162 -10
  39. package/scripts/install.py +11 -2
  40. package/scripts/install_steps/ai_tools.py +120 -5
  41. 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 .roomodes JSON from app/agents/*.md.
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()