#!/usr/bin/env python3 """coord.py - ClaudeTools coordination API helper (messages, todos, locks, status). Removes the recurring "how do I call coord again?" friction. Auto-derives from identity.json: the API base (coord_api), this session id (/claude-main), and the user/machine for attribution. Bakes in the fleet conventions: - broadcast to the whole fleet = to_session "ALL_SESSIONS" - session id = "/claude-main" - long message bodies via --body-file (avoids shell-quoting breakage) Usage: coord.py status coord.py msg send [body] [--body-file PATH] [--project KEY] : ALL | | /claude-main coord.py msg inbox [--all] # messages to me (unread by default) coord.py msg read coord.py todo add [--text-file PATH] [--user U] [--project KEY] [--parent ID] [--source TEXT] coord.py todo list [--user U] [--project KEY] [--status pending|done|all] coord.py todo done coord.py lock claim [--ttl HOURS] coord.py lock release coord.py lock list [--project KEY] """ import sys, os, json, argparse, urllib.request, urllib.error, urllib.parse def find_identity(): cands = [] env = os.environ.get("CLAUDETOOLS_ROOT") if env: cands.append(os.path.join(env, ".claude", "identity.json")) here = os.path.dirname(os.path.abspath(__file__)) cands.append(os.path.normpath(os.path.join(here, "..", "..", "..", "identity.json"))) for c in cands: if os.path.isfile(c): try: return json.load(open(c, encoding="utf-8")) except Exception: pass return {} IDENT = find_identity() API = (IDENT.get("coord_api") or "http://172.16.3.30:8001").rstrip("/") + "/api/coord" MACHINE = IDENT.get("machine") or os.environ.get("COMPUTERNAME") or "unknown" USER = IDENT.get("user") or "unknown" SESSION = f"{MACHINE}/claude-main" def call(method, path, body=None, query=None): url = API + path if query: q = {k: v for k, v in query.items() if v is not None} if q: url += "?" + urllib.parse.urlencode(q) data = json.dumps(body).encode() if body is not None else None headers = {"Content-Type": "application/json"} if data is not None else {} req = urllib.request.Request(url, data=data, method=method, headers=headers) try: with urllib.request.urlopen(req, timeout=25) as r: raw = r.read().decode("utf-8", "replace") return r.status, (json.loads(raw) if raw.strip() else None) except urllib.error.HTTPError as e: raw = e.read().decode("utf-8", "replace") try: return e.code, json.loads(raw) except Exception: return e.code, {"error": raw[:400]} except Exception as e: return 0, {"error": str(e)} def die(st, resp, ok=(200, 201)): if st not in ok: print(f"[coord] ERROR HTTP {st}: {json.dumps(resp)[:500]}", file=sys.stderr) sys.exit(1) def resolve_to(to): t = (to or "").strip() if t.lower() in ("all", "broadcast", "fleet", "all_sessions", "*"): return "ALL_SESSIONS" return t if "/" in t else f"{t}/claude-main" def listify(r, key): if isinstance(r, dict): return r.get(key, []) or [] return r or [] # --- commands --- def c_status(a): st, r = call("GET", "/status"); die(st, r, ok=(200,)) print(json.dumps(r, indent=2)[:3000]) def c_msg_send(a): body = a.body if a.body_file: body = open(a.body_file, encoding="utf-8").read() if body is None: print("[coord] need a body (positional or --body-file)", file=sys.stderr); sys.exit(2) to = resolve_to(a.to) payload = {"from_session": SESSION, "to_session": to, "subject": a.subject, "body": body} if a.project: payload["project_key"] = a.project st, r = call("POST", "/messages", payload); die(st, r) print(f"[coord] message sent id={r.get('id')} to={to}") def c_msg_inbox(a): q = {"to_session": SESSION} if not a.all: q["unread_only"] = "true" st, r = call("GET", "/messages", query=q); die(st, r, ok=(200,)) msgs = listify(r, "messages") print(f"[coord] {len(msgs)} message(s) for {SESSION}{'' if a.all else ' (unread)'}") for m in msgs: print(f"--- {m.get('id')} | from {m.get('from_session')} | {m.get('created_at','')}") print(f" SUBJECT: {m.get('subject')}") for line in (m.get("body") or "").rstrip().splitlines(): print(f" {line}") def c_msg_read(a): st, r = call("PUT", f"/messages/{a.id}/read"); die(st, r, ok=(200, 204)) print(f"[coord] marked read: {a.id}") def c_todo_add(a): text = a.text if a.text_file: text = open(a.text_file, encoding="utf-8").read().strip() payload = {"text": text, "created_by_user": USER, "created_by_machine": MACHINE, "auto_created": bool(a.auto)} if a.user: payload["assigned_to_user"] = a.user if a.project: payload["project_key"] = a.project if a.parent: payload["parent_id"] = a.parent if a.source: payload["source_context"] = a.source st, r = call("POST", "/todos", payload); die(st, r) print(f"[coord] todo created id={r.get('id')}") def c_todo_list(a): q = {"status_filter": a.status} if a.user: q["for_user"] = a.user if a.project: q["project_key"] = a.project st, r = call("GET", "/todos", query=q); die(st, r, ok=(200,)) todos = listify(r, "todos") print(f"[coord] {len(todos)} todo(s)") for t in todos: extra = "" if t.get("assigned_to_user"): extra += f" @{t['assigned_to_user']}" if t.get("project_key"): extra += f" ({t['project_key']})" print(f" [{t.get('status'):<7}] {str(t.get('id',''))[:8]} {str(t.get('text',''))[:88]}{extra}") def c_todo_done(a): st, r = call("PUT", f"/todos/{a.id}", {"status": "done", "completed_by": USER}) die(st, r, ok=(200,)) print(f"[coord] todo done: {a.id}") def c_lock_claim(a): st, r = call("POST", "/locks", {"project_key": a.project, "session_id": SESSION, "resource": a.resource, "description": a.desc, "ttl_hours": a.ttl}); die(st, r) print(f"[coord] lock id={r.get('id')} {a.project}:{a.resource}") def c_lock_release(a): st, r = call("DELETE", f"/locks/{a.id}", query={"session_id": SESSION}) die(st, r, ok=(200, 204)) print(f"[coord] lock released: {a.id}") def c_lock_list(a): st, r = call("GET", "/locks", query={"project_key": a.project} if a.project else None) die(st, r, ok=(200,)) locks = listify(r, "locks") print(f"[coord] {len(locks)} lock(s)") for l in locks: print(f" {str(l.get('id',''))[:8]} {l.get('project_key')}:{l.get('resource')} by {l.get('session_id')} exp {l.get('expires_at')}") def c_comp_set(a): payload = {"state": a.state, "updated_by": SESSION} if a.version: payload["version"] = a.version if a.notes: payload["notes"] = a.notes st, r = call("PUT", f"/components/{a.project}/{a.component}", payload) die(st, r, ok=(200, 201)) print(f"[coord] component {a.project}/{a.component} -> state={a.state}" + (f" v{a.version}" if a.version else "")) def main(): p = argparse.ArgumentParser(prog="coord.py", description="ClaudeTools coordination API helper") sub = p.add_subparsers(dest="cmd", required=True) sub.add_parser("status").set_defaults(fn=c_status) m = sub.add_parser("msg").add_subparsers(dest="sub", required=True) s = m.add_parser("send"); s.add_argument("to"); s.add_argument("subject"); s.add_argument("body", nargs="?") s.add_argument("--body-file"); s.add_argument("--project"); s.set_defaults(fn=c_msg_send) ib = m.add_parser("inbox"); ib.add_argument("--all", action="store_true"); ib.set_defaults(fn=c_msg_inbox) rd = m.add_parser("read"); rd.add_argument("id"); rd.set_defaults(fn=c_msg_read) t = sub.add_parser("todo").add_subparsers(dest="sub", required=True) ta = t.add_parser("add"); ta.add_argument("text", nargs="?"); ta.add_argument("--text-file") ta.add_argument("--user"); ta.add_argument("--project"); ta.add_argument("--parent") ta.add_argument("--source"); ta.add_argument("--auto", action="store_true"); ta.set_defaults(fn=c_todo_add) tl = t.add_parser("list"); tl.add_argument("--user"); tl.add_argument("--project") tl.add_argument("--status", default="pending"); tl.set_defaults(fn=c_todo_list) td = t.add_parser("done"); td.add_argument("id"); td.set_defaults(fn=c_todo_done) cp = sub.add_parser("component").add_subparsers(dest="sub", required=True) cs = cp.add_parser("set"); cs.add_argument("project"); cs.add_argument("component"); cs.add_argument("state") cs.add_argument("--version"); cs.add_argument("--notes"); cs.set_defaults(fn=c_comp_set) lk = sub.add_parser("lock").add_subparsers(dest="sub", required=True) lc = lk.add_parser("claim"); lc.add_argument("project"); lc.add_argument("resource") lc.add_argument("desc"); lc.add_argument("--ttl", type=int, default=2); lc.set_defaults(fn=c_lock_claim) lr = lk.add_parser("release"); lr.add_argument("id"); lr.set_defaults(fn=c_lock_release) ll = lk.add_parser("list"); ll.add_argument("--project"); ll.set_defaults(fn=c_lock_list) a = p.parse_args() a.fn(a) if __name__ == "__main__": main()