@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,10 +1,12 @@
1
1
  #!/usr/bin/env python3
2
- """Inject external hooks into ~/.claude/settings.json.
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; we propagate the 9 that
53
- # generate_codex_hooks.py also wires (all except PostCompact, whose only hook
54
- # was the removed environment-snapshot probe). Keeping this in sync with the
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 load_json(path: str) -> dict:
75
- """Load and parse a JSON file.
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
- Returns:
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 save_json(path: str, data: dict) -> None:
88
- """Write a dictionary to a JSON file with 4-space indent and trailing newline.
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
- Args:
91
- path: Filesystem path for the output JSON file.
92
- data: Dictionary to serialize.
93
- """
94
- with open(path, "w") as f:
95
- json.dump(data, f, indent=4, ensure_ascii=False)
96
- f.write("\n")
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(tuple(sorted(
145
- (key, value)
146
- for key, value in hook.items()
147
- if key != "_source"
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(hooks: dict, source: str, replacement_hooks: dict | None = None) -> dict:
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 for e in entries
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
- def _inject_codex(tagged_hooks: dict, source: str, target_dir: str) -> None:
251
- """Propagate hook entries to ~/.codex/hooks.json (Codex global layer).
252
-
253
- Only events in CODEX_EVENTS are propagated. Non-Codex events are silently
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
- codex_hooks = _filter_codex_events(tagged_hooks)
257
- if not codex_hooks:
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
- existing: dict = {}
264
- if codex_path.is_file():
265
- try:
266
- data = load_json(str(codex_path))
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
- merged = merge_hooks(codex_hooks, existing, source)
272
- save_json(str(codex_path), {"hooks": merged})
273
- events = ", ".join(sorted(codex_hooks.keys()))
274
- print(f"Propagated to Codex: {codex_path} (events: {events})")
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 _remove_codex(source_name: str, target_dir: str) -> None:
278
- """Remove hook entries from ~/.codex/hooks.json."""
279
- codex_path = _codex_hooks_path(target_dir)
280
- if not codex_path.is_file():
281
- return
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
- data = load_json(str(codex_path))
285
- except (json.JSONDecodeError, OSError):
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
- existing = data.get("hooks", {})
289
- cleaned = strip_source(existing, source_name)
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
- if cleaned:
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
- print(f"Removed '{source_name}' from Codex: {codex_path}")
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
- Args:
307
- url: HTTPS URL to fetch.
308
- source: Source name for caching and registry.
309
-
310
- Returns:
311
- Path to the cached hooks JSON file.
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
- if source == PROTECTED_SOURCE:
371
- print(
372
- f"Error: source name '{PROTECTED_SOURCE}' is reserved.",
373
- file=sys.stderr,
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
- if source == PROTECTED_SOURCE:
383
- print(
384
- f"Error: source name '{PROTECTED_SOURCE}' is reserved. "
385
- "Rename your hooks file.",
386
- file=sys.stderr,
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 = load_json(hooks_file)
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
- # Load existing settings
409
- settings_dir = Path(target_dir) / ".claude"
410
- settings_path = settings_dir / "settings.json"
411
-
412
- settings: dict = {}
413
- if settings_path.is_file():
414
- try:
415
- settings = load_json(str(settings_path))
416
- except json.JSONDecodeError as exc:
417
- print(
418
- f"Error: malformed JSON in {settings_path}: {exc}",
419
- file=sys.stderr,
420
- )
421
- sys.exit(2)
422
- else:
423
- settings_dir.mkdir(parents=True, exist_ok=True)
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
- existing_hooks = settings.get("hooks", {})
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
- # Register local-file source so `status` and future updates can see it.
431
- # URL sources are already registered earlier inside _fetch_and_cache().
432
- if not is_url:
433
- try:
434
- from hook_sources import register_path_source
435
- register_path_source(
436
- None, source, Path(hooks_file),
437
- content=Path(hooks_file).read_bytes(),
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
- except Exception as exc: # registration failure must not block injection
440
- print(f"Warning: could not register local source: {exc}", file=sys.stderr)
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
- # Propagate Codex-compatible events to ~/.codex/hooks.json
443
- _inject_codex(tagged, source, target_dir)
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
- if source_name == PROTECTED_SOURCE:
456
- print(
457
- f"Error: cannot remove '{PROTECTED_SOURCE}' entries. "
458
- "Use 'ai-toolkit uninstall' instead.",
459
- file=sys.stderr,
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
- settings_path = Path(target_dir) / ".claude" / "settings.json"
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
- if not settings_path.is_file():
466
- print(f"No settings.json found at {settings_path}")
467
- return
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
- settings = load_json(str(settings_path))
471
- except json.JSONDecodeError as exc:
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
- existing_hooks = settings.get("hooks", {})
479
- cleaned = strip_source(existing_hooks, source_name)
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.pop("hooks", None)
485
-
486
- save_json(str(settings_path), settings)
487
- print(f"Removed hooks with source '{source_name}' from {settings_path}")
488
-
489
- # Remove from Codex global hooks
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
- if _is_url(result["source_file"]):
549
- # Second positional after URL could be hook-name or target-dir
550
- # If it looks like a path (starts with / or ~ or .), it's target-dir
551
- if arg.startswith(("/", "~", ".")):
552
- result["target_dir"] = arg
553
- else:
554
- result["hook_name"] = arg
555
- else:
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 settings.json."""
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 ---------------------------------------------------------