@softspark/ai-toolkit 4.14.0 → 4.15.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 (48) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +11 -10
  3. package/app/.claude-plugin/plugin.json +1 -1
  4. package/app/CLAUDE.md.template +3 -0
  5. package/app/agents/fact-checker.md +1 -1
  6. package/app/hooks/_search-capability.sh +3 -2
  7. package/app/hooks/stop-search-check.sh +2 -1
  8. package/benchmarks/ecosystem-doctor-snapshot.json +73 -31
  9. package/kb/procedures/maintenance-sop.md +26 -13
  10. package/kb/procedures/release-verification-sop.md +41 -36
  11. package/kb/reference/architecture-overview.md +23 -7
  12. package/kb/reference/codex-cli-compatibility.md +96 -36
  13. package/kb/reference/extension-api.md +52 -9
  14. package/kb/reference/global-install-model.md +53 -21
  15. package/kb/reference/hooks-catalog.md +44 -8
  16. package/kb/reference/mcp-editor-compatibility.md +27 -6
  17. package/kb/reference/mcp-templates.md +12 -6
  18. package/kb/reference/opencode-compatibility.md +13 -7
  19. package/kb/reference/plugin-pack-conventions.md +7 -7
  20. package/kb/reference/skills-catalog.md +3 -3
  21. package/kb/reference/supported-tools-registry.md +19 -17
  22. package/kb/reference/windows-support.md +26 -3
  23. package/llms-full.txt +443 -180
  24. package/llms.txt +1 -1
  25. package/manifest.json +1 -1
  26. package/package.json +2 -2
  27. package/scripts/codex_skill_adapter.py +448 -198
  28. package/scripts/dir_rules_shared.py +2 -11
  29. package/scripts/ecosystem_tools.json +29 -8
  30. package/scripts/emission.py +5 -91
  31. package/scripts/generate_agents_md.py +4 -87
  32. package/scripts/generate_codex.py +5 -95
  33. package/scripts/generate_codex_agents.py +242 -0
  34. package/scripts/generate_codex_hooks.py +648 -55
  35. package/scripts/generate_codex_skills.py +15 -6
  36. package/scripts/generate_copilot.py +771 -74
  37. package/scripts/generate_copilot_hooks.py +606 -0
  38. package/scripts/generate_cursor_hooks.py +453 -121
  39. package/scripts/generate_opencode_commands.py +4 -6
  40. package/scripts/inject_hook_cli.py +770 -205
  41. package/scripts/injection.py +102 -23
  42. package/scripts/install_steps/ai_tools.py +123 -83
  43. package/scripts/instruction_core.py +95 -0
  44. package/scripts/mcp_editors.py +934 -80
  45. package/scripts/mcp_manager.py +46 -26
  46. package/scripts/plugin.py +291 -114
  47. package/scripts/secure_fs.py +538 -0
  48. package/scripts/uninstall.py +1279 -208
@@ -1,159 +1,491 @@
1
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]
2
+ """Generate native, self-contained Cursor hooks.
3
+
4
+ Repository installs write ``.cursor/hooks.json`` and a managed Python runtime
5
+ below ``.cursor/hooks/ai-toolkit/``. The same manifest also works for Cursor
6
+ user hooks: project hooks run from the repository root, while user hooks run
7
+ from ``~/.cursor`` and use the colocated runtime fallback.
8
+
9
+ Cursor Cloud Agents load only repository hooks and cannot access user-level
10
+ ``~/.cursor`` configuration. Keeping every project command repository-relative
11
+ makes the generated hook set portable to Cursor's isolated cloud VMs.
25
12
  """
26
13
  from __future__ import annotations
27
14
 
28
15
  import json
16
+ import os
17
+ import shlex
29
18
  import sys
19
+ import tempfile
30
20
  from pathlib import Path
21
+ from typing import Any
22
+
31
23
 
32
- HOOKS_PREFIX = '"$HOME/.softspark/ai-toolkit/hooks/'
33
24
  SOURCE_TAG = "ai-toolkit"
34
25
  SCHEMA_VERSION = 1
