@softspark/ai-toolkit 4.14.0 → 4.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +35 -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/agents/fact-checker.md +1 -1
  6. package/app/hooks/_search-capability.sh +3 -2
  7. package/app/hooks/stop-search-check.sh +2 -1
  8. package/benchmarks/ecosystem-doctor-snapshot.json +73 -31
  9. package/kb/procedures/maintenance-sop.md +26 -13
  10. package/kb/procedures/release-verification-sop.md +41 -36
  11. package/kb/reference/architecture-overview.md +23 -7
  12. package/kb/reference/codex-cli-compatibility.md +96 -36
  13. package/kb/reference/extension-api.md +52 -9
  14. package/kb/reference/global-install-model.md +53 -21
  15. package/kb/reference/hooks-catalog.md +44 -8
  16. package/kb/reference/mcp-editor-compatibility.md +27 -6
  17. package/kb/reference/mcp-templates.md +12 -6
  18. package/kb/reference/opencode-compatibility.md +13 -7
  19. package/kb/reference/plugin-pack-conventions.md +7 -7
  20. package/kb/reference/skills-catalog.md +3 -3
  21. package/kb/reference/supported-tools-registry.md +19 -17
  22. package/kb/reference/windows-support.md +26 -3
  23. package/llms-full.txt +443 -180
  24. package/llms.txt +1 -1
  25. package/manifest.json +1 -1
  26. package/package.json +2 -2
  27. package/scripts/codex_skill_adapter.py +448 -198
  28. package/scripts/dir_rules_shared.py +2 -11
  29. package/scripts/ecosystem_tools.json +29 -8
  30. package/scripts/emission.py +5 -91
  31. package/scripts/generate_agents_md.py +4 -87
  32. package/scripts/generate_codex.py +5 -95
  33. package/scripts/generate_codex_agents.py +242 -0
  34. package/scripts/generate_codex_hooks.py +648 -55
  35. package/scripts/generate_codex_skills.py +15 -6
  36. package/scripts/generate_copilot.py +771 -74
  37. package/scripts/generate_copilot_hooks.py +606 -0
  38. package/scripts/generate_cursor_hooks.py +453 -121
  39. package/scripts/generate_opencode_commands.py +4 -6
  40. package/scripts/inject_hook_cli.py +770 -205
  41. package/scripts/injection.py +102 -23
  42. package/scripts/install_steps/ai_tools.py +123 -83
  43. package/scripts/instruction_core.py +95 -0
  44. package/scripts/mcp_editors.py +934 -80
  45. package/scripts/mcp_manager.py +46 -26
  46. package/scripts/plugin.py +291 -114
  47. package/scripts/secure_fs.py +538 -0
  48. package/scripts/uninstall.py +1279 -208
package/scripts/plugin.py CHANGED
@@ -23,39 +23,50 @@ Actions:
23
23
  from __future__ import annotations
24
24
 
25
25
  import json
26
+ import os
26
27
  import re
27
28
  import shutil
28
29
  import sqlite3 as sqlite
29
30
  import subprocess
30
31
  import sys
32
+ import tempfile
31
33
  from pathlib import Path
32
34
 
33
35
  sys.path.insert(0, str(Path(__file__).resolve().parent))
34
- from _common import app_dir, inject_section, inject_rule, remove_rule_section
36
+ from _common import app_dir, inject_section, remove_rule_section
35
37
  from injection import strip_section, trim_trailing_blanks
36
- from codex_skill_adapter import cleanup_codex_skills, sync_codex_skill
37
- from generate_codex_hooks import generate as generate_codex_hooks
38
+ from codex_skill_adapter import (
39
+ cleanup_codex_skills,
40
+ prepare_codex_skills_dir,
41
+ sync_codex_skill,
42
+ unmanaged_codex_skill_names,
43
+ )
44
+ from generate_codex_hooks import (
45
+ SUPPORTED_EVENTS as SUPPORTED_CODEX_HOOK_EVENTS,
46
+ TOOLKIT_COMMAND_MARKER,
47
+ generate as generate_codex_hooks,
48
+ load_hooks_json,
49
+ validate_hooks_document,
50
+ write_hooks_json,
51
+ )
38
52
  from install_steps.ai_tools import inject_with_rules
39
53
  from paths import HOOKS_DIR as _HOOKS_DIR
40
54
  from paths import RULES_DIR, TOOLKIT_DATA_DIR
