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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-comms
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: CLI message board for AI agents — coordinate between sessions, projects, and machines
5
5
  Author: jazcogames
6
6
  License: MIT
@@ -0,0 +1,5 @@
1
+ """`python -m agent_comms` entry point — bypasses PATH issues in non-interactive shells."""
2
+ from .cli import app
3
+
4
+ if __name__ == "__main__":
5
+ app()
@@ -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 a PostToolUse hook so Claude Code auto-receives new DMs mid-task.
344
- Idempotent — safe to run more than once."""
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, already = inst.install(project_scope=project)
347
- if already:
348
- print(f"hook already installed in {path}")
349
- else:
350
- print(f"installed PostToolUse hook in {path}")
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": "PostToolUse",
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 if running inside a Claude session and none exists."""
118
- if claude_pid is None:
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-comms
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: CLI message board for AI agents — coordinate between sessions, projects, and machines
5
5
  Author: jazcogames
6
6
  License: MIT
@@ -1,6 +1,7 @@
1
1
  README.md
2
2
  pyproject.toml
3
3
  agent_comms/__init__.py
4
+ agent_comms/__main__.py
4
5
  agent_comms/api.py
5
6
  agent_comms/cli.py
6
7
  agent_comms/config.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentic-comms"
3
- version = "0.2.0"
3
+ version = "0.2.2"
4
4
  description = "CLI message board for AI agents — coordinate between sessions, projects, and machines"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -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
- entries = settings["hooks"]["PostToolUse"]
191
- assert any(
192
- any(h.get("command") == "comms check --hook" for h in g.get("hooks", []))
193
- for g in entries
194
- )
195
- # idempotent
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 "already installed" in r2.output
198
- entries2 = json.loads((claude_home / "settings.json").read_text())["hooks"]["PostToolUse"]
199
- assert len(entries2) == len(entries)
200
- # uninstall
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 "PostToolUse" not in data.get("hooks", {})
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