26
+ SCRIPT_MARKER = "# ai-toolkit-managed: cursor-hook"
27
+ SCRIPT_NAME = "cursor_hook.py"
28
+ PROJECT_RUNTIME = ".cursor/hooks/ai-toolkit/cursor_hook.py"
29
+ USER_RUNTIME = "./hooks/ai-toolkit/cursor_hook.py"
30
+
31
+ # Cursor 3.11 hook events documented at https://cursor.com/docs/hooks.
32
+ HOOK_DEFINITIONS: tuple[tuple[str, str, int], ...] = (
33
+ ("sessionStart", "session-start", 10),
34
+ ("sessionEnd", "observe", 10),
35
+ ("preToolUse", "pre-tool-use", 10),
36
+ ("postToolUse", "post-tool-use", 10),
37
+ ("postToolUseFailure", "post-tool-use-failure", 10),
38
+ ("subagentStart", "subagent-start", 10),
39
+ ("subagentStop", "subagent-stop", 10),
40
+ ("beforeShellExecution", "before-shell", 10),
41
+ ("afterShellExecution", "observe", 10),
42
+ ("beforeMCPExecution", "before-mcp", 10),
43
+ ("afterMCPExecution", "observe", 10),
44
+ ("beforeReadFile", "before-read", 10),
45
+ ("afterFileEdit", "post-file-edit", 10),
46
+ ("beforeSubmitPrompt", "before-submit-prompt", 10),
47
+ ("preCompact", "observe", 10),
48
+ ("stop", "stop", 120),
49
+ ("afterAgentResponse", "observe", 10),
50
+ ("afterAgentThought", "observe", 10),
51
+ ("beforeTabFileRead", "before-read", 10),
52
+ ("afterTabFileEdit", "post-file-edit", 10),
53
+ ("workspaceOpen", "workspace-open", 10),
54
+ )
55
+
56
+
57
+ HOOK_RUNTIME = r'''#!/usr/bin/env python3
58
+ # ai-toolkit-managed: cursor-hook
59
+ """Self-contained runtime for native Cursor hooks."""
60
+ from __future__ import annotations
61
+
62
+ import json
63
+ import os
64
+ import re
65
+ import shutil
66
+ import subprocess
67
+ import sys
68
+ from pathlib import Path
69
+ from typing import Any
70
+
71
+
72
+ MAX_OUTPUT_CHARS = 2_000
73
+ DESTRUCTIVE_PATTERNS = tuple(re.compile(pattern, re.IGNORECASE) for pattern in (
74
+ r"\brm\s+(?:-[rRf]{2,}|-r\s+-f|-f\s+-r|--recursive|--force)\b",
75
+ r"\bsudo\s+rm\b",
76
+ r"\b(?:xargs\s+rm|find\s+.+(?:-delete|-exec\s+rm))\b",
77
+ r"\bDROP\s+(?:TABLE|DATABASE|SCHEMA|INDEX)\b",
78
+ r"\bTRUNCATE\s+",
79
+ r"\bDELETE\s+FROM\s+\S+\s*(?:;|$|WHERE\s+1)\b",
80
+ r"\b(?:mkfs|shred)\b",
81
+ r"\bdd\s+if=",
82
+ r"\bgit\s+push\s+.*(?:--force(?:\s|$)|-f(?:\s|$))",
83
+ r"\bgit\s+(?:reset\s+--hard|clean\s+-[a-z]*f|branch\s+-D)\b",
84
+ r"\bchmod\s+(?:-R\s+)?(?:777|000)\b",
85
+ r"\bdocker\s+(?:system\s+prune|rm\s+-f|rmi\s+-f)\b",
86
+ r"\bkubectl\s+delete\s+(?:namespace|ns|all|node)\b",
87
+ r"\bterraform\s+destroy\b",
88
+ r"\bsystemctl\s+(?:stop|disable)\s+",
89
+ r">\s*/dev/sd[a-z]",
90
+ ))
91
+
92
+
93
+ def _payload() -> dict[str, Any]:
94
+ raw = sys.stdin.read()
95
+ if not raw.strip():
96
+ return {}
97
+ try:
98
+ value = json.loads(raw)
99
+ except json.JSONDecodeError as error:
100
+ print(f"ai-toolkit Cursor hook skipped invalid JSON: {error}", file=sys.stderr)
101
+ return {}
102
+ return value if isinstance(value, dict) else {}
103
+
104
+
105
+ def _emit(value: dict[str, Any]) -> None:
106
+ print(json.dumps(value, ensure_ascii=False, separators=(",", ":")))
107
+
108
+
109
+ def _all_strings(value: Any) -> list[str]:
110
+ if isinstance(value, str):
111
+ return [value]
112
+ if isinstance(value, dict):
113
+ result: list[str] = []
114
+ for item in value.values():
115
+ result.extend(_all_strings(item))
116
+ return result
117
+ if isinstance(value, list):
118
+ result = []
119
+ for item in value:
120
+ result.extend(_all_strings(item))
121
+ return result
122
+ return []
123
+
124
+
125
+ def _command_text(value: Any) -> str:
126
+ if isinstance(value, str):
127
+ return value
128
+ if not isinstance(value, dict):
129
+ return ""
130
+ for key in ("command", "commandLine", "command_line", "script", "code"):
131
+ command = value.get(key)
132
+ if isinstance(command, str):
133
+ return command
134
+ return ""
135
+
136
+
137
+ def _destructive_reason(command: str) -> str | None:
138
+ normalized = " ".join(command.replace("\\", "").split())
139
+ if not normalized:
140
+ return None
141
+ if not re.search(r"&&|\|\||;|\|", normalized):
142
+ if re.match(r"\s*(?:echo|printf|git\s+(?:commit|tag))(?:\s|$)", normalized):
143
+ return None
144
+ normalized = re.sub(r"--force-with-lease(?:=\S+)?|--force-if-includes", "", normalized)
145
+ if any(pattern.search(normalized) for pattern in DESTRUCTIVE_PATTERNS):
146
+ return "Potentially destructive command requires explicit user review."
147
+ return None
148
+
149
+
150
+ def _wrong_home_reason(value: Any) -> str | None:
151
+ actual_user = Path.home().name
152
+ if not actual_user:
153
+ return None
154
+ pattern = re.compile(r"/(?:Users|home)/([^/\s'\"]+)")
155
+ for text in _all_strings(value):
156
+ for match in pattern.finditer(text):
157
+ if match.group(1) != actual_user:
158
+ return (
159
+ f"Absolute path names user '{match.group(1)}', but the active "
160
+ f"home belongs to '{actual_user}'. Use $HOME or the correct path."
161
+ )
162
+ return None
163
+
164
+
165
+ def _deny(reason: str) -> None:
166
+ _emit({
167
+ "permission": "deny",
168
+ "user_message": reason,
169
+ "agent_message": reason,
170
+ })
171
+ raise SystemExit(2)
172
+
173
+
174
+ def _before_shell(payload: dict[str, Any]) -> None:
175
+ reason = _destructive_reason(_command_text(payload))
176
+ if reason:
177
+ _deny(reason)
178
+
179
+
180
+ def _pre_tool_use(payload: dict[str, Any]) -> None:
181
+ arguments = payload.get("tool_input", payload.get("toolInput", {}))
182
+ reason = _wrong_home_reason(arguments)
183
+ tool_name = str(payload.get("tool_name") or payload.get("toolName") or "")
184
+ if reason is None and tool_name.lower() in {
185
+ "bash", "shell", "terminal", "run_terminal_command",
186
+ }:
187
+ reason = _destructive_reason(_command_text(arguments))
188
+ if reason:
189
+ _deny(reason)
35
190
 
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
- ],
42
- "beforeShellExecution": [
43
- ("", "guard-destructive.sh"),
44
- ("", "commit-quality.sh"),
45
- ("", "revert-guard.sh"),
46
- ],
47
- "beforeReadFile": [
48
- ("", "guard-path.sh"),
49
- ],
50
- "afterFileEdit": [
51
- ("", "post-tool-use.sh"),
52
- ("", "governance-capture.sh"),
53
- ("", "test-cohesion.sh"),
54
- ],
55
- "beforeSubmitPrompt": [
56
- ("", "user-prompt-submit.sh"),
57
- ("", "track-usage.sh"),
58
- ],
59
- "beforeMCPExecution": [
60
- ("", "guard-config.sh"),
61
- ],
62
- "afterMCPExecution": [
63
- ("", "search-tracker.sh"),
64
- ],
65
- "preCompact": [
66
- ("", "pre-compact.sh"),
67
- ("", "pre-compact-save.sh"),
68
- ],
69
- "subagentStart": [
70
- ("", "subagent-start.sh"),
71
- ],
72
- "subagentStop": [
73
- ("", "subagent-stop.sh"),
74
- ],
75
- "stop": [
76
- ("", "quality-check.sh"),
77
- ("", "save-session.sh"),
78
- ("", "stop-search-check.sh"),
79
- ],
80
- "sessionEnd": [
81
- ("", "session-end.sh"),
82
- ],
83
- }
84
-
85
-
86
- def build_hook_entry(matcher: str, script: str) -> dict:
87
- entry: dict = {
88
- "_source": SOURCE_TAG,
89
- "command": f"{HOOKS_PREFIX}{script}\"",
90
- }
91
- if matcher:
92
- entry["matcher"] = matcher
93
- return entry
94
-
95
-
96
- def build_toolkit_hooks() -> dict[str, list[dict]]:
97
- result: dict[str, list[dict]] = {}
98
- for event, entries in CURSOR_HOOKS.items():
99
- result[event] = [build_hook_entry(m, s) for m, s in entries]
100
- return result
101
-
102
-
103
- def _is_toolkit_entry(entry: dict) -> bool:
104
- return isinstance(entry, dict) and entry.get("_source") == SOURCE_TAG
105
-
106
-
107
- def strip_toolkit_hooks(hooks: dict) -> dict:
108
- kept: dict = {}
191
+
192
+ def _before_read(payload: dict[str, Any]) -> None:
193
+ reason = _wrong_home_reason(payload.get("file_path", payload))
194
+ if reason:
195
+ _deny(reason)
196
+
197
+
198
+ def _quality_command(cwd: Path) -> tuple[str, list[str]] | None:
199
+ if (cwd / "pyproject.toml").is_file() or (cwd / "setup.py").is_file():
200
+ if shutil.which("ruff"):
201
+ return "ruff check", ["ruff", "check", "."]
202
+ if (cwd / "package.json").is_file() and (cwd / "tsconfig.json").is_file():
203
+ local_tsc = cwd / "node_modules" / ".bin" / "tsc"
204
+ if local_tsc.is_file():
205
+ return "TypeScript typecheck", [str(local_tsc), "--noEmit"]
206
+ if shutil.which("tsc"):
207
+ return "TypeScript typecheck", ["tsc", "--noEmit"]
208
+ if (cwd / "pubspec.yaml").is_file() and shutil.which("dart"):
209
+ return "Dart analysis", ["dart", "analyze"]
210
+ if (cwd / "go.mod").is_file() and shutil.which("go"):
211
+ return "Go vet", ["go", "vet", "./..."]
212
+ phpstan = cwd / "vendor" / "bin" / "phpstan"
213
+ if (cwd / "composer.json").is_file() and phpstan.is_file():
214
+ return "PHPStan", [str(phpstan), "analyse"]
215
+ return None
216
+
217
+
218
+ def _stop(payload: dict[str, Any]) -> None:
219
+ try:
220
+ loop_count = int(payload.get("loop_count", 0))
221
+ except (TypeError, ValueError):
222
+ loop_count = 0
223
+ if loop_count >= 2:
224
+ print(
225
+ "ai-toolkit Cursor quality circuit breaker reached after three "
226
+ "failed completion attempts",
227
+ file=sys.stderr,
228
+ )
229
+ return
230
+
231
+ cwd_value = payload.get("cwd") or os.environ.get("CURSOR_PROJECT_DIR") or os.getcwd()
232
+ cwd = Path(str(cwd_value))
233
+ if not cwd.is_dir():
234
+ return
235
+ selected = _quality_command(cwd)
236
+ if selected is None:
237
+ return
238
+ label, command = selected
239
+ try:
240
+ result = subprocess.run(
241
+ command,
242
+ cwd=cwd,
243
+ capture_output=True,
244
+ text=True,
245
+ timeout=110,
246
+ check=False,
247
+ )
248
+ except (OSError, subprocess.TimeoutExpired) as error:
249
+ print(f"ai-toolkit Cursor quality hook skipped {label}: {error}", file=sys.stderr)
250
+ return
251
+ if result.returncode == 0:
252
+ return
253
+ detail = (result.stdout + "\n" + result.stderr).strip()[-MAX_OUTPUT_CHARS:]
254
+ message = f"{label} failed. Fix the errors and verify again before finishing."
255
+ if detail:
256
+ message += f"\n\n{detail}"
257
+ _emit({"followup_message": message})
258
+
259
+
260
+ def main() -> None:
261
+ action = sys.argv[1] if len(sys.argv) > 1 else "observe"
262
+ payload = _payload()
263
+ if action == "session-start":
264
+ _emit({
265
+ "additional_context": (
266
+ "AI Toolkit: follow repository instructions and relevant skills, "
267
+ "keep tests and docs aligned, and verify evidence before completion."
268
+ )
269
+ })
270
+ elif action == "before-shell":
271
+ _before_shell(payload)
272
+ elif action == "pre-tool-use":
273
+ _pre_tool_use(payload)
274
+ elif action == "before-read":
275
+ _before_read(payload)
276
+ elif action == "stop":
277
+ _stop(payload)
278
+
279
+
280
+ if __name__ == "__main__":
281
+ try:
282
+ main()
283
+ except SystemExit:
284
+ raise
285
+ except Exception as error: # Adapter failures must not create agent loops.
286
+ print(f"ai-toolkit Cursor hook failed safely: {error}", file=sys.stderr)
287
+ '''
288
+
289
+
290
+ def _runtime_command(action: str) -> str:
291
+ action_arg = shlex.quote(action)
292
+ return (
293
+ f'if [ -f "{PROJECT_RUNTIME}" ]; then '
294
+ f'exec python3 "{PROJECT_RUNTIME}" {action_arg}; '
295
+ f'else exec python3 "{USER_RUNTIME}" {action_arg}; fi'
296
+ )
297
+
298
+
299
+ def build_toolkit_hooks() -> dict[str, list[dict[str, Any]]]:
300
+ hooks: dict[str, list[dict[str, Any]]] = {}
301
+ for event, action, timeout in HOOK_DEFINITIONS:
302
+ entry: dict[str, Any] = {
303
+ "command": _runtime_command(action),
304
+ "timeout": timeout,
305
+ }
306
+ if event in {"stop", "subagentStop"}:
307
+ entry["loop_limit"] = 5
308
+ hooks[event] = [entry]
309
+ return hooks
310
+
311
+
312
+ def _is_toolkit_entry(entry: Any) -> bool:
313
+ if not isinstance(entry, dict):
314
+ return False
315
+ if entry.get("_source") == SOURCE_TAG: # Migrate legacy generator output.
316
+ return True
317
+ command = entry.get("command")
318
+ return isinstance(command, str) and (
319
+ PROJECT_RUNTIME in command or USER_RUNTIME in command
320
+ )
321
+
322
+
323
+ def strip_toolkit_hooks(hooks: dict[str, Any]) -> dict[str, Any]:
324
+ kept: dict[str, Any] = {}
109
325
  for event, entries in hooks.items():
