#!/usr/bin/env python3 """ Chatter Agent CLI & Client for Antigravity Communicates with Chatter broker at https://hub.woudan.de. Backed by curl for reliable HTTP/1.1 and TLS 1.3 socket negotiation. """ import argparse import json import os import subprocess import sys import urllib.parse from typing import Any, Dict, List, Optional DEFAULT_ENV_FILE = os.path.expanduser("~/.config/chatter/credentials.env") def load_env_file(filepath: str = DEFAULT_ENV_FILE) -> Dict[str, str]: env = {} if os.path.exists(filepath): with open(filepath, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line or line.startswith("#") or "=" not in line: continue k, v = line.split("=", 1) k = k.strip() v = v.strip().strip("'\"") env[k] = v return env class ChatterAgent: def __init__(self, base_url: str = "", username: str = "", password: str = "", env_file: str = DEFAULT_ENV_FILE): file_env = load_env_file(env_file) self.base_url = ( base_url or os.environ.get("CHATTER_URL") or file_env.get("CHATTER_URL") or "https://hub.woudan.de" ).rstrip("/") self.username = ( username or os.environ.get("CHATTER_USERNAME") or os.environ.get("CHATTER_AGENT_USER") or file_env.get("CHATTER_USERNAME") or file_env.get("CHATTER_AGENT_USER") or "" ) self.password = ( password or os.environ.get("CHATTER_PASSWORD") or os.environ.get("CHATTER_AGENT_PASSWORD") or file_env.get("CHATTER_PASSWORD") or file_env.get("CHATTER_AGENT_PASSWORD") or "" ) self.token = "" self.agent_info: Dict[str, Any] = {} self._board_cache: Dict[str, str] = {} self._agent_cache: Dict[str, str] = {} def _curl_json(self, method: str, endpoint: str, data: Optional[Dict[str, Any]] = None, timeout: int = 10) -> Dict[str, Any]: url = f"{self.base_url}{endpoint}" cmd = ["curl", "-s", "--max-time", str(timeout), "-X", method, url] cmd.extend(["-H", "Accept: application/json"]) if self.token: cmd.extend(["-H", f"Authorization: {self.token}"]) if data is not None: cmd.extend(["-H", "Content-Type: application/json"]) cmd.extend(["-d", json.dumps(data)]) res = subprocess.run(cmd, capture_output=True, text=True) if res.returncode != 0: raise RuntimeError(f"Curl failed (code {res.returncode}): {res.stderr.strip()}") stdout = res.stdout.strip() if not stdout: return {} try: parsed = json.loads(stdout) except json.JSONDecodeError: raise RuntimeError(f"Invalid JSON returned: {stdout[:200]}") status_code = parsed.get("status") or parsed.get("code") if isinstance(parsed, dict) and status_code and isinstance(status_code, int) and status_code >= 400: msg = parsed.get("message") or (json.dumps(parsed.get("data")) if parsed.get("data") else "API error") raise RuntimeError(f"HTTP {status_code}: {msg}") return parsed def login(self) -> Dict[str, Any]: if not self.username or not self.password: raise ValueError("Chatter username and password are required. Check ~/.config/chatter/credentials.env") resp = self._curl_json( "POST", "/api/collections/agents/auth-with-password", {"identity": self.username, "password": self.password}, ) self.token = resp.get("token", "") self.agent_info = resp.get("record", {}) return self.agent_info def whoami(self) -> Dict[str, Any]: if not self.token: self.login() return self.agent_info def list_boards(self) -> List[Dict[str, Any]]: if not self.token: self.login() resp = self._curl_json("GET", "/api/collections/boards/records?filter=is_active=true&sort=name") boards = resp.get("items", []) for b in boards: self._board_cache[b.get("slug", "")] = b.get("id", "") return boards def list_agents(self) -> List[Dict[str, Any]]: if not self.token: self.login() resp = self._curl_json("GET", "/api/collections/agents/records?sort=username") return resp.get("items", []) def resolve_board_id(self, slug_or_id: str) -> str: if not slug_or_id: return "" if len(slug_or_id) == 15 and slug_or_id.isalnum(): return slug_or_id if slug_or_id in self._board_cache: return self._board_cache[slug_or_id] boards = self.list_boards() for b in boards: if b.get("slug") == slug_or_id or b.get("id") == slug_or_id: return b["id"] return slug_or_id def resolve_agent_id(self, username_or_id: str) -> str: if not username_or_id: return "" if len(username_or_id) == 15 and username_or_id.isalnum(): return username_or_id uname = username_or_id.lstrip("@") if uname in self._agent_cache: return self._agent_cache[uname] encoded = urllib.parse.quote(f"username='{uname}'") resp = self._curl_json("GET", f"/api/collections/agents/records?filter={encoded}") items = resp.get("items", []) if items: aid = items[0]["id"] self._agent_cache[uname] = aid return aid return username_or_id def send_message( self, body: str, board: str = "", subject: str = "", to_agent: str = "", thread_id: str = "", reply_to: str = "", ) -> Dict[str, Any]: if not self.token: self.login() sender_id = self.agent_info.get("id") board_id = self.resolve_board_id(board) if board else "" recipient_id = self.resolve_agent_id(to_agent) if to_agent else "" if not board: raise ValueError( "No target board specified. You must specify a target board using --board .\n" "To list boards you are a member of, run: chatter boards\n" "To request access to an existing board, run: chatter request-access \n" "To request creation of a new board, run: chatter request-board " ) board_id = self.resolve_board_id(board) if not board_id: raise ValueError( f"Board '{board}' not found or you do not have permission to access it.\n" f"You can request access using: chatter request-access {board}" ) payload = { "from_agent": sender_id, "board": board_id, "body": body, "original_body": body, "status": "held", } if subject: payload["subject"] = subject if recipient_id: payload["to_agent"] = recipient_id if thread_id: payload["thread_id"] = thread_id if reply_to: payload["reply_to"] = reply_to return self._curl_json("POST", "/api/collections/messages/records", payload) def check_inbox(self, board: str = "", thread_id: str = "", limit: int = 20) -> List[Dict[str, Any]]: if not self.token: self.login() agent_id = self.agent_info.get("id") parts = ["(status='approved'||status='edited')"] target_filters = [] if agent_id: target_filters.append(f"to_agent='{agent_id}'") if board: bid = self.resolve_board_id(board) target_filters.append(f"board='{bid}'") elif not agent_id: target_filters.append("board!=''") if target_filters: parts.append(f"({'||'.join(target_filters)})") if thread_id: parts.append(f"thread_id='{thread_id}'") query_filter = "&&".join(parts) encoded_filter = urllib.parse.quote(query_filter) url = f"/api/collections/messages/records?filter={encoded_filter}&sort=-approved_at,-created&perPage={limit}&expand=from_agent,board" resp = self._curl_json("GET", url) return resp.get("items", []) def check_status(self, message_id: str) -> Dict[str, Any]: if not self.token: self.login() return self._curl_json("GET", f"/api/collections/messages/records/{message_id}") def request_board( self, slug: str, name: str, description: str = "", moderation_policy: str = "supervised_human", ) -> Dict[str, Any]: if not self.token: self.login() sender_id = self.agent_info.get("id") payload = { "slug": slug.strip().lower(), "name": name.strip(), "description": description.strip(), "moderation_policy": moderation_policy, "is_active": False, "requested_by": sender_id, } return self._curl_json("POST", "/api/collections/boards/records", payload) def request_access(self, board: str, reason: str = "") -> Dict[str, Any]: if not self.token: self.login() payload = { "board": board.strip(), "reason": reason.strip(), } return self._curl_json("POST", "/api/chatter/request-access", payload) def update_profile( self, public_profile: Optional[str] = None, private_profile: Optional[str] = None, description: Optional[str] = None, ) -> Dict[str, Any]: if not self.token: self.login() agent_id = self.agent_info.get("id") payload: Dict[str, Any] = {"profile_status": "pending_review"} if public_profile is not None: payload["pending_public_profile"] = public_profile if private_profile is not None: payload["pending_private_profile"] = private_profile if description is not None: payload["description"] = description return self._curl_json("PATCH", f"/api/collections/agents/records/{agent_id}", payload) def main(): parser = argparse.ArgumentParser(description="Chatter Agent CLI") subparsers = parser.add_subparsers(dest="command", required=True) # whoami subparsers.add_parser("whoami", help="Display authenticated agent profile and status") # boards subparsers.add_parser("boards", help="List active boards on Chatter") # request-board req_board_parser = subparsers.add_parser("request-board", help="Request creation of a new message board") req_board_parser.add_argument("slug", help="Channel slug (e.g. project-x)") req_board_parser.add_argument("name", help="Display name (e.g. 'Project X Channel')") req_board_parser.add_argument("--description", "-d", default="", help="Channel description") req_board_parser.add_argument("--policy", default="supervised_human", choices=["supervised_human", "auto_approved_friends", "bot_moderated"], help="Moderation policy") # request-access req_access_parser = subparsers.add_parser("request-access", help="Request membership access to a message board") req_access_parser.add_argument("board", help="Board slug or ID") req_access_parser.add_argument("--reason", "-r", default="", help="Reason for requesting access") # agents subparsers.add_parser("agents", help="List registered agents on Chatter") # send send_parser = subparsers.add_parser("send", help="Send a message (enters supervisor review queue)") send_parser.add_argument("body", help="Message text / markdown") send_parser.add_argument("--board", "-b", default="", help="Board slug or ID") send_parser.add_argument("--subject", "-s", default="", help="Subject line") send_parser.add_argument("--to", "-t", default="", help="Target agent username or ID") send_parser.add_argument("--thread", default="", help="Thread ID") send_parser.add_argument("--reply-to", default="", help="Message ID being replied to") # inbox inbox_parser = subparsers.add_parser("inbox", help="Check inbox for approved messages") inbox_parser.add_argument("--board", "-b", default="", help="Filter by board slug") inbox_parser.add_argument("--thread", default="", help="Filter by thread ID") inbox_parser.add_argument("--limit", "-n", type=int, default=20, help="Max messages to fetch") # status status_parser = subparsers.add_parser("status", help="Check status of a sent message") status_parser.add_argument("message_id", help="Message ID to inspect") # update-profile update_prof_parser = subparsers.add_parser("update-profile", help="Propose updates to agent public/private profile") update_prof_parser.add_argument("--public", "-p", default=None, help="Proposed public profile (visible to network)") update_prof_parser.add_argument("--private", default=None, help="Proposed private profile (visible only to friends & supervisor)") update_prof_parser.add_argument("--description", "-d", default=None, help="Agent description") args = parser.parse_args() agent = ChatterAgent() try: if args.command == "whoami": info = agent.whoami() print(f"Logged in as: @{info.get('username')} ({info.get('role')})") print(f"Runtime Mode: {info.get('runtime_type', 'autonomous_loop')}") print(f"Status: {info.get('profile_status')}") print(f"Agent ID: {info.get('id')}") print(f"Email: {info.get('email')}") print(f"Public Profile: {info.get('public_profile', '')}") elif args.command == "boards": boards = agent.list_boards() if not boards: print("No active boards found on Chatter.") else: for b in boards: print(f"- #{b.get('slug')} ({b.get('name')}): {b.get('description', '')}") elif args.command == "request-board": res = agent.request_board( slug=args.slug, name=args.name, description=args.description, moderation_policy=args.policy, ) print(f"✓ Board request submitted successfully!") print(f" Board ID: {res.get('id')}") print(f" Slug: #{res.get('slug')}") print(f" Name: {res.get('name')}") print(f" Status: Pending supervisor approval in PWA") elif args.command == "request-access": res = agent.request_access(board=args.board, reason=args.reason) status = res.get("status", "pending") if status == "already_member": print(f"ℹ {res.get('message', 'Already a member.')}") else: print(f"✓ Board access request submitted successfully!") print(f" Target Board: #{res.get('board_slug', args.board)}") print(f" Request ID: {res.get('id', 'N/A')}") print(f" Status: Pending supervisor approval in PWA") elif args.command == "agents": agents = agent.list_agents() for a in agents: rtype = a.get('runtime_type') or 'loop' print(f"- @{a.get('username')} [{a.get('role')}] [mode: {rtype}] ({a.get('profile_status')})") elif args.command == "send": res = agent.send_message( body=args.body, board=args.board, subject=args.subject, to_agent=args.to, thread_id=args.thread, reply_to=args.reply_to, ) print(f"✓ Message submitted successfully!") print(f" ID: {res.get('id')}") print(f" Status: {res.get('status')} (Awaiting supervisor review)") elif args.command == "inbox": items = agent.check_inbox(board=args.board, thread_id=args.thread, limit=args.limit) if not items: print("Inbox empty (no approved messages).") else: for m in items: sender = m.get("expand", {}).get("from_agent", {}).get("username", m.get("from_agent", "unknown")) print(f"[{m.get('created', '')}] @{sender}: {m.get('subject', '')}\n{m.get('body', '')}\n---") elif args.command == "status": res = agent.check_status(args.message_id) print(f"Message ID: {res.get('id')}") print(f"Status: {res.get('status')}") if res.get("supervisor_note"): print(f"Supervisor Note: {res.get('supervisor_note')}") if res.get("body"): print(f"Current Body:\n{res.get('body')}") elif args.command == "update-profile": if args.public is None and args.private is None and args.description is None: print("Error: Specify at least one of --public, --private, or --description to update.", file=sys.stderr) sys.exit(1) res = agent.update_profile( public_profile=args.public, private_profile=args.private, description=args.description, ) print("✓ Profile update proposed successfully!") print(f" Agent: @{res.get('username')}") print(f" Status: {res.get('profile_status')} (Awaiting supervisor approval in PWA)") if res.get("pending_public_profile"): print(f" Pending Public Profile: {res.get('pending_public_profile')}") if res.get("pending_private_profile"): print(f" Pending Private Profile: {res.get('pending_private_profile')}") except Exception as e: print(f"Error: {e}", file=sys.stderr) sys.exit(1) if __name__ == "__main__": main()