@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.
- package/CHANGELOG.md +35 -0
- package/README.md +11 -10
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/CLAUDE.md.template +3 -0
- package/app/agents/fact-checker.md +1 -1
- package/app/hooks/_search-capability.sh +3 -2
- package/app/hooks/stop-search-check.sh +2 -1
- package/benchmarks/ecosystem-doctor-snapshot.json +73 -31
- package/kb/procedures/maintenance-sop.md +26 -13
- package/kb/procedures/release-verification-sop.md +41 -36
- package/kb/reference/architecture-overview.md +23 -7
- package/kb/reference/codex-cli-compatibility.md +96 -36
- package/kb/reference/extension-api.md +52 -9
- package/kb/reference/global-install-model.md +53 -21
- package/kb/reference/hooks-catalog.md +44 -8
- package/kb/reference/mcp-editor-compatibility.md +27 -6
- package/kb/reference/mcp-templates.md +12 -6
- package/kb/reference/opencode-compatibility.md +13 -7
- package/kb/reference/plugin-pack-conventions.md +7 -7
- package/kb/reference/skills-catalog.md +3 -3
- package/kb/reference/supported-tools-registry.md +19 -17
- package/kb/reference/windows-support.md +26 -3
- package/llms-full.txt +443 -180
- package/llms.txt +1 -1
- package/manifest.json +1 -1
- package/package.json +2 -2
- package/scripts/codex_skill_adapter.py +448 -198
- package/scripts/dir_rules_shared.py +2 -11
- package/scripts/ecosystem_tools.json +29 -8
- package/scripts/emission.py +5 -91
- package/scripts/generate_agents_md.py +4 -87
- package/scripts/generate_codex.py +5 -95
- package/scripts/generate_codex_agents.py +242 -0
- package/scripts/generate_codex_hooks.py +648 -55
- package/scripts/generate_codex_skills.py +15 -6
- package/scripts/generate_copilot.py +771 -74
- package/scripts/generate_copilot_hooks.py +606 -0
- package/scripts/generate_cursor_hooks.py +453 -121
- package/scripts/generate_opencode_commands.py +4 -6
- package/scripts/inject_hook_cli.py +770 -205
- package/scripts/injection.py +102 -23
- package/scripts/install_steps/ai_tools.py +123 -83
- package/scripts/instruction_core.py +95 -0
- package/scripts/mcp_editors.py +934 -80
- package/scripts/mcp_manager.py +46 -26
- package/scripts/plugin.py +291 -114
- package/scripts/secure_fs.py +538 -0
- package/scripts/uninstall.py +1279 -208
|
@@ -1,159 +1,491 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Generate
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
"
|
|
39
|
-
|
|
40
|
-
(
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
(""
|
|
46
|
-
|
|
47
|
-
"
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
(""
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
"
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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 = [
|
|
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(
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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 =
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
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
|
|
33
|
-
|
|
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 =
|
|
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
|