agentic-comms 0.8.2__tar.gz → 0.8.4__tar.gz
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.
- {agentic_comms-0.8.2 → agentic_comms-0.8.4}/PKG-INFO +1 -1
- {agentic_comms-0.8.2 → agentic_comms-0.8.4}/agent_comms/cli.py +152 -11
- {agentic_comms-0.8.2 → agentic_comms-0.8.4}/agent_comms/hook.py +69 -2
- agentic_comms-0.8.4/agent_comms/install.py +294 -0
- {agentic_comms-0.8.2 → agentic_comms-0.8.4}/agentic_comms.egg-info/PKG-INFO +1 -1
- {agentic_comms-0.8.2 → agentic_comms-0.8.4}/agentic_comms.egg-info/SOURCES.txt +2 -1
- {agentic_comms-0.8.2 → agentic_comms-0.8.4}/pyproject.toml +1 -1
- agentic_comms-0.8.4/tests/test_install_codex.py +153 -0
- agentic_comms-0.8.2/agent_comms/install.py +0 -139
- {agentic_comms-0.8.2 → agentic_comms-0.8.4}/README.md +0 -0
- {agentic_comms-0.8.2 → agentic_comms-0.8.4}/agent_comms/__init__.py +0 -0
- {agentic_comms-0.8.2 → agentic_comms-0.8.4}/agent_comms/__main__.py +0 -0
- {agentic_comms-0.8.2 → agentic_comms-0.8.4}/agent_comms/api.py +0 -0
- {agentic_comms-0.8.2 → agentic_comms-0.8.4}/agent_comms/config.py +0 -0
- {agentic_comms-0.8.2 → agentic_comms-0.8.4}/agentic_comms.egg-info/dependency_links.txt +0 -0
- {agentic_comms-0.8.2 → agentic_comms-0.8.4}/agentic_comms.egg-info/entry_points.txt +0 -0
- {agentic_comms-0.8.2 → agentic_comms-0.8.4}/agentic_comms.egg-info/requires.txt +0 -0
- {agentic_comms-0.8.2 → agentic_comms-0.8.4}/agentic_comms.egg-info/top_level.txt +0 -0
- {agentic_comms-0.8.2 → agentic_comms-0.8.4}/setup.cfg +0 -0
- {agentic_comms-0.8.2 → agentic_comms-0.8.4}/tests/test_cli.py +0 -0
|
@@ -540,29 +540,170 @@ def install_statusline(
|
|
|
540
540
|
print("Reopen Claude Code (or wait for next assistant message) to see the badge.")
|
|
541
541
|
|
|
542
542
|
|
|
543
|
+
@app.command("spawn")
|
|
544
|
+
def spawn(
|
|
545
|
+
task: str = typer.Option(..., "--task", "-t", help="Task description for the spawned agent."),
|
|
546
|
+
cwd: Optional[str] = typer.Option(None, "--cwd", help="Working directory for the new agent (default: current dir)."),
|
|
547
|
+
window: Optional[str] = typer.Option(None, "--window", "-w", help="tmux window name (default: agent display name)."),
|
|
548
|
+
report_to: Optional[str] = typer.Option(None, "--report-to", help="Handle to DM when done (default: autodetect from current identity)."),
|
|
549
|
+
json_out: bool = typer.Option(False, "--json", help="Print result as JSON."),
|
|
550
|
+
):
|
|
551
|
+
"""Spawn a new Claude Code agent in a tmux window.
|
|
552
|
+
|
|
553
|
+
Pre-registers the agent identity on the server so the caller knows the handle
|
|
554
|
+
and display name immediately. The spawned agent's first action is `comms claim
|
|
555
|
+
<handle>` — no broadcast, no polling, no race condition.
|
|
556
|
+
|
|
557
|
+
Requires: tmux running, claude CLI on PATH.
|
|
558
|
+
"""
|
|
559
|
+
import subprocess as _sp
|
|
560
|
+
import shutil as _shutil
|
|
561
|
+
import socket as _socket
|
|
562
|
+
import tempfile as _tmp
|
|
563
|
+
|
|
564
|
+
# Resolve caller identity for report-to default
|
|
565
|
+
caller = config.load_identity()
|
|
566
|
+
if report_to is None and caller:
|
|
567
|
+
report_to = caller.handle
|
|
568
|
+
|
|
569
|
+
target_cwd = str(Path(cwd).resolve()) if cwd else str(Path.cwd())
|
|
570
|
+
|
|
571
|
+
# Pre-register the new identity — server assigns handle + display name.
|
|
572
|
+
c = _client()
|
|
573
|
+
try:
|
|
574
|
+
identity = c.register_identity(
|
|
575
|
+
handle=None,
|
|
576
|
+
user=os.environ.get("USER", "agent"),
|
|
577
|
+
host=_socket.gethostname(),
|
|
578
|
+
cwd=target_cwd,
|
|
579
|
+
meta=f"spawned by {caller.handle if caller else 'unknown'}",
|
|
580
|
+
)
|
|
581
|
+
except RuntimeError as e:
|
|
582
|
+
err.print(f"[red]Failed to pre-register identity: {e}[/red]")
|
|
583
|
+
raise typer.Exit(1)
|
|
584
|
+
|
|
585
|
+
handle = identity["handle"]
|
|
586
|
+
display_name = identity.get("display_name") or handle
|
|
587
|
+
window_name = window or display_name
|
|
588
|
+
|
|
589
|
+
# Build the launch prompt. Written to a tempfile so quoting is never an issue.
|
|
590
|
+
monitor_cmd = (
|
|
591
|
+
"seen=\"\"; while true; do\n"
|
|
592
|
+
f" ids=$(comms inbox --handle {handle} --unread --json --monitor 2>/dev/null"
|
|
593
|
+
" | python3 -c \"import sys,json; d=json.load(sys.stdin);"
|
|
594
|
+
" print('\\n'.join(m['id']+'|'+m['from_handle']+'|'+m['title'] for m in d))\" 2>/dev/null)\n"
|
|
595
|
+
" while IFS= read -r line; do\n"
|
|
596
|
+
" [ -z \"$line\" ] && continue\n"
|
|
597
|
+
" if ! grep -qF \"$line\" <<<\"$seen\"; then echo \"NEW_DM: $line\"; seen=\"$seen\"$'\\n'\"$line\"; fi\n"
|
|
598
|
+
" done <<<\"$ids\"\n"
|
|
599
|
+
" sleep 1\n"
|
|
600
|
+
"done"
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
report_line = (
|
|
604
|
+
f"\nWhen your task is complete, DM your results to {report_to}:\n"
|
|
605
|
+
f" comms post --to {report_to} --title \"Done: <short title>\" --summary \"<one line>\" --body - <<'BODY'\n"
|
|
606
|
+
f" <your results here>\n"
|
|
607
|
+
f" BODY\n"
|
|
608
|
+
) if report_to else ""
|
|
609
|
+
|
|
610
|
+
prompt = f"""\
|
|
611
|
+
You are a new agent in the comms fleet. Your identity has been pre-registered on the server.
|
|
612
|
+
|
|
613
|
+
SETUP — run these four commands immediately before doing anything else:
|
|
614
|
+
comms claim {handle}
|
|
615
|
+
comms install-hook
|
|
616
|
+
comms install-statusline
|
|
617
|
+
# Then arm your Monitor (paste into the Monitor tool):
|
|
618
|
+
# description: "Poll agent-comms inbox every 1s"
|
|
619
|
+
# timeout_ms: 3600000 persistent: false
|
|
620
|
+
# command: cd /tmp && {monitor_cmd}
|
|
621
|
+
|
|
622
|
+
You are now: {display_name} (handle: {handle})
|
|
623
|
+
Working directory: {target_cwd}
|
|
624
|
+
{report_line}
|
|
625
|
+
YOUR TASK:
|
|
626
|
+
{task}
|
|
627
|
+
"""
|
|
628
|
+
|
|
629
|
+
# Write prompt + a tiny Python launcher that passes it cleanly to claude.
|
|
630
|
+
prompt_file = Path(_tmp.gettempdir()) / f"comms-spawn-{handle}.txt"
|
|
631
|
+
launcher_file = Path(_tmp.gettempdir()) / f"comms-spawn-{handle}.py"
|
|
632
|
+
prompt_file.write_text(prompt)
|
|
633
|
+
launcher_file.write_text(
|
|
634
|
+
f"import subprocess, sys\n"
|
|
635
|
+
f"prompt = open({str(prompt_file)!r}).read()\n"
|
|
636
|
+
f"sys.exit(subprocess.run(['claude', '--dangerously-skip-permissions', '-p', prompt],"
|
|
637
|
+
f" cwd={target_cwd!r}).returncode)\n"
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
if not _shutil.which("tmux"):
|
|
641
|
+
err.print("[red]tmux not found — cannot spawn. Start a tmux session first.[/red]")
|
|
642
|
+
raise typer.Exit(1)
|
|
643
|
+
if not _shutil.which("claude"):
|
|
644
|
+
err.print("[red]claude CLI not found on PATH.[/red]")
|
|
645
|
+
raise typer.Exit(1)
|
|
646
|
+
|
|
647
|
+
_sp.run(["tmux", "new-window", "-n", window_name,
|
|
648
|
+
f"python3 {launcher_file}"], check=True)
|
|
649
|
+
|
|
650
|
+
if json_out:
|
|
651
|
+
print(json.dumps({"handle": handle, "display_name": display_name,
|
|
652
|
+
"window": window_name, "cwd": target_cwd,
|
|
653
|
+
"report_to": report_to}))
|
|
654
|
+
else:
|
|
655
|
+
print(f"handle={handle} name={display_name} window={window_name}")
|
|
656
|
+
if report_to:
|
|
657
|
+
print(f"report_to={report_to}")
|
|
658
|
+
print(f"Watch: comms history {handle}")
|
|
659
|
+
|
|
660
|
+
|
|
543
661
|
@app.command("install-hook")
|
|
544
662
|
def install_hook(
|
|
545
|
-
project: bool = typer.Option(False, "--project", help="Install in .claude/
|
|
663
|
+
project: bool = typer.Option(False, "--project", help="Install in project-scoped settings (.claude/ or .codex/) instead of user-scoped (~/.claude/ or ~/.codex/)."),
|
|
546
664
|
with_control: bool = typer.Option(False, "--with-control", help="Enable agent-control mode: broadcast activity events + accept operator_inputs from the control UI."),
|
|
665
|
+
codex: bool = typer.Option(False, "--codex", help="Install into Codex CLI (~/.codex/hooks.json) instead of Claude Code (~/.claude/settings.json). The hook script itself is unchanged; its JSON output is already Codex-compatible."),
|
|
547
666
|
):
|
|
548
|
-
"""Register Claude Code hooks so new DMs (and optional operator_inputs) auto-inject into context.
|
|
667
|
+
"""Register Claude Code or Codex CLI hooks so new DMs (and optional operator_inputs) auto-inject into context.
|
|
549
668
|
Idempotent — re-running updates the stored python path + the set of registered events."""
|
|
550
669
|
from . import install as inst
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
670
|
+
if codex:
|
|
671
|
+
path, status = inst.install_codex(project_scope=project, with_control=with_control)
|
|
672
|
+
for event, s in status.items():
|
|
673
|
+
if event.startswith("_"):
|
|
674
|
+
continue
|
|
675
|
+
print(f" {event}: {s}")
|
|
676
|
+
flag = status.get("_feature_flag", "")
|
|
677
|
+
if flag == "enabled":
|
|
678
|
+
print(" codex_hooks feature: enabled via `codex features enable`")
|
|
679
|
+
elif flag == "codex-not-on-path":
|
|
680
|
+
print(" WARNING: codex CLI not on PATH — run `codex features enable codex_hooks` manually before starting a session")
|
|
681
|
+
elif flag:
|
|
682
|
+
print(f" WARNING: feature flag enable: {flag} — run `codex features enable codex_hooks` manually")
|
|
683
|
+
print(f"hooks: {path}")
|
|
684
|
+
else:
|
|
685
|
+
path, status = inst.install(project_scope=project, with_control=with_control)
|
|
686
|
+
for event, s in status.items():
|
|
687
|
+
print(f" {event}: {s}")
|
|
688
|
+
print(f"settings: {path}")
|
|
555
689
|
if with_control:
|
|
556
690
|
print("agent-control mode ON — activity broadcasts + operator_input delivery enabled.")
|
|
557
|
-
if any(v in ("installed", "
|
|
558
|
-
|
|
691
|
+
if any(v not in ("already-installed",) and not str(v).startswith("_") for v in status.values()):
|
|
692
|
+
target = "Codex" if codex else "Claude"
|
|
693
|
+
print(f"new direct messages will now appear in {target}'s context automatically.")
|
|
559
694
|
|
|
560
695
|
|
|
561
696
|
@app.command("uninstall-hook")
|
|
562
|
-
def uninstall_hook(
|
|
563
|
-
|
|
697
|
+
def uninstall_hook(
|
|
698
|
+
project: bool = typer.Option(False, "--project"),
|
|
699
|
+
codex: bool = typer.Option(False, "--codex", help="Uninstall from Codex CLI (~/.codex/hooks.json) instead of Claude Code (~/.claude/settings.json)."),
|
|
700
|
+
):
|
|
701
|
+
"""Remove the agent-comms hook entries (Claude Code by default, --codex for Codex CLI)."""
|
|
564
702
|
from . import install as inst
|
|
565
|
-
|
|
703
|
+
if codex:
|
|
704
|
+
path, n = inst.uninstall_codex(project_scope=project)
|
|
705
|
+
else:
|
|
706
|
+
path, n = inst.uninstall(project_scope=project)
|
|
566
707
|
print(f"removed {n} hook entries from {path}")
|
|
567
708
|
|
|
568
709
|
|
|
@@ -518,6 +518,63 @@ def _get_version() -> str:
|
|
|
518
518
|
return "unknown"
|
|
519
519
|
|
|
520
520
|
|
|
521
|
+
_UPGRADE_CHECK_INTERVAL = 6 * 3600 # check PyPI at most once per 6 hours per handle
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _maybe_auto_upgrade(handle: str) -> str | None:
|
|
525
|
+
"""Check PyPI for a newer agentic-comms version. If found: upgrade, reinstall hook +
|
|
526
|
+
statusline, return a one-line notice (the skill block fires automatically on the next
|
|
527
|
+
hook invocation when the new version is detected). Returns None on no-op or any error."""
|
|
528
|
+
import subprocess
|
|
529
|
+
import urllib.request
|
|
530
|
+
cache = _cache_dir()
|
|
531
|
+
check_path = cache / f"upgrade-check-{_hash(handle)}"
|
|
532
|
+
try:
|
|
533
|
+
if check_path.exists() and (time.time() - check_path.stat().st_mtime) < _UPGRADE_CHECK_INTERVAL:
|
|
534
|
+
return None
|
|
535
|
+
check_path.parent.mkdir(parents=True, exist_ok=True)
|
|
536
|
+
check_path.write_text(str(time.time()))
|
|
537
|
+
except Exception:
|
|
538
|
+
return None
|
|
539
|
+
try:
|
|
540
|
+
installed = _get_version()
|
|
541
|
+
if installed == "unknown":
|
|
542
|
+
return None
|
|
543
|
+
req = urllib.request.Request(
|
|
544
|
+
"https://pypi.org/pypi/agentic-comms/json",
|
|
545
|
+
headers={"User-Agent": f"agentic-comms/{installed} autoupgrade"},
|
|
546
|
+
)
|
|
547
|
+
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
548
|
+
data = json.loads(resp.read())
|
|
549
|
+
latest = data["info"]["version"]
|
|
550
|
+
if latest == installed:
|
|
551
|
+
return None
|
|
552
|
+
# Newer version on PyPI — upgrade. Try without flag first, fall back for
|
|
553
|
+
# externally-managed-environment systems (Ubuntu 23+, Debian 12+).
|
|
554
|
+
r = subprocess.run(
|
|
555
|
+
[sys.executable, "-m", "pip", "install", "--upgrade", "--quiet", "agentic-comms"],
|
|
556
|
+
capture_output=True, timeout=60,
|
|
557
|
+
)
|
|
558
|
+
if r.returncode != 0:
|
|
559
|
+
subprocess.run(
|
|
560
|
+
[sys.executable, "-m", "pip", "install", "--upgrade", "--quiet",
|
|
561
|
+
"--break-system-packages", "agentic-comms"],
|
|
562
|
+
capture_output=True, timeout=60,
|
|
563
|
+
)
|
|
564
|
+
# Reinstall hook + statusline so the stored command path stays current.
|
|
565
|
+
subprocess.run(
|
|
566
|
+
[sys.executable, "-m", "agent_comms", "install-hook"],
|
|
567
|
+
capture_output=True, timeout=10,
|
|
568
|
+
)
|
|
569
|
+
subprocess.run(
|
|
570
|
+
[sys.executable, "-m", "agent_comms", "install-statusline"],
|
|
571
|
+
capture_output=True, timeout=10,
|
|
572
|
+
)
|
|
573
|
+
return f"agent-comms auto-upgraded {installed} → {latest}. Full skill block will arrive on next tool call."
|
|
574
|
+
except Exception:
|
|
575
|
+
return None
|
|
576
|
+
|
|
577
|
+
|
|
521
578
|
def _detect_revive(handle: str, current_session_id: str | None, current_pid: int | None) -> bool:
|
|
522
579
|
"""Returns True if the stored session_id for this identity differs from current.
|
|
523
580
|
Falls back to PID comparison if session_id is unavailable on either side.
|
|
@@ -583,12 +640,18 @@ def main() -> int:
|
|
|
583
640
|
except Exception:
|
|
584
641
|
return 0
|
|
585
642
|
|
|
643
|
+
# Auto-upgrade check — at most once per 6h. Silent on failure.
|
|
644
|
+
_upgrade_notice = _maybe_auto_upgrade(handle)
|
|
645
|
+
|
|
586
646
|
# Mission-title tailer — piggybacks on Claude Code's own ai-title generation.
|
|
587
647
|
# Free (no inference), runs on every event that has a transcript_path.
|
|
588
648
|
_maybe_push_mission(client, handle, hook_input.get("transcript_path"))
|
|
589
649
|
|
|
590
|
-
# Broadcast activity event
|
|
591
|
-
|
|
650
|
+
# Broadcast activity event. Always post so activity_tag stays fresh on the server
|
|
651
|
+
# (and the statusline pill works) even without --with-control. In non-control mode
|
|
652
|
+
# we skip PreToolUse — it fires before every tool and adds noise without the full
|
|
653
|
+
# control-mode transcript consumer on the other end.
|
|
654
|
+
if control or event_name != "PreToolUse":
|
|
592
655
|
_post_event(client, ident, event_name, hook_input)
|
|
593
656
|
|
|
594
657
|
# --- Self-healing system warnings — fire on EVERY hook fire (no rate-limit) ---
|
|
@@ -596,6 +659,10 @@ def main() -> int:
|
|
|
596
659
|
early_pieces: list[str] = []
|
|
597
660
|
|
|
598
661
|
if event_name in ("PostToolUse", "UserPromptSubmit"):
|
|
662
|
+
if _upgrade_notice:
|
|
663
|
+
early_pieces.append(
|
|
664
|
+
f'<system_warning kind="auto_upgraded">\n{_upgrade_notice}\n</system_warning>'
|
|
665
|
+
)
|
|
599
666
|
session_id = hook_input.get("session_id")
|
|
600
667
|
revived = _detect_revive(handle, session_id, claude_pid)
|
|
601
668
|
# Skill onboarding block — fires once per (handle, session_id, version) tuple
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Install/uninstall the agent-comms Claude Code hook.
|
|
2
|
+
|
|
3
|
+
Edits ~/.claude/settings.json (or .claude/settings.json with --project) to
|
|
4
|
+
register a PostToolUse hook running `comms check --hook`.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import shlex
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
HOOK_TIMEOUT = 5
|
|
15
|
+
MARKER_FIELD = "agent_comms_marker" # embedded in the hook entry so we can find+remove
|
|
16
|
+
BASE_EVENTS = ("PostToolUse", "UserPromptSubmit")
|
|
17
|
+
CONTROL_EVENTS = ("PreToolUse", "PostToolUse", "UserPromptSubmit", "Stop", "SessionStart", "SessionEnd")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _hook_command(control: bool = False) -> str:
|
|
21
|
+
"""Absolute python path + module form — works in any shell / PATH."""
|
|
22
|
+
cmd = f"{shlex.quote(sys.executable)} -m agent_comms check --hook"
|
|
23
|
+
if control:
|
|
24
|
+
cmd += " --with-control"
|
|
25
|
+
return cmd
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _settings_path(project_scope: bool) -> Path:
|
|
29
|
+
if project_scope:
|
|
30
|
+
return Path(".claude") / "settings.json"
|
|
31
|
+
claude_home = Path(os.environ.get("CLAUDE_CONFIG_DIR", str(Path.home() / ".claude")))
|
|
32
|
+
return claude_home / "settings.json"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _load(path: Path) -> dict:
|
|
36
|
+
if not path.exists():
|
|
37
|
+
return {}
|
|
38
|
+
try:
|
|
39
|
+
return json.loads(path.read_text() or "{}")
|
|
40
|
+
except json.JSONDecodeError:
|
|
41
|
+
return {}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _save(path: Path, data: dict) -> None:
|
|
45
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
path.write_text(json.dumps(data, indent=2) + "\n")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def install(project_scope: bool = False, with_control: bool = False) -> tuple[Path, dict]:
|
|
50
|
+
"""Install hooks. Returns (settings_path, status_by_event).
|
|
51
|
+
Idempotent — existing entries are rewritten with the current command (useful when
|
|
52
|
+
the python path changes due to venv / reinstall, or when toggling --with-control).
|
|
53
|
+
|
|
54
|
+
with_control=True extends the registered events (adds Stop, SessionStart, SessionEnd)
|
|
55
|
+
and adds `--with-control` to the hook command so the hook broadcasts activity events
|
|
56
|
+
to /api/events and delivers operator_inputs as user-turn-framed context.
|
|
57
|
+
"""
|
|
58
|
+
path = _settings_path(project_scope)
|
|
59
|
+
data = _load(path)
|
|
60
|
+
hooks = data.setdefault("hooks", {})
|
|
61
|
+
command = _hook_command(control=with_control)
|
|
62
|
+
status: dict[str, str] = {}
|
|
63
|
+
|
|
64
|
+
events = CONTROL_EVENTS if with_control else BASE_EVENTS
|
|
65
|
+
|
|
66
|
+
# When toggling off control, strip any control-only event entries we previously added.
|
|
67
|
+
if not with_control:
|
|
68
|
+
for e in ("PreToolUse", "Stop", "SessionStart", "SessionEnd"):
|
|
69
|
+
entries = hooks.get(e) or []
|
|
70
|
+
new_entries = []
|
|
71
|
+
for group in entries:
|
|
72
|
+
kept = [h for h in group.get("hooks", []) if not h.get(MARKER_FIELD)]
|
|
73
|
+
if kept:
|
|
74
|
+
group["hooks"] = kept
|
|
75
|
+
new_entries.append(group)
|
|
76
|
+
if new_entries:
|
|
77
|
+
hooks[e] = new_entries
|
|
78
|
+
else:
|
|
79
|
+
hooks.pop(e, None)
|
|
80
|
+
|
|
81
|
+
for event in events:
|
|
82
|
+
entries = hooks.setdefault(event, [])
|
|
83
|
+
found = None
|
|
84
|
+
for group in entries:
|
|
85
|
+
for h in group.get("hooks", []):
|
|
86
|
+
if h.get(MARKER_FIELD):
|
|
87
|
+
found = h
|
|
88
|
+
break
|
|
89
|
+
if found:
|
|
90
|
+
break
|
|
91
|
+
if found:
|
|
92
|
+
if found.get("command") == command and found.get("timeout") == HOOK_TIMEOUT:
|
|
93
|
+
status[event] = "already-installed"
|
|
94
|
+
else:
|
|
95
|
+
found["command"] = command
|
|
96
|
+
found["timeout"] = HOOK_TIMEOUT
|
|
97
|
+
status[event] = "updated"
|
|
98
|
+
else:
|
|
99
|
+
entries.append({
|
|
100
|
+
"matcher": "*",
|
|
101
|
+
"hooks": [{
|
|
102
|
+
"type": "command",
|
|
103
|
+
"command": command,
|
|
104
|
+
"timeout": HOOK_TIMEOUT,
|
|
105
|
+
MARKER_FIELD: True,
|
|
106
|
+
}],
|
|
107
|
+
})
|
|
108
|
+
status[event] = "installed"
|
|
109
|
+
|
|
110
|
+
_save(path, data)
|
|
111
|
+
return path, status
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def uninstall(project_scope: bool = False) -> tuple[Path, int]:
|
|
115
|
+
"""Remove any agent-comms hook entries across all events. Returns (path, num_removed)."""
|
|
116
|
+
path = _settings_path(project_scope)
|
|
117
|
+
if not path.exists():
|
|
118
|
+
return path, 0
|
|
119
|
+
data = _load(path)
|
|
120
|
+
hooks = (data.get("hooks") or {})
|
|
121
|
+
removed = 0
|
|
122
|
+
for event in list(hooks.keys()):
|
|
123
|
+
entries = hooks.get(event) or []
|
|
124
|
+
new_entries = []
|
|
125
|
+
for group in entries:
|
|
126
|
+
kept = [h for h in group.get("hooks", []) if not h.get(MARKER_FIELD)]
|
|
127
|
+
removed += len(group.get("hooks", [])) - len(kept)
|
|
128
|
+
if kept:
|
|
129
|
+
group["hooks"] = kept
|
|
130
|
+
new_entries.append(group)
|
|
131
|
+
if new_entries:
|
|
132
|
+
hooks[event] = new_entries
|
|
133
|
+
else:
|
|
134
|
+
hooks.pop(event, None)
|
|
135
|
+
if not hooks:
|
|
136
|
+
data.pop("hooks", None)
|
|
137
|
+
if removed:
|
|
138
|
+
_save(path, data)
|
|
139
|
+
return path, removed
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
# Codex CLI integration
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
#
|
|
146
|
+
# Codex CLI uses the same hook architecture as Claude Code: matcher + hooks-array
|
|
147
|
+
# entries grouped under event names (PostToolUse, UserPromptSubmit, etc.). The
|
|
148
|
+
# only differences from Claude Code's install path are:
|
|
149
|
+
#
|
|
150
|
+
# 1. The settings file lives at ~/.codex/hooks.json (or .codex/hooks.json
|
|
151
|
+
# for project scope) instead of ~/.claude/settings.json.
|
|
152
|
+
# 2. Codex requires the `codex_hooks` feature flag to be enabled.
|
|
153
|
+
#
|
|
154
|
+
# The hook script itself (`comms check --hook`) already emits Codex-compatible
|
|
155
|
+
# JSON of the form `{"hookSpecificOutput": {"hookEventName": "...",
|
|
156
|
+
# "additionalContext": "..."}}` so no Codex-specific output adapter is needed.
|
|
157
|
+
# This installer just plants the same per-event entries used for Claude Code,
|
|
158
|
+
# rooted at Codex's settings file, and best-effort enables the feature flag
|
|
159
|
+
# via `codex features enable codex_hooks` if the `codex` binary is on PATH.
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _codex_hooks_path(project_scope: bool) -> Path:
|
|
163
|
+
"""~/.codex/hooks.json (or .codex/hooks.json with --project).
|
|
164
|
+
|
|
165
|
+
Honours $CODEX_HOME for parity with Claude Code's $CLAUDE_CONFIG_DIR
|
|
166
|
+
so users with non-default install layouts work without flags."""
|
|
167
|
+
if project_scope:
|
|
168
|
+
return Path(".codex") / "hooks.json"
|
|
169
|
+
codex_home = Path(os.environ.get("CODEX_HOME", str(Path.home() / ".codex")))
|
|
170
|
+
return codex_home / "hooks.json"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _try_enable_codex_hooks_feature() -> str:
|
|
174
|
+
"""Best-effort run of `codex features enable codex_hooks`.
|
|
175
|
+
|
|
176
|
+
Returns one of:
|
|
177
|
+
"enabled" — codex CLI was on PATH and the command exited 0
|
|
178
|
+
"codex-not-on-path" — codex CLI not installed; caller must enable manually
|
|
179
|
+
"enable-failed: <msg>" — codex CLI returned non-zero; <msg> is short stderr
|
|
180
|
+
"enable-error: <exc>" — subprocess raised before completion
|
|
181
|
+
"""
|
|
182
|
+
import shutil
|
|
183
|
+
import subprocess
|
|
184
|
+
if not shutil.which("codex"):
|
|
185
|
+
return "codex-not-on-path"
|
|
186
|
+
try:
|
|
187
|
+
result = subprocess.run(
|
|
188
|
+
["codex", "features", "enable", "codex_hooks"],
|
|
189
|
+
capture_output=True, text=True, timeout=10,
|
|
190
|
+
)
|
|
191
|
+
except Exception as e:
|
|
192
|
+
return f"enable-error: {e}"
|
|
193
|
+
if result.returncode == 0:
|
|
194
|
+
return "enabled"
|
|
195
|
+
msg = (result.stderr or result.stdout or "").strip().splitlines()[:1]
|
|
196
|
+
return "enable-failed: " + (msg[0][:120] if msg else f"exit {result.returncode}")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def install_codex(project_scope: bool = False, with_control: bool = False) -> tuple[Path, dict]:
|
|
200
|
+
"""Install agent-comms hooks into Codex CLI's hooks.json.
|
|
201
|
+
|
|
202
|
+
Idempotent — existing entries (identified by MARKER_FIELD) are rewritten with
|
|
203
|
+
the current python path + control-mode setting. Same dict-mutation semantics
|
|
204
|
+
as install() — only the on-disk location and the feature-flag tail differ.
|
|
205
|
+
|
|
206
|
+
Returns (settings_path, status_by_event). status_by_event has one key per
|
|
207
|
+
registered event with values {"installed", "updated", "already-installed"},
|
|
208
|
+
plus a "_feature_flag" key carrying the codex_hooks enablement status."""
|
|
209
|
+
path = _codex_hooks_path(project_scope)
|
|
210
|
+
data = _load(path)
|
|
211
|
+
hooks = data.setdefault("hooks", {})
|
|
212
|
+
command = _hook_command(control=with_control)
|
|
213
|
+
status: dict[str, str] = {}
|
|
214
|
+
|
|
215
|
+
events = CONTROL_EVENTS if with_control else BASE_EVENTS
|
|
216
|
+
|
|
217
|
+
# When toggling off control, strip any control-only event entries we previously added.
|
|
218
|
+
if not with_control:
|
|
219
|
+
for e in ("PreToolUse", "Stop", "SessionStart", "SessionEnd"):
|
|
220
|
+
entries = hooks.get(e) or []
|
|
221
|
+
new_entries = []
|
|
222
|
+
for group in entries:
|
|
223
|
+
kept = [h for h in group.get("hooks", []) if not h.get(MARKER_FIELD)]
|
|
224
|
+
if kept:
|
|
225
|
+
group["hooks"] = kept
|
|
226
|
+
new_entries.append(group)
|
|
227
|
+
if new_entries:
|
|
228
|
+
hooks[e] = new_entries
|
|
229
|
+
else:
|
|
230
|
+
hooks.pop(e, None)
|
|
231
|
+
|
|
232
|
+
for event in events:
|
|
233
|
+
entries = hooks.setdefault(event, [])
|
|
234
|
+
found = None
|
|
235
|
+
for group in entries:
|
|
236
|
+
for h in group.get("hooks", []):
|
|
237
|
+
if h.get(MARKER_FIELD):
|
|
238
|
+
found = h
|
|
239
|
+
break
|
|
240
|
+
if found:
|
|
241
|
+
break
|
|
242
|
+
if found:
|
|
243
|
+
if found.get("command") == command and found.get("timeout") == HOOK_TIMEOUT:
|
|
244
|
+
status[event] = "already-installed"
|
|
245
|
+
else:
|
|
246
|
+
found["command"] = command
|
|
247
|
+
found["timeout"] = HOOK_TIMEOUT
|
|
248
|
+
status[event] = "updated"
|
|
249
|
+
else:
|
|
250
|
+
entries.append({
|
|
251
|
+
"matcher": "*",
|
|
252
|
+
"hooks": [{
|
|
253
|
+
"type": "command",
|
|
254
|
+
"command": command,
|
|
255
|
+
"timeout": HOOK_TIMEOUT,
|
|
256
|
+
MARKER_FIELD: True,
|
|
257
|
+
}],
|
|
258
|
+
})
|
|
259
|
+
status[event] = "installed"
|
|
260
|
+
|
|
261
|
+
_save(path, data)
|
|
262
|
+
status["_feature_flag"] = _try_enable_codex_hooks_feature()
|
|
263
|
+
return path, status
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def uninstall_codex(project_scope: bool = False) -> tuple[Path, int]:
|
|
267
|
+
"""Remove agent-comms hook entries from Codex's hooks.json. Returns (path, num_removed).
|
|
268
|
+
|
|
269
|
+
Mirrors uninstall() — only the path resolution differs. Does NOT touch the
|
|
270
|
+
codex_hooks feature flag (other tools may rely on it)."""
|
|
271
|
+
path = _codex_hooks_path(project_scope)
|
|
272
|
+
if not path.exists():
|
|
273
|
+
return path, 0
|
|
274
|
+
data = _load(path)
|
|
275
|
+
hooks = (data.get("hooks") or {})
|
|
276
|
+
removed = 0
|
|
277
|
+
for event in list(hooks.keys()):
|
|
278
|
+
entries = hooks.get(event) or []
|
|
279
|
+
new_entries = []
|
|
280
|
+
for group in entries:
|
|
281
|
+
kept = [h for h in group.get("hooks", []) if not h.get(MARKER_FIELD)]
|
|
282
|
+
removed += len(group.get("hooks", [])) - len(kept)
|
|
283
|
+
if kept:
|
|
284
|
+
group["hooks"] = kept
|
|
285
|
+
new_entries.append(group)
|
|
286
|
+
if new_entries:
|
|
287
|
+
hooks[event] = new_entries
|
|
288
|
+
else:
|
|
289
|
+
hooks.pop(event, None)
|
|
290
|
+
if not hooks:
|
|
291
|
+
data.pop("hooks", None)
|
|
292
|
+
if removed:
|
|
293
|
+
_save(path, data)
|
|
294
|
+
return path, removed
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Tests for the Codex CLI hook installer (install_codex / uninstall_codex)."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from unittest.mock import patch
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from agent_comms import install
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _hooks_at(path):
|
|
13
|
+
"""Helper: load hooks.json, return its `hooks` dict (or {})."""
|
|
14
|
+
return json.loads(path.read_text())["hooks"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture
|
|
18
|
+
def codex_home(tmp_path, monkeypatch):
|
|
19
|
+
"""Isolated CODEX_HOME so tests don't touch the user's real ~/.codex."""
|
|
20
|
+
home = tmp_path / "codex_home"
|
|
21
|
+
monkeypatch.setenv("CODEX_HOME", str(home))
|
|
22
|
+
# Don't actually shell out to `codex features enable` in tests.
|
|
23
|
+
monkeypatch.setattr(install, "_try_enable_codex_hooks_feature",
|
|
24
|
+
lambda: "codex-not-on-path")
|
|
25
|
+
return home
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_codex_hooks_path_user_scope(codex_home):
|
|
29
|
+
p = install._codex_hooks_path(project_scope=False)
|
|
30
|
+
assert p == codex_home / "hooks.json"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_codex_hooks_path_project_scope(codex_home):
|
|
34
|
+
p = install._codex_hooks_path(project_scope=True)
|
|
35
|
+
assert p == Path(".codex") / "hooks.json"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_codex_hooks_path_honors_env_var(monkeypatch, tmp_path):
|
|
39
|
+
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "alt"))
|
|
40
|
+
monkeypatch.setattr(install, "_try_enable_codex_hooks_feature",
|
|
41
|
+
lambda: "codex-not-on-path")
|
|
42
|
+
assert install._codex_hooks_path(False) == tmp_path / "alt" / "hooks.json"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_install_codex_creates_file_and_entries(codex_home):
|
|
46
|
+
path, status = install.install_codex(project_scope=False, with_control=False)
|
|
47
|
+
|
|
48
|
+
# Wrote to the right location.
|
|
49
|
+
assert path == codex_home / "hooks.json"
|
|
50
|
+
assert path.is_file()
|
|
51
|
+
|
|
52
|
+
# Both base events registered.
|
|
53
|
+
assert status["PostToolUse"] == "installed"
|
|
54
|
+
assert status["UserPromptSubmit"] == "installed"
|
|
55
|
+
assert status["_feature_flag"] == "codex-not-on-path"
|
|
56
|
+
|
|
57
|
+
# The on-disk schema matches what Codex expects.
|
|
58
|
+
hooks = _hooks_at(path)
|
|
59
|
+
for event in ("PostToolUse", "UserPromptSubmit"):
|
|
60
|
+
entry = hooks[event][0]
|
|
61
|
+
assert entry["matcher"] == "*"
|
|
62
|
+
h = entry["hooks"][0]
|
|
63
|
+
assert h["type"] == "command"
|
|
64
|
+
assert "agent_comms check --hook" in h["command"]
|
|
65
|
+
assert h["timeout"] == install.HOOK_TIMEOUT
|
|
66
|
+
assert h[install.MARKER_FIELD] is True
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_install_codex_idempotent(codex_home):
|
|
70
|
+
install.install_codex()
|
|
71
|
+
_, status = install.install_codex()
|
|
72
|
+
assert status["PostToolUse"] == "already-installed"
|
|
73
|
+
assert status["UserPromptSubmit"] == "already-installed"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_install_codex_with_control_adds_extra_events(codex_home):
|
|
77
|
+
_, status = install.install_codex(with_control=True)
|
|
78
|
+
for event in ("PreToolUse", "PostToolUse", "UserPromptSubmit",
|
|
79
|
+
"Stop", "SessionStart", "SessionEnd"):
|
|
80
|
+
assert status[event] == "installed", f"{event} missing"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_install_codex_toggle_off_control_strips_extra_events(codex_home):
|
|
84
|
+
install.install_codex(with_control=True)
|
|
85
|
+
install.install_codex(with_control=False)
|
|
86
|
+
hooks = _hooks_at(codex_home / "hooks.json")
|
|
87
|
+
# Only base events remain
|
|
88
|
+
assert set(hooks.keys()) == {"PostToolUse", "UserPromptSubmit"}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_uninstall_codex_removes_only_marker_entries(codex_home, tmp_path):
|
|
92
|
+
install.install_codex()
|
|
93
|
+
# Add a foreign hook entry that should NOT be touched.
|
|
94
|
+
path = codex_home / "hooks.json"
|
|
95
|
+
data = json.loads(path.read_text())
|
|
96
|
+
data["hooks"]["PostToolUse"].append({
|
|
97
|
+
"matcher": "Bash",
|
|
98
|
+
"hooks": [{"type": "command", "command": "/bin/foreign-tool", "timeout": 10}],
|
|
99
|
+
})
|
|
100
|
+
path.write_text(json.dumps(data))
|
|
101
|
+
|
|
102
|
+
_, removed = install.uninstall_codex()
|
|
103
|
+
assert removed == 2 # PostToolUse + UserPromptSubmit marker entries
|
|
104
|
+
|
|
105
|
+
# Foreign entry preserved.
|
|
106
|
+
hooks = _hooks_at(path)
|
|
107
|
+
assert hooks["PostToolUse"][0]["hooks"][0]["command"] == "/bin/foreign-tool"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_uninstall_codex_missing_file_is_noop(codex_home):
|
|
111
|
+
path, n = install.uninstall_codex()
|
|
112
|
+
assert n == 0
|
|
113
|
+
assert path == codex_home / "hooks.json"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_install_codex_does_not_touch_claude_settings(codex_home, tmp_path, monkeypatch):
|
|
117
|
+
# Set CLAUDE_CONFIG_DIR to a separate dir; install_codex must NOT write there.
|
|
118
|
+
claude = tmp_path / "claude_home"
|
|
119
|
+
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(claude))
|
|
120
|
+
install.install_codex()
|
|
121
|
+
assert not (claude / "settings.json").exists()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_install_does_not_touch_codex_settings(codex_home, tmp_path, monkeypatch):
|
|
125
|
+
# Symmetric: install() (Claude path) must NOT write to ~/.codex/.
|
|
126
|
+
claude = tmp_path / "claude_home"
|
|
127
|
+
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(claude))
|
|
128
|
+
install.install()
|
|
129
|
+
assert (claude / "settings.json").exists()
|
|
130
|
+
assert not (codex_home / "hooks.json").exists()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_try_enable_codex_hooks_feature_when_codex_missing(monkeypatch):
|
|
134
|
+
monkeypatch.setattr("shutil.which", lambda name: None)
|
|
135
|
+
assert install._try_enable_codex_hooks_feature() == "codex-not-on-path"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_try_enable_codex_hooks_feature_when_codex_succeeds(monkeypatch):
|
|
139
|
+
import subprocess
|
|
140
|
+
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/codex")
|
|
141
|
+
fake = type("R", (), {"returncode": 0, "stderr": "", "stdout": ""})()
|
|
142
|
+
monkeypatch.setattr(subprocess, "run", lambda *a, **k: fake)
|
|
143
|
+
assert install._try_enable_codex_hooks_feature() == "enabled"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_try_enable_codex_hooks_feature_when_codex_fails(monkeypatch):
|
|
147
|
+
import subprocess
|
|
148
|
+
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/codex")
|
|
149
|
+
fake = type("R", (), {"returncode": 1, "stderr": "feature unknown", "stdout": ""})()
|
|
150
|
+
monkeypatch.setattr(subprocess, "run", lambda *a, **k: fake)
|
|
151
|
+
result = install._try_enable_codex_hooks_feature()
|
|
152
|
+
assert result.startswith("enable-failed:")
|
|
153
|
+
assert "feature unknown" in result
|
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
"""Install/uninstall the agent-comms Claude Code hook.
|
|
2
|
-
|
|
3
|
-
Edits ~/.claude/settings.json (or .claude/settings.json with --project) to
|
|
4
|
-
register a PostToolUse hook running `comms check --hook`.
|
|
5
|
-
"""
|
|
6
|
-
from __future__ import annotations
|
|
7
|
-
|
|
8
|
-
import json
|
|
9
|
-
import os
|
|
10
|
-
import shlex
|
|
11
|
-
import sys
|
|
12
|
-
from pathlib import Path
|
|
13
|
-
|
|
14
|
-
HOOK_TIMEOUT = 5
|
|
15
|
-
MARKER_FIELD = "agent_comms_marker" # embedded in the hook entry so we can find+remove
|
|
16
|
-
BASE_EVENTS = ("PostToolUse", "UserPromptSubmit")
|
|
17
|
-
CONTROL_EVENTS = ("PreToolUse", "PostToolUse", "UserPromptSubmit", "Stop", "SessionStart", "SessionEnd")
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def _hook_command(control: bool = False) -> str:
|
|
21
|
-
"""Absolute python path + module form — works in any shell / PATH."""
|
|
22
|
-
cmd = f"{shlex.quote(sys.executable)} -m agent_comms check --hook"
|
|
23
|
-
if control:
|
|
24
|
-
cmd += " --with-control"
|
|
25
|
-
return cmd
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def _settings_path(project_scope: bool) -> Path:
|
|
29
|
-
if project_scope:
|
|
30
|
-
return Path(".claude") / "settings.json"
|
|
31
|
-
claude_home = Path(os.environ.get("CLAUDE_CONFIG_DIR", str(Path.home() / ".claude")))
|
|
32
|
-
return claude_home / "settings.json"
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def _load(path: Path) -> dict:
|
|
36
|
-
if not path.exists():
|
|
37
|
-
return {}
|
|
38
|
-
try:
|
|
39
|
-
return json.loads(path.read_text() or "{}")
|
|
40
|
-
except json.JSONDecodeError:
|
|
41
|
-
return {}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def _save(path: Path, data: dict) -> None:
|
|
45
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
-
path.write_text(json.dumps(data, indent=2) + "\n")
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def install(project_scope: bool = False, with_control: bool = False) -> tuple[Path, dict]:
|
|
50
|
-
"""Install hooks. Returns (settings_path, status_by_event).
|
|
51
|
-
Idempotent — existing entries are rewritten with the current command (useful when
|
|
52
|
-
the python path changes due to venv / reinstall, or when toggling --with-control).
|
|
53
|
-
|
|
54
|
-
with_control=True extends the registered events (adds Stop, SessionStart, SessionEnd)
|
|
55
|
-
and adds `--with-control` to the hook command so the hook broadcasts activity events
|
|
56
|
-
to /api/events and delivers operator_inputs as user-turn-framed context.
|
|
57
|
-
"""
|
|
58
|
-
path = _settings_path(project_scope)
|
|
59
|
-
data = _load(path)
|
|
60
|
-
hooks = data.setdefault("hooks", {})
|
|
61
|
-
command = _hook_command(control=with_control)
|
|
62
|
-
status: dict[str, str] = {}
|
|
63
|
-
|
|
64
|
-
events = CONTROL_EVENTS if with_control else BASE_EVENTS
|
|
65
|
-
|
|
66
|
-
# When toggling off control, strip any control-only event entries we previously added.
|
|
67
|
-
if not with_control:
|
|
68
|
-
for e in ("PreToolUse", "Stop", "SessionStart", "SessionEnd"):
|
|
69
|
-
entries = hooks.get(e) or []
|
|
70
|
-
new_entries = []
|
|
71
|
-
for group in entries:
|
|
72
|
-
kept = [h for h in group.get("hooks", []) if not h.get(MARKER_FIELD)]
|
|
73
|
-
if kept:
|
|
74
|
-
group["hooks"] = kept
|
|
75
|
-
new_entries.append(group)
|
|
76
|
-
if new_entries:
|
|
77
|
-
hooks[e] = new_entries
|
|
78
|
-
else:
|
|
79
|
-
hooks.pop(e, None)
|
|
80
|
-
|
|
81
|
-
for event in events:
|
|
82
|
-
entries = hooks.setdefault(event, [])
|
|
83
|
-
found = None
|
|
84
|
-
for group in entries:
|
|
85
|
-
for h in group.get("hooks", []):
|
|
86
|
-
if h.get(MARKER_FIELD):
|
|
87
|
-
found = h
|
|
88
|
-
break
|
|
89
|
-
if found:
|
|
90
|
-
break
|
|
91
|
-
if found:
|
|
92
|
-
if found.get("command") == command and found.get("timeout") == HOOK_TIMEOUT:
|
|
93
|
-
status[event] = "already-installed"
|
|
94
|
-
else:
|
|
95
|
-
found["command"] = command
|
|
96
|
-
found["timeout"] = HOOK_TIMEOUT
|
|
97
|
-
status[event] = "updated"
|
|
98
|
-
else:
|
|
99
|
-
entries.append({
|
|
100
|
-
"matcher": "*",
|
|
101
|
-
"hooks": [{
|
|
102
|
-
"type": "command",
|
|
103
|
-
"command": command,
|
|
104
|
-
"timeout": HOOK_TIMEOUT,
|
|
105
|
-
MARKER_FIELD: True,
|
|
106
|
-
}],
|
|
107
|
-
})
|
|
108
|
-
status[event] = "installed"
|
|
109
|
-
|
|
110
|
-
_save(path, data)
|
|
111
|
-
return path, status
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
def uninstall(project_scope: bool = False) -> tuple[Path, int]:
|
|
115
|
-
"""Remove any agent-comms hook entries across all events. Returns (path, num_removed)."""
|
|
116
|
-
path = _settings_path(project_scope)
|
|
117
|
-
if not path.exists():
|
|
118
|
-
return path, 0
|
|
119
|
-
data = _load(path)
|
|
120
|
-
hooks = (data.get("hooks") or {})
|
|
121
|
-
removed = 0
|
|
122
|
-
for event in list(hooks.keys()):
|
|
123
|
-
entries = hooks.get(event) or []
|
|
124
|
-
new_entries = []
|
|
125
|
-
for group in entries:
|
|
126
|
-
kept = [h for h in group.get("hooks", []) if not h.get(MARKER_FIELD)]
|
|
127
|
-
removed += len(group.get("hooks", [])) - len(kept)
|
|
128
|
-
if kept:
|
|
129
|
-
group["hooks"] = kept
|
|
130
|
-
new_entries.append(group)
|
|
131
|
-
if new_entries:
|
|
132
|
-
hooks[event] = new_entries
|
|
133
|
-
else:
|
|
134
|
-
hooks.pop(event, None)
|
|
135
|
-
if not hooks:
|
|
136
|
-
data.pop("hooks", None)
|
|
137
|
-
if removed:
|
|
138
|
-
_save(path, data)
|
|
139
|
-
return path, removed
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|