@softspark/ai-toolkit 4.14.0 → 4.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/README.md +11 -10
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/CLAUDE.md.template +3 -0
- package/app/agents/fact-checker.md +1 -1
- package/app/hooks/_search-capability.sh +3 -2
- package/app/hooks/stop-search-check.sh +2 -1
- package/benchmarks/ecosystem-doctor-snapshot.json +73 -31
- package/kb/procedures/maintenance-sop.md +26 -13
- package/kb/procedures/release-verification-sop.md +41 -36
- package/kb/reference/architecture-overview.md +23 -7
- package/kb/reference/codex-cli-compatibility.md +96 -36
- package/kb/reference/extension-api.md +52 -9
- package/kb/reference/global-install-model.md +53 -21
- package/kb/reference/hooks-catalog.md +44 -8
- package/kb/reference/mcp-editor-compatibility.md +27 -6
- package/kb/reference/mcp-templates.md +12 -6
- package/kb/reference/opencode-compatibility.md +13 -7
- package/kb/reference/plugin-pack-conventions.md +7 -7
- package/kb/reference/skills-catalog.md +3 -3
- package/kb/reference/supported-tools-registry.md +19 -17
- package/kb/reference/windows-support.md +26 -3
- package/llms-full.txt +443 -180
- package/llms.txt +1 -1
- package/manifest.json +1 -1
- package/package.json +2 -2
- package/scripts/codex_skill_adapter.py +448 -198
- package/scripts/dir_rules_shared.py +2 -11
- package/scripts/ecosystem_tools.json +29 -8
- package/scripts/emission.py +5 -91
- package/scripts/generate_agents_md.py +4 -87
- package/scripts/generate_codex.py +5 -95
- package/scripts/generate_codex_agents.py +242 -0
- package/scripts/generate_codex_hooks.py +648 -55
- package/scripts/generate_codex_skills.py +15 -6
- package/scripts/generate_copilot.py +771 -74
- package/scripts/generate_copilot_hooks.py +606 -0
- package/scripts/generate_cursor_hooks.py +453 -121
- package/scripts/generate_opencode_commands.py +4 -6
- package/scripts/inject_hook_cli.py +770 -205
- package/scripts/injection.py +102 -23
- package/scripts/install_steps/ai_tools.py +123 -83
- package/scripts/instruction_core.py +95 -0
- package/scripts/mcp_editors.py +934 -80
- package/scripts/mcp_manager.py +46 -26
- package/scripts/plugin.py +291 -114
- package/scripts/secure_fs.py +538 -0
- package/scripts/uninstall.py +1279 -208
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,
|
|
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
|
|
37
|
-
|
|
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
|
|
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
|
-
|
|
53
|
-
|
|
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
|
|
515
|
-
skills_dst
|
|
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
|
-
|
|
528
|
-
skills_dst
|
|
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
|
-
|
|
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.
|
|
542
|
-
|
|
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
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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(
|
|
611
|
+
print(
|
|
612
|
+
" Skipped Codex hook (unsupported event): "
|
|
613
|
+
f"{spec['name']} -> {spec['event']}"
|
|
614
|
+
)
|
|
588
615
|
continue
|
|
589
|
-
if spec["is_core"] and
|
|
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
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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
|
-
|
|
611
|
-
|
|
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 =
|
|
793
|
+
hooks_path = CODEX_HOME / "hooks.json"
|
|
616
794
|
if not hooks_path.is_file():
|
|
617
795
|
return
|
|
618
|
-
data =
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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
|
|
640
|
-
|
|
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']} ->
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
938
|
-
|
|
939
|
-
|
|
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()
|