110
326
  if not isinstance(entries, list):
111
327
  kept[event] = entries
112
328
  continue
113
- survivors = [e for e in entries if not _is_toolkit_entry(e)]
329
+ survivors = [entry for entry in entries if not _is_toolkit_entry(entry)]
114
330
  if survivors:
115
331
  kept[event] = survivors
116
332
  return kept
117
333
 
118
334
 
119
- def merge_hooks(existing: dict, toolkit: dict) -> dict:
335
+ def merge_hooks(
336
+ existing: dict[str, Any],
337
+ toolkit: dict[str, list[dict[str, Any]]],
338
+ ) -> dict[str, Any]:
120
339
  merged = strip_toolkit_hooks(existing)
121
340
  for event, entries in toolkit.items():
122
341
  merged.setdefault(event, []).extend(entries)
123
342
  return merged
124
343
 
125
344
 
345
+ def _load_document(path: Path) -> dict[str, Any]:
346
+ if not path.exists():
347
+ return {}
348
+ try:
349
+ value = json.loads(path.read_text(encoding="utf-8"))
350
+ except json.JSONDecodeError as error:
351
+ raise ValueError(f"Refusing to overwrite invalid Cursor hooks JSON: {path}") from error
352
+ if not isinstance(value, dict):
353
+ raise ValueError(f"Cursor hooks file must contain a JSON object: {path}")
354
+ version = value.get("version", SCHEMA_VERSION)
355
+ if version != SCHEMA_VERSION:
356
+ raise ValueError(f"Unsupported Cursor hooks version {version!r}: {path}")
357
+ hooks = value.get("hooks", {})
358
+ if not isinstance(hooks, dict):
359
+ raise ValueError(f"Cursor hooks field must contain an object: {path}")
360
+ return value
361
+
362
+
363
+ def _assert_not_symlink(path: Path, label: str) -> None:
364
+ if path.is_symlink():
365
+ raise RuntimeError(f"Refusing symlinked Cursor {label}: {path}")
366
+
367
+
368
+ def _is_managed_runtime(path: Path) -> bool:
369
+ if path.is_symlink() or not path.is_file():
370
+ return False
371
+ try:
372
+ return SCRIPT_MARKER in path.read_text(encoding="utf-8")[:256]
373
+ except (OSError, UnicodeError):
374
+ return False
375
+
376
+
377
+ def _stage_file(destination: Path, content: bytes, mode: int) -> Path:
378
+ fd, temp_name = tempfile.mkstemp(
379
+ dir=destination.parent,
380
+ prefix=f".{destination.name}.",
381
+ suffix=".tmp",
382
+ )
383
+ temp_path = Path(temp_name)
384
+ try:
385
+ with os.fdopen(fd, "wb") as handle:
386
+ fd = -1
387
+ handle.write(content)
388
+ handle.flush()
389
+ os.fsync(handle.fileno())
390
+ os.chmod(temp_path, mode)
391
+ return temp_path
392
+ except Exception:
393
+ if fd >= 0:
394
+ os.close(fd)
395
+ temp_path.unlink(missing_ok=True)
396
+ raise
397
+
398
+
399
+ def _write_transaction(outputs: list[tuple[Path, bytes, int]]) -> None:
400
+ staged: list[tuple[Path, Path]] = []
401
+ backups: dict[Path, Path] = {}
402
+ applied: list[Path] = []
403
+ try:
404
+ for destination, content, mode in outputs:
405
+ staged.append((_stage_file(destination, content, mode), destination))
406
+ for _, destination in staged:
407
+ if destination.exists():
408
+ current_mode = destination.stat().st_mode & 0o777
409
+ backups[destination] = _stage_file(
410
+ destination,
411
+ destination.read_bytes(),
412
+ current_mode,
413
+ )
414
+ for temp_path, destination in staged:
415
+ _assert_not_symlink(destination, "hook output")
416
+ os.replace(temp_path, destination)
417
+ applied.append(destination)
418
+ except Exception as error:
419
+ rollback_errors: list[Exception] = []
420
+ for destination in reversed(applied):
421
+ backup = backups.get(destination)
422
+ try:
423
+ if backup is None:
424
+ destination.unlink(missing_ok=True)
425
+ else:
426
+ os.replace(backup, destination)
427
+ except Exception as rollback_error: # pragma: no cover
428
+ rollback_errors.append(rollback_error)
429
+ if rollback_errors:
430
+ raise RuntimeError(
431
+ "Cursor hook update failed and rollback was incomplete: "
432
+ f"{rollback_errors}"
433
+ ) from error
434
+ raise
435
+ finally:
436
+ for temp_path, _ in staged:
437
+ temp_path.unlink(missing_ok=True)
438
+ for backup in backups.values():
439
+ backup.unlink(missing_ok=True)
440
+
441
+
126
442
  def generate(target_dir: Path) -> Path:
