@softspark/ai-toolkit 2.12.0 → 3.0.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +25 -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 +94 -12
  12. package/kb/procedures/release-verification-sop.md +112 -8
  13. package/kb/reference/global-install-model.md +15 -2
  14. package/kb/reference/supported-tools-registry.md +229 -0
  15. package/llms-full.txt +1175 -24
  16. package/llms.txt +4 -0
  17. package/manifest.json +1 -1
  18. package/package.json +4 -1
  19. package/scripts/ecosystem_doctor.py +348 -0
  20. package/scripts/ecosystem_tools.json +500 -0
  21. package/scripts/generate_aider_conf.py +26 -1
  22. package/scripts/generate_antigravity.py +77 -8
  23. package/scripts/generate_augment_agents.py +161 -0
  24. package/scripts/generate_augment_commands.py +160 -0
  25. package/scripts/generate_augment_hooks.py +162 -0
  26. package/scripts/generate_augment_skills.py +98 -0
  27. package/scripts/generate_cline_rules.py +96 -9
  28. package/scripts/generate_codex_hooks.py +13 -2
  29. package/scripts/generate_codex_skills.py +195 -0
  30. package/scripts/generate_copilot.py +296 -18
  31. package/scripts/generate_cursor_agents.py +144 -0
  32. package/scripts/generate_cursor_hooks.py +155 -0
  33. package/scripts/generate_cursor_mdc.py +20 -8
  34. package/scripts/generate_gemini_commands.py +158 -0
  35. package/scripts/generate_gemini_hooks.py +159 -0
  36. package/scripts/generate_gemini_skills.py +98 -0
  37. package/scripts/generate_roo_modes.py +42 -1
  38. package/scripts/generate_windsurf_hooks.py +143 -0
  39. package/scripts/generate_windsurf_rules.py +162 -10
  40. package/scripts/install.py +11 -2
  41. package/scripts/install_steps/ai_tools.py +120 -5
  42. package/scripts/validate.py +20 -3
@@ -1,7 +1,34 @@
1
1
  #!/usr/bin/env python3
2
- """Generate .github/copilot-instructions.md from app/agents/*.md and app/skills/*/SKILL.md.
2
+ """Generate GitHub Copilot customization files.
3
3
 
