@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,41 +1,86 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Generate
|
|
2
|
+
"""Generate native, self-contained Codex hooks without bypassing trust.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
``PostCompact``, ``SessionStart``, ``UserPromptSubmit``, ``SubagentStart``,
|
|
10
|
-
``SubagentStop``, ``Stop``. PreToolUse/PostToolUse only support the ``Bash``
|
|
11
|
-
matcher. We wire 9 of the 10 events in ``CODEX_HOOKS`` below to the shared
|
|
12
|
-
toolkit hook scripts, mirroring the Claude Code mapping in ``app/hooks.json``.
|
|
13
|
-
``PostCompact`` is intentionally unwired (its only hook was the removed
|
|
14
|
-
environment-snapshot probe).
|
|
15
|
-
|
|
16
|
-
Handler types in Codex: ``command`` (what we emit). ``prompt`` and ``agent``
|
|
17
|
-
are parsed by Codex but not yet executed, so hand-authored handlers of those
|
|
18
|
-
types are inert. Reference: codex-rs/config/src/hook_config.rs.
|
|
19
|
-
|
|
20
|
-
Usage:
|
|
21
|
-
python3 scripts/generate_codex_hooks.py [target-dir]
|
|
22
|
-
|
|
23
|
-
Writes .codex/hooks.json to target-dir.
|
|
4
|
+
Project installs write ``.codex/hooks.json`` and executable assets under
|
|
5
|
+
``.codex/hooks/``. Global installs write the same JSON under ``$CODEX_HOME``
|
|
6
|
+
(default ``~/.codex``) and assets under its ``ai-toolkit-hooks/`` directory.
|
|
7
|
+
Existing user command hooks are preserved; only handlers carrying ai-toolkit's
|
|
8
|
+
command marker (or its legacy shared-hook path) are replaced.
|
|
24
9
|
"""
|
|
10
|
+
|
|
25
11
|
from __future__ import annotations
|
|
26
12
|
|
|
27
13
|
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
28
16
|
import sys
|
|
29
17
|
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
21
|
+
|
|
22
|
+
from secure_fs import (
|
|
23
|
+
SECURE_DIR_FD,
|
|
24
|
+
SecureDestination,
|
|
25
|
+
SecureTransaction,
|
|
26
|
+
lexical_absolute,
|
|
27
|
+
nearest_existing_root,
|
|
28
|
+
run_secure_transaction,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
TOOLKIT_COMMAND_MARKER = "AI_TOOLKIT_HOOK_OWNER=ai-toolkit"
|
|
33
|
+
SCRIPT_MARKER = "# ai-toolkit-managed: codex-hook-script"
|
|
34
|
+
LEGACY_COMMAND_MARKER = ".softspark/ai-toolkit/hooks/"
|
|
35
|
+
PLUGIN_OWNER_PATTERN = re.compile(r"ai-toolkit-plugin-[a-z0-9][a-z0-9-]*")
|
|
36
|
+
COMMAND_OWNER_PATTERN = re.compile(
|
|
37
|
+
r"(?:^|\s)AI_TOOLKIT_HOOK_OWNER=(?P<owner>[a-z0-9][a-z0-9-]*)(?=\s|$)"
|
|
38
|
+
)
|
|
39
|
+
_SECURE_DIR_FD = SECURE_DIR_FD
|
|
40
|
+
_UNSAFE_MUTATION_PLATFORM_ERROR = (
|
|
41
|
+
"Safe Codex hook mutations require POSIX dir_fd and O_NOFOLLOW support, "
|
|
42
|
+
"which this Python runtime does not provide. No files were changed. "
|
|
43
|
+
"On Windows, generate or update Codex hooks from WSL."
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _require_secure_mutation_support() -> None:
|
|
48
|
+
if not _SECURE_DIR_FD:
|
|
49
|
+
raise RuntimeError(_UNSAFE_MUTATION_PLATFORM_ERROR)
|
|
30
50
|
|
|
31
51
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
52
|
+
SUPPORTED_EVENTS = frozenset(
|
|
53
|
+
{
|
|
54
|
+
"PreToolUse",
|
|
55
|
+
"PostToolUse",
|
|
56
|
+
"PermissionRequest",
|
|
57
|
+
"PreCompact",
|
|
58
|
+
"PostCompact",
|
|
59
|
+
"SessionStart",
|
|
60
|
+
"UserPromptSubmit",
|
|
61
|
+
"SubagentStart",
|
|
62
|
+
"SubagentStop",
|
|
63
|
+
"Stop",
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
GROUP_KEYS = frozenset({"matcher", "hooks"})
|
|
68
|
+
HANDLER_KEYS = frozenset(
|
|
69
|
+
{
|
|
70
|
+
"type",
|
|
71
|
+
"command",
|
|
72
|
+
"commandWindows",
|
|
73
|
+
"timeout",
|
|
74
|
+
"statusMessage",
|
|
75
|
+
"async",
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# Format: event -> (matcher, executable asset). Empty matchers are omitted.
|
|
35
80
|
CODEX_HOOKS: dict[str, list[tuple[str, str]]] = {
|
|
36
81
|
"SessionStart": [
|
|
37
|
-
("startup|resume", "session-start.sh"),
|
|
38
|
-
("startup|resume", "mcp-health.sh"),
|
|
82
|
+
("startup|resume", "codex-session-start.sh"),
|
|
83
|
+
("startup|resume", "codex-mcp-health.sh"),
|
|
39
84
|
],
|
|
40
85
|
"PreToolUse": [
|
|
41
86
|
("Bash", "guard-destructive.sh"),
|
|
@@ -43,16 +88,11 @@ CODEX_HOOKS: dict[str, list[tuple[str, str]]] = {
|
|
|
43
88
|
("Bash", "revert-guard.sh"),
|
|
44
89
|
],
|
|
45
90
|
"PostToolUse": [
|
|
46
|
-
# Fires after Bash/apply_patch/MCP tool output. Capture governance
|
|
47
|
-
# signals and detect repetition loops (mirrors app/hooks.json).
|
|
48
91
|
("Bash", "governance-capture.sh"),
|
|
49
92
|
("Bash", "loop-guard.sh"),
|
|
50
93
|
],
|
|
51
94
|
"PermissionRequest": [
|
|
52
|
-
|
|
53
|
-
# reviews the tool input and can veto destructive patterns before the
|
|
54
|
-
# approval prompt reaches the human.
|
|
55
|
-
("", "guard-destructive.sh"),
|
|
95
|
+
("Bash", "guard-destructive.sh"),
|
|
56
96
|
],
|
|
57
97
|
"UserPromptSubmit": [
|
|
58
98
|
("", "user-prompt-submit.sh"),
|
|
@@ -65,46 +105,599 @@ CODEX_HOOKS: dict[str, list[tuple[str, str]]] = {
|
|
|
65
105
|
("", "subagent-stop.sh"),
|
|
66
106
|
],
|
|
67
107
|
"PreCompact": [
|
|
68
|
-
|
|
69
|
-
("", "pre-compact.sh"),
|
|
70
|
-
("", "pre-compact-save.sh"),
|
|
108
|
+
("", "codex-pre-compact.sh"),
|
|
71
109
|
],
|
|
72
110
|
"Stop": [
|
|
73
111
|
("", "quality-check.sh"),
|
|
74
112
|
("", "save-session.sh"),
|
|
75
|
-
("", "stop-search-check.sh"),
|
|
113
|
+
("", "codex-stop-search-check.sh"),
|
|
76
114
|
],
|
|
77
115
|
}
|
|
78
116
|
|
|
117
|
+
HELPER_ASSETS = frozenset(
|
|
118
|
+
{
|
|
119
|
+
"_hook-io.sh",
|
|
120
|
+
"_locate-toolkit.sh",
|
|
121
|
+
"_profile-check.sh",
|
|
122
|
+
"_search-capability.sh",
|
|
123
|
+
"_session-paths.sh",
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
CODEX_STOP_SEARCH_ADAPTER = r"""#!/usr/bin/env bash
|
|
128
|
+
# Convert the shared Stop search guard's legacy decision object into Codex's
|
|
129
|
+
# documented Stop output fields.
|
|
130
|
+
INPUT=$(cat)
|
|
131
|
+
OUTPUT=$(printf '%s' "$INPUT" | "$(dirname "$0")/stop-search-check.sh")
|
|
132
|
+
STATUS=$?
|
|
133
|
+
[ "$STATUS" -eq 0 ] || exit "$STATUS"
|
|
134
|
+
[ -n "$OUTPUT" ] || exit 0
|
|
135
|
+
if printf '%s' "$OUTPUT" | jq -e '.decision == "block"' >/dev/null 2>&1; then
|
|
136
|
+
REASON=$(printf '%s' "$OUTPUT" | jq -r '.reason // "Hook requested another turn."')
|
|
137
|
+
jq -nc --arg reason "$REASON" \
|
|
138
|
+
'{"continue":false,"stopReason":$reason,"systemMessage":$reason}'
|
|
139
|
+
else
|
|
140
|
+
printf '%s\n' "$OUTPUT"
|
|
141
|
+
fi
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
CODEX_SESSION_START_ADAPTER = r"""#!/usr/bin/env bash
|
|
145
|
+
# Native Codex SessionStart context. Plain stdout becomes developer context.
|
|
146
|
+
cat >/dev/null || true
|
|
147
|
+
cat <<'EOF'
|
|
148
|
+
AI Toolkit: apply the active AGENTS.md instruction chain and repository rules before technical work. Use current Codex skills when their descriptions match. For changes, keep tests and affected documentation aligned, and verify before claiming completion.
|
|
149
|
+
EOF
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
CODEX_PRE_COMPACT_ADAPTER = r"""#!/usr/bin/env bash
|
|
153
|
+
# Native Codex PreCompact reminder. Plain stdout is retained as hook context.
|
|
154
|
+
cat >/dev/null || true
|
|
155
|
+
printf '%s\n' 'AI Toolkit: context is being compacted. Re-read the active AGENTS.md instruction chain, the current plan, and git status before continuing.'
|
|
156
|
+
if command -v git >/dev/null 2>&1 && git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
157
|
+
printf 'Branch: %s; uncommitted paths: %s\n' \
|
|
158
|
+
"$(git branch --show-current 2>/dev/null || printf detached)" \
|
|
159
|
+
"$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')"
|
|
160
|
+
fi
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
CODEX_MCP_HEALTH_ADAPTER = r"""#!/usr/bin/env bash
|
|
164
|
+
# Check documented Codex MCP config layers without starting any MCP server.
|
|
165
|
+
cat >/dev/null || true
|
|
166
|
+
command -v python3 >/dev/null 2>&1 || exit 0
|
|
167
|
+
PROJECT_CONFIG=""
|
|
168
|
+
if command -v git >/dev/null 2>&1; then
|
|
169
|
+
PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true)
|
|
170
|
+
[ -n "$PROJECT_ROOT" ] && PROJECT_CONFIG="$PROJECT_ROOT/.codex/config.toml"
|
|
171
|
+
fi
|
|
172
|
+
python3 - "${CODEX_HOME:-$HOME/.codex}/config.toml" "$PROJECT_CONFIG" <<'PY'
|
|
173
|
+
import shutil
|
|
174
|
+
import sys
|
|
175
|
+
from pathlib import Path
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
import tomllib
|
|
179
|
+
except ModuleNotFoundError:
|
|
180
|
+
raise SystemExit(0)
|
|
181
|
+
|
|
182
|
+
for raw_path in sys.argv[1:]:
|
|
183
|
+
if not raw_path:
|
|
184
|
+
continue
|
|
185
|
+
path = Path(raw_path)
|
|
186
|
+
if not path.is_file() or path.is_symlink():
|
|
187
|
+
continue
|
|
188
|
+
try:
|
|
189
|
+
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
190
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
191
|
+
continue
|
|
192
|
+
servers = data.get("mcp_servers", {})
|
|
193
|
+
if not isinstance(servers, dict):
|
|
194
|
+
continue
|
|
195
|
+
for name, config in servers.items():
|
|
196
|
+
if not isinstance(config, dict) or config.get("enabled") is False:
|
|
197
|
+
continue
|
|
198
|
+
command = config.get("command")
|
|
199
|
+
if isinstance(command, str) and command and shutil.which(command) is None:
|
|
200
|
+
print(f"MCP health: {name} command not found ({command}).")
|
|
201
|
+
PY
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
GENERATED_ADAPTERS = {
|
|
205
|
+
"codex-stop-search-check.sh": CODEX_STOP_SEARCH_ADAPTER,
|
|
206
|
+
"codex-session-start.sh": CODEX_SESSION_START_ADAPTER,
|
|
207
|
+
"codex-pre-compact.sh": CODEX_PRE_COMPACT_ADAPTER,
|
|
208
|
+
"codex-mcp-health.sh": CODEX_MCP_HEALTH_ADAPTER,
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _source_hooks_dir() -> Path:
|
|
213
|
+
return Path(__file__).resolve().parent.parent / "app" / "hooks"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _asset_names() -> set[str]:
|
|
217
|
+
names = {
|
|
218
|
+
script
|
|
219
|
+
for entries in CODEX_HOOKS.values()
|
|
220
|
+
for _, script in entries
|
|
221
|
+
if script not in GENERATED_ADAPTERS
|
|
222
|
+
}
|
|
223
|
+
names.update(HELPER_ASSETS)
|
|
224
|
+
names.add("stop-search-check.sh")
|
|
225
|
+
names.update(GENERATED_ADAPTERS)
|
|
226
|
+
return names
|
|
227
|
+
|
|
79
228
|
|
|
80
|
-
def
|
|
81
|
-
|
|
82
|
-
|
|
229
|
+
def _command_for(script: str, global_install: bool) -> str:
|
|
230
|
+
if global_install:
|
|
231
|
+
executable = f'"${{CODEX_HOME:-$HOME/.codex}}/ai-toolkit-hooks/{script}"'
|
|
232
|
+
else:
|
|
233
|
+
executable = (
|
|
234
|
+
f'"$(git rev-parse --show-toplevel 2>/dev/null)/.codex/hooks/{script}"'
|
|
235
|
+
)
|
|
236
|
+
return f"AI_TOOLKIT_HOOK_QUIET=1 {TOOLKIT_COMMAND_MARKER} {executable}"
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def build_hooks_json(*, global_install: bool = False) -> dict[str, Any]:
|
|
240
|
+
"""Build only ai-toolkit's schema-valid native command handlers."""
|
|
241
|
+
hooks: dict[str, list[dict[str, Any]]] = {}
|
|
83
242
|
for event, entries in CODEX_HOOKS.items():
|
|
84
|
-
|
|
243
|
+
groups: list[dict[str, Any]] = []
|
|
85
244
|
for matcher, script in entries:
|
|
86
|
-
|
|
245
|
+
group: dict[str, Any] = {
|
|
246
|
+
"hooks": [
|
|
247
|
+
{
|
|
248
|
+
"type": "command",
|
|
249
|
+
"command": _command_for(script, global_install),
|
|
250
|
+
}
|
|
251
|
+
]
|
|
252
|
+
}
|
|
87
253
|
if matcher:
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
254
|
+
group["matcher"] = matcher
|
|
255
|
+
groups.append(group)
|
|
256
|
+
hooks[event] = groups
|
|
257
|
+
result = {"hooks": hooks}
|
|
258
|
+
_validate_hooks_document(result)
|
|
259
|
+
return result
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _validate_hooks_document(data: Any) -> None:
|
|
263
|
+
if not isinstance(data, dict) or set(data) - {"hooks"}:
|
|
264
|
+
raise ValueError("Codex hooks.json must contain only the top-level hooks key")
|
|
265
|
+
hooks = data.get("hooks", {})
|
|
266
|
+
if not isinstance(hooks, dict):
|
|
267
|
+
raise ValueError("Codex hooks.json hooks must be an object")
|
|
268
|
+
unsupported = set(hooks) - SUPPORTED_EVENTS
|
|
269
|
+
if unsupported:
|
|
270
|
+
raise ValueError(f"Unsupported Codex hook events: {sorted(unsupported)}")
|
|
271
|
+
|
|
272
|
+
for event, groups in hooks.items():
|
|
273
|
+
if not isinstance(groups, list):
|
|
274
|
+
raise ValueError(f"Codex hook event {event} must contain a list")
|
|
275
|
+
for group in groups:
|
|
276
|
+
_validate_matcher_group(event, group)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _validate_matcher_group(event: str, group: Any) -> None:
|
|
280
|
+
if not isinstance(group, dict) or "hooks" not in group:
|
|
281
|
+
raise ValueError(f"Codex {event} matcher group must contain hooks")
|
|
282
|
+
unknown = set(group) - GROUP_KEYS
|
|
283
|
+
if unknown:
|
|
284
|
+
raise ValueError(f"Unknown Codex {event} matcher keys: {sorted(unknown)}")
|
|
285
|
+
matcher = group.get("matcher")
|
|
286
|
+
if matcher is not None:
|
|
287
|
+
if not isinstance(matcher, str):
|
|
288
|
+
raise ValueError(f"Codex {event} matcher must be a string")
|
|
289
|
+
re.compile(matcher)
|
|
290
|
+
if event in {"UserPromptSubmit", "Stop"} and "matcher" in group:
|
|
291
|
+
raise ValueError(f"Codex {event} does not support matchers")
|
|
292
|
+
|
|
293
|
+
handlers = group["hooks"]
|
|
294
|
+
if not isinstance(handlers, list) or not handlers:
|
|
295
|
+
raise ValueError(f"Codex {event} matcher group must have command handlers")
|
|
296
|
+
for handler in handlers:
|
|
297
|
+
_validate_handler(event, handler)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _validate_handler(event: str, handler: Any) -> None:
|
|
301
|
+
if not isinstance(handler, dict):
|
|
302
|
+
raise ValueError(f"Codex {event} handler must be an object")
|
|
303
|
+
unknown = set(handler) - HANDLER_KEYS
|
|
304
|
+
if unknown:
|
|
305
|
+
raise ValueError(f"Unknown Codex {event} handler keys: {sorted(unknown)}")
|
|
306
|
+
command = handler.get("command")
|
|
307
|
+
if (
|
|
308
|
+
handler.get("type") != "command"
|
|
309
|
+
or not isinstance(command, str)
|
|
310
|
+
or not command.strip()
|
|
311
|
+
):
|
|
312
|
+
raise ValueError(f"Codex {event} supports executable command handlers only")
|
|
313
|
+
if "timeout" in handler:
|
|
314
|
+
timeout = handler["timeout"]
|
|
315
|
+
if type(timeout) is not int or timeout <= 0:
|
|
316
|
+
raise ValueError(f"Codex {event} timeout must be a positive integer")
|
|
317
|
+
for key in ("commandWindows", "statusMessage"):
|
|
318
|
+
if key in handler and not isinstance(handler[key], str):
|
|
319
|
+
raise ValueError(f"Codex {event} {key} must be a string")
|
|
320
|
+
if "async" in handler and not isinstance(handler["async"], bool):
|
|
321
|
+
raise ValueError(f"Codex {event} async must be boolean")
|
|
322
|
+
|
|
91
323
|
|
|
324
|
+
def _normalize_legacy_plugin_groups(data: Any) -> Any:
|
|
325
|
+
"""Remove the retired ``_source`` key from known toolkit plugin groups.
|
|
92
326
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
327
|
+
Older ai-toolkit releases used Claude's private ownership key in Codex
|
|
328
|
+
``hooks.json``. Keep those handlers usable during upgrade by moving their
|
|
329
|
+
ownership into the command marker that the native schema accepts.
|
|
330
|
+
"""
|
|
331
|
+
if not isinstance(data, dict) or not isinstance(data.get("hooks", {}), dict):
|
|
332
|
+
return data
|
|
333
|
+
for groups in data.get("hooks", {}).values():
|
|
334
|
+
if not isinstance(groups, list):
|
|
335
|
+
continue
|
|
336
|
+
for group in groups:
|
|
337
|
+
if not isinstance(group, dict) or "_source" not in group:
|
|
338
|
+
continue
|
|
339
|
+
owner = group.get("_source")
|
|
340
|
+
if (
|
|
341
|
+
not isinstance(owner, str)
|
|
342
|
+
or PLUGIN_OWNER_PATTERN.fullmatch(owner) is None
|
|
343
|
+
):
|
|
344
|
+
continue
|
|
345
|
+
if set(group) - GROUP_KEYS - {"_source"}:
|
|
346
|
+
continue
|
|
347
|
+
handlers = group.get("hooks")
|
|
348
|
+
if not isinstance(handlers, list):
|
|
349
|
+
continue
|
|
350
|
+
for handler in handlers:
|
|
351
|
+
if not isinstance(handler, dict):
|
|
352
|
+
continue
|
|
353
|
+
command = handler.get("command")
|
|
354
|
+
if not isinstance(command, str) or not command.strip():
|
|
355
|
+
continue
|
|
356
|
+
match = COMMAND_OWNER_PATTERN.search(command)
|
|
357
|
+
if match is not None and match.group("owner") != owner:
|
|
358
|
+
raise ValueError(
|
|
359
|
+
"Legacy Codex plugin hook owner conflicts with its command marker"
|
|
360
|
+
)
|
|
361
|
+
if match is None:
|
|
362
|
+
handler["command"] = f"AI_TOOLKIT_HOOK_OWNER={owner} {command}"
|
|
363
|
+
del group["_source"]
|
|
364
|
+
return data
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _load_existing_hooks_bytes(
|
|
368
|
+
content: bytes | None,
|
|
369
|
+
hooks_path: Path,
|
|
370
|
+
) -> dict[str, Any]:
|
|
371
|
+
if content is None:
|
|
372
|
+
return {"hooks": {}}
|
|
373
|
+
try:
|
|
374
|
+
data = json.loads(content.decode("utf-8"))
|
|
375
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
376
|
+
raise ValueError(
|
|
377
|
+
f"Cannot read existing Codex hooks: {hooks_path}: {error}"
|
|
378
|
+
) from error
|
|
379
|
+
data = _normalize_legacy_plugin_groups(data)
|
|
380
|
+
_validate_hooks_document(data)
|
|
381
|
+
data.setdefault("hooks", {})
|
|
382
|
+
return data
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _load_existing_hooks(hooks_path: Path) -> dict[str, Any]:
|
|
386
|
+
if not hooks_path.exists():
|
|
387
|
+
return {"hooks": {}}
|
|
388
|
+
try:
|
|
389
|
+
content = hooks_path.read_bytes()
|
|
390
|
+
except OSError as error:
|
|
391
|
+
raise ValueError(
|
|
392
|
+
f"Cannot read existing Codex hooks: {hooks_path}: {error}"
|
|
393
|
+
) from error
|
|
394
|
+
return _load_existing_hooks_bytes(content, hooks_path)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _is_managed_handler(handler: dict[str, Any]) -> bool:
|
|
398
|
+
command = handler.get("command", "")
|
|
399
|
+
owner_match = COMMAND_OWNER_PATTERN.search(command)
|
|
400
|
+
if owner_match is not None:
|
|
401
|
+
owner = owner_match.group("owner")
|
|
402
|
+
if PLUGIN_OWNER_PATTERN.fullmatch(owner) is not None:
|
|
403
|
+
return False
|
|
404
|
+
if owner == "ai-toolkit":
|
|
405
|
+
return True
|
|
406
|
+
return LEGACY_COMMAND_MARKER in command
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _merge_hooks(existing: dict[str, Any], generated: dict[str, Any]) -> dict[str, Any]:
|
|
410
|
+
merged: dict[str, Any] = {"hooks": {}}
|
|
411
|
+
for event, groups in existing.get("hooks", {}).items():
|
|
412
|
+
retained_groups: list[dict[str, Any]] = []
|
|
413
|
+
for group in groups:
|
|
414
|
+
retained = [
|
|
415
|
+
handler
|
|
416
|
+
for handler in group["hooks"]
|
|
417
|
+
if not _is_managed_handler(handler)
|
|
418
|
+
]
|
|
419
|
+
if retained:
|
|
420
|
+
retained_group = dict(group)
|
|
421
|
+
retained_group["hooks"] = retained
|
|
422
|
+
retained_groups.append(retained_group)
|
|
423
|
+
if retained_groups:
|
|
424
|
+
merged["hooks"][event] = retained_groups
|
|
425
|
+
|
|
426
|
+
for event, groups in generated["hooks"].items():
|
|
427
|
+
merged["hooks"].setdefault(event, []).extend(groups)
|
|
428
|
+
_validate_hooks_document(merged)
|
|
429
|
+
return merged
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _managed_asset_content(name: str) -> bytes:
|
|
433
|
+
if name in GENERATED_ADAPTERS:
|
|
434
|
+
text = GENERATED_ADAPTERS[name]
|
|
435
|
+
else:
|
|
436
|
+
source = _source_hooks_dir() / name
|
|
437
|
+
if source.is_symlink() or not source.is_file():
|
|
438
|
+
raise RuntimeError(f"Missing safe Codex hook source asset: {source}")
|
|
439
|
+
text = source.read_text(encoding="utf-8")
|
|
440
|
+
|
|
441
|
+
lines = text.splitlines(keepends=True)
|
|
442
|
+
marker_line = f"{SCRIPT_MARKER}\n"
|
|
443
|
+
if lines and lines[0].startswith("#!"):
|
|
444
|
+
text = "".join((lines[0], marker_line, *lines[1:]))
|
|
445
|
+
else:
|
|
446
|
+
text = marker_line + text
|
|
447
|
+
return text.encode("utf-8")
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _is_managed_asset(path: Path) -> bool:
|
|
451
|
+
if path.is_symlink() or not path.is_file():
|
|
452
|
+
return False
|
|
453
|
+
try:
|
|
454
|
+
prefix = path.read_text(encoding="utf-8")[:256]
|
|
455
|
+
except (OSError, UnicodeError):
|
|
456
|
+
return False
|
|
457
|
+
return SCRIPT_MARKER in prefix
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _assert_safe_paths(
|
|
461
|
+
target_dir: Path, codex_dir: Path, assets_dir: Path, hooks_path: Path
|
|
462
|
+
) -> None:
|
|
463
|
+
for path, label in (
|
|
464
|
+
(target_dir, "target"),
|
|
465
|
+
(codex_dir, ".codex"),
|
|
466
|
+
(assets_dir, "hook assets"),
|
|
467
|
+
(hooks_path, "hooks.json"),
|
|
468
|
+
):
|
|
469
|
+
if path.is_symlink():
|
|
470
|
+
raise RuntimeError(f"Refusing symlinked Codex {label}: {path}")
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _assert_no_asset_collisions(assets_dir: Path, names: set[str]) -> None:
|
|
474
|
+
if not assets_dir.exists():
|
|
475
|
+
return
|
|
476
|
+
if not assets_dir.is_dir():
|
|
477
|
+
raise RuntimeError(f"Codex hook assets path is not a directory: {assets_dir}")
|
|
478
|
+
for name in names:
|
|
479
|
+
destination = assets_dir / name
|
|
480
|
+
if destination.is_symlink():
|
|
481
|
+
raise RuntimeError(f"Refusing symlinked Codex hook asset: {destination}")
|
|
482
|
+
if destination.exists() and not _is_managed_asset(destination):
|
|
483
|
+
raise RuntimeError(
|
|
484
|
+
f"Refusing user-owned Codex hook asset collision: {destination}"
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _write_outputs(
|
|
489
|
+
hooks_path: Path,
|
|
490
|
+
assets_dir: Path,
|
|
491
|
+
generated_hooks: dict[str, Any],
|
|
492
|
+
assets: dict[str, bytes],
|
|
493
|
+
trusted_root: Path,
|
|
494
|
+
stale: list[SecureDestination],
|
|
495
|
+
) -> None:
|
|
496
|
+
_require_secure_mutation_support()
|
|
497
|
+
asset_outputs: list[tuple[SecureDestination, bytes]] = []
|
|
498
|
+
for name in sorted(assets):
|
|
499
|
+
asset_outputs.append(
|
|
500
|
+
(
|
|
501
|
+
SecureDestination(
|
|
502
|
+
assets_dir / name,
|
|
503
|
+
trusted_root,
|
|
504
|
+
f"Codex hook asset {name}",
|
|
505
|
+
),
|
|
506
|
+
assets[name],
|
|
507
|
+
)
|
|
508
|
+
)
|
|
509
|
+
hooks_destination = SecureDestination(
|
|
510
|
+
hooks_path,
|
|
511
|
+
trusted_root,
|
|
512
|
+
"Codex hooks.json",
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
def apply(transaction: SecureTransaction) -> None:
|
|
516
|
+
marker = SCRIPT_MARKER.encode()
|
|
517
|
+
existing_hooks = _load_existing_hooks_bytes(
|
|
518
|
+
transaction.initial_content(hooks_destination),
|
|
519
|
+
hooks_path,
|
|
520
|
+
)
|
|
521
|
+
merged_hooks = _merge_hooks(existing_hooks, generated_hooks)
|
|
522
|
+
hooks_content = (
|
|
523
|
+
json.dumps(merged_hooks, indent=4, ensure_ascii=False) + "\n"
|
|
524
|
+
).encode()
|
|
525
|
+
|
|
526
|
+
for destination, _ in asset_outputs:
|
|
527
|
+
existing = transaction.initial_content(destination)
|
|
528
|
+
if existing is not None and marker not in existing[:256]:
|
|
529
|
+
raise RuntimeError(
|
|
530
|
+
f"Refusing user-owned Codex hook asset collision: "
|
|
531
|
+
f"{destination.path}"
|
|
532
|
+
)
|
|
533
|
+
for destination, content in asset_outputs:
|
|
534
|
+
transaction.atomic_write(destination, content, 0o755)
|
|
535
|
+
transaction.atomic_write(hooks_destination, hooks_content)
|
|
536
|
+
|
|
537
|
+
for destination in stale:
|
|
538
|
+
existing = transaction.initial_content(destination)
|
|
539
|
+
if existing is not None and marker in existing[:256]:
|
|
540
|
+
transaction.unlink(destination)
|
|
541
|
+
|
|
542
|
+
destinations = [item[0] for item in asset_outputs]
|
|
543
|
+
destinations.append(hooks_destination)
|
|
544
|
+
destinations.extend(stale)
|
|
545
|
+
run_secure_transaction(destinations, apply)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _stale_asset_destinations(
|
|
549
|
+
assets_dir: Path,
|
|
550
|
+
expected_names: set[str],
|
|
551
|
+
trusted_root: Path,
|
|
552
|
+
) -> list[SecureDestination]:
|
|
553
|
+
if not assets_dir.is_dir():
|
|
554
|
+
return []
|
|
555
|
+
stale: list[SecureDestination] = []
|
|
556
|
+
for path in assets_dir.iterdir():
|
|
557
|
+
if path.name in expected_names or path.is_symlink() or not path.is_file():
|
|
558
|
+
continue
|
|
559
|
+
stale.append(
|
|
560
|
+
SecureDestination(
|
|
561
|
+
path,
|
|
562
|
+
trusted_root,
|
|
563
|
+
f"stale Codex hook asset {path.name}",
|
|
564
|
+
)
|
|
565
|
+
)
|
|
566
|
+
return stale
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def write_hooks_json(
|
|
570
|
+
hooks_path: Path,
|
|
571
|
+
data: dict[str, Any],
|
|
572
|
+
*,
|
|
573
|
+
transaction: SecureTransaction | None = None,
|
|
574
|
+
trusted_root: Path | None = None,
|
|
575
|
+
) -> None:
|
|
576
|
+
"""Validate and atomically replace a Codex hooks document."""
|
|
577
|
+
_require_secure_mutation_support()
|
|
578
|
+
_validate_hooks_document(data)
|
|
579
|
+
hooks_path = lexical_absolute(hooks_path)
|
|
580
|
+
root = (
|
|
581
|
+
lexical_absolute(trusted_root)
|
|
582
|
+
if trusted_root is not None
|
|
583
|
+
else nearest_existing_root(hooks_path.parent)
|
|
584
|
+
)
|
|
585
|
+
destination = SecureDestination(hooks_path, root, "Codex hooks.json")
|
|
586
|
+
content = (json.dumps(data, indent=4, ensure_ascii=False) + "\n").encode()
|
|
587
|
+
if transaction is not None:
|
|
588
|
+
transaction.atomic_write(destination, content)
|
|
589
|
+
return
|
|
590
|
+
run_secure_transaction(
|
|
591
|
+
[destination],
|
|
592
|
+
lambda secure_transaction: secure_transaction.atomic_write(
|
|
593
|
+
destination, content
|
|
594
|
+
),
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def load_hooks_json(hooks_path: Path) -> dict[str, Any]:
|
|
599
|
+
"""Load, migrate, and validate a native Codex hooks document safely."""
|
|
600
|
+
hooks_path = Path(hooks_path)
|
|
601
|
+
codex_dir = hooks_path.parent
|
|
602
|
+
if (
|
|
603
|
+
hooks_path.is_symlink()
|
|
604
|
+
or codex_dir.is_symlink()
|
|
605
|
+
or codex_dir.parent.is_symlink()
|
|
606
|
+
):
|
|
607
|
+
raise RuntimeError(f"Refusing symlinked Codex hooks path: {hooks_path}")
|
|
608
|
+
return _load_existing_hooks(hooks_path)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def parse_hooks_json_bytes(
|
|
612
|
+
content: bytes | None,
|
|
613
|
+
hooks_path: Path,
|
|
614
|
+
) -> dict[str, Any]:
|
|
615
|
+
"""Parse a hooks snapshot already captured by a secure transaction."""
|
|
616
|
+
return _load_existing_hooks_bytes(content, hooks_path)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def validate_hooks_document(data: Any) -> None:
|
|
620
|
+
"""Validate a Codex hooks document without writing it."""
|
|
621
|
+
_validate_hooks_document(data)
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _infer_global_install(target_dir: Path) -> bool:
|
|
625
|
+
return os.path.abspath(target_dir) == os.path.abspath(Path.home())
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _global_codex_home(target_dir: Path, explicit_home: Path | None) -> Path:
|
|
629
|
+
if explicit_home is not None:
|
|
630
|
+
return Path(explicit_home).expanduser()
|
|
631
|
+
configured = os.environ.get("CODEX_HOME")
|
|
632
|
+
if configured:
|
|
633
|
+
path = Path(configured).expanduser()
|
|
634
|
+
if not path.is_absolute():
|
|
635
|
+
raise RuntimeError("CODEX_HOME must be an absolute path")
|
|
636
|
+
if not path.is_dir():
|
|
637
|
+
raise RuntimeError("Configured CODEX_HOME must already exist")
|
|
638
|
+
return path
|
|
639
|
+
return target_dir / ".codex"
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def generate(
|
|
643
|
+
target_dir: Path,
|
|
644
|
+
*,
|
|
645
|
+
global_install: bool | None = None,
|
|
646
|
+
codex_home: Path | None = None,
|
|
647
|
+
) -> None:
|
|
648
|
+
"""Merge native Codex hooks and install their self-contained assets."""
|
|
649
|
+
_require_secure_mutation_support()
|
|
650
|
+
target_dir = lexical_absolute(target_dir)
|
|
651
|
+
if global_install is None:
|
|
652
|
+
global_install = _infer_global_install(target_dir)
|
|
653
|
+
|
|
654
|
+
codex_dir = (
|
|
655
|
+
_global_codex_home(target_dir, codex_home)
|
|
656
|
+
if global_install
|
|
657
|
+
else target_dir / ".codex"
|
|
658
|
+
)
|
|
659
|
+
codex_dir = lexical_absolute(codex_dir)
|
|
97
660
|
hooks_path = codex_dir / "hooks.json"
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
661
|
+
assets_dir = codex_dir / ("ai-toolkit-hooks" if global_install else "hooks")
|
|
662
|
+
_assert_safe_paths(target_dir, codex_dir, assets_dir, hooks_path)
|
|
663
|
+
|
|
664
|
+
generated = build_hooks_json(global_install=global_install)
|
|
665
|
+
names = _asset_names()
|
|
666
|
+
assets = {name: _managed_asset_content(name) for name in names}
|
|
667
|
+
_assert_no_asset_collisions(assets_dir, names)
|
|
668
|
+
|
|
669
|
+
trusted_root = (
|
|
670
|
+
codex_dir if codex_dir.is_dir() else nearest_existing_root(codex_dir.parent)
|
|
671
|
+
)
|
|
672
|
+
stale = _stale_asset_destinations(assets_dir, names, trusted_root)
|
|
673
|
+
_write_outputs(
|
|
674
|
+
hooks_path,
|
|
675
|
+
assets_dir,
|
|
676
|
+
generated,
|
|
677
|
+
assets,
|
|
678
|
+
trusted_root,
|
|
679
|
+
stale,
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
if global_install:
|
|
683
|
+
print(
|
|
684
|
+
"Codex global hooks changed. Review and trust them with /hooks before use."
|
|
685
|
+
)
|
|
686
|
+
else:
|
|
687
|
+
print(
|
|
688
|
+
"Codex project hooks require a trusted .codex project layer. "
|
|
689
|
+
"Review and trust the exact definitions with /hooks before use."
|
|
690
|
+
)
|
|
102
691
|
|
|
103
692
|
|
|
104
693
|
def main() -> None:
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
694
|
+
args = sys.argv[1:]
|
|
695
|
+
global_install = "--global" in args
|
|
696
|
+
positional = [arg for arg in args if not arg.startswith("--")]
|
|
697
|
+
target = Path(positional[0]) if positional else Path.cwd()
|
|
698
|
+
generate(target, global_install=True if global_install else None)
|
|
699
|
+
hook_count = sum(len(entries) for entries in CODEX_HOOKS.values())
|
|
700
|
+
print(f"Generated: .codex/hooks.json ({hook_count} hooks)")
|
|
108
701
|
|
|
109
702
|
|
|
110
703
|
if __name__ == "__main__":
|