443
+ target_dir = Path(target_dir).expanduser().absolute()
127
444
  cursor_dir = target_dir / ".cursor"
128
- cursor_dir.mkdir(parents=True, exist_ok=True)
129
- path = cursor_dir / "hooks.json"
130
-
131
- doc: dict = {}
132
- if path.is_file():
133
- try:
134
- with open(path, encoding="utf-8") as f:
135
- doc = json.load(f)
136
- if not isinstance(doc, dict):
137
- doc = {}
138
- except (json.JSONDecodeError, OSError):
139
- doc = {}
445
+ hooks_dir = cursor_dir / "hooks"
446
+ assets_dir = hooks_dir / "ai-toolkit"
447
+ config_path = cursor_dir / "hooks.json"
448
+ runtime_path = assets_dir / SCRIPT_NAME
140
449
 
141
- existing_hooks = doc.get("hooks") if isinstance(doc.get("hooks"), dict) else {}
142
- doc["version"] = SCHEMA_VERSION
143
- doc["hooks"] = merge_hooks(existing_hooks or {}, build_toolkit_hooks())
450
+ safe_paths = (
451
+ (target_dir, "target root"),
452
+ (cursor_dir, "config directory"),
453
+ (hooks_dir, "hooks directory"),
454
+ (assets_dir, "assets directory"),
455
+ (config_path, "hooks config"),
456
+ (runtime_path, "hook runtime"),
457
+ )
458
+ for path, label in safe_paths:
459
+ _assert_not_symlink(path, label)
460
+ for directory in (cursor_dir, hooks_dir, assets_dir):
461
+ if directory.exists() and not directory.is_dir():
462
+ raise RuntimeError(f"Cursor hook path is not a directory: {directory}")
463
+ directory.mkdir(parents=True, exist_ok=True)
464
+ for path, label in safe_paths:
465
+ _assert_not_symlink(path, label)
466
+ if runtime_path.exists() and not _is_managed_runtime(runtime_path):
467
+ raise RuntimeError(f"Refusing user-owned Cursor hook runtime: {runtime_path}")
144
468
 
