@softspark/ai-toolkit 4.14.1 → 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 (47) hide show
  1. package/CHANGELOG.md +28 -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/hooks/_search-capability.sh +3 -2
  6. package/app/hooks/stop-search-check.sh +2 -1
  7. package/benchmarks/ecosystem-doctor-snapshot.json +73 -31
  8. package/kb/procedures/maintenance-sop.md +26 -13
  9. package/kb/procedures/release-verification-sop.md +41 -36
  10. package/kb/reference/architecture-overview.md +23 -7
  11. package/kb/reference/codex-cli-compatibility.md +96 -36
  12. package/kb/reference/extension-api.md +52 -9
  13. package/kb/reference/global-install-model.md +53 -21
  14. package/kb/reference/hooks-catalog.md +44 -8
  15. package/kb/reference/mcp-editor-compatibility.md +27 -6
  16. package/kb/reference/mcp-templates.md +12 -6
  17. package/kb/reference/opencode-compatibility.md +13 -7
  18. package/kb/reference/plugin-pack-conventions.md +7 -7
  19. package/kb/reference/skills-catalog.md +3 -3
  20. package/kb/reference/supported-tools-registry.md +19 -17
  21. package/kb/reference/windows-support.md +26 -3
  22. package/llms-full.txt +443 -180
  23. package/llms.txt +1 -1
  24. package/manifest.json +1 -1
  25. package/package.json +2 -2
  26. package/scripts/codex_skill_adapter.py +448 -198
  27. package/scripts/dir_rules_shared.py +2 -11
  28. package/scripts/ecosystem_tools.json +29 -8
  29. package/scripts/emission.py +5 -91
  30. package/scripts/generate_agents_md.py +4 -87
  31. package/scripts/generate_codex.py +5 -95
  32. package/scripts/generate_codex_agents.py +242 -0
  33. package/scripts/generate_codex_hooks.py +648 -55
  34. package/scripts/generate_codex_skills.py +15 -6
  35. package/scripts/generate_copilot.py +771 -74
  36. package/scripts/generate_copilot_hooks.py +606 -0
  37. package/scripts/generate_cursor_hooks.py +453 -121
  38. package/scripts/generate_opencode_commands.py +4 -6
  39. package/scripts/inject_hook_cli.py +770 -205
  40. package/scripts/injection.py +102 -23
  41. package/scripts/install_steps/ai_tools.py +123 -83
  42. package/scripts/instruction_core.py +95 -0
  43. package/scripts/mcp_editors.py +934 -80
  44. package/scripts/mcp_manager.py +46 -26
  45. package/scripts/plugin.py +291 -114
  46. package/scripts/secure_fs.py +538 -0
  47. package/scripts/uninstall.py +1279 -208
@@ -1,41 +1,86 @@
1
1
  #!/usr/bin/env python3
2
- """Generate .codex/hooks.json for OpenAI Codex CLI.
2
+ """Generate native, self-contained Codex hooks without bypassing trust.
3
3
 
4
- Maps compatible ai-toolkit hooks to Codex lifecycle events.
5
- Hook scripts are shared with Claude Code (stored in ~/.softspark/ai-toolkit/hooks/).
6
-
7
- Codex exposes 10 lifecycle events (PascalCase in config.toml / hooks.json):
8
- ``PreToolUse``, ``PostToolUse``, ``PermissionRequest``, ``PreCompact``,
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
- HOOKS_PREFIX = 'AI_TOOLKIT_HOOK_QUIET=1 "$HOME/.softspark/ai-toolkit/hooks/'
33
- # Hooks compatible with Codex, grouped by event.
34
- # Format: (matcher, script_name)
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
- # Fires when Codex asks the user to approve a tool call. Our guard
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
- # Capture session memory before Codex compacts the conversation.
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 build_hooks_json() -> dict:
81
- """Build the hooks.json structure for Codex."""
82
- hooks: dict[str, list] = {}
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
- hooks[event] = []
243
+ groups: list[dict[str, Any]] = []
85
244
  for matcher, script in entries:
86
- entry: dict = {"hooks": [{"type": "command", "command": f"{HOOKS_PREFIX}{script}\""}]}
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
- entry["matcher"] = matcher
89
- hooks[event].append(entry)
90
- return {"hooks": hooks}
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
- def generate(target_dir: Path) -> None:
94
- """Write .codex/hooks.json to target_dir."""
95
- codex_dir = target_dir / ".codex"
96
- codex_dir.mkdir(parents=True, exist_ok=True)
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
- data = build_hooks_json()
99
- with open(hooks_path, "w", encoding="utf-8") as f:
100
- json.dump(data, f, indent=4, ensure_ascii=False)
101
- f.write("\n")
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
- target = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd()
106
- generate(target)
107
- print(f"Generated: .codex/hooks.json ({sum(len(v) for v in CODEX_HOOKS.values())} hooks)")
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__":