@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
|
@@ -1,7 +1,34 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Generate
|
|
2
|
+
"""Generate GitHub Copilot customization files.
|
|
3
3
|
|
|
4
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
"
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
"
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
2
|
+
"""Generate ``.cursor/rules/*.mdc`` files for Cursor IDE.
|
|
3
3
|
|
|
4
|
-
Cursor reads rules from
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
11
|
-
|
|
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]
|