145
- with open(path, "w", encoding="utf-8") as f:
146
- json.dump(doc, f, indent=4, ensure_ascii=False, sort_keys=True)
147
- f.write("\n")
148
- return path
469
+ document = _load_document(config_path)
470
+ existing_hooks = document.get("hooks", {})
471
+ document["version"] = SCHEMA_VERSION
472
+ document["hooks"] = merge_hooks(existing_hooks, build_toolkit_hooks())
473
+ config_bytes = (
474
+ json.dumps(document, indent=4, ensure_ascii=False, sort_keys=True) + "\n"
475
+ ).encode("utf-8")
476
+ _write_transaction([
477
+ (runtime_path, HOOK_RUNTIME.encode("utf-8"), 0o755),
478
+ (config_path, config_bytes, 0o644),
479
+ ])
480
+ return config_path
149
481
 
150
482
 
151
483
  def main() -> None:
152
484
  target = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd()
153
485
  path = generate(target)
154
- total = sum(len(v) for v in CURSOR_HOOKS.values())
155
- print(f"Generated: {path.relative_to(target) if path.is_relative_to(target) else path} "
156
- f"({total} hooks across {len(CURSOR_HOOKS)} events)")
486
+ total = len(HOOK_DEFINITIONS)
487
+ relative = path.relative_to(target) if path.is_relative_to(target) else path
488
+ print(f"Generated: {relative} ({total} hooks across {total} events)")
157
489
 