41
- from plugin_schema import resolve_hook_event, validate_manifest, validate_references
55
+ from plugin_schema import resolve_hook_event
42
56
 
43
57
 
44
58
  PLUGINS_DIR = app_dir / "plugins"
45
59
  CLAUDE_DIR = Path.home() / ".claude"
46
60
  CODEX_ROOT = Path.home()
61
+ CODEX_HOME = Path(os.environ.get("CODEX_HOME", CODEX_ROOT / ".codex")).expanduser()
62
+ CODEX_HOOKS_DIR = CODEX_HOME / "ai-toolkit-hooks"
47
63
  HOOKS_DIR = _HOOKS_DIR
48
64
  PLUGINS_STATE_FILE = TOOLKIT_DATA_DIR / "plugins.json"
49
65
  MEMORY_DB = TOOLKIT_DATA_DIR / "memory.db"
50
66
 
51
67
  VALID_EDITORS = ("claude", "codex")
52
- SUPPORTED_CODEX_HOOK_EVENTS = frozenset({
53
- "SessionStart",
54
- "PreToolUse",
55
- "PostToolUse",
56
- "UserPromptSubmit",
57
- "Stop",
58
- })
68
+ CODEX_PLUGIN_NAME_PATTERN = re.compile(r"[a-z0-9][a-z0-9-]*")
69
+ CODEX_PLUGIN_ASSET_MARKER = "# ai-toolkit-managed: codex-plugin-hook"
59
70
 
60
71
 
61
72
  # ---------------------------------------------------------------------------
@@ -340,18 +351,6 @@ def _load_core_hook_matchers() -> dict[str, str]:
340
351
  CORE_HOOK_MATCHERS = _load_core_hook_matchers()
341
352
 
342
353
 
343
- def _hook_already_present(hooks_data: dict, hook_name: str) -> bool:
344
- for entries in hooks_data.get("hooks", {}).values():
345
- if not isinstance(entries, list):
346
- continue
347
- for entry in entries:
348
- for hook in entry.get("hooks", []):
349
- command = hook.get("command", "")
350
- if Path(command.replace("\"", "")).name == hook_name:
351
- return True
352
- return False
353
-
354
-
355
354
  # ---------------------------------------------------------------------------
356
355
  # Claude runtime
357
356
  # ---------------------------------------------------------------------------
