agentic-comms 0.2.0__tar.gz → 0.2.2__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.2.0 → agentic_comms-0.2.2}/PKG-INFO +1 -1
- agentic_comms-0.2.2/agent_comms/__main__.py +5 -0
- {agentic_comms-0.2.0 → agentic_comms-0.2.2}/agent_comms/cli.py +14 -8
- {agentic_comms-0.2.0 → agentic_comms-0.2.2}/agent_comms/config.py +28 -0
- {agentic_comms-0.2.0 → agentic_comms-0.2.2}/agent_comms/hook.py +6 -23
- agentic_comms-0.2.2/agent_comms/install.py +113 -0
- {agentic_comms-0.2.0 → agentic_comms-0.2.2}/agentic_comms.egg-info/PKG-INFO +1 -1
- {agentic_comms-0.2.0 → agentic_comms-0.2.2}/agentic_comms.egg-info/SOURCES.txt +1 -0
- {agentic_comms-0.2.0 → agentic_comms-0.2.2}/pyproject.toml +1 -1
- {agentic_comms-0.2.0 → agentic_comms-0.2.2}/tests/test_cli.py +18 -12
- agentic_comms-0.2.0/agent_comms/install.py +0 -91
- {agentic_comms-0.2.0 → agentic_comms-0.2.2}/README.md +0 -0
- {agentic_comms-0.2.0 → agentic_comms-0.2.2}/agent_comms/__init__.py +0 -0
- {agentic_comms-0.2.0 → agentic_comms-0.2.2}/agent_comms/api.py +0 -0
- {agentic_comms-0.2.0 → agentic_comms-0.2.2}/agentic_comms.egg-info/dependency_links.txt +0 -0
- {agentic_comms-0.2.0 → agentic_comms-0.2.2}/agentic_comms.egg-info/entry_points.txt +0 -0
- {agentic_comms-0.2.0 → agentic_comms-0.2.2}/agentic_comms.egg-info/requires.txt +0 -0
- {agentic_comms-0.2.0 → agentic_comms-0.2.2}/agentic_comms.egg-info/top_level.txt +0 -0
- {agentic_comms-0.2.0 → agentic_comms-0.2.2}/setup.cfg +0 -0
|
@@ -59,6 +59,10 @@ def _run(fn, *args, **kwargs):
|
|
|
59
59
|
|
|
60
60
|
def _current_identity_or_exit() -> str:
|
|
61
61
|
ident = config.load_identity()
|
|
62
|
+
if not ident:
|
|
63
|
+
# Under Claude Code: silently auto-register so the first CLI call just works.
|
|
64
|
+
# Outside Claude: preserve the explicit "run comms init" UX (scripts/cron shouldn't spawn identities).
|
|
65
|
+
ident = config.auto_init_identity(require_claude=True)
|
|
62
66
|
if not ident:
|
|
63
67
|
_die("no identity for this directory. Run `comms init` or `comms claim <handle>`.")
|
|
64
68
|
return ident.handle
|
|
@@ -147,8 +151,10 @@ def init(
|
|
|
147
151
|
|
|
148
152
|
@app.command()
|
|
149
153
|
def whoami(json_: bool = typer.Option(False, "--json")):
|
|
150
|
-
"""Show the identity attached to this directory."""
|
|
154
|
+
"""Show the identity attached to this directory (auto-creates one under Claude Code)."""
|
|
151
155
|
ident = config.load_identity()
|
|
156
|
+
if not ident:
|
|
157
|
+
ident = config.auto_init_identity(require_claude=True)
|
|
152
158
|
if not ident:
|
|
153
159
|
_die("no identity for this directory. Run `comms init`.")
|
|
154
160
|
if json_:
|
|
@@ -340,14 +346,14 @@ def set_server(url: str):
|
|
|
340
346
|
|
|
341
347
|
@app.command("install-hook")
|
|
342
348
|
def install_hook(project: bool = typer.Option(False, "--project", help="Install in .claude/settings.json (project) instead of ~/.claude/settings.json (user).")):
|
|
343
|
-
"""Register
|
|
344
|
-
Idempotent —
|
|
349
|
+
"""Register PostToolUse + UserPromptSubmit hooks so Claude Code auto-receives new DMs.
|
|
350
|
+
Idempotent — re-running will update the stored python path if it changed."""
|
|
345
351
|
from . import install as inst
|
|
346
|
-
path,
|
|
347
|
-
|
|
348
|
-
print(f"
|
|
349
|
-
|
|
350
|
-
|
|
352
|
+
path, status = inst.install(project_scope=project)
|
|
353
|
+
for event, s in status.items():
|
|
354
|
+
print(f" {event}: {s}")
|
|
355
|
+
print(f"settings: {path}")
|
|
356
|
+
if any(v in ("installed", "updated") for v in status.values()):
|
|
351
357
|
print("new direct messages will now appear in Claude's context automatically.")
|
|
352
358
|
|
|
353
359
|
|
|
@@ -184,6 +184,34 @@ def save_server_url(url: str) -> None:
|
|
|
184
184
|
SERVER_FILE.write_text(url.strip() + "\n")
|
|
185
185
|
|
|
186
186
|
|
|
187
|
+
def auto_init_identity(claude_pid: int | None = None, require_claude: bool = True) -> "LocalIdentity | None":
|
|
188
|
+
"""Silently create and register a new identity for the current cwd.
|
|
189
|
+
require_claude=True (default) means we only auto-create under Claude Code —
|
|
190
|
+
otherwise random CLI calls from cron/scripts would spam identities."""
|
|
191
|
+
if claude_pid is None:
|
|
192
|
+
claude_pid = find_claude_pid()
|
|
193
|
+
if require_claude and claude_pid is None:
|
|
194
|
+
return None
|
|
195
|
+
try:
|
|
196
|
+
import uuid as _uuid
|
|
197
|
+
from .api import Client # local import to avoid circulars at module load
|
|
198
|
+
ctx = derive_context()
|
|
199
|
+
parts = [p for p in [ctx["user"], ctx["project"]] if p]
|
|
200
|
+
base = "-".join(parts) or "agent"
|
|
201
|
+
if ctx["branch"]:
|
|
202
|
+
base += f"-{ctx['branch']}"
|
|
203
|
+
handle = f"{base}-{_uuid.uuid4().hex[:4]}"
|
|
204
|
+
c = Client()
|
|
205
|
+
result = c.register_identity(handle=handle, **ctx)
|
|
206
|
+
ident = LocalIdentity(
|
|
207
|
+
handle=result["handle"], server_url=c.url, cwd=ctx["cwd"], claude_pid=claude_pid,
|
|
208
|
+
)
|
|
209
|
+
ident.save()
|
|
210
|
+
return ident
|
|
211
|
+
except Exception:
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
|
|
187
215
|
def derive_context() -> dict:
|
|
188
216
|
cwd = repo_root()
|
|
189
217
|
return {
|
|
@@ -89,7 +89,7 @@ def _fmt_dm(m: dict) -> str:
|
|
|
89
89
|
)
|
|
90
90
|
|
|
91
91
|
|
|
92
|
-
def _emit_context(handle: str, dms: list[dict]) -> None:
|
|
92
|
+
def _emit_context(handle: str, dms: list[dict], event_name: str) -> None:
|
|
93
93
|
dms_block = "\n\n".join(_fmt_dm(m) for m in dms)
|
|
94
94
|
plural = "s" if len(dms) != 1 else ""
|
|
95
95
|
ids = ", ".join(m["id"] for m in dms)
|
|
@@ -106,7 +106,7 @@ def _emit_context(handle: str, dms: list[dict]) -> None:
|
|
|
106
106
|
)
|
|
107
107
|
out = {
|
|
108
108
|
"hookSpecificOutput": {
|
|
109
|
-
"hookEventName":
|
|
109
|
+
"hookEventName": event_name,
|
|
110
110
|
"additionalContext": ctx,
|
|
111
111
|
}
|
|
112
112
|
}
|
|
@@ -114,32 +114,15 @@ def _emit_context(handle: str, dms: list[dict]) -> None:
|
|
|
114
114
|
|
|
115
115
|
|
|
116
116
|
def _auto_init_identity(cwd: Path, claude_pid: int | None) -> config.LocalIdentity | None:
|
|
117
|
-
"""Create an identity silently
|
|
118
|
-
|
|
119
|
-
return None
|
|
120
|
-
try:
|
|
121
|
-
from .api import Client
|
|
122
|
-
ctx = config.derive_context()
|
|
123
|
-
parts = [p for p in [ctx["user"], ctx["project"]] if p]
|
|
124
|
-
base = "-".join(parts) or "agent"
|
|
125
|
-
if ctx["branch"]:
|
|
126
|
-
base += f"-{ctx['branch']}"
|
|
127
|
-
handle = f"{base}-{uuid.uuid4().hex[:4]}"
|
|
128
|
-
c = Client()
|
|
129
|
-
result = c.register_identity(handle=handle, **ctx)
|
|
130
|
-
ident = config.LocalIdentity(
|
|
131
|
-
handle=result["handle"], server_url=c.url, cwd=ctx["cwd"], claude_pid=claude_pid,
|
|
132
|
-
)
|
|
133
|
-
ident.save()
|
|
134
|
-
return ident
|
|
135
|
-
except Exception:
|
|
136
|
-
return None
|
|
117
|
+
"""Create an identity silently. Uses the shared config.auto_init."""
|
|
118
|
+
return config.auto_init_identity(claude_pid=claude_pid, require_claude=True)
|
|
137
119
|
|
|
138
120
|
|
|
139
121
|
def main() -> int:
|
|
140
122
|
hook_input = _read_hook_input()
|
|
141
123
|
cwd_str = hook_input.get("cwd") or str(Path.cwd())
|
|
142
124
|
cwd = Path(cwd_str)
|
|
125
|
+
event_name = hook_input.get("hook_event_name") or "PostToolUse"
|
|
143
126
|
claude_pid = config.find_claude_pid()
|
|
144
127
|
|
|
145
128
|
ident = config.load_identity(cwd=cwd, claude_pid=claude_pid)
|
|
@@ -187,7 +170,7 @@ def main() -> int:
|
|
|
187
170
|
newest = new_dms[-1]["created_at"]
|
|
188
171
|
_write(watermark_path, newest)
|
|
189
172
|
|
|
190
|
-
_emit_context(handle, new_dms)
|
|
173
|
+
_emit_context(handle, new_dms, event_name)
|
|
191
174
|
return 0
|
|
192
175
|
|
|
193
176
|
|
|
@@ -0,0 +1,113 @@
|
|
|
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
|
+
HOOK_EVENTS = ("PostToolUse", "UserPromptSubmit")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _hook_command() -> str:
|
|
20
|
+
"""Absolute python path + module form — works in any shell / PATH."""
|
|
21
|
+
return f"{shlex.quote(sys.executable)} -m agent_comms check --hook"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _settings_path(project_scope: bool) -> Path:
|
|
25
|
+
if project_scope:
|
|
26
|
+
return Path(".claude") / "settings.json"
|
|
27
|
+
claude_home = Path(os.environ.get("CLAUDE_CONFIG_DIR", str(Path.home() / ".claude")))
|
|
28
|
+
return claude_home / "settings.json"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _load(path: Path) -> dict:
|
|
32
|
+
if not path.exists():
|
|
33
|
+
return {}
|
|
34
|
+
try:
|
|
35
|
+
return json.loads(path.read_text() or "{}")
|
|
36
|
+
except json.JSONDecodeError:
|
|
37
|
+
return {}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _save(path: Path, data: dict) -> None:
|
|
41
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
path.write_text(json.dumps(data, indent=2) + "\n")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def install(project_scope: bool = False) -> tuple[Path, dict]:
|
|
46
|
+
"""Install hooks for every event in HOOK_EVENTS. Returns (settings_path, status).
|
|
47
|
+
Idempotent — existing entries are rewritten with the current command (useful when
|
|
48
|
+
the python path changes due to venv / reinstall)."""
|
|
49
|
+
path = _settings_path(project_scope)
|
|
50
|
+
data = _load(path)
|
|
51
|
+
hooks = data.setdefault("hooks", {})
|
|
52
|
+
command = _hook_command()
|
|
53
|
+
status: dict[str, str] = {}
|
|
54
|
+
|
|
55
|
+
for event in HOOK_EVENTS:
|
|
56
|
+
entries = hooks.setdefault(event, [])
|
|
57
|
+
found = None
|
|
58
|
+
for group in entries:
|
|
59
|
+
for h in group.get("hooks", []):
|
|
60
|
+
if h.get(MARKER_FIELD):
|
|
61
|
+
found = h
|
|
62
|
+
break
|
|
63
|
+
if found:
|
|
64
|
+
break
|
|
65
|
+
if found:
|
|
66
|
+
if found.get("command") == command and found.get("timeout") == HOOK_TIMEOUT:
|
|
67
|
+
status[event] = "already-installed"
|
|
68
|
+
else:
|
|
69
|
+
found["command"] = command
|
|
70
|
+
found["timeout"] = HOOK_TIMEOUT
|
|
71
|
+
status[event] = "updated"
|
|
72
|
+
else:
|
|
73
|
+
entries.append({
|
|
74
|
+
"matcher": "*",
|
|
75
|
+
"hooks": [{
|
|
76
|
+
"type": "command",
|
|
77
|
+
"command": command,
|
|
78
|
+
"timeout": HOOK_TIMEOUT,
|
|
79
|
+
MARKER_FIELD: True,
|
|
80
|
+
}],
|
|
81
|
+
})
|
|
82
|
+
status[event] = "installed"
|
|
83
|
+
|
|
84
|
+
_save(path, data)
|
|
85
|
+
return path, status
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def uninstall(project_scope: bool = False) -> tuple[Path, int]:
|
|
89
|
+
"""Remove any agent-comms hook entries across all events. Returns (path, num_removed)."""
|
|
90
|
+
path = _settings_path(project_scope)
|
|
91
|
+
if not path.exists():
|
|
92
|
+
return path, 0
|
|
93
|
+
data = _load(path)
|
|
94
|
+
hooks = (data.get("hooks") or {})
|
|
95
|
+
removed = 0
|
|
96
|
+
for event in list(hooks.keys()):
|
|
97
|
+
entries = hooks.get(event) or []
|
|
98
|
+
new_entries = []
|
|
99
|
+
for group in entries:
|
|
100
|
+
kept = [h for h in group.get("hooks", []) if not h.get(MARKER_FIELD)]
|
|
101
|
+
removed += len(group.get("hooks", [])) - len(kept)
|
|
102
|
+
if kept:
|
|
103
|
+
group["hooks"] = kept
|
|
104
|
+
new_entries.append(group)
|
|
105
|
+
if new_entries:
|
|
106
|
+
hooks[event] = new_entries
|
|
107
|
+
else:
|
|
108
|
+
hooks.pop(event, None)
|
|
109
|
+
if not hooks:
|
|
110
|
+
data.pop("hooks", None)
|
|
111
|
+
if removed:
|
|
112
|
+
_save(path, data)
|
|
113
|
+
return path, removed
|
|
@@ -112,7 +112,10 @@ def test_claim_unknown(env):
|
|
|
112
112
|
assert r.exit_code != 0
|
|
113
113
|
|
|
114
114
|
|
|
115
|
-
def test_forget(env):
|
|
115
|
+
def test_forget(env, monkeypatch):
|
|
116
|
+
# Simulate running outside a Claude session so auto-init doesn't re-create identity.
|
|
117
|
+
from agent_comms import config as cfg
|
|
118
|
+
monkeypatch.setattr(cfg, "find_claude_pid", lambda: None)
|
|
116
119
|
run(["init", "--handle", "alpha"])
|
|
117
120
|
run(["forget"])
|
|
118
121
|
r = run(["whoami"])
|
|
@@ -187,21 +190,24 @@ def test_install_and_uninstall_hook(env, tmp_path, monkeypatch):
|
|
|
187
190
|
r = run(["install-hook"])
|
|
188
191
|
assert r.exit_code == 0
|
|
189
192
|
settings = json.loads((claude_home / "settings.json").read_text())
|
|
190
|
-
|
|
191
|
-
assert
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
193
|
+
assert "PostToolUse" in settings["hooks"]
|
|
194
|
+
assert "UserPromptSubmit" in settings["hooks"]
|
|
195
|
+
# command must use python -m form, not bare `comms`
|
|
196
|
+
for event in ("PostToolUse", "UserPromptSubmit"):
|
|
197
|
+
groups = settings["hooks"][event]
|
|
198
|
+
cmds = [h["command"] for g in groups for h in g.get("hooks", [])]
|
|
199
|
+
assert any("-m agent_comms" in c for c in cmds)
|
|
200
|
+
# idempotent — no duplicate entries
|
|
196
201
|
r2 = run(["install-hook"])
|
|
197
|
-
assert
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
202
|
+
assert r2.exit_code == 0
|
|
203
|
+
settings2 = json.loads((claude_home / "settings.json").read_text())
|
|
204
|
+
for event in ("PostToolUse", "UserPromptSubmit"):
|
|
205
|
+
assert len(settings2["hooks"][event]) == len(settings["hooks"][event])
|
|
206
|
+
# uninstall removes both
|
|
201
207
|
r3 = run(["uninstall-hook"])
|
|
202
208
|
assert r3.exit_code == 0
|
|
203
209
|
data = json.loads((claude_home / "settings.json").read_text())
|
|
204
|
-
assert "hooks" not in data or
|
|
210
|
+
assert "hooks" not in data or all(e not in data.get("hooks", {}) for e in ("PostToolUse", "UserPromptSubmit"))
|
|
205
211
|
|
|
206
212
|
|
|
207
213
|
def test_hook_silent_when_no_dms(env, tmp_path, monkeypatch):
|
|
@@ -1,91 +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
|
-
from pathlib import Path
|
|
11
|
-
|
|
12
|
-
HOOK_COMMAND = "comms check --hook"
|
|
13
|
-
HOOK_TIMEOUT = 5
|
|
14
|
-
MARKER_FIELD = "agent_comms_marker" # embedded in the hook entry so we can find+remove
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def _settings_path(project_scope: bool) -> Path:
|
|
18
|
-
if project_scope:
|
|
19
|
-
return Path(".claude") / "settings.json"
|
|
20
|
-
claude_home = Path(os.environ.get("CLAUDE_CONFIG_DIR", str(Path.home() / ".claude")))
|
|
21
|
-
return claude_home / "settings.json"
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def _load(path: Path) -> dict:
|
|
25
|
-
if not path.exists():
|
|
26
|
-
return {}
|
|
27
|
-
try:
|
|
28
|
-
return json.loads(path.read_text() or "{}")
|
|
29
|
-
except json.JSONDecodeError:
|
|
30
|
-
return {}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def _save(path: Path, data: dict) -> None:
|
|
34
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
35
|
-
path.write_text(json.dumps(data, indent=2) + "\n")
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def install(project_scope: bool = False) -> tuple[Path, bool]:
|
|
39
|
-
"""Install the PostToolUse hook. Returns (settings_path, already_installed_bool).
|
|
40
|
-
Idempotent — detects existing entry by marker field."""
|
|
41
|
-
path = _settings_path(project_scope)
|
|
42
|
-
data = _load(path)
|
|
43
|
-
hooks = data.setdefault("hooks", {})
|
|
44
|
-
entries = hooks.setdefault("PostToolUse", [])
|
|
45
|
-
|
|
46
|
-
for group in entries:
|
|
47
|
-
for h in group.get("hooks", []):
|
|
48
|
-
if h.get(MARKER_FIELD):
|
|
49
|
-
return path, True
|
|
50
|
-
|
|
51
|
-
entries.append({
|
|
52
|
-
"matcher": "*",
|
|
53
|
-
"hooks": [
|
|
54
|
-
{
|
|
55
|
-
"type": "command",
|
|
56
|
-
"command": HOOK_COMMAND,
|
|
57
|
-
"timeout": HOOK_TIMEOUT,
|
|
58
|
-
MARKER_FIELD: True,
|
|
59
|
-
}
|
|
60
|
-
],
|
|
61
|
-
})
|
|
62
|
-
_save(path, data)
|
|
63
|
-
return path, False
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def uninstall(project_scope: bool = False) -> tuple[Path, int]:
|
|
67
|
-
"""Remove any agent-comms hook entries. Returns (path, num_removed)."""
|
|
68
|
-
path = _settings_path(project_scope)
|
|
69
|
-
if not path.exists():
|
|
70
|
-
return path, 0
|
|
71
|
-
data = _load(path)
|
|
72
|
-
hooks = (data.get("hooks") or {})
|
|
73
|
-
entries = hooks.get("PostToolUse") or []
|
|
74
|
-
removed = 0
|
|
75
|
-
new_entries = []
|
|
76
|
-
for group in entries:
|
|
77
|
-
kept_hooks = [h for h in group.get("hooks", []) if not h.get(MARKER_FIELD)]
|
|
78
|
-
removed += len(group.get("hooks", [])) - len(kept_hooks)
|
|
79
|
-
if kept_hooks:
|
|
80
|
-
group["hooks"] = kept_hooks
|
|
81
|
-
new_entries.append(group)
|
|
82
|
-
if entries:
|
|
83
|
-
if new_entries:
|
|
84
|
-
hooks["PostToolUse"] = new_entries
|
|
85
|
-
else:
|
|
86
|
-
hooks.pop("PostToolUse", None)
|
|
87
|
-
if not hooks:
|
|
88
|
-
data.pop("hooks", None)
|
|
89
|
-
if removed:
|
|
90
|
-
_save(path, data)
|
|
91
|
-
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
|