158
490
 
159
491
  if __name__ == "__main__":
@@ -18,7 +18,7 @@ import sys
18
18
  from pathlib import Path
19
19
 
20
20
  sys.path.insert(0, str(Path(__file__).resolve().parent))
21
- from codex_skill_adapter import build_codex_skill_text, is_codex_adapted_skill
21
+ from codex_skill_adapter import build_opencode_skill_text, is_codex_adapted_skill
22
22
  from emission import skills_dir
23
23
  from frontmatter import frontmatter_field
24
24
 
@@ -29,13 +29,11 @@ def _skill_body(skill_file: Path) -> str:
29
29
  """Return the markdown body of a skill (content after frontmatter).
30
30
 
31
31
  For skills that rely on Claude-only orchestration primitives, route
32
- through the Codex adapter so `Agent`/`TeamCreate`/`TaskCreate` get
33
- rewritten to opencode-compatible delegation guidance. The adapter's
34
- output is compatible with opencode's subagent model (``spawn_agent``
35
- conventions, plan tracking) because both lack Claude primitives.
32
+ through the portable adapter so Claude-only placeholders and APIs become
33
+ OpenCode-native, signature-free guidance.
36
34
  """
37
35
  if is_codex_adapted_skill(skill_file):
38
- adapted = build_codex_skill_text(skill_file)
36
+ adapted = build_opencode_skill_text(skill_file)
39
37
  # Strip the adapted frontmatter — we emit our own below
40
38
  parts = adapted.split("---", 2)
41
39
  return parts[2].lstrip("\n") if len(parts) >= 3 else adapted