@@ -511,143 +510,308 @@ def remove_pack_claude(name: str, pack: dict, pack_dir: Path, *, keep_shared_ass
511
510
 
512
511
  def _install_all_codex_skills(target_root: Path) -> None:
513
512
  skills_src = app_dir / "skills"
514
- skills_dst = target_root / ".agents" / "skills"
515
- skills_dst.mkdir(parents=True, exist_ok=True)
513
+ skills_dst = prepare_codex_skills_dir(target_root)
514
+ user_names = unmanaged_codex_skill_names(skills_dst, skills_src)
516
515
  for skill_dir in sorted(skills_src.iterdir()):
517
516
  if not skill_dir.is_dir() or skill_dir.name.startswith("_"):
518
517
  continue
519
518
  skill_md = skill_dir / "SKILL.md"
520
519
  if not skill_md.is_file():
521
520
  continue
521
+ if skill_dir.name in user_names:
522
+ continue
522
523
  sync_codex_skill(skill_dir, skills_dst)
523
- cleanup_codex_skills(skills_dst, skills_src)
524
+ cleanup_codex_skills(skills_dst, skills_src, user_names)
524
525
 
525
526
 
526
527
  def _install_codex_extra_skills(pack: dict, pack_dir: Path) -> None:
527
- skills_dst = CODEX_ROOT / ".agents" / "skills"
528
- skills_dst.mkdir(parents=True, exist_ok=True)
528
+ skills_src = app_dir / "skills"
529
+ skills_dst = prepare_codex_skills_dir(CODEX_ROOT)
530
+ user_names = unmanaged_codex_skill_names(skills_dst, skills_src)
529
531
  for skill in pack.get("includes", {}).get("skills", []):
530
532
  source_dir = _resolve_skill_source(pack_dir, skill)
531
533
  if not source_dir:
532
534
  print(f" WARN skill not found: {skill}")
533
535
  continue
536
+ if source_dir.name in user_names:
537
+ print(f" Preserved user Codex skill: {skill}")
538
+ continue
534
539
  sync_codex_skill(source_dir, skills_dst)
535
540
  print(f" Ensured Codex skill: {skill}")
541
+ cleanup_codex_skills(skills_dst, skills_src, user_names)
542
+
543
+
544
+ def _codex_plugin_owner(name: str) -> str:
545
+ if CODEX_PLUGIN_NAME_PATTERN.fullmatch(name) is None:
546
+ raise ValueError(f"Unsafe Codex plugin name: {name}")
547
+ return f"ai-toolkit-plugin-{name}"
548
+
549
+
550
+ def _codex_command_has_owner(command: str, owner: str) -> bool:
551
+ pattern = rf"(?:^|\s)AI_TOOLKIT_HOOK_OWNER={re.escape(owner)}(?=\s|$)"
552
+ return re.search(pattern, command) is not None
553
+
554
+
555
+ def _assert_safe_codex_surface() -> None:
556
+ configured_home = os.environ.get("CODEX_HOME")
557
+ if not CODEX_HOME.is_absolute():
558
+ raise RuntimeError("CODEX_HOME must be an absolute path")
559
+ if configured_home and not CODEX_HOME.is_dir():
560
+ raise RuntimeError("Configured CODEX_HOME must already exist")
561
+ paths = (
562
+ (CODEX_HOME, "home"),
563
+ (CODEX_HOME / "AGENTS.md", "AGENTS.md"),
564
+ (CODEX_HOME / "hooks.json", "hooks.json"),
565
+ (CODEX_HOOKS_DIR, "hook assets"),
566
+ )
567
+ for path, label in paths:
568
+ if path.is_symlink():
569
+ raise RuntimeError(f"Refusing symlinked Codex {label}: {path}")
536
570
 
537
571
 
538
572
  def _install_codex_base() -> None:
539
- _ensure_core_hook_scripts()
573
+ _assert_safe_codex_surface()
574
+ generate_codex_hooks(
575
+ CODEX_ROOT,
576
+ global_install=True,
577
+ codex_home=CODEX_HOME,
578
+ )
540
579
  # Universal coding rules are inlined into AGENTS.md by generate_codex.py;
541
- # Codex does not read a .agents/rules/ directory. Global instructions must
542
- # live at $CODEX_HOME/AGENTS.md (default ~/.codex/AGENTS.md), not ~/AGENTS.md.
543
- inject_with_rules("generate_codex.py", CODEX_ROOT / ".codex" / "AGENTS.md", RULES_DIR)
544
- hooks_path = CODEX_ROOT / ".codex" / "hooks.json"
545
- existing = _load_json(hooks_path, {"hooks": {}})
546
- plugin_entries: dict[str, list[dict]] = {}
547
- for event, entries in existing.get("hooks", {}).items():
548
- kept = [entry for entry in entries if str(entry.get("_source", "")).startswith("ai-toolkit-plugin-")]
549
- if kept:
550
- plugin_entries[event] = kept
551
-
552
- generate_codex_hooks(CODEX_ROOT)
553
-
554
- if plugin_entries:
555
- refreshed = _load_json(hooks_path, {"hooks": {}})
556
- bucket = refreshed.setdefault("hooks", {})
557
- for event, entries in plugin_entries.items():
558
- bucket.setdefault(event, [])
559
- bucket[event].extend(entries)
560
- _write_json(hooks_path, refreshed)
580
+ # Codex does not read a .agents/rules/ directory.
581
+ inject_with_rules("generate_codex.py", CODEX_HOME / "AGENTS.md", RULES_DIR)
561
582
  _install_all_codex_skills(CODEX_ROOT)
562
583
 
563
584
 
564
- def _ensure_codex_hooks_file() -> Path:
565
- hooks_path = CODEX_ROOT / ".codex" / "hooks.json"
566
- if not hooks_path.is_file():
567
- generate_codex_hooks(CODEX_ROOT)
568
- return hooks_path
569
-
570
-
571
585
  def _codex_matcher(spec: dict) -> str:
572
- if spec["event"] in {"PreToolUse", "PostToolUse"}:
586
+ if spec["event"] in {"PreToolUse", "PostToolUse", "PermissionRequest"}:
573
587
  return "Bash"
574
588
  if spec["event"] == "SessionStart":
575
589
  return "startup|resume"
590
+ if spec["event"] in {"UserPromptSubmit", "Stop"}:
591
+ return ""
576
592
  return CORE_HOOK_MATCHERS.get(spec["name"], "")
577
593
 
578
594
 
579
- def _merge_codex_hooks(name: str, hook_specs: list[dict]) -> None:
580
- hooks_path = _ensure_codex_hooks_file()
581
- data = _load_json(hooks_path, {"hooks": {}})
582
- bucket = data.setdefault("hooks", {})
583
- source_tag = f"ai-toolkit-plugin-{name}"
595
+ def _codex_base_hook_present(data: dict, hook_name: str) -> bool:
596
+ for groups in data.get("hooks", {}).values():
597
+ for group in groups:
598
+ for handler in group.get("hooks", []):
599
+ command = handler.get("command", "")
600
+ if not _codex_command_has_owner(command, TOOLKIT_COMMAND_MARKER.split("=", 1)[1]):
601
+ continue
602
+ if re.search(rf"/{re.escape(hook_name)}(?=[\"'\s]|$)", command):
603
+ return True
604
+ return False
584
605
 
606
+
607
+ def _select_codex_hook_specs(data: dict, hook_specs: list[dict]) -> list[dict]:
608
+ selected: list[dict] = []
585
609
  for spec in hook_specs:
586
610
  if spec["event"] not in SUPPORTED_CODEX_HOOK_EVENTS:
587
- print(f" Skipped Codex hook (unsupported event): {spec['name']} -> {spec['event']}")
611
+ print(
612
+ " Skipped Codex hook (unsupported event): "
613
+ f"{spec['name']} -> {spec['event']}"
614
+ )
588
615
  continue
589
- if spec["is_core"] and _hook_already_present(data, spec["name"]):
616
+ if spec["is_core"] and _codex_base_hook_present(data, spec["name"]):
590
617
  print(f" OK Codex hook (base install): {spec['name']}")
591
618
  continue
592
- entry = {
593
- "_source": source_tag,
594
- "hooks": [
595
- {
596
- "type": "command",
597
- "command": _plugin_hook_command(name, spec),
598
- }
599
- ],
619
+ selected.append(spec)
620
+ return selected
621
+
622
+
623
+ def _remove_codex_handlers(data: dict, name: str) -> bool:
624
+ owner = _codex_plugin_owner(name)
625
+ changed = False
626
+ bucket = data.get("hooks", {})
627
+ for event in list(bucket):
628
+ retained_groups: list[dict] = []
629
+ for group in bucket[event]:
630
+ handlers = group.get("hooks", [])
631
+ retained = [
632
+ handler
633
+ for handler in handlers
634
+ if not _codex_command_has_owner(handler.get("command", ""), owner)
635
+ ]
636
+ if len(retained) != len(handlers):
637
+ changed = True
638
+ if retained:
639
+ retained_group = dict(group)
640
+ retained_group["hooks"] = retained
641
+ retained_groups.append(retained_group)
642
+ if retained_groups:
643
+ bucket[event] = retained_groups
644
+ else:
645
+ del bucket[event]
646
+ return changed
647
+
648
+
649
+ def _codex_plugin_asset_name(name: str, spec: dict) -> str:
650
+ return f"plugin-{name}-{Path(spec['name']).name}"
651
+
652
+
653
+ def _codex_plugin_asset_marker(name: str) -> str:
654
+ return f"{CODEX_PLUGIN_ASSET_MARKER} owner={_codex_plugin_owner(name)}"
655
+
656
+
657
+ def _codex_plugin_asset_content(name: str, spec: dict) -> bytes:
658
+ source = Path(spec["source"])
659
+ if source.is_symlink() or not source.is_file():
660
+ raise RuntimeError(f"Refusing unsafe Codex plugin hook source: {source}")
661
+ text = source.read_text(encoding="utf-8")
662
+ marker = _codex_plugin_asset_marker(name) + "\n"
663
+ lines = text.splitlines(keepends=True)
664
+ if lines and lines[0].startswith("#!"):
665
+ text = "".join((lines[0], marker, *lines[1:]))
666
+ else:
667
+ text = marker + text
668
+ return text.encode("utf-8")
669
+
670
+
671
+ def _is_owned_codex_plugin_asset(path: Path, name: str) -> bool:
672
+ if path.is_symlink() or not path.is_file():
673
+ return False
674
+ try:
675
+ prefix = path.read_text(encoding="utf-8")[:512]
676
+ except (OSError, UnicodeError):
677
+ return False
678
+ return _codex_plugin_asset_marker(name) in prefix
679
+
680
+
681
+ def _stage_codex_plugin_asset(destination: Path, content: bytes, mode: int) -> Path:
682
+ fd, temp_name = tempfile.mkstemp(
683
+ dir=destination.parent,
684
+ prefix=f".{destination.name}.",
685
+ suffix=".tmp",
686
+ )
687
+ temp_path = Path(temp_name)
688
+ try:
689
+ with os.fdopen(fd, "wb") as handle:
690
+ fd = -1
691
+ handle.write(content)
692
+ handle.flush()
693
+ os.fsync(handle.fileno())
694
+ os.chmod(temp_path, mode)
695
+ return temp_path
696
+ except Exception:
697
+ if fd >= 0:
698
+ os.close(fd)
699
+ temp_path.unlink(missing_ok=True)
700
+ raise
701
+
702
+
703
+ def _write_codex_plugin_asset(destination: Path, content: bytes, mode: int = 0o755) -> None:
704
+ staged = _stage_codex_plugin_asset(destination, content, mode)
705
+ try:
706
+ if destination.is_symlink():
707
+ raise RuntimeError(f"Refusing symlinked Codex plugin hook: {destination}")
708
+ os.replace(staged, destination)
709
+ finally:
710
+ staged.unlink(missing_ok=True)
711
+
712
+
713
+ def _prepare_codex_plugin_assets(name: str, specs: list[dict]) -> dict[Path, bytes]:
714
+ _assert_safe_codex_surface()
715
+ CODEX_HOOKS_DIR.mkdir(parents=True, exist_ok=True)
716
+ _assert_safe_codex_surface()
717
+ assets: dict[Path, bytes] = {}
718
+ for spec in specs:
719
+ destination = CODEX_HOOKS_DIR / _codex_plugin_asset_name(name, spec)
720
+ if destination.is_symlink():
721
+ raise RuntimeError(f"Refusing symlinked Codex plugin hook: {destination}")
722
+ if destination.exists() and not _is_owned_codex_plugin_asset(destination, name):
723
+ raise RuntimeError(f"Refusing user-owned Codex hook collision: {destination}")
724
+ assets[destination] = _codex_plugin_asset_content(name, spec)
725
+ return assets
726
+
727
+
728
+ def _restore_codex_plugin_assets(
729
+ snapshots: dict[Path, tuple[bytes, int] | None],
730
+ ) -> None:
731
+ for destination, snapshot in snapshots.items():
732
+ if snapshot is None:
733
+ destination.unlink(missing_ok=True)
734
+ else:
735
+ content, mode = snapshot
736
+ _write_codex_plugin_asset(destination, content, mode)
737
+
738
+
739
+ def _codex_plugin_command(name: str, spec: dict) -> str:
740
+ owner = _codex_plugin_owner(name)
741
+ asset_name = _codex_plugin_asset_name(name, spec)
742
+ return (
743
+ f'AI_TOOLKIT_HOOK_OWNER={owner} '
744
+ f'"${{CODEX_HOME:-$HOME/.codex}}/ai-toolkit-hooks/{asset_name}"'
745
+ )
746
+
747
+
748
+ def _install_codex_hooks(
749
+ name: str,
750
+ hook_specs: list[dict],
751
+ installed_items: list[str],
752
+ ) -> None:
753
+ hooks_path = CODEX_HOME / "hooks.json"
754
+ data = load_hooks_json(hooks_path)
755
+ specs = _select_codex_hook_specs(data, hook_specs)
756
+ _remove_codex_handlers(data, name)
757
+ bucket = data.setdefault("hooks", {})
758
+ for spec in specs:
759
+ group = {
760
+ "hooks": [{
761
+ "type": "command",
762
+ "command": _codex_plugin_command(name, spec),
763
+ }]
600
764
  }
601
765
  matcher = _codex_matcher(spec)
602
766
  if matcher:
603
- entry["matcher"] = matcher
604
-
605
- event_hooks = bucket.setdefault(spec["event"], [])
606
- event_hooks = [h for h in event_hooks if h.get("_source") != source_tag]
607
- event_hooks.append(entry)
608
- bucket[spec["event"]] = event_hooks
767
+ group["matcher"] = matcher
768
+ bucket.setdefault(spec["event"], []).append(group)
769
+ validate_hooks_document(data)
770
+
771
+ assets = _prepare_codex_plugin_assets(name, specs)
772
+ snapshots = {
773
+ path: (path.read_bytes(), path.stat().st_mode & 0o777)
774
+ if path.exists()
775
+ else None
776
+ for path in assets
777
+ }
778
+ try:
779
+ for path, content in assets.items():
780
+ _write_codex_plugin_asset(path, content)
781
+ write_hooks_json(hooks_path, data)
782
+ except Exception:
783
+ _restore_codex_plugin_assets(snapshots)
784
+ raise
609
785
 
610
- _write_json(hooks_path, data)
611
- print(" Merged hooks into ~/.codex/hooks.json")
786
+ for path in assets:
787
+ installed_items.append(f"hook:{path.name}")
788
+ print(f" Copied Codex hook: {path.name}")
789
+ print(" Merged hooks into $CODEX_HOME/hooks.json")
612
790
 
613
791
 
614
792
  def _strip_codex_hooks(name: str) -> None:
615
- hooks_path = CODEX_ROOT / ".codex" / "hooks.json"
793
+ hooks_path = CODEX_HOME / "hooks.json"
616
794
  if not hooks_path.is_file():
617
795
  return
618
- data = _load_json(hooks_path, {"hooks": {}})
619
- bucket = data.get("hooks", {})
620
- source_tag = f"ai-toolkit-plugin-{name}"
621
- changed = False
622
-
623
- for event in list(bucket.keys()):
624
- original = bucket[event]
625
- filtered = [h for h in original if h.get("_source") != source_tag]
626
- if len(filtered) != len(original):
627
- bucket[event] = filtered
628
- changed = True
629
- if not bucket[event]:
630
- del bucket[event]
631
-
632
- if changed:
633
- _write_json(hooks_path, data)
634
- print(" Stripped hooks from ~/.codex/hooks.json")
796
+ data = load_hooks_json(hooks_path)
797
+ if _remove_codex_handlers(data, name):
798
+ write_hooks_json(hooks_path, data)
799
+ print(" Stripped hooks from $CODEX_HOME/hooks.json")
635
800
 
636
801
 
637
802
  def _install_codex_rules(name: str, rule_specs: list[dict]) -> None:
638
803
  # Codex reads instructions only from AGENTS.md, never from a .agents/rules/
639
- # directory, so pack rules are marker-injected into ~/.codex/AGENTS.md (the
640
- # documented global instruction file) instead of written as dead files.
641
- agents_md = CODEX_ROOT / ".codex" / "AGENTS.md"
804
+ # directory, so pack rules are marker-injected into $CODEX_HOME/AGENTS.md.
805
+ agents_md = CODEX_HOME / "AGENTS.md"
642
806
  for spec in rule_specs:
643
807
  section = f"plugin-{name}-{spec['name']}"
644
808
  inject_section(spec["source"], agents_md, section)
645
- print(f" Injected Codex rule: {spec['name']} -> ~/.codex/AGENTS.md")
809
+ print(f" Injected Codex rule: {spec['name']} -> $CODEX_HOME/AGENTS.md")
646
810
  _clean_legacy_codex_rule_files(name)
647
811
 
648
812
 
649
813
  def _remove_codex_rules(name: str) -> None:
650
- agents_md = CODEX_ROOT / ".codex" / "AGENTS.md"
814
+ agents_md = CODEX_HOME / "AGENTS.md"
651
815
  if agents_md.is_file():
652
816
  content = agents_md.read_text(encoding="utf-8")
653
817
  changed = False
@@ -656,7 +820,7 @@ def _remove_codex_rules(name: str) -> None:
656
820
  changed = True
657
821
  if changed:
658
822
  agents_md.write_text(trim_trailing_blanks(content) + "\n", encoding="utf-8")
659
- print(f" Removed Codex rules for {name} from ~/.codex/AGENTS.md")
823
+ print(f" Removed Codex rules for {name} from $CODEX_HOME/AGENTS.md")
660
824
  _clean_legacy_codex_rule_files(name)
661
825
 
662
826
 
@@ -664,7 +828,10 @@ def _clean_legacy_codex_rule_files(name: str) -> None:
664
828
  """Remove dead ~/.agents/rules/plugin-<name>-*.md files written by earlier
665
829
  versions. Codex never read them; Antigravity's .agents/rules/ is a separate,
666
830
  project-local surface, so this only touches our own plugin-prefixed files."""
667
- rules_dir = CODEX_ROOT / ".agents" / "rules"
831
+ agents_dir = CODEX_ROOT / ".agents"
832
+ rules_dir = agents_dir / "rules"
833
+ if agents_dir.is_symlink() or rules_dir.is_symlink():
834
+ raise RuntimeError(f"Refusing symlinked legacy Codex rules path: {rules_dir}")
668
835
  if not rules_dir.is_dir():
669
836
  return
670
837
  for rule_file in sorted(rules_dir.glob(f"plugin-{name}-*.md")):
@@ -679,17 +846,31 @@ def install_pack_codex(name: str, pack: dict, pack_dir: Path) -> bool:
679
846
 
680
847
  _install_codex_base()
681
848
  _install_codex_extra_skills(pack, pack_dir)
682
- _copy_plugin_hook_scripts(name, hook_specs, installed_items)
849
+ _install_codex_hooks(name, hook_specs, installed_items)
683
850
  _copy_plugin_scripts(name, pack_dir, installed_items)
684
851
  _install_codex_rules(name, rule_specs)
685
- _merge_codex_hooks(name, hook_specs)
686
852
 
687
853
  print(f" Done: {name} for codex ({len(installed_items)} file items)")
688
854
  return True
689
855
 
690
856
 
691
857
  def remove_pack_codex(name: str, pack: dict, pack_dir: Path, *, keep_shared_assets: bool) -> bool:
858
+ _assert_safe_codex_surface()
859
+ _strip_codex_hooks(name)
860
+
861
+ if CODEX_HOOKS_DIR.is_dir():
862
+ for hook in CODEX_HOOKS_DIR.glob(f"plugin-{name}-*.sh"):
863
+ if hook.is_symlink():
864
+ raise RuntimeError(f"Refusing symlinked Codex plugin hook: {hook}")
865
+ if _is_owned_codex_plugin_asset(hook, name):
866
+ hook.unlink()
867
+ print(f" Removed Codex hook: {hook.name}")
868
+ else:
869
+ print(f" WARN preserved user-owned Codex hook: {hook.name}")
870
+
692
871
  if not keep_shared_assets:
872
+ # Clean paths used by releases before native Codex plugin assets moved
873
+ # under $CODEX_HOME. Claude still owns these when installed for both.
693
874
  for hook in HOOKS_DIR.glob(f"plugin-{name}-*.sh"):
694
875
  hook.unlink()
695
876
  print(f" Removed hook: {hook.name}")
@@ -699,7 +880,6 @@ def remove_pack_codex(name: str, pack: dict, pack_dir: Path, *, keep_shared_asse
699
880
  shutil.rmtree(scripts_dir)
700
881
  print(f" Removed scripts: {scripts_dir}")
701
882
 
702
- _strip_codex_hooks(name)
703
883
  _remove_codex_rules(name)
704
884
  print(f" Done: removed {name} from codex")
705
885
  return True
@@ -717,8 +897,6 @@ def install_pack(name: str, editor: str) -> bool:
717
897
  return False
718
898
 
719
899
  pack_dir = Path(pack["_dir"])
720
- installed_items: list[str] = []
721
-
722
900
  print(f" Installing: {name} for {editor} ({pack.get('description', '')})")
723
901
 
724
902
  ok = install_pack_claude(name, pack, pack_dir) if editor == "claude" else install_pack_codex(name, pack, pack_dir)
@@ -934,10 +1112,9 @@ def cmd_status(editors: list[str]) -> None:
934
1112
  if hooks:
935
1113
  print(f" Hooks: {', '.join(h.name for h in hooks)}")
936
1114
  elif editor == "codex":
937
- rules_dir = CODEX_ROOT / ".agents" / "rules"
938
- rule_files = sorted(rules_dir.glob(f"plugin-{name}-*.md")) if rules_dir.is_dir() else []
939
- if rule_files:
940
- print(f" Rules: {', '.join(f.name for f in rule_files)}")
1115
+ hooks = sorted(CODEX_HOOKS_DIR.glob(f"plugin-{name}-*.sh"))
1116
+ if hooks:
1117
+ print(f" Hooks: {', '.join(h.name for h in hooks)}")
941
1118
  if name == "memory-pack":
942
1119
  _show_memory_stats()
943
1120
  print()