4
- Usage: ./scripts/generate_copilot.py > .github/copilot-instructions.md
4
+ This generator produces three surfaces, all on the OSS/Free/Pro tier
5
+ (no Business/Enterprise gating, no server-side MCP config):
6
+
7
+ 1. ``.github/copilot-instructions.md`` — always-on repository instructions.
8
+ Supported by GitHub.com Copilot Chat, Copilot cloud agent, and VS Code
9
+ Copilot. Generated to stdout by default (backwards compatible).
10
+
11
+ 2. ``.github/instructions/*.instructions.md`` — path-specific instructions.
12
+ Each file has ``applyTo`` frontmatter with a glob pattern. Supported
13
+ by VS Code Copilot and Copilot cloud agent / code review on GitHub.com.
14
+ Written only when ``generate()`` is called with a target directory.
15
+
16
+ 3. ``.github/prompts/*.prompt.md`` — prompt files (slash commands).
17
+ Invoked manually in VS Code Copilot Chat via ``/name``. Written only
18
+ when ``generate()`` is called with a target directory.
19
+
20
+ Features that live on Pro/Pro+/Business/Enterprise tiers are intentionally
21
+ not generated (classified as class C in the ecosystem-sync SOP):
22
+ * ``.github/agents/*.agent.md`` — custom agent profiles (tier-gated)
23
+ * repo-level MCP configuration (GitHub repo Settings UI, tier-gated)
24
+ * organization-wide and enterprise-wide instructions
25
+
26
+ Usage:
27
+ # Legacy stdout mode (repo-wide instructions only)
28
+ python3 scripts/generate_copilot.py > .github/copilot-instructions.md
29
+
30
+ # Directory mode (repo-wide + path-specific + prompt files)
31
+ python3 scripts/generate_copilot.py <target-dir>
5
32
  """
6
33
  from __future__ import annotations
7
34
 
@@ -9,22 +36,273 @@ import sys
9
36
  from pathlib import Path
10
37
 
11
38
  sys.path.insert(0, str(Path(__file__).resolve().parent))
39
+ from dir_rules_shared import (
40
+ LANG_GLOBS,
41
+ PREFIX,
42
+ build_language_rules,
43
+ build_registered_rules,
44
+ rule_code_style,
45
+ rule_quality_standards,
46
+ rule_security,
47
+ rule_testing,
48
+ rule_workflow,
49
+ )
50
+ from emission import (
51
+ agents_dir,
52
+ skills_dir,
53
+ )
54
+ from frontmatter import frontmatter_field
12
55
  from generator_base import render_generator
13
56
 
14
- if __name__ == "__main__":
15
- render_generator({
16
- "title": "# GitHub Copilot Instructions",
17
- "intro_template": (
18
- "This repository uses the ai-toolkit — a shared AI development toolkit"
19
- " with specialized agent personas and skills."
57
+ # ---------------------------------------------------------------------------
58
+ # Shared configuration for the legacy stdout output
59
+ # ---------------------------------------------------------------------------
60
+
61
+ _STDOUT_CONFIG: dict = {
62
+ "title": "# GitHub Copilot Instructions",
63
+ "intro_template": (
64
+ "This repository uses the ai-toolkit — a shared AI development toolkit"
65
+ " with specialized agent personas and skills."
66
+ ),
67
+ "agents_section": "## Available Agent Personas",
68
+ "agents_intro": "Apply the expertise of these agents when working on relevant tasks:",
69
+ "agents_format": "headings",
70
+ "agents_level": "###",
71
+ "skills_section": "## Available Skills",
72
+ "skills_intro": "The following skills are available as slash commands or knowledge sources:",
73
+ "skills_format": "headings",
74
+ "skills_level": "###",
75
+ "guidelines": ["quality"],
76
+ }
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # Path-specific .instructions.md emission
81
+ # ---------------------------------------------------------------------------
82
+
83
+ def _instructions_file(content: str, *, apply_to: str,
84
+ description: str = "") -> str:
85
+ """Wrap markdown content with Copilot ``.instructions.md`` frontmatter."""
86
+ lines = ["---"]
87
+ lines.append(f'applyTo: "{apply_to}"')
88
+ if description:
89
+ lines.append(f"description: {description}")
90
+ lines.append("---")
91
+ lines.append("")
92
+ lines.append(content.rstrip("\n"))
93
+ lines.append("")
94
+ return "\n".join(lines)
95
+
96
+
97
+ def _make_instruction_files() -> dict[str, callable]:
98
+ """Build the ``.instructions.md`` file registry.
99
+
100
+ Filenames use the standard ai-toolkit prefix so they can be cleaned up
101
+ on re-run without touching user files.
102
+ """
103
+ return {
104
+ # Always applies (repo-wide)
105
+ f"{PREFIX}security.instructions.md": lambda: _instructions_file(
106
+ rule_security(),
107
+ apply_to="**",
108
+ description="Security rules — OWASP, secrets, input validation",
109
+ ),
110
+ f"{PREFIX}quality-standards.instructions.md": lambda: _instructions_file(
111
+ rule_quality_standards(),
112
+ apply_to="**",
113
+ description="Quality standards — tests, safety, operational integrity",
114
+ ),
115
+ f"{PREFIX}workflow.instructions.md": lambda: _instructions_file(
116
+ rule_workflow(),
117
+ apply_to="**",
118
+ description="Development workflow — planning, commits, quality gates",
20
119
  ),
21
- "agents_section": "## Available Agent Personas",
22
- "agents_intro": "Apply the expertise of these agents when working on relevant tasks:",
23
- "agents_format": "headings",
24
- "agents_level": "###",
25
- "skills_section": "## Available Skills",
26
- "skills_intro": "The following skills are available as slash commands or knowledge sources:",
27
- "skills_format": "headings",
28
- "skills_level": "###",
29
- "guidelines": ["quality"],
30
- })
120
+ f"{PREFIX}code-style.instructions.md": lambda: _instructions_file(
121
+ rule_code_style(),
122
+ apply_to="**",
123
+ description="Code style conventions for all languages",
124
+ ),
125
+ # Scoped to test files only
126
+ f"{PREFIX}testing.instructions.md": lambda: _instructions_file(
127
+ rule_testing(),
128
+ apply_to="**/*.test.*,**/*.spec.*,**/test_*,**/tests/**",
129
+ description="Testing standards and patterns",
130
+ ),
131
+ }
132
+
133
+
134
+ # ---------------------------------------------------------------------------
135
+ # Prompt-file emission (.github/prompts/*.prompt.md)
136
+ # ---------------------------------------------------------------------------
137
+
138
+ def _prompt_file(description: str, body: str, *,
139
+ agent: str | None = None) -> str:
140
+ """Wrap a skill body with Copilot ``.prompt.md`` frontmatter."""
141
+ lines = ["---"]
142
+ # Description is required for visibility in the slash menu.
143
+ lines.append(f"description: {description}")
144
+ if agent:
145
+ lines.append(f"agent: {agent}")
146
+ lines.append("---")
147
+ lines.append("")
148
+ lines.append(body.rstrip("\n"))
149
+ lines.append("")
150
+ return "\n".join(lines)
151
+
152
+
153
+ def _read_skill_body(skill_file: Path) -> str:
154
+ """Read SKILL.md body after the closing frontmatter delimiter."""
155
+ lines: list[str] = []
156
+ fence_count = 0
157
+ with open(skill_file, encoding="utf-8") as f:
158
+ for line in f:
159
+ stripped = line.rstrip("\n")
160
+ if stripped == "---":
161
+ fence_count += 1
162
+ continue
163
+ if fence_count >= 2:
164
+ lines.append(line.rstrip("\n"))
165
+ while lines and not lines[-1]:
166
+ lines.pop()
167
+ return "\n".join(lines)
168
+
169
+
170
+ def _user_invocable_skills() -> list[tuple[str, str, str]]:
171
+ """Return user-invocable skills as (name, description, body) tuples.
172
+
173
+ Only skills whose SKILL.md is suitable for slash-command invocation
174
+ are returned. Knowledge-only skills (``user-invocable: false`` or
175
+ ``disable-model-invocation: true``) are filtered out.
176
+ """
177
+ if not skills_dir.is_dir():
178
+ return []
179
+ result: list[tuple[str, str, str]] = []
180
+ for skill_dir in sorted(skills_dir.iterdir()):
181
+ if skill_dir.name.startswith("_") or not skill_dir.is_dir():
182
+ continue
183
+ skill_file = skill_dir / "SKILL.md"
184
+ if not skill_file.is_file():
185
+ continue
186
+ name = frontmatter_field(skill_file, "name")
187
+ description = frontmatter_field(skill_file, "description")
188
+ if not name or not description:
189
+ continue
190
+ # Honour the same visibility filter used by generate_opencode_commands
191
+ user_invocable = frontmatter_field(skill_file, "user-invocable")
192
+ disable_model = frontmatter_field(skill_file, "disable-model-invocation")
193
+ if user_invocable == "false":
194
+ continue
195
+ # Task skills (disable-model-invocation: true) are still fine as
196
+ # slash commands; knowledge skills with user-invocable: false are not.
197
+ del disable_model # not used beyond inspection
198
+ body = _read_skill_body(skill_file)
199
+ if not body:
200
+ continue
201
+ result.append((name, description, body))
202
+ return result
203
+
204
+
205
+ # ---------------------------------------------------------------------------
206
+ # Directory-mode generation
207
+ # ---------------------------------------------------------------------------
208
+
209
+ def _cleanup_prefixed(directory: Path, suffix: str,
210
+ keep: set[str]) -> None:
211
+ """Remove ai-toolkit-prefixed files in ``directory`` that aren't in ``keep``."""
212
+ if not directory.is_dir():
213
+ return
214
+ for f in directory.iterdir():
215
+ if f.name.startswith(PREFIX) and f.name.endswith(suffix) and f.name not in keep:
216
+ f.unlink()
217
+ rel = f.relative_to(directory.parent.parent) if len(directory.parents) >= 2 else f
218
+ print(f" Removed stale: {rel}")
219
+
220
+
221
+ def generate(target_dir: Path, *,
222
+ language_modules: list[str] | None = None,
223
+ rules_dir: Path | None = None,
224
+ emit_prompts: bool = True,
225
+ emit_instructions: bool = True) -> None:
226
+ """Write Copilot path-specific instructions and prompt files.
227
+
228
+ ``.github/copilot-instructions.md`` is intentionally not written here —
229
+ the legacy ``main()`` entry point still emits it to stdout so existing
230
+ scripts (including ``ai-toolkit install``) keep working unchanged.
231
+ """
232
+ github_dir = target_dir / ".github"
233
+
234
+ if emit_instructions:
235
+ instr_dir = github_dir / "instructions"
236
+ instr_dir.mkdir(parents=True, exist_ok=True)
237
+
238
+ instruction_files: dict[str, callable] = dict(_make_instruction_files())
239
+
240
+ # Language-specific instructions (auto-applied by file glob)
241
+ for filename, content_fn in build_language_rules(language_modules).items():
242
+ lang = filename.removeprefix(f"{PREFIX}lang-").removesuffix(".md")
243
+ globs = LANG_GLOBS.get(lang)
244
+ apply_to = ",".join(globs) if globs else "**"
245
+ new_name = f"{PREFIX}lang-{lang}.instructions.md"
246
+ instruction_files[new_name] = (lambda fn, l, a: lambda: _instructions_file(
247
+ fn(),
248
+ apply_to=a,
249
+ description=f"{l.title()} language rules",
250
+ ))(content_fn, lang, apply_to)
251
+
252
+ # User-registered custom rules (always-on)
253
+ for filename, content_fn in build_registered_rules(rules_dir).items():
254
+ stem = filename.removeprefix(f"{PREFIX}custom-").removesuffix(".md")
255
+ new_name = f"{PREFIX}custom-{stem}.instructions.md"
256
+ instruction_files[new_name] = (lambda fn, n: lambda: _instructions_file(
257
+ fn(),
258
+ apply_to="**",
259
+ description=f"Custom rule: {n}",
260
+ ))(content_fn, stem)
261
+
262
+ _cleanup_prefixed(instr_dir, ".instructions.md", set(instruction_files.keys()))
263
+
264
+ for name, content_fn in instruction_files.items():
265
+ (instr_dir / name).write_text(content_fn(), encoding="utf-8")
266
+ print(f" Generated: .github/instructions/{name}")
267
+
268
+ if emit_prompts:
269
+ prompt_dir = github_dir / "prompts"
270
+ prompt_dir.mkdir(parents=True, exist_ok=True)
271
+
272
+ skills = _user_invocable_skills()
273
+ prompt_filenames: set[str] = set()
274
+ for name, description, body in skills:
275
+ filename = f"{PREFIX}{name}.prompt.md"
276
+ prompt_filenames.add(filename)
277
+ content = _prompt_file(description, body)
278
+ (prompt_dir / filename).write_text(content, encoding="utf-8")
279
+ print(f" Generated: .github/prompts/{filename}")
280
+
281
+ _cleanup_prefixed(prompt_dir, ".prompt.md", prompt_filenames)
282
+
283
+
284
+ # ---------------------------------------------------------------------------
285
+ # Entry points
286
+ # ---------------------------------------------------------------------------
287
+
288
+ def main() -> None:
289
+ """Main entry point.
290
+
291
+ With no argument: emit the repo-wide ``copilot-instructions.md`` to stdout
292
+ (preserves the historical contract).
293
+
294
+ With a directory argument: write the path-specific ``instructions/`` and
295
+ ``prompts/`` files under ``<target>/.github/``. The caller is responsible
296
+ for redirecting the stdout generator separately if they want the full set.
297
+ """
298
+ if len(sys.argv) > 1:
299
+ target = Path(sys.argv[1])
300
+ from paths import RULES_DIR
301
+ generate(target, rules_dir=RULES_DIR)
302
+ return
303
+
304
+ render_generator(_STDOUT_CONFIG)
305
+
306
+
307
+ if __name__ == "__main__":
308
+ main()
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env python3
2
+ """Generate ``.cursor/agents/ai-toolkit-*.md`` files for Cursor IDE.
3
+
4
+ Each ai-toolkit agent is mirrored as a Cursor custom agent. Per Cursor's
5
+ docs (docs.cursor.com -> custom agents), agent files use YAML frontmatter:
6
+
7
+ ---
8
+ name: <slug>
9
+ description: "<one-line summary shown in the picker>"
10
+ model: <model-id> # optional, inherits user default if absent
11
+ color: <color-name> # optional UI hint
12
+ tools: [Read, Write, ...] # list of allowed tool names
13
+ ---
14
+
15
+ <system prompt body>
16
+
17
+ Design choices:
18
+
19
+ * ``model`` is omitted. Our agents store short aliases (``opus``, ``sonnet``,
20
+ ``haiku``) that do not map to Cursor's provider-qualified model ids.
21
+ Omitting the field makes Cursor fall back to the user's default model,
22
+ which is the same behaviour our opencode generator uses.
23
+ * ``tools`` is emitted as a YAML flow-list parsed verbatim from the source.
24
+ * Files are prefixed ``ai-toolkit-`` so install/uninstall can identify ours.
25
+ * Regeneration removes stale ``ai-toolkit-*.md`` files whose source agent
26
+ no longer exists, but leaves user-authored agents untouched.
27
+
28
+ Usage:
29
+ python3 scripts/generate_cursor_agents.py [target-dir]
30
+
31
+ Writes files to ``target-dir/.cursor/agents/``.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import sys
36
+ from pathlib import Path
37
+
38
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
39
+ from emission import agents_dir
40
+ from frontmatter import frontmatter_field
41
+
42
+ AGENT_PREFIX = "ai-toolkit-"
43
+
44
+
45
+ def _agent_body(agent_file: Path) -> str:
46
+ """Return the markdown body of an agent file (content after frontmatter)."""
47
+ text = agent_file.read_text(encoding="utf-8")
48
+ if not text.startswith("---"):
49
+ return text.strip() + "\n"
50
+ parts = text.split("---", 2)
51
+ if len(parts) < 3:
52
+ return text.strip() + "\n"
53
+ return parts[2].lstrip("\n")
54
+
55
+
56
+ def _parse_tools(tools_raw: str) -> list[str]:
57
+ """Parse the comma-separated ``tools:`` frontmatter value into a list."""
58
+ if not tools_raw:
59
+ return []
60
+ return [t.strip() for t in tools_raw.split(",") if t.strip()]
61
+
62
+
63
+ def _render_cursor_agent(agent_file: Path) -> str:
64
+ """Render a single Cursor custom agent .md file."""
65
+ name = frontmatter_field(agent_file, "name")
66
+ description = frontmatter_field(agent_file, "description")
67
+ color = frontmatter_field(agent_file, "color")
68
+ tools_raw = frontmatter_field(agent_file, "tools")
69
+ tools = _parse_tools(tools_raw)
70
+
71
+ safe_desc = description.replace('"', "'")
72
+
73
+ lines: list[str] = ["---"]
74
+ lines.append(f"name: {name}")
75
+ lines.append(f'description: "{safe_desc}"')
76
+ # model intentionally omitted — Cursor falls back to the user default.
77
+ if color:
78
+ lines.append(f"color: {color}")
79
+ if tools:
80
+ tools_flow = ", ".join(tools)
81
+ lines.append(f"tools: [{tools_flow}]")
82
+ else:
83
+ lines.append("tools: []")
84
+ lines.append("---")
85
+ lines.append("")
86
+ body = _agent_body(agent_file).rstrip()
87
+ if body:
88
+ lines.append(body)
89
+ lines.append("")
90
+ return "\n".join(lines)
91
+
92
+
93
+ def _cleanup_stale(agents_out: Path) -> int:
94
+ """Remove stale ai-toolkit-* agent files whose source no longer exists."""
95
+ if not agents_out.is_dir():
96
+ return 0
97
+ removed = 0
98
+ for f in sorted(agents_out.glob(f"{AGENT_PREFIX}*.md")):
99
+ source_name = f.stem[len(AGENT_PREFIX):]
100
+ source = agents_dir / f"{source_name}.md"
101
+ if not source.is_file():
102
+ f.unlink()
103
+ removed += 1
104
+ return removed
105
+
106
+
107
+ def generate(
108
+ target_dir: Path, config_root: Path | None = None
109
+ ) -> tuple[int, int]:
110
+ """Write Cursor agent files and return (written, removed_stale).
111
+
112
+ By default writes to ``target_dir/.cursor/agents/`` (project-local).
113
+ Pass ``config_root=~/.cursor`` for the global layout.
114
+ """
115
+ base = config_root if config_root is not None else target_dir / ".cursor"
116
+ agents_out = base / "agents"
117
+ agents_out.mkdir(parents=True, exist_ok=True)
118
+
119
+ written = 0
120
+ for agent_file in sorted(agents_dir.glob("*.md")):
121
+ name = frontmatter_field(agent_file, "name")
122
+ description = frontmatter_field(agent_file, "description")
123
+ if not name or not description:
124
+ continue
125
+ out_path = agents_out / f"{AGENT_PREFIX}{name}.md"
126
+ out_path.write_text(_render_cursor_agent(agent_file), encoding="utf-8")
127
+ written += 1
128
+
129
+ removed = _cleanup_stale(agents_out)
130
+ return written, removed
131
+
132
+
133
+ def main() -> None:
134
+ target = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd()
135
+ written, removed = generate(target)
136
+ msg = f"Generated: .cursor/agents/ ({written} agents"
137
+ if removed:
138
+ msg += f", {removed} stale removed"
139
+ msg += ")"
140
+ print(msg)
141
+
142
+
143
+ if __name__ == "__main__":
144
+ main()
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env python3
2
+ """Generate .cursor/hooks.json for Cursor Agent / Tab.
3
+
4
+ Writes `<target>/.cursor/hooks.json`. Existing user-authored hook entries are
5
+ preserved; entries tagged `_source: ai-toolkit` are the only ones replaced on
6
+ regeneration. Top-level `version` is set to 1 (current Cursor schema).
7
+
8
+ Cursor hook events (per cursor.com/docs/hooks.md):
9
+ Agent: sessionStart, sessionEnd, preToolUse, postToolUse,
10
+ postToolUseFailure, subagentStart, subagentStop,
11
+ beforeShellExecution, afterShellExecution,
12
+ beforeMCPExecution, afterMCPExecution,
13
+ beforeReadFile, afterFileEdit, beforeSubmitPrompt,
14
+ preCompact, stop, afterAgentResponse, afterAgentThought
15
+ Tab: beforeTabFileRead, afterTabFileEdit
16
+ Entry schema: {"command": "...", "matcher"?: "...", "timeout"?: number}
17
+ Exit code 2 blocks the action, so `guard-destructive.sh` continues to work.
18
+
19
+ Hook scripts are shared with Claude Code and live under
20
+ `~/.softspark/ai-toolkit/hooks/`. Cursor runs commands via the shell so the
21
+ `"$HOME/..."` expansion works identically.
22
+
23
+ Usage:
24
+ python3 scripts/generate_cursor_hooks.py [target-dir]
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
+ SCHEMA_VERSION = 1
35
+
36
+ # Event -> list of (matcher, script). Empty matcher = omit the field.
37
+ CURSOR_HOOKS: dict[str, list[tuple[str, str]]] = {
38
+ "sessionStart": [
39
+ ("", "session-start.sh"),
40
+ ("", "mcp-health.sh"),
41
+ ("", "session-context.sh"),
42
+ ],
43
+ "beforeShellExecution": [
44
+ ("", "guard-destructive.sh"),
45
+ ("", "commit-quality.sh"),
46
+ ],
47
+ "beforeReadFile": [
48
+ ("", "guard-path.sh"),
49
+ ],
50
+ "afterFileEdit": [
51
+ ("", "post-tool-use.sh"),
52
+ ("", "governance-capture.sh"),
53
+ ],
54
+ "beforeSubmitPrompt": [
55
+ ("", "user-prompt-submit.sh"),
56
+ ("", "track-usage.sh"),
57
+ ],
58
+ "beforeMCPExecution": [
59
+ ("", "guard-config.sh"),
60
+ ],
61
+ "preCompact": [
62
+ ("", "pre-compact.sh"),
63
+ ("", "pre-compact-save.sh"),
64
+ ],
65
+ "subagentStart": [
66
+ ("", "subagent-start.sh"),
67
+ ],
68
+ "subagentStop": [
69
+ ("", "subagent-stop.sh"),
70
+ ],
71
+ "stop": [
72
+ ("", "quality-check.sh"),
73
+ ("", "save-session.sh"),
74
+ ],
75
+ "sessionEnd": [
76
+ ("", "session-end.sh"),
77
+ ],
78
+ }
79
+
80
+
81
+ def build_hook_entry(matcher: str, script: str) -> dict:
82
+ entry: dict = {
83
+ "_source": SOURCE_TAG,
84
+ "command": f"{HOOKS_PREFIX}{script}\"",
85
+ }
86
+ if matcher:
87
+ entry["matcher"] = matcher
88
+ return entry
89
+
90
+
91
+ def build_toolkit_hooks() -> dict[str, list[dict]]:
92
+ result: dict[str, list[dict]] = {}
93
+ for event, entries in CURSOR_HOOKS.items():
94
+ result[event] = [build_hook_entry(m, s) for m, s in entries]
95
+ return result
96
+
97
+
98
+ def _is_toolkit_entry(entry: dict) -> bool:
99
+ return isinstance(entry, dict) and entry.get("_source") == SOURCE_TAG
100
+
101
+
102
+ def strip_toolkit_hooks(hooks: dict) -> dict:
103
+ kept: dict = {}
104
+ for event, entries in hooks.items():
105
+ if not isinstance(entries, list):
106
+ kept[event] = entries
107
+ continue
108
+ survivors = [e for e in entries if not _is_toolkit_entry(e)]
109
+ if survivors:
110
+ kept[event] = survivors
111
+ return kept
112
+
113
+
114
+ def merge_hooks(existing: dict, toolkit: dict) -> dict:
115
+ merged = strip_toolkit_hooks(existing)
116
+ for event, entries in toolkit.items():
117
+ merged.setdefault(event, []).extend(entries)
118
+ return merged
119
+
120
+
121
+ def generate(target_dir: Path) -> Path:
122
+ cursor_dir = target_dir / ".cursor"
123
+ cursor_dir.mkdir(parents=True, exist_ok=True)
124
+ path = cursor_dir / "hooks.json"
125
+
126
+ doc: dict = {}
127
+ if path.is_file():
128
+ try:
129
+ with open(path, encoding="utf-8") as f:
130
+ doc = json.load(f)
131
+ if not isinstance(doc, dict):
132
+ doc = {}
133
+ except (json.JSONDecodeError, OSError):
134
+ doc = {}
135
+
136
+ existing_hooks = doc.get("hooks") if isinstance(doc.get("hooks"), dict) else {}
137
+ doc["version"] = SCHEMA_VERSION
138
+ doc["hooks"] = merge_hooks(existing_hooks or {}, build_toolkit_hooks())
139
+
140
+ with open(path, "w", encoding="utf-8") as f:
141
+ json.dump(doc, f, indent=4, ensure_ascii=False, sort_keys=True)
142
+ f.write("\n")
143
+ return path
144
+
145
+
146
+ def main() -> None:
147
+ target = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd()
148
+ path = generate(target)
149
+ total = sum(len(v) for v in CURSOR_HOOKS.values())
150
+ print(f"Generated: {path.relative_to(target) if path.is_relative_to(target) else path} "
151
+ f"({total} hooks across {len(CURSOR_HOOKS)} events)")
152
+
153
+
154
+ if __name__ == "__main__":
155
+ main()
@@ -1,14 +1,26 @@
1
1
  #!/usr/bin/env python3
2
- """Generate .cursor/rules/*.mdc files for Cursor IDE.
2
+ """Generate ``.cursor/rules/*.mdc`` files for Cursor IDE.
3
3
 
4
- Cursor reads rules from .cursor/rules/*.mdc (since Cursor 0.45).
5
- Each .mdc file has YAML frontmatter controlling when the rule applies:
6
- - alwaysApply: true — always in context
7
- - globs: ["**/*.ts"] — auto-attached for matching files
8
- - description: "..." — AI decides whether to include (Agent Requested)
4
+ Cursor reads rules from ``.cursor/rules/`` (since Cursor 0.45). Both ``.mdc``
5
+ and plain ``.md`` extensions are accepted; we emit ``.mdc`` because only it
6
+ carries the full YAML frontmatter used to control activation:
9
7
 
10
- The legacy .cursorrules format is still generated separately by
11
- generate_cursor_rules.py for backwards compatibility.
8
+ - ``alwaysApply: true`` — always in context
9
+ - ``globs: ["**/*.ts"]`` — auto-attached for matching files
10
+ - ``description: "..."`` — Agent Requested (AI decides whether to include)
11
+ - manual (no description, no globs, ``alwaysApply: false``) — ``@rule-name``
12
+
13
+ Rule types map to Cursor's UI labels as follows:
14
+
15
+ Always Apply → ``alwaysApply: true``
16
+ Apply to Specific Files → ``globs: [...]``
17
+ Apply Intelligently → ``description: ...``
18
+ Apply Manually → none of the above
19
+
20
+ The legacy ``.cursorrules`` single-file format is still generated separately
21
+ by ``generate_cursor_rules.py`` for backwards compatibility. Cursor also
22
+ supports ``AGENTS.md`` (root + nested subdirectories) as an alternative to
23
+ ``.cursor/rules/``; ``generate_agents_md.py`` covers that surface.
12
24
 
13
25
  Usage:
14
26
  python3 scripts/generate_cursor_mdc.py [target-dir]