@softspark/ai-toolkit 4.14.1 → 4.15.1
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/AGENTS.md +117 -0
- package/CHANGELOG.md +37 -0
- package/README.md +9 -10
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/CLAUDE.md.template +3 -0
- 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 +56 -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 +27 -3
- package/llms-full.txt +447 -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/copilot_legacy_hashes.json +338 -0
- 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 +1187 -97
- package/scripts/generate_copilot_hooks.py +723 -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 +136 -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,10 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Inject external hooks into
|
|
2
|
+
"""Inject external hooks into Claude settings and native Codex hooks.
|
|
3
3
|
|
|
4
4
|
Allows external tools (MCP servers, plugins, etc.) to register their own
|
|
5
5
|
hooks alongside ai-toolkit's hooks. Each injected file is tagged with a
|
|
6
6
|
``_source`` derived from the filename stem so that re-running is idempotent
|
|
7
|
-
and removal is safe.
|
|
7
|
+
and removal is safe in Claude settings. Command handlers for native Codex
|
|
8
|
+
events are translated without ``_source`` and carry exact command ownership
|
|
9
|
+
markers under the active ``CODEX_HOME``.
|
|
8
10
|
|
|
9
11
|
Usage:
|
|
10
12
|
inject_hook_cli.py <hooks-file-or-url> [hook-name] [target-dir]
|
|
@@ -35,30 +37,43 @@ Exit codes:
|
|
|
35
37
|
1 usage / argument error
|
|
36
38
|
2 JSON parse error
|
|
37
39
|
"""
|
|
40
|
+
|
|
38
41
|
from __future__ import annotations
|
|
39
42
|
|
|
43
|
+
import hashlib
|
|
40
44
|
import json
|
|
41
45
|
import os
|
|
42
46
|
import re
|
|
43
47
|
import sys
|
|
44
48
|
import urllib.parse
|
|
49
|
+
from dataclasses import dataclass
|
|
50
|
+
from datetime import datetime, timezone
|
|
45
51
|
from pathlib import Path
|
|
52
|
+
from typing import Callable
|
|
46
53
|
|
|
47
54
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
48
55
|
|
|
56
|
+
from secure_fs import (
|
|
57
|
+
SECURE_DIR_FD,
|
|
58
|
+
SecureDestination,
|
|
59
|
+
SecureTransaction,
|
|
60
|
+
lexical_absolute,
|
|
61
|
+
nearest_existing_root,
|
|
62
|
+
run_secure_transaction,
|
|
63
|
+
)
|
|
64
|
+
|
|
49
65
|
# Protected source tag -- this CLI must never touch ai-toolkit's own entries.
|
|
50
66
|
PROTECTED_SOURCE = "ai-toolkit"
|
|
51
67
|
|
|
52
|
-
# Codex CLI's HookEventName enum defines 10 events
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
# generator prevents injected custom hooks from silently losing events Codex
|
|
56
|
-
# supports.
|
|
68
|
+
# Codex CLI's native HookEventName enum defines these 10 events. External
|
|
69
|
+
# command hooks can target all of them even though ai-toolkit's base bundle does
|
|
70
|
+
# not currently ship a PostCompact handler.
|
|
57
71
|
CODEX_EVENTS = {
|
|
58
72
|
"SessionStart",
|
|
59
73
|
"PreToolUse",
|
|
60
74
|
"PostToolUse",
|
|
61
75
|
"PermissionRequest",
|
|
76
|
+
"PostCompact",
|
|
62
77
|
"UserPromptSubmit",
|
|
63
78
|
"SubagentStart",
|
|
64
79
|
"SubagentStop",
|
|
@@ -66,40 +81,129 @@ CODEX_EVENTS = {
|
|
|
66
81
|
"Stop",
|
|
67
82
|
}
|
|
68
83
|
|
|
84
|
+
CODEX_OWNER_PREFIX = "ai-toolkit-external"
|
|
85
|
+
CODEX_NATIVE_GROUP_KEYS = frozenset({"matcher", "hooks"})
|
|
86
|
+
CODEX_NATIVE_HANDLER_KEYS = frozenset(
|
|
87
|
+
{
|
|
88
|
+
"type",
|
|
89
|
+
"command",
|
|
90
|
+
"commandWindows",
|
|
91
|
+
"timeout",
|
|
92
|
+
"statusMessage",
|
|
93
|
+
"async",
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
CODEX_OWNER_PATTERN = re.compile(
|
|
97
|
+
r"(?:^|\s)AI_TOOLKIT_HOOK_OWNER=(?P<owner>[a-z0-9][a-z0-9-]*)(?=\s|$)"
|
|
98
|
+
)
|
|
99
|
+
SOURCE_NAME_PATTERN = re.compile(r"[a-zA-Z0-9_-]+")
|
|
100
|
+
_SECURE_DIR_FD = SECURE_DIR_FD
|
|
101
|
+
_UNSAFE_MUTATION_PLATFORM_ERROR = (
|
|
102
|
+
"Safe hook mutations require POSIX dir_fd and O_NOFOLLOW support, "
|
|
103
|
+
"which this Python runtime does not provide. No files were changed. "
|
|
104
|
+
"On Windows, run ai-toolkit inject-hook or remove-hook from WSL."
|
|
105
|
+
)
|
|
69
106
|
|
|
70
|
-
# ---------------------------------------------------------------------------
|
|
71
|
-
# JSON helpers (same style as merge-hooks.py)
|
|
72
|
-
# ---------------------------------------------------------------------------
|
|
73
107
|
|
|
74
|
-
def
|
|
75
|
-
|
|
108
|
+
def _require_secure_mutation_support() -> None:
|
|
109
|
+
if not _SECURE_DIR_FD:
|
|
110
|
+
raise RuntimeError(_UNSAFE_MUTATION_PLATFORM_ERROR)
|
|
76
111
|
|
|
77
|
-
Args:
|
|
78
|
-
path: Filesystem path to the JSON file.
|
|
79
112
|
|
|
80
|
-
|
|
81
|
-
Parsed JSON content as a dictionary.
|
|
82
|
-
"""
|
|
83
|
-
with open(path) as f:
|
|
84
|
-
return json.load(f)
|
|
113
|
+
_Destination = SecureDestination
|
|
85
114
|
|
|
86
115
|
|
|
87
|
-
def
|
|
88
|
-
"""
|
|
116
|
+
def _absolute_path(path: str | os.PathLike[str]) -> Path:
|
|
117
|
+
"""Return an absolute lexical path without resolving symlinks."""
|
|
118
|
+
return lexical_absolute(path)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _trusted_target_root(target_dir: str) -> Path:
|
|
122
|
+
"""Validate the caller-selected root before inspecting child configs."""
|
|
123
|
+
root = _absolute_path(target_dir)
|
|
124
|
+
if root.is_symlink():
|
|
125
|
+
raise RuntimeError(f"Refusing symlinked target directory: {root}")
|
|
126
|
+
if not root.is_dir():
|
|
127
|
+
raise RuntimeError(f"Target directory must already exist: {root}")
|
|
128
|
+
return root
|
|
89
129
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
130
|
+
|
|
131
|
+
def _nearest_existing_root(path: Path) -> Path:
|
|
132
|
+
"""Find a real existing ancestor that can anchor symlink checks."""
|
|
133
|
+
return nearest_existing_root(path)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _assert_safe_destination(destination: _Destination) -> None:
|
|
137
|
+
"""Reject symlinks or non-directory ancestors below a trusted root."""
|
|
138
|
+
root = _absolute_path(destination.trusted_root)
|
|
139
|
+
path = _absolute_path(destination.path)
|
|
140
|
+
try:
|
|
141
|
+
relative = path.relative_to(root)
|
|
142
|
+
except ValueError as error:
|
|
143
|
+
raise RuntimeError(
|
|
144
|
+
f"{destination.label} escapes trusted root {root}: {path}"
|
|
145
|
+
) from error
|
|
146
|
+
|
|
147
|
+
candidates = [root]
|
|
148
|
+
current = root
|
|
149
|
+
for part in relative.parts:
|
|
150
|
+
current /= part
|
|
151
|
+
candidates.append(current)
|
|
152
|
+
|
|
153
|
+
for candidate in candidates:
|
|
154
|
+
if candidate.is_symlink():
|
|
155
|
+
raise RuntimeError(
|
|
156
|
+
f"Refusing symlinked {destination.label} path: {candidate}"
|
|
157
|
+
)
|
|
158
|
+
if not candidate.exists():
|
|
159
|
+
continue
|
|
160
|
+
if candidate == path:
|
|
161
|
+
if not candidate.is_file():
|
|
162
|
+
raise RuntimeError(
|
|
163
|
+
f"{destination.label} destination is not a file: {candidate}"
|
|
164
|
+
)
|
|
165
|
+
elif not candidate.is_dir():
|
|
166
|
+
raise RuntimeError(
|
|
167
|
+
f"{destination.label} ancestor is not a directory: {candidate}"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _json_bytes(data: dict, indent: int = 4) -> bytes:
|
|
172
|
+
return (json.dumps(data, indent=indent, ensure_ascii=False) + "\n").encode()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class _MalformedDestinationJson(ValueError):
|
|
176
|
+
def __init__(self, path: Path, error: Exception | str) -> None:
|
|
177
|
+
super().__init__(f"malformed JSON in {path}: {error}")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _load_destination_json(content: bytes | None, path: Path) -> dict:
|
|
181
|
+
"""Parse configuration bytes captured by the active transaction."""
|
|
182
|
+
if content is None:
|
|
183
|
+
return {}
|
|
184
|
+
try:
|
|
185
|
+
data = json.loads(content.decode("utf-8"))
|
|
186
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
187
|
+
raise _MalformedDestinationJson(path, error) from error
|
|
188
|
+
if not isinstance(data, dict):
|
|
189
|
+
raise _MalformedDestinationJson(path, "top-level value must be an object")
|
|
190
|
+
return data
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _run_transaction(
|
|
194
|
+
destinations: list[_Destination],
|
|
195
|
+
mutation: Callable[[SecureTransaction], None],
|
|
196
|
+
) -> None:
|
|
197
|
+
"""Apply through pinned parent fds with byte-for-byte rollback."""
|
|
198
|
+
_require_secure_mutation_support()
|
|
199
|
+
run_secure_transaction(destinations, mutation)
|
|
97
200
|
|
|
98
201
|
|
|
99
202
|
# ---------------------------------------------------------------------------
|
|
100
203
|
# URL helpers
|
|
101
204
|
# ---------------------------------------------------------------------------
|
|
102
205
|
|
|
206
|
+
|
|
103
207
|
def _is_url(source: str) -> bool:
|
|
104
208
|
"""Check if source looks like an HTTP(S) URL."""
|
|
105
209
|
return source.startswith("https://") or source.startswith("http://")
|
|
@@ -117,6 +221,7 @@ def _name_from_url(url: str) -> str:
|
|
|
117
221
|
# Core logic
|
|
118
222
|
# ---------------------------------------------------------------------------
|
|
119
223
|
|
|
224
|
+
|
|
120
225
|
def _entry_source(entry: dict) -> str | None:
|
|
121
226
|
"""Return the ``_source`` tag of a hook entry, checking nested hooks too.
|
|
122
227
|
|
|
@@ -141,15 +246,17 @@ def _entry_signature(entry: dict) -> tuple:
|
|
|
141
246
|
if not isinstance(hook, dict):
|
|
142
247
|
handlers.append(hook)
|
|
143
248
|
continue
|
|
144
|
-
handlers.append(
|
|
145
|
-
(
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
)
|
|
249
|
+
handlers.append(
|
|
250
|
+
tuple(
|
|
251
|
+
sorted((key, value) for key, value in hook.items() if key != "_source")
|
|
252
|
+
)
|
|
253
|
+
)
|
|
149
254
|
return (entry.get("matcher", ""), tuple(handlers))
|
|
150
255
|
|
|
151
256
|
|
|
152
|
-
def strip_source(
|
|
257
|
+
def strip_source(
|
|
258
|
+
hooks: dict, source: str, replacement_hooks: dict | None = None
|
|
259
|
+
) -> dict:
|
|
153
260
|
"""Remove all entries whose ``_source`` matches *source*.
|
|
154
261
|
|
|
155
262
|
Args:
|
|
@@ -167,16 +274,15 @@ def strip_source(hooks: dict, source: str, replacement_hooks: dict | None = None
|
|
|
167
274
|
if replacement_hooks:
|
|
168
275
|
for event, entries in replacement_hooks.items():
|
|
169
276
|
legacy_signatures[event] = {
|
|
170
|
-
_entry_signature(entry)
|
|
171
|
-
for entry in entries
|
|
172
|
-
if isinstance(entry, dict)
|
|
277
|
+
_entry_signature(entry) for entry in entries if isinstance(entry, dict)
|
|
173
278
|
}
|
|
174
279
|
|
|
175
280
|
result: dict = {}
|
|
176
281
|
for event, entries in hooks.items():
|
|
177
282
|
signatures = legacy_signatures.get(event, set())
|
|
178
283
|
filtered = [
|
|
179
|
-
e
|
|
284
|
+
e
|
|
285
|
+
for e in entries
|
|
180
286
|
if _entry_source(e) != source
|
|
181
287
|
and not (
|
|
182
288
|
isinstance(e, dict)
|
|
@@ -236,85 +342,449 @@ def merge_hooks(new_hooks: dict, existing_hooks: dict, source: str) -> dict:
|
|
|
236
342
|
# Codex propagation
|
|
237
343
|
# ---------------------------------------------------------------------------
|
|
238
344
|
|
|
239
|
-
def _codex_hooks_path(target_dir: str) -> Path:
|
|
240
|
-
"""Return the global Codex hooks.json path."""
|
|
241
|
-
return Path(target_dir) / ".codex" / "hooks.json"
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
def _filter_codex_events(hooks: dict) -> dict:
|
|
245
|
-
"""Keep only events supported by Codex CLI."""
|
|
246
|
-
return {event: entries for event, entries in hooks.items()
|
|
247
|
-
if event in CODEX_EVENTS}
|
|
248
345
|
|
|
346
|
+
def _codex_hooks_path(target_dir: str) -> Path:
|
|
347
|
+
"""Return the active user-level Codex hooks path.
|
|
249
348
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
skipped.
|
|
349
|
+
``CODEX_HOME`` replaces the default ``~/.codex`` root. The configured path
|
|
350
|
+
must follow Codex's documented contract: absolute, existing, and a real
|
|
351
|
+
directory. The public Codex writer performs the final per-path symlink
|
|
352
|
+
checks before replacing any bytes.
|
|
255
353
|
"""
|
|
256
|
-
|
|
257
|
-
if not
|
|
258
|
-
return
|
|
354
|
+
configured = os.environ.get("CODEX_HOME", "").strip()
|
|
355
|
+
if not configured:
|
|
356
|
+
return _absolute_path(target_dir) / ".codex" / "hooks.json"
|
|
357
|
+
|
|
358
|
+
codex_home = Path(configured).expanduser()
|
|
359
|
+
if not codex_home.is_absolute():
|
|
360
|
+
raise RuntimeError("CODEX_HOME must be an absolute path")
|
|
361
|
+
if not codex_home.exists():
|
|
362
|
+
raise RuntimeError(f"Configured CODEX_HOME does not exist: {codex_home}")
|
|
363
|
+
if not codex_home.is_dir():
|
|
364
|
+
raise RuntimeError(f"Configured CODEX_HOME is not a directory: {codex_home}")
|
|
365
|
+
if codex_home.is_symlink():
|
|
366
|
+
raise RuntimeError(f"Refusing symlinked CODEX_HOME: {codex_home}")
|
|
367
|
+
return _absolute_path(codex_home) / "hooks.json"
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _codex_destination(target_root: Path) -> _Destination:
|
|
371
|
+
"""Resolve and preflight the active Codex hooks destination."""
|
|
372
|
+
hooks_path = _codex_hooks_path(str(target_root))
|
|
373
|
+
configured = os.environ.get("CODEX_HOME", "").strip()
|
|
374
|
+
trusted_root = hooks_path.parent if configured else target_root
|
|
375
|
+
destination = _Destination(hooks_path, trusted_root, "Codex hooks")
|
|
376
|
+
_assert_safe_destination(destination)
|
|
377
|
+
return destination
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _codex_owner(source: str) -> str:
|
|
381
|
+
"""Build a collision-resistant, shell-safe owner marker for a source."""
|
|
382
|
+
slug = re.sub(r"[^a-z0-9]+", "-", source.lower()).strip("-") or "source"
|
|
383
|
+
slug = slug[:48].rstrip("-") or "source"
|
|
384
|
+
digest = hashlib.sha256(source.encode("utf-8")).hexdigest()[:12]
|
|
385
|
+
return f"{CODEX_OWNER_PREFIX}-{slug}-{digest}"
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _has_codex_owner(command: str, owner: str) -> bool:
|
|
389
|
+
match = CODEX_OWNER_PATTERN.search(command)
|
|
390
|
+
return match is not None and match.group("owner") == owner
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _owned_codex_command(command: str, owner: str) -> str:
|
|
394
|
+
"""Attach native ownership without allowing an injected owner conflict."""
|
|
395
|
+
match = CODEX_OWNER_PATTERN.search(command)
|
|
396
|
+
if match is not None:
|
|
397
|
+
raise ValueError(
|
|
398
|
+
"External Codex hook commands must not set AI_TOOLKIT_HOOK_OWNER"
|
|
399
|
+
)
|
|
400
|
+
return f"AI_TOOLKIT_HOOK_OWNER={owner} {command}"
|
|
259
401
|
|
|
260
|
-
codex_path = _codex_hooks_path(target_dir)
|
|
261
|
-
codex_path.parent.mkdir(parents=True, exist_ok=True)
|
|
262
402
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
existing = data.get("hooks", {})
|
|
268
|
-
except (json.JSONDecodeError, OSError):
|
|
269
|
-
existing = {}
|
|
403
|
+
def _translate_codex_hooks(hooks: dict, source: str) -> tuple[dict, list[str]]:
|
|
404
|
+
"""Translate the unambiguous command-only subset to native Codex groups."""
|
|
405
|
+
if not isinstance(hooks, dict):
|
|
406
|
+
raise ValueError("hooks must be an object")
|
|
270
407
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
408
|
+
owner = _codex_owner(source)
|
|
409
|
+
translated: dict[str, list[dict]] = {}
|
|
410
|
+
skipped: list[str] = []
|
|
411
|
+
for event, groups in hooks.items():
|
|
412
|
+
if event not in CODEX_EVENTS:
|
|
413
|
+
skipped.append(f"event {event!r} is not supported by Codex")
|
|
414
|
+
continue
|
|
415
|
+
if not isinstance(groups, list):
|
|
416
|
+
raise ValueError(f"Hook event {event} must contain a list")
|
|
417
|
+
|
|
418
|
+
translated_groups: list[dict] = []
|
|
419
|
+
for index, group in enumerate(groups):
|
|
420
|
+
if not isinstance(group, dict):
|
|
421
|
+
raise ValueError(f"Hook event {event} group {index} must be an object")
|
|
422
|
+
unknown_group_keys = set(group) - CODEX_NATIVE_GROUP_KEYS - {"_source"}
|
|
423
|
+
if unknown_group_keys:
|
|
424
|
+
skipped.append(
|
|
425
|
+
f"{event} group {index} uses unsupported fields "
|
|
426
|
+
f"{sorted(unknown_group_keys)}"
|
|
427
|
+
)
|
|
428
|
+
continue
|
|
429
|
+
|
|
430
|
+
handlers = group.get("hooks")
|
|
431
|
+
if not isinstance(handlers, list) or not handlers:
|
|
432
|
+
raise ValueError(f"Hook event {event} group {index} needs handlers")
|
|
433
|
+
native_handlers: list[dict] = []
|
|
434
|
+
for handler_index, handler in enumerate(handlers):
|
|
435
|
+
if not isinstance(handler, dict):
|
|
436
|
+
raise ValueError(
|
|
437
|
+
f"Hook event {event} handler {handler_index} must be an object"
|
|
438
|
+
)
|
|
439
|
+
unknown_handler_keys = (
|
|
440
|
+
set(handler) - CODEX_NATIVE_HANDLER_KEYS - {"_source"}
|
|
441
|
+
)
|
|
442
|
+
if unknown_handler_keys:
|
|
443
|
+
skipped.append(
|
|
444
|
+
f"{event} handler {handler_index} uses unsupported fields "
|
|
445
|
+
f"{sorted(unknown_handler_keys)}"
|
|
446
|
+
)
|
|
447
|
+
continue
|
|
448
|
+
if handler.get("type") != "command":
|
|
449
|
+
skipped.append(
|
|
450
|
+
f"{event} handler {handler_index} is not a command handler"
|
|
451
|
+
)
|
|
452
|
+
continue
|
|
453
|
+
command = handler.get("command")
|
|
454
|
+
if not isinstance(command, str) or not command.strip():
|
|
455
|
+
raise ValueError(
|
|
456
|
+
f"Hook event {event} handler {handler_index} needs a command"
|
|
457
|
+
)
|
|
458
|
+
native_handler = {
|
|
459
|
+
key: value
|
|
460
|
+
for key, value in handler.items()
|
|
461
|
+
if key in CODEX_NATIVE_HANDLER_KEYS
|
|
462
|
+
}
|
|
463
|
+
native_handler["command"] = _owned_codex_command(command, owner)
|
|
464
|
+
native_handlers.append(native_handler)
|
|
465
|
+
|
|
466
|
+
if not native_handlers:
|
|
467
|
+
continue
|
|
468
|
+
native_group: dict = {"hooks": native_handlers}
|
|
469
|
+
matcher = group.get("matcher")
|
|
470
|
+
if matcher not in (None, ""):
|
|
471
|
+
if event in {"UserPromptSubmit", "Stop"}:
|
|
472
|
+
skipped.append(f"{event} does not support a matcher in Codex")
|
|
473
|
+
continue
|
|
474
|
+
native_group["matcher"] = matcher
|
|
475
|
+
translated_groups.append(native_group)
|
|
476
|
+
|
|
477
|
+
if translated_groups:
|
|
478
|
+
translated[event] = translated_groups
|
|
479
|
+
return translated, skipped
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _migrate_legacy_codex_sources(data: dict) -> dict:
|
|
483
|
+
"""Convert the invalid ``_source`` ownership emitted by older releases."""
|
|
484
|
+
if not isinstance(data, dict) or not isinstance(data.get("hooks", {}), dict):
|
|
485
|
+
return data
|
|
486
|
+
|
|
487
|
+
for event, groups in data.get("hooks", {}).items():
|
|
488
|
+
if not isinstance(groups, list):
|
|
489
|
+
continue
|
|
490
|
+
for group in groups:
|
|
491
|
+
if not isinstance(group, dict) or "_source" not in group:
|
|
492
|
+
continue
|
|
493
|
+
source = group.get("_source")
|
|
494
|
+
if not isinstance(source, str) or not source:
|
|
495
|
+
continue
|
|
496
|
+
if source == PROTECTED_SOURCE:
|
|
497
|
+
owner = PROTECTED_SOURCE
|
|
498
|
+
elif re.fullmatch(r"ai-toolkit-plugin-[a-z0-9][a-z0-9-]*", source):
|
|
499
|
+
owner = source
|
|
500
|
+
else:
|
|
501
|
+
owner = _codex_owner(source)
|
|
502
|
+
handlers = group.get("hooks", [])
|
|
503
|
+
if not isinstance(handlers, list):
|
|
504
|
+
continue
|
|
505
|
+
for handler in handlers:
|
|
506
|
+
if not isinstance(handler, dict) or handler.get("type") != "command":
|
|
507
|
+
continue
|
|
508
|
+
command = handler.get("command")
|
|
509
|
+
if not isinstance(command, str) or not command.strip():
|
|
510
|
+
continue
|
|
511
|
+
match = CODEX_OWNER_PATTERN.search(command)
|
|
512
|
+
if match is not None and match.group("owner") != owner:
|
|
513
|
+
raise ValueError(
|
|
514
|
+
f"Legacy Codex {event} owner conflicts with its command marker"
|
|
515
|
+
)
|
|
516
|
+
if match is None:
|
|
517
|
+
handler["command"] = f"AI_TOOLKIT_HOOK_OWNER={owner} {command}"
|
|
518
|
+
matcher = group.get("matcher")
|
|
519
|
+
if matcher == "":
|
|
520
|
+
group.pop("matcher", None)
|
|
521
|
+
elif event in {"UserPromptSubmit", "Stop"} and matcher is not None:
|
|
522
|
+
raise ValueError(
|
|
523
|
+
f"Legacy Codex {event} hook has an unsupported matcher"
|
|
524
|
+
)
|
|
525
|
+
del group["_source"]
|
|
526
|
+
return data
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _has_legacy_source(data: object) -> bool:
|
|
530
|
+
if isinstance(data, dict):
|
|
531
|
+
return "_source" in data or any(
|
|
532
|
+
_has_legacy_source(value) for value in data.values()
|
|
533
|
+
)
|
|
534
|
+
if isinstance(data, list):
|
|
535
|
+
return any(_has_legacy_source(value) for value in data)
|
|
536
|
+
return False
|
|
275
537
|
|
|
276
538
|
|
|
277
|
-
def
|
|
278
|
-
"""
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
539
|
+
def _load_codex_hooks_bytes(content: bytes | None, path: Path) -> dict:
|
|
540
|
+
"""Parse a pinned snapshot and migrate only the known legacy shape."""
|
|
541
|
+
from generate_codex_hooks import (
|
|
542
|
+
parse_hooks_json_bytes,
|
|
543
|
+
validate_hooks_document,
|
|
544
|
+
)
|
|
282
545
|
|
|
283
546
|
try:
|
|
284
|
-
|
|
285
|
-
except
|
|
547
|
+
return parse_hooks_json_bytes(content, path)
|
|
548
|
+
except ValueError as validation_error:
|
|
549
|
+
if content is None:
|
|
550
|
+
raise
|
|
551
|
+
try:
|
|
552
|
+
data = json.loads(content.decode("utf-8"))
|
|
553
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
554
|
+
raise validation_error
|
|
555
|
+
if not _has_legacy_source(data):
|
|
556
|
+
raise validation_error
|
|
557
|
+
migrated = _migrate_legacy_codex_sources(data)
|
|
558
|
+
validate_hooks_document(migrated)
|
|
559
|
+
return migrated
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _strip_codex_owner(data: dict, owner: str) -> bool:
|
|
563
|
+
"""Remove only handlers carrying the exact external-source owner marker."""
|
|
564
|
+
changed = False
|
|
565
|
+
hooks = data.get("hooks", {})
|
|
566
|
+
for event in list(hooks):
|
|
567
|
+
retained_groups: list[dict] = []
|
|
568
|
+
for group in hooks[event]:
|
|
569
|
+
handlers = group.get("hooks", [])
|
|
570
|
+
retained = [
|
|
571
|
+
handler
|
|
572
|
+
for handler in handlers
|
|
573
|
+
if not _has_codex_owner(handler.get("command", ""), owner)
|
|
574
|
+
]
|
|
575
|
+
if len(retained) != len(handlers):
|
|
576
|
+
changed = True
|
|
577
|
+
if retained:
|
|
578
|
+
retained_group = dict(group)
|
|
579
|
+
retained_group["hooks"] = retained
|
|
580
|
+
retained_groups.append(retained_group)
|
|
581
|
+
if retained_groups:
|
|
582
|
+
hooks[event] = retained_groups
|
|
583
|
+
else:
|
|
584
|
+
del hooks[event]
|
|
585
|
+
return changed
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
@dataclass(frozen=True)
|
|
589
|
+
class _CodexUpdate:
|
|
590
|
+
destination: _Destination
|
|
591
|
+
data: dict
|
|
592
|
+
message: str
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _prepare_codex_injection(
|
|
596
|
+
codex_hooks: dict,
|
|
597
|
+
source: str,
|
|
598
|
+
target_root: Path,
|
|
599
|
+
content: bytes | None,
|
|
600
|
+
) -> _CodexUpdate | None:
|
|
601
|
+
"""Merge native Codex hooks from a transaction-pinned snapshot."""
|
|
602
|
+
destination = _codex_destination(target_root)
|
|
603
|
+
if not codex_hooks and content is None:
|
|
286
604
|
return
|
|
287
605
|
|
|
288
|
-
|
|
289
|
-
|
|
606
|
+
data = _load_codex_hooks_bytes(content, destination.path)
|
|
607
|
+
changed = _strip_codex_owner(data, _codex_owner(source))
|
|
608
|
+
for event, groups in codex_hooks.items():
|
|
609
|
+
data.setdefault("hooks", {}).setdefault(event, []).extend(groups)
|
|
610
|
+
changed = True
|
|
611
|
+
if not changed:
|
|
612
|
+
return
|
|
290
613
|
|
|
291
|
-
|
|
292
|
-
save_json(str(codex_path), {"hooks": cleaned})
|
|
293
|
-
else:
|
|
294
|
-
save_json(str(codex_path), {"hooks": {}})
|
|
614
|
+
from generate_codex_hooks import validate_hooks_document
|
|
295
615
|
|
|
296
|
-
|
|
616
|
+
validate_hooks_document(data)
|
|
617
|
+
events = ", ".join(sorted(codex_hooks.keys()))
|
|
618
|
+
action = f"events: {events}" if events else "removed stale source handlers"
|
|
619
|
+
return _CodexUpdate(
|
|
620
|
+
destination,
|
|
621
|
+
data,
|
|
622
|
+
f"Propagated to Codex: {destination.path} ({action})",
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _prepare_codex_removal(
|
|
627
|
+
source_name: str,
|
|
628
|
+
target_root: Path,
|
|
629
|
+
content: bytes | None,
|
|
630
|
+
) -> _CodexUpdate | None:
|
|
631
|
+
"""Prepare an exact removal from a transaction-pinned snapshot."""
|
|
632
|
+
destination = _codex_destination(target_root)
|
|
633
|
+
if content is None:
|
|
634
|
+
return None
|
|
635
|
+
data = _load_codex_hooks_bytes(content, destination.path)
|
|
636
|
+
if not _strip_codex_owner(data, _codex_owner(source_name)):
|
|
637
|
+
return None
|
|
638
|
+
from generate_codex_hooks import validate_hooks_document
|
|
639
|
+
|
|
640
|
+
validate_hooks_document(data)
|
|
641
|
+
return _CodexUpdate(
|
|
642
|
+
destination,
|
|
643
|
+
data,
|
|
644
|
+
f"Removed '{source_name}' from Codex: {destination.path}",
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _write_codex_update(
|
|
649
|
+
update: _CodexUpdate,
|
|
650
|
+
transaction: SecureTransaction | None = None,
|
|
651
|
+
) -> None:
|
|
652
|
+
_require_secure_mutation_support()
|
|
653
|
+
from generate_codex_hooks import write_hooks_json
|
|
654
|
+
|
|
655
|
+
_assert_safe_destination(update.destination)
|
|
656
|
+
write_hooks_json(
|
|
657
|
+
update.destination.path,
|
|
658
|
+
update.data,
|
|
659
|
+
transaction=transaction,
|
|
660
|
+
trusted_root=update.destination.trusted_root,
|
|
661
|
+
)
|
|
297
662
|
|
|
298
663
|
|
|
299
664
|
# ---------------------------------------------------------------------------
|
|
300
665
|
# CLI actions
|
|
301
666
|
# ---------------------------------------------------------------------------
|
|
302
667
|
|
|
303
|
-
def _fetch_and_cache(url: str, source: str) -> str:
|
|
304
|
-
"""Fetch hooks JSON from URL, cache locally, register source.
|
|
305
668
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
669
|
+
def _validate_source_name(source: str) -> str:
|
|
670
|
+
"""Reject reserved or path-like source names before building destinations."""
|
|
671
|
+
if source == PROTECTED_SOURCE:
|
|
672
|
+
raise ValueError(f"source name '{PROTECTED_SOURCE}' is reserved")
|
|
673
|
+
if SOURCE_NAME_PATTERN.fullmatch(source) is None:
|
|
674
|
+
raise ValueError(
|
|
675
|
+
"hook source names may contain only letters, numbers, '_' and '-'"
|
|
676
|
+
)
|
|
677
|
+
return source
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
@dataclass(frozen=True)
|
|
681
|
+
class _RegistryUpdate:
|
|
682
|
+
destination: _Destination
|
|
683
|
+
data: dict
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _registry_destinations(source: str) -> tuple[_Destination, _Destination]:
|
|
687
|
+
"""Return safe source-registry and URL-cache destinations."""
|
|
688
|
+
from paths import EXTERNAL_HOOKS_DIR, TOOLKIT_DATA_DIR
|
|
689
|
+
|
|
690
|
+
toolkit_root = _absolute_path(TOOLKIT_DATA_DIR)
|
|
691
|
+
trusted_root = _nearest_existing_root(toolkit_root)
|
|
692
|
+
registry = _Destination(
|
|
693
|
+
_absolute_path(EXTERNAL_HOOKS_DIR / "sources.json"),
|
|
694
|
+
trusted_root,
|
|
695
|
+
"hook source registry",
|
|
696
|
+
)
|
|
697
|
+
cache = _Destination(
|
|
698
|
+
_absolute_path(EXTERNAL_HOOKS_DIR / f"{source}.json"),
|
|
699
|
+
trusted_root,
|
|
700
|
+
"hook URL cache",
|
|
701
|
+
)
|
|
702
|
+
_assert_safe_destination(registry)
|
|
703
|
+
_assert_safe_destination(cache)
|
|
704
|
+
return registry, cache
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def _load_sources_bytes(content: bytes | None) -> dict[str, dict]:
|
|
708
|
+
"""Mirror hook_sources.load_sources using transaction-pinned bytes."""
|
|
709
|
+
if content is None:
|
|
710
|
+
return {}
|
|
711
|
+
try:
|
|
712
|
+
data = json.loads(content.decode("utf-8"))
|
|
713
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
714
|
+
return {}
|
|
715
|
+
if not isinstance(data, dict) or not isinstance(data.get("hooks", {}), dict):
|
|
716
|
+
return {}
|
|
717
|
+
return dict(data.get("hooks", {}))
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def _prepare_registry_update(
|
|
721
|
+
source: str,
|
|
722
|
+
content: bytes,
|
|
723
|
+
registry_content: bytes | None,
|
|
724
|
+
*,
|
|
725
|
+
source_path: Path | None = None,
|
|
726
|
+
url: str | None = None,
|
|
727
|
+
) -> tuple[_RegistryUpdate | None, _Destination]:
|
|
728
|
+
"""Build source metadata without mutating the registry or cache."""
|
|
729
|
+
registry, cache = _registry_destinations(source)
|
|
730
|
+
sources = _load_sources_bytes(registry_content)
|
|
731
|
+
existing = sources.get(source) or {}
|
|
732
|
+
if url is None and "url" in existing:
|
|
733
|
+
return None, cache
|
|
734
|
+
|
|
735
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
736
|
+
digest = hashlib.sha256(content).hexdigest()
|
|
737
|
+
if url is not None:
|
|
738
|
+
previous_digest = existing.get("sha256")
|
|
739
|
+
if previous_digest and previous_digest != digest:
|
|
740
|
+
print(
|
|
741
|
+
f" CHECKSUM CHANGED: hook '{source}' sha256 "
|
|
742
|
+
f"{previous_digest[:12]}... -> {digest[:12]}..."
|
|
743
|
+
)
|
|
744
|
+
if os.environ.get("AI_TOOLKIT_STRICT_PIN") == "1":
|
|
745
|
+
raise SystemExit(
|
|
746
|
+
f"Refusing to update '{source}' under AI_TOOLKIT_STRICT_PIN=1."
|
|
747
|
+
)
|
|
748
|
+
entry = {"url": url, "fetched_at": timestamp, "sha256": digest}
|
|
749
|
+
else:
|
|
750
|
+
assert source_path is not None
|
|
751
|
+
entry = {
|
|
752
|
+
"path": str(source_path.resolve()),
|
|
753
|
+
"fetched_at": timestamp,
|
|
754
|
+
"sha256": digest,
|
|
755
|
+
}
|
|
756
|
+
sources[source] = entry
|
|
757
|
+
return _RegistryUpdate(
|
|
758
|
+
registry,
|
|
759
|
+
{"schema_version": 1, "hooks": sources},
|
|
760
|
+
), cache
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def _prepare_registry_removal(
|
|
764
|
+
source: str,
|
|
765
|
+
registry_content: bytes | None,
|
|
766
|
+
) -> tuple[_RegistryUpdate | None, _Destination, bool]:
|
|
767
|
+
"""Prepare registry removal and report whether the entry owns a URL cache."""
|
|
768
|
+
registry, cache = _registry_destinations(source)
|
|
769
|
+
sources = _load_sources_bytes(registry_content)
|
|
770
|
+
existing = sources.get(source)
|
|
771
|
+
if existing is None:
|
|
772
|
+
return None, cache, False
|
|
773
|
+
was_url = isinstance(existing, dict) and "url" in existing
|
|
774
|
+
del sources[source]
|
|
775
|
+
return (
|
|
776
|
+
_RegistryUpdate(
|
|
777
|
+
registry,
|
|
778
|
+
{"schema_version": 1, "hooks": sources},
|
|
779
|
+
),
|
|
780
|
+
cache,
|
|
781
|
+
was_url,
|
|
782
|
+
)
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def _fetch_url(url: str) -> bytes:
|
|
786
|
+
"""Fetch and validate a remote hooks document without changing local state."""
|
|
313
787
|
from url_fetch import fetch_url
|
|
314
|
-
from hook_sources import register_url_source
|
|
315
|
-
from paths import EXTERNAL_HOOKS_DIR
|
|
316
|
-
|
|
317
|
-
EXTERNAL_HOOKS_DIR.mkdir(parents=True, exist_ok=True)
|
|
318
788
|
|
|
319
789
|
try:
|
|
320
790
|
data = fetch_url(url)
|
|
@@ -331,12 +801,7 @@ def _fetch_and_cache(url: str, source: str) -> str:
|
|
|
331
801
|
|
|
332
802
|
if "hooks" not in parsed:
|
|
333
803
|
print("Warning: no 'hooks' key found in URL response", file=sys.stderr)
|
|
334
|
-
|
|
335
|
-
cached_path = EXTERNAL_HOOKS_DIR / f"{source}.json"
|
|
336
|
-
cached_path.write_bytes(data)
|
|
337
|
-
register_url_source(None, source, url, content=data)
|
|
338
|
-
|
|
339
|
-
return str(cached_path)
|
|
804
|
+
return data
|
|
340
805
|
|
|
341
806
|
|
|
342
807
|
def inject(hooks_file: str, target_dir: str, source_override: str = "") -> None:
|
|
@@ -347,7 +812,10 @@ def inject(hooks_file: str, target_dir: str, source_override: str = "") -> None:
|
|
|
347
812
|
target_dir: Directory containing ``.claude/settings.json``.
|
|
348
813
|
source_override: Explicit source name (overrides filename-derived name).
|
|
349
814
|
"""
|
|
815
|
+
_require_secure_mutation_support()
|
|
350
816
|
is_url = _is_url(hooks_file)
|
|
817
|
+
source_url: str | None = hooks_file if is_url else None
|
|
818
|
+
source_path: Path | None = None
|
|
351
819
|
|
|
352
820
|
if is_url:
|
|
353
821
|
if hooks_file.startswith("http://"):
|
|
@@ -361,41 +829,38 @@ def inject(hooks_file: str, target_dir: str, source_override: str = "") -> None:
|
|
|
361
829
|
source = re.sub(r"[^a-zA-Z0-9_-]", "", source)
|
|
362
830
|
if not source:
|
|
363
831
|
print(
|
|
364
|
-
"Error: could not derive hook name from URL. "
|
|
365
|
-
"Provide one explicitly.",
|
|
832
|
+
"Error: could not derive hook name from URL. Provide one explicitly.",
|
|
366
833
|
file=sys.stderr,
|
|
367
834
|
)
|
|
368
835
|
sys.exit(1)
|
|
369
836
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
)
|
|
837
|
+
try:
|
|
838
|
+
_validate_source_name(source)
|
|
839
|
+
except ValueError as error:
|
|
840
|
+
print(f"Error: {error}", file=sys.stderr)
|
|
375
841
|
sys.exit(1)
|
|
376
|
-
|
|
377
|
-
hooks_file = _fetch_and_cache(hooks_file, source)
|
|
378
|
-
print(f"Fetched hooks from URL (source: '{source}')")
|
|
842
|
+
hooks_content = _fetch_url(hooks_file)
|
|
379
843
|
else:
|
|
380
844
|
# Derive source name from filename stem
|
|
381
845
|
source = source_override or Path(hooks_file).stem
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
846
|
+
try:
|
|
847
|
+
_validate_source_name(source)
|
|
848
|
+
except ValueError as error:
|
|
849
|
+
print(f"Error: {error}", file=sys.stderr)
|
|
850
|
+
sys.exit(1)
|
|
851
|
+
source_path = _absolute_path(hooks_file)
|
|
852
|
+
try:
|
|
853
|
+
hooks_content = source_path.read_bytes()
|
|
854
|
+
except OSError as error:
|
|
855
|
+
print(f"Error reading hooks file: {error}", file=sys.stderr)
|
|
388
856
|
sys.exit(1)
|
|
389
857
|
|
|
390
858
|
# Load the hooks file
|
|
391
859
|
try:
|
|
392
|
-
hooks_data =
|
|
860
|
+
hooks_data = json.loads(hooks_content)
|
|
393
861
|
except json.JSONDecodeError as exc:
|
|
394
862
|
print(f"Error: malformed JSON in {hooks_file}: {exc}", file=sys.stderr)
|
|
395
863
|
sys.exit(2)
|
|
396
|
-
except OSError as exc:
|
|
397
|
-
print(f"Error reading hooks file: {exc}", file=sys.stderr)
|
|
398
|
-
sys.exit(1)
|
|
399
864
|
|
|
400
865
|
new_hooks = hooks_data.get("hooks", {})
|
|
401
866
|
if not new_hooks:
|
|
@@ -405,42 +870,80 @@ def inject(hooks_file: str, target_dir: str, source_override: str = "") -> None:
|
|
|
405
870
|
# Tag entries with source
|
|
406
871
|
tagged = tag_entries(new_hooks, source)
|
|
407
872
|
|
|
408
|
-
#
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
873
|
+
# Prepare every destination before changing any file. The root is trusted;
|
|
874
|
+
# all descendants are checked component-by-component for symlinks.
|
|
875
|
+
target_root = _trusted_target_root(target_dir)
|
|
876
|
+
settings_path = target_root / ".claude" / "settings.json"
|
|
877
|
+
settings_destination = _Destination(
|
|
878
|
+
settings_path,
|
|
879
|
+
target_root,
|
|
880
|
+
"Claude settings",
|
|
881
|
+
)
|
|
882
|
+
_assert_safe_destination(settings_destination)
|
|
883
|
+
|
|
884
|
+
codex_hooks, skipped = _translate_codex_hooks(new_hooks, source)
|
|
885
|
+
for reason in skipped:
|
|
886
|
+
print(f"Skipped Codex hook: {reason}", file=sys.stderr)
|
|
887
|
+
codex_destination = _codex_destination(target_root)
|
|
888
|
+
include_codex = bool(codex_hooks) or codex_destination.path.exists()
|
|
889
|
+
registry_destination, cache_destination = _registry_destinations(source)
|
|
890
|
+
|
|
891
|
+
destinations = [settings_destination, registry_destination]
|
|
892
|
+
if is_url:
|
|
893
|
+
destinations.append(cache_destination)
|
|
894
|
+
if include_codex:
|
|
895
|
+
destinations.append(codex_destination)
|
|
424
896
|
|
|
425
|
-
|
|
426
|
-
settings["hooks"] = merge_hooks(tagged, existing_hooks, source)
|
|
427
|
-
save_json(str(settings_path), settings)
|
|
428
|
-
print(f"Injected hooks from '{source}' into {settings_path}")
|
|
897
|
+
result: dict[str, _CodexUpdate | None] = {"codex": None}
|
|
429
898
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
899
|
+
def apply_injection(transaction: SecureTransaction) -> None:
|
|
900
|
+
settings = _load_destination_json(
|
|
901
|
+
transaction.initial_content(settings_destination),
|
|
902
|
+
settings_path,
|
|
903
|
+
)
|
|
904
|
+
existing_hooks = settings.get("hooks", {})
|
|
905
|
+
settings["hooks"] = merge_hooks(tagged, existing_hooks, source)
|
|
906
|
+
registry_update, _ = _prepare_registry_update(
|
|
907
|
+
source,
|
|
908
|
+
hooks_content,
|
|
909
|
+
transaction.initial_content(registry_destination),
|
|
910
|
+
source_path=source_path,
|
|
911
|
+
url=source_url,
|
|
912
|
+
)
|
|
913
|
+
codex_update = (
|
|
914
|
+
_prepare_codex_injection(
|
|
915
|
+
codex_hooks,
|
|
916
|
+
source,
|
|
917
|
+
target_root,
|
|
918
|
+
transaction.initial_content(codex_destination),
|
|
919
|
+
)
|
|
920
|
+
if include_codex
|
|
921
|
+
else None
|
|
922
|
+
)
|
|
923
|
+
result["codex"] = codex_update
|
|
924
|
+
|
|
925
|
+
if is_url:
|
|
926
|
+
transaction.atomic_write(cache_destination, hooks_content)
|
|
927
|
+
if registry_update is not None:
|
|
928
|
+
transaction.atomic_write(
|
|
929
|
+
registry_update.destination,
|
|
930
|
+
_json_bytes(registry_update.data, indent=2),
|
|
438
931
|
)
|
|
439
|
-
|
|
440
|
-
|
|
932
|
+
transaction.atomic_write(settings_destination, _json_bytes(settings))
|
|
933
|
+
if codex_update is not None:
|
|
934
|
+
_write_codex_update(codex_update, transaction)
|
|
441
935
|
|
|
442
|
-
|
|
443
|
-
|
|
936
|
+
try:
|
|
937
|
+
_run_transaction(destinations, apply_injection)
|
|
938
|
+
except _MalformedDestinationJson as error:
|
|
939
|
+
print(f"Error: {error}", file=sys.stderr)
|
|
940
|
+
sys.exit(2)
|
|
941
|
+
if is_url:
|
|
942
|
+
print(f"Fetched hooks from URL (source: '{source}')")
|
|
943
|
+
print(f"Injected hooks from '{source}' into {settings_path}")
|
|
944
|
+
codex_update = result["codex"]
|
|
945
|
+
if isinstance(codex_update, _CodexUpdate):
|
|
946
|
+
print(codex_update.message)
|
|
444
947
|
|
|
445
948
|
|
|
446
949
|
def remove(source_name: str, target_dir: str) -> None:
|
|
@@ -452,63 +955,117 @@ def remove(source_name: str, target_dir: str) -> None:
|
|
|
452
955
|
source_name: The ``_source`` tag to remove.
|
|
453
956
|
target_dir: Directory containing ``.claude/settings.json``.
|
|
454
957
|
"""
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
958
|
+
_require_secure_mutation_support()
|
|
959
|
+
try:
|
|
960
|
+
_validate_source_name(source_name)
|
|
961
|
+
except ValueError as error:
|
|
962
|
+
suffix = (
|
|
963
|
+
" Use 'ai-toolkit uninstall' instead."
|
|
964
|
+
if source_name == PROTECTED_SOURCE
|
|
965
|
+
else ""
|
|
460
966
|
)
|
|
967
|
+
print(f"Error: {error}.{suffix}", file=sys.stderr)
|
|
461
968
|
sys.exit(1)
|
|
462
969
|
|
|
463
|
-
|
|
970
|
+
target_root = _trusted_target_root(target_dir)
|
|
971
|
+
settings_path = target_root / ".claude" / "settings.json"
|
|
972
|
+
settings_destination = _Destination(
|
|
973
|
+
settings_path,
|
|
974
|
+
target_root,
|
|
975
|
+
"Claude settings",
|
|
976
|
+
)
|
|
977
|
+
_assert_safe_destination(settings_destination)
|
|
978
|
+
codex_destination = _codex_destination(target_root)
|
|
979
|
+
registry_destination, cache_destination = _registry_destinations(source_name)
|
|
980
|
+
include_settings = settings_path.is_file()
|
|
981
|
+
include_codex = codex_destination.path.is_file()
|
|
982
|
+
include_registry = registry_destination.path.is_file()
|
|
983
|
+
destinations: list[_Destination] = []
|
|
984
|
+
if include_settings:
|
|
985
|
+
destinations.append(settings_destination)
|
|
986
|
+
if include_codex:
|
|
987
|
+
destinations.append(codex_destination)
|
|
988
|
+
if include_registry:
|
|
989
|
+
destinations.append(registry_destination)
|
|
990
|
+
destinations.append(cache_destination)
|
|
991
|
+
|
|
992
|
+
result: dict[str, object] = {
|
|
993
|
+
"settings": None,
|
|
994
|
+
"codex": None,
|
|
995
|
+
"registry": None,
|
|
996
|
+
}
|
|
464
997
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
998
|
+
def apply_removal(transaction: SecureTransaction) -> None:
|
|
999
|
+
settings_update: dict | None = None
|
|
1000
|
+
if include_settings:
|
|
1001
|
+
settings_content = transaction.initial_content(settings_destination)
|
|
1002
|
+
if settings_content is not None:
|
|
1003
|
+
settings = _load_destination_json(settings_content, settings_path)
|
|
1004
|
+
existing_hooks = settings.get("hooks", {})
|
|
1005
|
+
cleaned = strip_source(existing_hooks, source_name)
|
|
1006
|
+
if cleaned:
|
|
1007
|
+
settings["hooks"] = cleaned
|
|
1008
|
+
else:
|
|
1009
|
+
settings.pop("hooks", None)
|
|
1010
|
+
settings_update = settings
|
|
1011
|
+
codex_update = (
|
|
1012
|
+
_prepare_codex_removal(
|
|
1013
|
+
source_name,
|
|
1014
|
+
target_root,
|
|
1015
|
+
transaction.initial_content(codex_destination),
|
|
1016
|
+
)
|
|
1017
|
+
if include_codex
|
|
1018
|
+
else None
|
|
1019
|
+
)
|
|
1020
|
+
registry_update, _, remove_cache = (
|
|
1021
|
+
_prepare_registry_removal(
|
|
1022
|
+
source_name,
|
|
1023
|
+
transaction.initial_content(registry_destination),
|
|
1024
|
+
)
|
|
1025
|
+
if include_registry
|
|
1026
|
+
else (None, cache_destination, False)
|
|
1027
|
+
)
|
|
1028
|
+
result["settings"] = settings_update
|
|
1029
|
+
result["codex"] = codex_update
|
|
1030
|
+
result["registry"] = registry_update
|
|
1031
|
+
|
|
1032
|
+
if settings_update is not None:
|
|
1033
|
+
transaction.atomic_write(
|
|
1034
|
+
settings_destination,
|
|
1035
|
+
_json_bytes(settings_update),
|
|
1036
|
+
)
|
|
1037
|
+
if codex_update is not None:
|
|
1038
|
+
_write_codex_update(codex_update, transaction)
|
|
1039
|
+
if registry_update is not None:
|
|
1040
|
+
transaction.atomic_write(
|
|
1041
|
+
registry_update.destination,
|
|
1042
|
+
_json_bytes(registry_update.data, indent=2),
|
|
1043
|
+
)
|
|
1044
|
+
if remove_cache:
|
|
1045
|
+
transaction.unlink(cache_destination)
|
|
468
1046
|
|
|
469
1047
|
try:
|
|
470
|
-
|
|
471
|
-
except
|
|
472
|
-
print(
|
|
473
|
-
f"Error: malformed JSON in {settings_path}: {exc}",
|
|
474
|
-
file=sys.stderr,
|
|
475
|
-
)
|
|
1048
|
+
_run_transaction(destinations, apply_removal)
|
|
1049
|
+
except _MalformedDestinationJson as error:
|
|
1050
|
+
print(f"Error: {error}", file=sys.stderr)
|
|
476
1051
|
sys.exit(2)
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
if cleaned:
|
|
482
|
-
settings["hooks"] = cleaned
|
|
1052
|
+
settings_update = result["settings"]
|
|
1053
|
+
if settings_update is not None:
|
|
1054
|
+
print(f"Removed hooks with source '{source_name}' from {settings_path}")
|
|
483
1055
|
else:
|
|
484
|
-
settings.
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
_remove_codex(source_name, target_dir)
|
|
491
|
-
|
|
492
|
-
# Unregister URL source if present
|
|
493
|
-
try:
|
|
494
|
-
from hook_sources import unregister_source
|
|
495
|
-
from paths import EXTERNAL_HOOKS_DIR
|
|
496
|
-
|
|
497
|
-
if unregister_source(None, source_name):
|
|
498
|
-
print(f"Unregistered URL source '{source_name}'")
|
|
499
|
-
|
|
500
|
-
# Remove cached file if exists
|
|
501
|
-
cached = EXTERNAL_HOOKS_DIR / f"{source_name}.json"
|
|
502
|
-
if cached.is_file():
|
|
503
|
-
cached.unlink()
|
|
504
|
-
except ImportError:
|
|
505
|
-
pass
|
|
1056
|
+
print(f"No settings.json found at {settings_path}")
|
|
1057
|
+
codex_update = result["codex"]
|
|
1058
|
+
if isinstance(codex_update, _CodexUpdate):
|
|
1059
|
+
print(codex_update.message)
|
|
1060
|
+
if isinstance(result["registry"], _RegistryUpdate):
|
|
1061
|
+
print(f"Unregistered hook source '{source_name}'")
|
|
506
1062
|
|
|
507
1063
|
|
|
508
1064
|
# ---------------------------------------------------------------------------
|
|
509
1065
|
# Argument parsing
|
|
510
1066
|
# ---------------------------------------------------------------------------
|
|
511
1067
|
|
|
1068
|
+
|
|
512
1069
|
def _parse_args(argv: list[str]) -> dict:
|
|
513
1070
|
"""Parse CLI arguments.
|
|
514
1071
|
|
|
@@ -545,15 +1102,23 @@ def _parse_args(argv: list[str]) -> dict:
|
|
|
545
1102
|
else:
|
|
546
1103
|
result["target_dir"] = arg
|
|
547
1104
|
elif positional == 1:
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
1105
|
+
# Preserve the historical two-positional form
|
|
1106
|
+
# ``<source> <target-dir>`` while honoring the documented
|
|
1107
|
+
# ``<source> <hook-name> [target-dir]`` form. Valid hook names
|
|
1108
|
+
# cannot begin with path sigils.
|
|
1109
|
+
looks_like_path = (
|
|
1110
|
+
arg.startswith(("/", "~", "."))
|
|
1111
|
+
or os.sep in arg
|
|
1112
|
+
or (os.altsep is not None and os.altsep in arg)
|
|
1113
|
+
or (
|
|
1114
|
+
not _is_url(result["source_file"])
|
|
1115
|
+
and Path(arg).expanduser().is_dir()
|
|
1116
|
+
)
|
|
1117
|
+
)
|
|
1118
|
+
if looks_like_path:
|
|
556
1119
|
result["target_dir"] = arg
|
|
1120
|
+
else:
|
|
1121
|
+
result["hook_name"] = arg
|
|
557
1122
|
elif positional == 2:
|
|
558
1123
|
result["target_dir"] = arg
|
|
559
1124
|
positional += 1
|
|
@@ -563,7 +1128,7 @@ def _parse_args(argv: list[str]) -> dict:
|
|
|
563
1128
|
|
|
564
1129
|
|
|
565
1130
|
def main() -> None:
|
|
566
|
-
"""Inject or remove external hooks in
|
|
1131
|
+
"""Inject or remove external hooks in Claude and Codex configs."""
|
|
567
1132
|
args = _parse_args(sys.argv[1:])
|
|
568
1133
|
|
|
569
1134
|
# -- remove mode ---------------------------------------------------------
|