antiphon 0.1.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.
@@ -0,0 +1,1178 @@
1
+ #!/usr/bin/env python3
2
+ """Antiphon — an open-identity bridge between Claude Code and the Codex CLI.
3
+
4
+ Two terminals, two separate agents: what you tell one, the other finds out.
5
+
6
+ Usage:
7
+ antiphon setup # installs the hook on both sides
8
+ antiphon status # shows what's happening on both sides (for humans)
9
+ antiphon summary [side] # the text that side would see (claude | codex)
10
+ antiphon hook <side> # UserPromptSubmit hook (reads JSON from stdin)
11
+ antiphon push <target> # Stop hook: pushes `@codex` / `@claude` lines
12
+ antiphon reply # sends a Claude Channel reply to Codex (stdin JSON)
13
+ antiphon channel # long-lived Node.js MCP Channel server (started by Claude Code)
14
+ antiphon mcp # MCP stdio server for Codex (fallback path)
15
+
16
+ Design: NO SHARED LOG IS KEPT. Both CLIs already write transcripts; Antiphon
17
+ reads and derives from them. That way there's no write race, no stale record,
18
+ and no second source of truth. The only persistent state is a cursor tracking
19
+ how far each side has read.
20
+
21
+ Both sides are symmetric: Claude Code and Codex CLI speak the same
22
+ `UserPromptSubmit` hook contract (the same input fields, the same output
23
+ wrapper), so a single `hook` function serves both.
24
+
25
+ The pull and hook layer uses the Python standard library; the Claude Channel
26
+ server runs on Node.js with the official MCP SDK.
27
+ """
28
+
29
+ import glob
30
+ import hashlib
31
+ import json
32
+ import os
33
+ import re
34
+ import socket
35
+ import subprocess
36
+ import sys
37
+ import time
38
+ import uuid
39
+ from datetime import datetime
40
+
41
+ HOME = os.path.expanduser("~")
42
+ CLAUDE_PROJECTS = os.path.join(HOME, ".claude", "projects")
43
+ CODEX_SESSIONS = os.path.join(HOME, ".codex", "sessions")
44
+
45
+ TAIL_BYTES = 300_000 # amount to read from the tail of each transcript file
46
+ SUMMARY_BUDGET = 2600 # character budget for the injected summary
47
+ EVENT_LIMIT = 40 # max events that go into the summary
48
+ LOOKBACK = 6 * 3600 # anything older than this doesn't count as part of "this session"
49
+
50
+ # A marker at the start of a line in a reply says that line should be pushed
51
+ # to the target. The line-start requirement is deliberate: mentioning the
52
+ # marker inside prose shouldn't trigger it.
53
+ PUSH_MARKERS = {
54
+ "codex": re.compile(r"^\s*@codex\b[:,]?\s*(.+)$", re.MULTILINE),
55
+ "claude": re.compile(r"^\s*@claude\b[:,]?\s*(.+)$", re.MULTILINE),
56
+ }
57
+ SESSION_ID = re.compile(r"([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-"
58
+ r"[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$")
59
+
60
+ # The labels every Claude→Codex message carries. `push()` and `reply()` send
61
+ # these, and the self-injection guard below recognizes them — one definition,
62
+ # so the two can't drift apart.
63
+ PUSH_LABEL = "[Antiphon bridge] Claude:"
64
+ CHANNEL_LABEL = "[Antiphon channel] Claude:"
65
+
66
+ # A message this bridge pushed arrives on the other side as an ordinary
67
+ # `role: user` event. Skipping those keeps the summary from echoing the
68
+ # bridge's own traffic back at it. Matched on the label, anchored at the start:
69
+ # a substring test would also swallow a user typing "antiphon is dropping
70
+ # messages", and that message would then never reach the other agent.
71
+ _SELF_INJECTION_PREFIXES = tuple(
72
+ label.lower() for label in (PUSH_LABEL, CHANNEL_LABEL))
73
+
74
+
75
+ def _is_self_injected(text):
76
+ """True if `text` is a message this bridge itself delivered."""
77
+ return text.lstrip().lower().startswith(_SELF_INJECTION_PREFIXES)
78
+
79
+
80
+ # ---------- helpers ----------
81
+
82
+ def project_dir():
83
+ return os.path.abspath(os.environ.get("ANTIPHON_CWD") or os.getcwd())
84
+
85
+
86
+ def state_path(cwd):
87
+ return os.path.join(cwd, ".antiphon", "cursor.json")
88
+
89
+
90
+ LEGACY_KEYS = {
91
+ "codex_gordu": "codex_seen",
92
+ "claude_gordu": "claude_seen",
93
+ "son_itilen_codex": "last_pushed_codex",
94
+ "son_itilen_claude": "last_pushed_claude",
95
+ "son_itilen": "last_pushed_codex",
96
+ }
97
+
98
+
99
+ def _translate_cursor_keys(data):
100
+ return {LEGACY_KEYS.get(k, k): v for k, v in data.items()}
101
+
102
+
103
+ def read_cursor(cwd):
104
+ new_path = state_path(cwd)
105
+ try:
106
+ with open(new_path, encoding="utf-8") as f:
107
+ data = json.load(f)
108
+ except (OSError, json.JSONDecodeError):
109
+ return {}
110
+
111
+ translated = _translate_cursor_keys(data)
112
+ if translated != data:
113
+ write_cursor(cwd, translated)
114
+ return translated
115
+
116
+
117
+ def write_cursor(cwd, data):
118
+ path = state_path(cwd)
119
+ os.makedirs(os.path.dirname(path), exist_ok=True)
120
+ tmp = f"{path}.{os.getpid()}.tmp"
121
+ try:
122
+ with open(tmp, "w", encoding="utf-8") as f:
123
+ json.dump(data, f, ensure_ascii=False, indent=1)
124
+ os.replace(tmp, path)
125
+ return True
126
+ except OSError:
127
+ try:
128
+ os.unlink(tmp)
129
+ except OSError:
130
+ pass
131
+ return False
132
+
133
+
134
+ def truncate(s, n):
135
+ s = " ".join((s or "").split())
136
+ return s if len(s) <= n else s[:n].rstrip() + "…"
137
+
138
+
139
+ def tail_lines(path):
140
+ try:
141
+ size = os.path.getsize(path)
142
+ with open(path, "rb") as f:
143
+ if size > TAIL_BYTES:
144
+ f.seek(size - TAIL_BYTES)
145
+ f.readline()
146
+ return f.read().decode("utf-8", "replace").splitlines()
147
+ except OSError:
148
+ return []
149
+
150
+
151
+ def head_lines(path, limit=12, num_bytes=64 * 1024):
152
+ """Returns the lines at the start of the file, used for session metadata."""
153
+ try:
154
+ with open(path, "rb") as f:
155
+ return f.read(num_bytes).decode("utf-8", "replace").splitlines()[:limit]
156
+ except OSError:
157
+ return []
158
+
159
+
160
+ def iso_epoch(s):
161
+ if not s:
162
+ return 0.0
163
+ try:
164
+ return datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp()
165
+ except (ValueError, TypeError):
166
+ return 0.0
167
+
168
+
169
+ # ---------- Claude side ----------
170
+
171
+ def _claude_slug(cwd):
172
+ """The ~/.claude/projects directory name Claude Code derives from a path.
173
+
174
+ Every character that isn't alphanumeric becomes `-`, not just `/`: an
175
+ underscore, a dot and an existing `-` all end up as `-`. Getting this
176
+ wrong is silent — the slug simply names no directory, and the whole
177
+ Claude→Codex direction goes empty forever."""
178
+ return re.sub(r"[^A-Za-z0-9]", "-", cwd)
179
+
180
+
181
+ def _transcript_cwd(path):
182
+ """The project directory a Claude transcript records, or None."""
183
+ for line in head_lines(path, limit=40):
184
+ if '"cwd"' not in line: # cheap pre-filter, skips parsing big lines
185
+ continue
186
+ try:
187
+ d = json.loads(line)
188
+ except (json.JSONDecodeError, ValueError):
189
+ continue
190
+ if isinstance(d, dict) and isinstance(d.get("cwd"), str):
191
+ return d["cwd"]
192
+ return None
193
+
194
+
195
+ def _find_claude_project_dir(cwd):
196
+ """Finds the project directory by the cwd its transcripts carry.
197
+
198
+ The fallback for when the slug rule names no existing directory — an older
199
+ naming rule, or a new one. Only reached when the slug misses, so the
200
+ common path never pays for the scan."""
201
+ try:
202
+ entries = [os.path.join(CLAUDE_PROJECTS, name)
203
+ for name in os.listdir(CLAUDE_PROJECTS)]
204
+ except OSError:
205
+ return None
206
+ def mtime(path):
207
+ try:
208
+ return os.path.getmtime(path)
209
+ except OSError: # vanished mid-scan; sort it last
210
+ return 0.0
211
+
212
+ candidates = [p for p in entries if os.path.isdir(p)]
213
+ candidates.sort(key=mtime, reverse=True) # most recently used first
214
+ for directory in candidates:
215
+ transcripts = sorted(glob.glob(os.path.join(directory, "*.jsonl")),
216
+ key=mtime, reverse=True)
217
+ for path in transcripts[:3]:
218
+ recorded = _transcript_cwd(path)
219
+ if recorded is not None:
220
+ if recorded == cwd:
221
+ return directory
222
+ break # this directory belongs to another project
223
+ return None
224
+
225
+
226
+ def claude_project_dir(cwd):
227
+ """The ~/.claude/projects directory holding this project's transcripts."""
228
+ directory = os.path.join(CLAUDE_PROJECTS, _claude_slug(cwd))
229
+ if os.path.isdir(directory):
230
+ return directory
231
+ return _find_claude_project_dir(cwd)
232
+
233
+
234
+ def claude_transcripts(cwd):
235
+ """Claude Code transcript files belonging to this project directory (newest first)."""
236
+ directory = claude_project_dir(cwd)
237
+ if not directory:
238
+ return []
239
+ files = [p for p in glob.glob(os.path.join(directory, "*.jsonl"))
240
+ if os.path.getsize(p) > 0]
241
+ files.sort(key=os.path.getmtime, reverse=True)
242
+ return files
243
+
244
+
245
+ def claude_events(cwd, start=0.0):
246
+ """(time, type, text) — type: you | claude | tool"""
247
+ events = []
248
+ for path in claude_transcripts(cwd)[:3]:
249
+ for line in tail_lines(path):
250
+ try:
251
+ d = json.loads(line)
252
+ except json.JSONDecodeError:
253
+ continue
254
+ if d.get("isMeta"):
255
+ continue
256
+ ts = iso_epoch(d.get("timestamp"))
257
+ if ts <= start:
258
+ continue
259
+ kind = d.get("type")
260
+ msg = d.get("message") or {}
261
+ content = msg.get("content")
262
+ if kind == "user":
263
+ text = ""
264
+ if isinstance(content, str):
265
+ text = content
266
+ elif isinstance(content, list):
267
+ text = " ".join(c.get("text", "") for c in content
268
+ if isinstance(c, dict) and c.get("type") == "text")
269
+ text = text.strip()
270
+ # tool results and system injections are not the user's own words
271
+ if text and not text.startswith("<") and not _is_self_injected(text):
272
+ events.append((ts, "you", text))
273
+ elif kind == "assistant":
274
+ for c in content if isinstance(content, list) else []:
275
+ if c.get("type") == "text" and c.get("text", "").strip():
276
+ events.append((ts, "claude", c["text"].strip()))
277
+ elif c.get("type") == "tool_use":
278
+ i = c.get("input") or {}
279
+ detail = i.get("file_path") or i.get("command") or i.get("pattern") or ""
280
+ events.append((ts, "tool", f"{c.get('name', '?')} {detail}".strip()))
281
+ return sorted(events)
282
+
283
+
284
+ # ---------- Codex side ----------
285
+
286
+ def _rollout_cwd(lines):
287
+ """The cwd a Codex rollout records in its session metadata (None if absent)."""
288
+ for line in lines:
289
+ if '"cwd"' not in line: # cheap pre-filter, skips parsing big lines
290
+ continue
291
+ try:
292
+ d = json.loads(line)
293
+ except (json.JSONDecodeError, ValueError):
294
+ continue
295
+ if not isinstance(d, dict):
296
+ continue
297
+ for holder in (d, d.get("payload")):
298
+ if isinstance(holder, dict) and isinstance(holder.get("cwd"), str):
299
+ return holder["cwd"]
300
+ return None
301
+
302
+
303
+ def codex_rollout_files(cwd, days=3):
304
+ """Codex rollout files whose cwd matches this project (newest first)."""
305
+ pattern = os.path.join(CODEX_SESSIONS, "**", "rollout-*.jsonl")
306
+ candidates = []
307
+ now = time.time()
308
+ for path in glob.glob(pattern, recursive=True):
309
+ try:
310
+ if now - os.path.getmtime(path) > days * 86400:
311
+ continue
312
+ except OSError:
313
+ continue
314
+ candidates.append(path)
315
+ candidates.sort(key=os.path.getmtime, reverse=True)
316
+ matched = []
317
+ for path in candidates[:60]:
318
+ # cwd lives in the session metadata. Reading the tail of a growing,
319
+ # active rollout could miss it and match the wrong, already-closed
320
+ # subsession instead.
321
+ lines = head_lines(path)
322
+ recorded = _rollout_cwd(lines)
323
+ if recorded is not None:
324
+ # An equality test, never a substring one: `/x/api` used to match
325
+ # a rollout recorded for `/x/api-v2`, and a push then landed in
326
+ # the sibling project's Codex.
327
+ if recorded == cwd:
328
+ matched.append(path)
329
+ continue
330
+ # No head line carries a cwd field: fall back to the old substring
331
+ # test rather than dropping a file we can't read properly.
332
+ if any(cwd in line for line in lines):
333
+ matched.append(path)
334
+ return matched
335
+
336
+
337
+ def codex_events(cwd, start=0.0):
338
+ """(time, type, text) — type: you | codex | tool"""
339
+ events = []
340
+ for path in codex_rollout_files(cwd)[:3]:
341
+ for line in tail_lines(path):
342
+ try:
343
+ d = json.loads(line)
344
+ except json.JSONDecodeError:
345
+ continue
346
+ ts = iso_epoch(d.get("timestamp"))
347
+ if ts <= start:
348
+ continue
349
+ kind, p = d.get("type"), d.get("payload") or {}
350
+ if kind == "response_item" and p.get("type") == "message":
351
+ role = p.get("role")
352
+ text = " ".join(
353
+ c.get("text") or c.get("input_text") or ""
354
+ for c in p.get("content") or [] if isinstance(c, dict)
355
+ ).strip()
356
+ if not text or role == "developer":
357
+ continue
358
+ if role == "user":
359
+ if text.startswith("<") or _is_self_injected(text):
360
+ continue
361
+ events.append((ts, "you", text))
362
+ elif role == "assistant":
363
+ events.append((ts, "codex", text))
364
+ elif kind == "event_msg" and p.get("type") == "exec_command_begin":
365
+ cmd = p.get("command")
366
+ if isinstance(cmd, list):
367
+ cmd = " ".join(cmd)
368
+ if cmd:
369
+ events.append((ts, "tool", f"shell {cmd}"))
370
+ return sorted(events)
371
+
372
+
373
+ # ---------- summary ----------
374
+
375
+ LABEL = {"you": "YOU", "claude": "Claude", "codex": "Codex", "tool": "·"}
376
+
377
+
378
+ # side -> (the other side's key, its display name for headings, its phrasing in notices)
379
+ OTHER_SIDE = {
380
+ "claude": ("codex", "Codex", "from Codex"),
381
+ "codex": ("claude", "Claude Code", "from Claude Code"),
382
+ }
383
+
384
+
385
+ def build_summary(cwd, side, start=0.0):
386
+ """`side` is the side that will READ the summary ('claude' | 'codex').
387
+ Turns what happened on the other side, and what the user said, into
388
+ compact text.
389
+
390
+ Returns: (text, last_event_time, message_count). `message_count` doesn't
391
+ count tool calls — the notice shown in the terminal uses it."""
392
+ if side == "claude":
393
+ events = codex_events(cwd, start)
394
+ else:
395
+ events = claude_events(cwd, start)
396
+ other = OTHER_SIDE[side][1]
397
+
398
+ if not events:
399
+ return "", 0.0, 0
400
+
401
+ events = events[-EVENT_LIMIT:]
402
+ last_time = events[-1][0]
403
+ count = sum(1 for _, kind, _ in events if kind != "tool")
404
+
405
+ lines = []
406
+ tools = []
407
+ for ts, kind, text in events:
408
+ if kind == "tool":
409
+ tools.append(truncate(text, 70))
410
+ continue
411
+ if tools:
412
+ lines.append(f" · {len(tools)} tool calls: " + truncate(" | ".join(tools[-3:]), 130))
413
+ tools = []
414
+ clock = datetime.fromtimestamp(ts).strftime("%H:%M")
415
+ lines.append(f"[{clock}] {LABEL.get(kind, kind)}: {truncate(text, 420)}")
416
+ if tools:
417
+ lines.append(f" · {len(tools)} tool calls: " + truncate(" | ".join(tools[-3:]), 130))
418
+
419
+ body = "\n".join(lines)
420
+ truncated = False
421
+ if len(body) > SUMMARY_BUDGET:
422
+ # keep the newest; trim from the front
423
+ while len(body) > SUMMARY_BUDGET and len(lines) > 1:
424
+ lines.pop(0)
425
+ body = "\n".join(lines)
426
+ truncated = True
427
+
428
+ head = f"## What happened on the {other} side (since your last turn)"
429
+ foot = ("\nThis record belongs to the Antiphon bridge — this is what actually happened "
430
+ "there. Do not assume anything that is not in it.")
431
+ if truncated:
432
+ foot = "\n(older lines were cut for budget)" + foot
433
+ return f"{head}\n{body}{foot}", last_time, count
434
+
435
+
436
+ # ---------- hook (both sides share the same contract) ----------
437
+
438
+ def hook(side="claude"):
439
+ """UserPromptSubmit hook: injects the other side's summary into the context.
440
+
441
+ `side` is which CLI this hook is running inside ('claude' | 'codex').
442
+ Claude Code and Codex CLI speak the same input fields (`cwd`) and the same
443
+ output wrapper, so a single `hook` function serves both."""
444
+ if side not in OTHER_SIDE:
445
+ print(f"hook: unknown side {side!r} (claude | codex)", file=sys.stderr)
446
+ return 1
447
+ try:
448
+ input_data = json.load(sys.stdin)
449
+ except (json.JSONDecodeError, ValueError):
450
+ input_data = {}
451
+ cwd = os.path.abspath(input_data.get("cwd") or project_dir())
452
+
453
+ cursor = read_cursor(cwd)
454
+ key = f"{side}_seen"
455
+ start = float(cursor.get(key) or (time.time() - LOOKBACK))
456
+ text, last, _ = build_summary(cwd, side, start)
457
+ if text and last:
458
+ cursor[key] = last
459
+ write_cursor(cwd, cursor)
460
+
461
+ if not text:
462
+ return 0
463
+
464
+ # The hook prints nothing to the terminal. The counter used to say
465
+ # "message" but it was counting the other side's transcript events;
466
+ # incoming channel messages already show up via their own notices.
467
+ # Context is injected silently.
468
+ print(json.dumps({
469
+ "hookSpecificOutput": {
470
+ "hookEventName": "UserPromptSubmit",
471
+ "additionalContext": text,
472
+ }
473
+ }, ensure_ascii=False))
474
+ return 0
475
+
476
+
477
+ def notice_text(side, count):
478
+ """The one-line notice in `status` output (the hook no longer uses this)."""
479
+ noun = "message" if count == 1 else "messages"
480
+ return f"💬 {count} new {noun} {OTHER_SIDE[side][2]}"
481
+
482
+
483
+ # ---------- push (both directions) ----------
484
+
485
+ def last_claude_reply(transcript_path):
486
+ """Returns the most recent assistant text in the Claude transcript."""
487
+ chunks = []
488
+ for line in tail_lines(transcript_path):
489
+ try:
490
+ d = json.loads(line)
491
+ except json.JSONDecodeError:
492
+ continue
493
+ if d.get("type") != "assistant" or d.get("isMeta"):
494
+ continue
495
+ content = (d.get("message") or {}).get("content")
496
+ texts = [c.get("text", "") for c in content or []
497
+ if isinstance(c, dict) and c.get("type") == "text"]
498
+ if texts:
499
+ chunks = texts # each new assistant message supersedes the last
500
+ return "\n".join(chunks).strip()
501
+
502
+
503
+ def last_codex_reply(transcript_path):
504
+ """Returns the most recent assistant text in the Codex rollout."""
505
+ chunks = []
506
+ for line in tail_lines(transcript_path):
507
+ try:
508
+ d = json.loads(line)
509
+ except json.JSONDecodeError:
510
+ continue
511
+ p = d.get("payload") or {}
512
+ if (d.get("type") != "response_item" or p.get("type") != "message"
513
+ or p.get("role") != "assistant"):
514
+ continue
515
+ texts = [c.get("text") or c.get("output_text") or c.get("input_text") or ""
516
+ for c in p.get("content") or [] if isinstance(c, dict)]
517
+ if any(texts):
518
+ chunks = texts
519
+ return "\n".join(chunks).strip()
520
+
521
+
522
+ def codex_session_id(cwd):
523
+ """The UUID of the newest Codex session matching cwd (None if there isn't one)."""
524
+ for path in codex_rollout_files(cwd)[:1]:
525
+ m = SESSION_ID.search(os.path.basename(path))
526
+ if m:
527
+ return m.group(1)
528
+ return None
529
+
530
+
531
+ def push(target="codex"):
532
+ """Stop hook: pushes explicit target lines in the latest reply to the other side.
533
+
534
+ `target=codex` is called from Claude's Stop hook, `target=claude` from
535
+ Codex's Stop hook. Addressing is never inferred from free text — only an
536
+ explicit `@codex` or `@claude` marker at the start of a line triggers a
537
+ push.
538
+ """
539
+ if target not in PUSH_MARKERS:
540
+ print(f"push: unknown target {target!r} (claude | codex)", file=sys.stderr)
541
+ return 1
542
+ try:
543
+ input_data = json.load(sys.stdin)
544
+ except (json.JSONDecodeError, ValueError):
545
+ input_data = {}
546
+ if input_data.get("stop_hook_active"):
547
+ return 0 # don't re-enter a turn we triggered ourselves
548
+ cwd = os.path.abspath(input_data.get("cwd") or project_dir())
549
+ transcript = input_data.get("transcript_path")
550
+ if not transcript or not os.path.exists(transcript):
551
+ return 0
552
+
553
+ reply_reader = last_claude_reply if target == "codex" else last_codex_reply
554
+ reply_text = reply_reader(transcript)
555
+ messages = [m.strip() for m in PUSH_MARKERS[target].findall(reply_text) if m.strip()]
556
+ if not messages:
557
+ return 0
558
+
559
+ outgoing = "\n".join(messages)
560
+ cursor = read_cursor(cwd)
561
+ key = f"last_pushed_{target}"
562
+ previous = cursor.get(key)
563
+ if previous == outgoing:
564
+ return 0 # don't push the same message twice
565
+
566
+ if target == "codex":
567
+ session_id = codex_session_id(cwd)
568
+ if not session_id:
569
+ print("antiphon: no Codex session found in this directory, not pushed", file=sys.stderr)
570
+ return 0
571
+ ok, detail = send_to_codex(session_id, f"{PUSH_LABEL} {outgoing}")
572
+ else:
573
+ ok, detail = send_to_claude(cwd, outgoing)
574
+
575
+ if ok:
576
+ cursor[key] = outgoing
577
+ write_cursor(cwd, cursor)
578
+ print(f"antiphon: delivered to {target.title()} ({len(outgoing)} characters)",
579
+ file=sys.stderr)
580
+ else:
581
+ print(f"antiphon: delivery failed — {detail}", file=sys.stderr)
582
+ return 0
583
+
584
+
585
+ def send_to_codex(session, message):
586
+ """Leaves a message with the running session via `codex queue`. Returns: (success, detail)."""
587
+ try:
588
+ result = subprocess.run(
589
+ ["codex", "queue", "--thread", session, "--message", message],
590
+ capture_output=True, text=True, timeout=15, stdin=subprocess.DEVNULL,
591
+ )
592
+ except FileNotFoundError:
593
+ return False, "codex command not found"
594
+ except subprocess.SubprocessError as e:
595
+ return False, f"{type(e).__name__}"
596
+ if result.returncode != 0:
597
+ return False, (result.stderr or result.stdout or "unknown error").strip()[:200]
598
+ return True, ""
599
+
600
+
601
+ def claude_socket_path(cwd):
602
+ """Deterministic path to this project's MCP Channel Unix socket."""
603
+ key = hashlib.sha256(os.path.abspath(cwd).encode()).hexdigest()[:20]
604
+ return os.path.join(os.environ.get("TMPDIR") or "/tmp",
605
+ f"antiphon-channel-{key}.sock")
606
+
607
+
608
+ def send_to_claude(cwd, text):
609
+ """Sends a Codex message to Claude Code's MCP Channel socket."""
610
+ request = {
611
+ "content": text,
612
+ "message_id": str(uuid.uuid4()),
613
+ }
614
+ last_error = None
615
+ for path in (claude_socket_path(cwd),):
616
+ try:
617
+ with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
618
+ sock.settimeout(5)
619
+ sock.connect(path)
620
+ sock.sendall(json.dumps(request, ensure_ascii=False).encode())
621
+ sock.shutdown(socket.SHUT_WR)
622
+ reply_bytes = b""
623
+ while len(reply_bytes) < 64 * 1024:
624
+ chunk = sock.recv(8192)
625
+ if not chunk:
626
+ break
627
+ reply_bytes += chunk
628
+ break
629
+ except OSError as e:
630
+ last_error = e
631
+ else:
632
+ return False, ("Claude MCP Channel is down: "
633
+ f"{last_error.strerror or type(last_error).__name__}")
634
+ try:
635
+ result = json.loads(reply_bytes.decode())
636
+ except (UnicodeDecodeError, json.JSONDecodeError):
637
+ return False, "Claude MCP Channel returned an invalid response"
638
+ if not result.get("ok"):
639
+ return False, str(result.get("error") or "channel delivery failed")[:200]
640
+ return True, ""
641
+
642
+
643
+ def reply(*_):
644
+ """Sends the reply from the Claude channel reply tool to the running Codex session."""
645
+ try:
646
+ input_data = json.load(sys.stdin)
647
+ except (json.JSONDecodeError, ValueError):
648
+ input_data = {}
649
+ text = input_data.get("text")
650
+ if not isinstance(text, str) or not text.strip():
651
+ print("reply: empty text", file=sys.stderr)
652
+ return 1
653
+ cwd = project_dir()
654
+ session_id = codex_session_id(cwd)
655
+ if not session_id:
656
+ print("reply: no running Codex session found", file=sys.stderr)
657
+ return 1
658
+ ok, detail = send_to_codex(session_id, f"{CHANNEL_LABEL} {text.strip()}")
659
+ if not ok:
660
+ print(f"reply: {detail}", file=sys.stderr)
661
+ return 1
662
+ return 0
663
+
664
+
665
+ # ---------- Codex MCP server ----------
666
+
667
+ TOOLS = [{
668
+ "name": "antiphon_read",
669
+ "description": ("Returns what happened on the Claude Code side since your last turn. "
670
+ "This normally arrives automatically via the hook, no extra effort "
671
+ "required; this tool is the fallback — call it by hand if you suspect "
672
+ "the bridge has gone quiet."),
673
+ "inputSchema": {"type": "object", "properties": {}},
674
+ }]
675
+
676
+
677
+ def _mcp_result(mid, result):
678
+ sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": mid, "result": result},
679
+ ensure_ascii=False) + "\n")
680
+ sys.stdout.flush()
681
+
682
+
683
+ def mcp():
684
+ """The MCP stdio server Codex connects to."""
685
+ cwd = project_dir()
686
+ for line in sys.stdin:
687
+ line = line.strip()
688
+ if not line:
689
+ continue
690
+ try:
691
+ request = json.loads(line)
692
+ except json.JSONDecodeError:
693
+ continue
694
+ method, mid = request.get("method"), request.get("id")
695
+ if method == "initialize":
696
+ _mcp_result(mid, {
697
+ "protocolVersion": "2024-11-05",
698
+ "capabilities": {"tools": {}},
699
+ "serverInfo": {"name": "antiphon", "version": "0.1.0"},
700
+ })
701
+ elif method == "tools/list":
702
+ _mcp_result(mid, {"tools": TOOLS})
703
+ elif method == "tools/call":
704
+ p = request.get("params") or {}
705
+ name = p.get("name")
706
+ if name == "antiphon_read":
707
+ cursor = read_cursor(cwd)
708
+ start = float(cursor.get("codex_seen") or (time.time() - LOOKBACK))
709
+ text, last, _ = build_summary(cwd, "codex", start)
710
+ if text and last:
711
+ cursor["codex_seen"] = last
712
+ write_cursor(cwd, cursor)
713
+ output = text or "Nothing new on the Claude Code side since your last turn."
714
+ else:
715
+ output = f"unknown tool: {name}"
716
+ _mcp_result(mid, {"content": [{"type": "text", "text": output}]})
717
+ elif mid is not None:
718
+ _mcp_result(mid, {})
719
+ return 0
720
+
721
+
722
+ # ---------- setup ----------
723
+
724
+ HOOK_COMMAND = "antiphon hook {side}"
725
+ PUSH_COMMAND = "antiphon push {target}"
726
+
727
+ SECTION_HEADING = "## The Antiphon bridge"
728
+
729
+ AGENTS_RULE = ("\n## The Antiphon bridge\n\n"
730
+ "You are working alongside Claude Code on this project. What happens on the "
731
+ "other side is injected into your context automatically at the start of each "
732
+ "turn — you don't need to do anything else. If you suspect the bridge has gone "
733
+ "quiet, you can call the `antiphon_read` tool by hand.\n\n"
734
+ "When Claude wants to tell you something directly, you'll see it as a user "
735
+ "message starting with `[Antiphon bridge] Claude:` (pushed from Claude's Stop "
736
+ "hook) or `[Antiphon channel] Claude:` (a direct reply through the channel) — "
737
+ "either way, these are Claude's words, not the user's. When you want to hand "
738
+ "Claude a task directly, put `@claude` at the start of a line in your reply; "
739
+ "only that line is sent to the running Claude session as an MCP Channel event.\n")
740
+
741
+ CLAUDE_RULE = ("\n## The Antiphon bridge\n\n"
742
+ "You are working alongside another agent on this project. What happens on the "
743
+ "other side is injected into your context at the start of each turn. Events "
744
+ "that come directly from that agent are marked "
745
+ "`<channel source=\"antiphon\" sender=\"codex\" sender_kind=\"agent\">`; they "
746
+ "are the words of the Codex agent, not of the human user. Use the "
747
+ "`reply_to_codex` tool to answer them.\n")
748
+
749
+
750
+ class ConfigFileError(Exception):
751
+ """A config file exists but can't be read, so it must not be rewritten.
752
+
753
+ A trailing comma, a `//` comment or a UTF-8 BOM is enough to make a
754
+ hand-edited settings file unparseable. Overwriting it would silently throw
755
+ away the user's permissions, env, statusLine and every other tool's hooks,
756
+ so `setup` reports the file and leaves it exactly as it found it."""
757
+
758
+
759
+ def _read_json_object(path):
760
+ """Reads a JSON object from `path`. A missing (or empty) file reads as {}.
761
+
762
+ Raises ConfigFileError for anything that exists but can't be understood —
763
+ the caller must not overwrite such a file."""
764
+ try:
765
+ with open(path, encoding="utf-8") as f:
766
+ text = f.read()
767
+ except FileNotFoundError:
768
+ return {} # the normal first install
769
+ except OSError as e:
770
+ raise ConfigFileError(
771
+ f"{path} could not be read ({e.strerror or type(e).__name__}); "
772
+ "refusing to overwrite it. Fix the file's permissions or move it "
773
+ "aside, then run `antiphon setup` again.") from e
774
+ except UnicodeDecodeError as e:
775
+ raise ConfigFileError(
776
+ f"{path} is not valid UTF-8; refusing to overwrite it. Fix the "
777
+ "file or move it aside, then run `antiphon setup` again.") from e
778
+ if not text.strip():
779
+ return {} # an empty file has nothing to lose
780
+ try:
781
+ data = json.loads(text)
782
+ except json.JSONDecodeError as e:
783
+ raise ConfigFileError(
784
+ f"{path} is not valid JSON ({e.msg}, line {e.lineno} column "
785
+ f"{e.colno}); refusing to overwrite it — that would throw away "
786
+ "everything else in the file. Fix the file (a trailing comma, a "
787
+ "`//` comment and a byte-order mark are the usual culprits) or "
788
+ "move it aside, then run `antiphon setup` again.") from e
789
+ if not isinstance(data, dict):
790
+ raise ConfigFileError(
791
+ f"{path} holds a JSON {type(data).__name__}, not an object; "
792
+ "refusing to overwrite it. Fix the file or move it aside, then "
793
+ "run `antiphon setup` again.")
794
+ return data
795
+
796
+
797
+ def _update_json(path, mutate):
798
+ """Update existing JSON in place without clobbering it. Returns True if the file changed."""
799
+ data = _read_json_object(path)
800
+ if not mutate(data):
801
+ return False
802
+ os.makedirs(os.path.dirname(path), exist_ok=True)
803
+ with open(path, "w", encoding="utf-8") as f:
804
+ json.dump(data, f, ensure_ascii=False, indent=2)
805
+ return True
806
+
807
+
808
+ def _legacy_commands(script, verb, arg):
809
+ """Values/patterns for finding legacy Python hooks pinned to an absolute path."""
810
+ exact = [f"python3 {script} {verb} {arg}", f"python3 {script} {verb}"]
811
+ # A global npm install has no way to know where the old clone lived; also
812
+ # recognize any legacy absolute path safely, by filename and subcommand.
813
+ pattern = re.compile(
814
+ rf"^python3\s+\S*antiphon\.py\s+{re.escape(verb)}"
815
+ rf"(?:\s+{re.escape(arg)})?$"
816
+ )
817
+ return [*exact, pattern]
818
+
819
+
820
+ def _dedupe_hooks(hooks, command):
821
+ """Leaves a single entry for `command`. Returns True if anything was dropped.
822
+
823
+ Upgrading a legacy entry can land on a command that is already installed —
824
+ or two legacy entries can upgrade to the same one. Without this collapse
825
+ the hook is listed twice and fires twice per turn."""
826
+ seen = False
827
+ dropped = False
828
+ emptied = []
829
+ for group in hooks:
830
+ entries = group.get("hooks")
831
+ if not isinstance(entries, list):
832
+ continue
833
+ kept = []
834
+ for entry in entries:
835
+ if isinstance(entry, dict) and entry.get("command") == command:
836
+ if seen:
837
+ dropped = True
838
+ continue # a duplicate of one we already keep
839
+ seen = True
840
+ kept.append(entry)
841
+ if len(kept) == len(entries):
842
+ continue
843
+ group["hooks"] = kept
844
+ if not kept:
845
+ emptied.append(id(group))
846
+ if emptied:
847
+ hooks[:] = [group for group in hooks if id(group) not in emptied]
848
+ return dropped
849
+
850
+
851
+ def _add_hook(hooks, command, legacy_commands=None, label=None):
852
+ """Adds the command to the UserPromptSubmit list; does nothing if it's already there.
853
+
854
+ If `legacy_commands` is given, upgrade those first — otherwise, once the
855
+ side argument gets added, the old entry would stick around and the hook
856
+ would fire twice."""
857
+ changed = False
858
+ if legacy_commands:
859
+ if isinstance(legacy_commands, (str, re.Pattern)):
860
+ legacy_commands = [legacy_commands]
861
+ for group in hooks:
862
+ for entry in group.get("hooks") or []:
863
+ current = entry.get("command", "")
864
+ matched = any(
865
+ (candidate.fullmatch(current) if isinstance(candidate, re.Pattern)
866
+ else current == candidate)
867
+ for candidate in legacy_commands
868
+ )
869
+ if matched:
870
+ entry["command"] = command
871
+ if label:
872
+ entry["statusMessage"] = label
873
+ changed = True
874
+ if _dedupe_hooks(hooks, command):
875
+ changed = True
876
+ for group in hooks:
877
+ for entry in group.get("hooks") or []:
878
+ if entry.get("command") == command:
879
+ if label and entry.get("statusMessage") != label:
880
+ entry["statusMessage"] = label
881
+ changed = True
882
+ return changed
883
+ new_entry = {"type": "command", "command": command}
884
+ if label:
885
+ new_entry["statusMessage"] = label
886
+ hooks.append({"hooks": [new_entry]})
887
+ return True
888
+
889
+
890
+ def _update_instructions(current, rule):
891
+ """Adds the Antiphon section, or edits it in place if it's stale.
892
+
893
+ Just checking the heading and skipping wasn't enough: when the rule text
894
+ changed, the old text stayed put and kept telling the agent something no
895
+ longer true."""
896
+ heading = SECTION_HEADING
897
+ start = current.find(heading)
898
+ if start == -1:
899
+ return current + rule, "added"
900
+ end = current.find("\n## ", start + len(heading))
901
+ old_section = current[start:] if end == -1 else current[start:end]
902
+ if old_section.strip() == rule.strip():
903
+ return current, "already up to date"
904
+ tail = "" if end == -1 else current[end:]
905
+ return current[:start].rstrip("\n") + rule + tail, "updated"
906
+
907
+
908
+ CODEX_MCP_TABLE = "mcp_servers.antiphon"
909
+ # Matches `[table]` and `[[table]]` headers, capturing the name between them.
910
+ TOML_HEADER = re.compile(r"^\s*\[\[?\s*([^\[\]]+?)\s*\]\]?\s*$")
911
+
912
+
913
+ def _codex_config_block(cwd):
914
+ """The `.codex/config.toml` entry that gives Codex the `antiphon_read` tool.
915
+
916
+ Note `args = ["mcp"]`, not `["channel"]`: the channel server is Claude's side
917
+ and hands out `reply_to_codex`. Pointing Codex at it would let Codex publish
918
+ messages labelled as Claude's — the one thing this bridge exists to prevent."""
919
+ return (f'[{CODEX_MCP_TABLE}]\n'
920
+ 'command = "antiphon"\n'
921
+ 'args = ["mcp"]\n'
922
+ '# read-only local bridge; no need to ask on every turn\n'
923
+ 'default_tools_approval_mode = "approve"\n'
924
+ f'\n[{CODEX_MCP_TABLE}.env]\n'
925
+ f'ANTIPHON_CWD = "{cwd}"\n')
926
+
927
+
928
+ def _strip_toml_table(text, table):
929
+ """Drops `[table]` and its sub-tables, leaving every other section intact."""
930
+ kept, skipping = [], False
931
+ for line in text.splitlines(keepends=True):
932
+ header = TOML_HEADER.match(line)
933
+ if header:
934
+ name = header.group(1)
935
+ skipping = name == table or name.startswith(table + ".")
936
+ if not skipping:
937
+ kept.append(line)
938
+ return "".join(kept)
939
+
940
+
941
+ def _update_codex_config(path, cwd):
942
+ """Rewrites our own table in place; anything else in the file survives."""
943
+ current = ""
944
+ if os.path.exists(path):
945
+ with open(path, encoding="utf-8") as f:
946
+ current = f.read()
947
+ kept = _strip_toml_table(current, CODEX_MCP_TABLE).rstrip()
948
+ block = _codex_config_block(cwd)
949
+ new_text = f"{kept}\n\n{block}" if kept else block
950
+ if new_text == current:
951
+ return False
952
+ os.makedirs(os.path.dirname(path), exist_ok=True)
953
+ with open(path, "w", encoding="utf-8") as f:
954
+ f.write(new_text)
955
+ return True
956
+
957
+
958
+ def setup():
959
+ cwd = project_dir()
960
+ script = os.path.abspath(__file__)
961
+ failures = []
962
+
963
+ def install(target, mutate, done, already):
964
+ """Applies one config change and reports it.
965
+
966
+ A file that can't be parsed is left alone and recorded as a failure;
967
+ the rest of the installation still runs, so one broken file doesn't
968
+ cost the user everything else. `setup` fails at the end instead."""
969
+ if target in failures:
970
+ return # already reported; two hooks share a file
971
+ try:
972
+ changed = _update_json(target, mutate)
973
+ except ConfigFileError as error:
974
+ failures.append(target)
975
+ print(f"✗ {error}", file=sys.stderr)
976
+ return
977
+ print(f"{'✓' if changed else '·'} {done if changed else already}: {target}")
978
+
979
+ # --- Claude Code side: .claude/settings.json ---
980
+ claude_target = os.path.join(cwd, ".claude", "settings.json")
981
+ claude_command = HOOK_COMMAND.format(side="claude")
982
+ legacy_commands = _legacy_commands(script, "kanca", "claude")
983
+
984
+ def claude_mutate(data):
985
+ hooks = data.setdefault("hooks", {}).setdefault("UserPromptSubmit", [])
986
+ changed = _add_hook(hooks, claude_command, legacy_commands)
987
+ allowed = data.setdefault("permissions", {}).setdefault("allow", [])
988
+ reply_tool = "mcp__antiphon__reply_to_codex"
989
+ if reply_tool not in allowed:
990
+ allowed.append(reply_tool)
991
+ changed = True
992
+ return changed
993
+
994
+ install(claude_target, claude_mutate,
995
+ "Claude hook installed", "Claude hook already installed")
996
+
997
+ # --- Claude side: push to Codex (Stop hook) ---
998
+ push_command = PUSH_COMMAND.format(target="codex")
999
+ legacy_push_commands = _legacy_commands(script, "it", "codex")
1000
+
1001
+ def push_mutate(data):
1002
+ hooks = data.setdefault("hooks", {}).setdefault("Stop", [])
1003
+ return _add_hook(hooks, push_command, legacy_push_commands)
1004
+
1005
+ install(claude_target, push_mutate,
1006
+ "Push-to-Codex hook installed (Stop)",
1007
+ "Push-to-Codex hook already installed")
1008
+
1009
+ # --- Codex side: .codex/hooks.json (same contract, same body) ---
1010
+ codex_target = os.path.join(cwd, ".codex", "hooks.json")
1011
+ codex_command = HOOK_COMMAND.format(side="codex")
1012
+ legacy_codex_commands = _legacy_commands(script, "kanca", "codex")
1013
+
1014
+ def codex_mutate(data):
1015
+ hooks = data.setdefault("hooks", {}).setdefault("UserPromptSubmit", [])
1016
+ return _add_hook(hooks, codex_command, legacy_codex_commands,
1017
+ label="Antiphon bridge")
1018
+
1019
+ install(codex_target, codex_mutate,
1020
+ "Codex hook installed", "Codex hook already installed")
1021
+
1022
+ # --- Codex side: push to Claude (Stop hook) ---
1023
+ reverse_push_command = PUSH_COMMAND.format(target="claude")
1024
+ legacy_reverse_push_commands = _legacy_commands(script, "it", "claude")
1025
+
1026
+ def reverse_push_mutate(data):
1027
+ hooks = data.setdefault("hooks", {}).setdefault("Stop", [])
1028
+ return _add_hook(hooks, reverse_push_command, legacy_reverse_push_commands)
1029
+
1030
+ install(codex_target, reverse_push_mutate,
1031
+ "Push-to-Claude hook installed (Stop)",
1032
+ "Push-to-Claude hook already installed")
1033
+
1034
+ # --- Codex side: the antiphon_read MCP tool (.codex/config.toml) ---
1035
+ codex_config = os.path.join(cwd, ".codex", "config.toml")
1036
+ written = _update_codex_config(codex_config, cwd)
1037
+ print(f"{'✓' if written else '·'} Codex MCP tool "
1038
+ f"{'registered' if written else 'already registered'}: {codex_config}")
1039
+
1040
+ # --- Claude Code MCP Channel ---
1041
+ mcp_target = os.path.join(cwd, ".mcp.json")
1042
+ channel_config = {
1043
+ "command": "antiphon",
1044
+ "args": ["channel"],
1045
+ "env": {"ANTIPHON_CWD": cwd},
1046
+ }
1047
+
1048
+ def mcp_mutate(data):
1049
+ servers = data.setdefault("mcpServers", {})
1050
+ if servers.get("antiphon") == channel_config:
1051
+ return False
1052
+ servers["antiphon"] = channel_config
1053
+ return True
1054
+
1055
+ install(mcp_target, mcp_mutate,
1056
+ "Claude MCP Channel registered", "Claude MCP Channel already registered")
1057
+
1058
+ # Claude Code may also keep .mcp.json servers in a local allowlist.
1059
+ local_target = os.path.join(cwd, ".claude", "settings.local.json")
1060
+
1061
+ def local_mutate(data):
1062
+ enabled = data.setdefault("enabledMcpjsonServers", [])
1063
+ if "antiphon" in enabled:
1064
+ return False
1065
+ enabled.append("antiphon")
1066
+ return True
1067
+
1068
+ install(local_target, local_mutate,
1069
+ "Claude MCP local permission updated",
1070
+ "Claude MCP local permission already up to date")
1071
+
1072
+ # --- AGENTS.md rule ---
1073
+ agents = os.path.join(cwd, "AGENTS.md")
1074
+ current = ""
1075
+ if os.path.exists(agents):
1076
+ with open(agents, encoding="utf-8") as f:
1077
+ current = f.read()
1078
+ new_text, status_word = _update_instructions(current, AGENTS_RULE)
1079
+ if new_text != current:
1080
+ with open(agents, "w", encoding="utf-8") as f:
1081
+ f.write(new_text)
1082
+ print(f"{'✓' if new_text != current else '·'} AGENTS.md rule {status_word}: {agents}")
1083
+
1084
+ # --- CLAUDE.md rule ---
1085
+ claude_md = os.path.join(cwd, "CLAUDE.md")
1086
+ current = ""
1087
+ if os.path.exists(claude_md):
1088
+ with open(claude_md, encoding="utf-8") as f:
1089
+ current = f.read()
1090
+ new_text, status_word = _update_instructions(current, CLAUDE_RULE)
1091
+ if new_text != current:
1092
+ with open(claude_md, "w", encoding="utf-8") as f:
1093
+ f.write(new_text)
1094
+ print(f"{'✓' if new_text != current else '·'} CLAUDE.md rule {status_word}: {claude_md}")
1095
+
1096
+ print("\n— One last step: Codex hooks need a one-time security approval.")
1097
+ print(" Open `codex` in this directory; approve the hook at the 'New hook - review required' prompt.")
1098
+ print(" Approval is granted once and then persists (it asks again only if the file changes).")
1099
+ print("\n— Start Claude with the channel enabled:")
1100
+ print(" claude --dangerously-load-development-channels server:antiphon")
1101
+ print(" In the research preview, the first launch needs both a development channel and an MCP approval.")
1102
+ if failures:
1103
+ listed = "\n ".join(failures)
1104
+ print(f"\n✗ setup did not finish. {len(failures)} file(s) were left untouched "
1105
+ f"because they could not be read:\n {listed}\n"
1106
+ " Fix or move them, then run `antiphon setup` again.", file=sys.stderr)
1107
+ return 1
1108
+ return 0
1109
+
1110
+
1111
+ # ---------- status, for humans ----------
1112
+
1113
+ def status():
1114
+ cwd = project_dir()
1115
+ print(f"project: {cwd}\n")
1116
+ c = claude_transcripts(cwd)
1117
+ x = codex_rollout_files(cwd)
1118
+ print(f"Claude transcripts: {len(c)} files" + (f" (newest: {os.path.basename(c[0])})" if c else " — none"))
1119
+ print(f"Codex rollouts: {len(x)} files" + (f" (newest: {os.path.basename(x[0])})" if x else " — none"))
1120
+ socket_path = claude_socket_path(cwd)
1121
+ print(f"Claude channel: {'live' if os.path.exists(socket_path) else 'down'} ({socket_path})")
1122
+ cursor = read_cursor(cwd)
1123
+ for k, v in (cursor or {}).items():
1124
+ if k.endswith("_seen") and isinstance(v, (int, float)):
1125
+ shown = datetime.fromtimestamp(v).strftime('%H:%M:%S') if v else '—'
1126
+ else:
1127
+ shown = truncate(str(v), 80) if v else '—'
1128
+ print(f"cursor {k}: {shown}")
1129
+ for side in ("claude", "codex"):
1130
+ start = float((cursor or {}).get(f"{side}_seen") or (time.time() - LOOKBACK))
1131
+ text, _, count = build_summary(cwd, side, start)
1132
+ print(f"\n=== what {side} would see ===")
1133
+ if count:
1134
+ print(notice_text(side, count))
1135
+ print(text or "(nothing new)")
1136
+ return 0
1137
+
1138
+
1139
+ def print_summary(side="claude"):
1140
+ cwd = project_dir()
1141
+ text, _, _ = build_summary(cwd, side, time.time() - LOOKBACK)
1142
+ print(text or "(nothing new)")
1143
+ return 0
1144
+
1145
+
1146
+ COMMANDS = {
1147
+ "setup": setup, "status": status, "hook": hook, "summary": print_summary,
1148
+ "push": push, "reply": reply, "mcp": mcp,
1149
+ # Legacy aliases for old local installs, kept during the transition period.
1150
+ "kur": setup, "durum": status, "kanca": hook, "ozet": print_summary,
1151
+ "it": push, "yanit": reply,
1152
+ }
1153
+
1154
+ _CO_VARARGS = 0x04 # CO_VARARGS, without importing inspect
1155
+
1156
+
1157
+ def _max_args(func):
1158
+ """How many positional arguments a command takes (None means any)."""
1159
+ if func.__code__.co_flags & _CO_VARARGS:
1160
+ return None
1161
+ return func.__code__.co_argcount
1162
+
1163
+
1164
+ if __name__ == "__main__":
1165
+ command = sys.argv[1] if len(sys.argv) > 1 else "status"
1166
+ func = COMMANDS.get(command)
1167
+ if not func:
1168
+ print(__doc__)
1169
+ sys.exit(1)
1170
+ args = sys.argv[2:]
1171
+ limit = _max_args(func)
1172
+ if limit is not None and len(args) > limit:
1173
+ wanted = "no arguments" if limit == 0 else f"at most {limit} argument"
1174
+ print(f"antiphon: `{command}` takes {wanted}, got {len(args)}: "
1175
+ f"{' '.join(args)}", file=sys.stderr)
1176
+ print("Run `antiphon` with no arguments to see the usage.", file=sys.stderr)
1177
+ sys.exit(2)
1178
+ sys.exit(func(*args) or 0)