#!/usr/bin/env python3 """Atlas CLI — dispatch jobs and control the mesh. Usage: atlas run "" [--agent shell|claude|python] [--cwd PATH] [--timeout SEC] atlas status — show heartbeats from all mesh nodes atlas where — alias for status with extra detail atlas wake — Wake-on-LAN via tailscale atlas notify "" [--target N] [--title T] [--push] atlas todo "" [--due YYYY-MM-DD] [--category CAT] atlas brain "" — one-shot ask the brain (Claude with mesh context) Examples: atlas run atlasworkspace "df -h" atlas notify "deploy passed" --push atlas todo "renew Hetzner SSL" --due 2026-06-01 --category infra atlas brain "what overdue invoices do we have?" atlas where """ from __future__ import annotations import argparse import asyncio import datetime as dt import json import os import socket import subprocess import sys import time import uuid from pathlib import Path try: import nats except ImportError: print("Installing nats-py...", file=sys.stderr) subprocess.check_call([sys.executable, "-m", "pip", "install", "--quiet", "nats-py"]) import nats def load_config(): env = dict(os.environ) cfg_file = Path.home() / ".config/atlas/orchestrator.env" if cfg_file.exists(): for line in cfg_file.read_text().splitlines(): line = line.strip() if not line or line.startswith("#"): continue if "=" in line: k, v = line.split("=", 1) env.setdefault(k, v) # Pushover keys come from the same env file or atlas-secrets unlocked secrets = Path.home() / ".config/atlas-secrets/unlocked/atlas-api-secrets.env" if secrets.exists(): for line in secrets.read_text(errors="ignore").splitlines(): line = line.strip() if not line or line.startswith("#") or line.startswith("["): continue if "=" in line: k, v = line.split("=", 1) env.setdefault(k.strip(), v.strip()) return env CFG = load_config() NATS_URL = CFG.get("ATLAS_NATS_URL", "nats://atlas-01:4222") NATS_TOKEN = CFG.get("ATLAS_NATS_TOKEN", "") PUSHOVER_USER = CFG.get("PUSHOVER_USER_KEY", "") PUSHOVER_TOKEN = CFG.get("PUSHOVER_APP_TOKEN", "") HOSTNAME = socket.gethostname().lower() DAILY_DIR = Path.home() / "Sync/atlas-artifacts/atlas-os/daily" DAILY_DIR.mkdir(parents=True, exist_ok=True) # ----------------------------- helpers ----------------------------- async def connect(): opts = {"servers": [NATS_URL], "reconnect_time_wait": 2} if NATS_TOKEN: opts["token"] = NATS_TOKEN return await nats.connect(**opts) def push_phone(title: str, body: str, priority: int = 0): """Pushover one-way alert. priority=1 bypasses quiet hours.""" if not (PUSHOVER_USER and PUSHOVER_TOKEN): return False try: import urllib.request, urllib.parse data = urllib.parse.urlencode({ "token": PUSHOVER_TOKEN, "user": PUSHOVER_USER, "title": title, "message": body, "priority": str(priority), }).encode() urllib.request.urlopen("https://api.pushover.net/1/messages.json", data, timeout=10) return True except Exception as e: print(f"pushover failed: {e}", file=sys.stderr) return False def toast_local(title: str, body: str): """Windows toast via BurntToast. Best-effort, no-op on non-Windows.""" if sys.platform != "win32": return cmd = ["powershell", "-NoProfile", "-Command", f"Import-Module BurntToast -ErrorAction SilentlyContinue; " f"if (Get-Command New-BurntToastNotification -ErrorAction SilentlyContinue) " f"{{ New-BurntToastNotification -Text {json.dumps(title)},{json.dumps(body)} }}"] subprocess.run(cmd, capture_output=True, timeout=15) # ----------------------------- run ----------------------------- async def cmd_run(args): nc = await connect() job = { "id": str(uuid.uuid4()), "agent": args.agent, "command": args.command, "cwd": args.cwd, "timeout_s": args.timeout, "env": {}, "originator": HOSTNAME, } topic = f"atlas.jobs.{args.node}" print(f"-> {topic} ({job['id']})", file=sys.stderr) inbox = "_INBOX." + uuid.uuid4().hex fut = asyncio.Future() async def on_reply(msg): if not fut.done(): fut.set_result(msg) sub = await nc.subscribe(inbox, cb=on_reply) await nc.publish(topic, json.dumps(job).encode(), reply=inbox) try: msg = await asyncio.wait_for(fut, timeout=args.timeout + 10) result = json.loads(msg.data.decode()) if result.get("stdout"): print(result["stdout"], end="" if result["stdout"].endswith("\n") else "\n") if result.get("stderr"): print(result["stderr"], file=sys.stderr, end="" if result["stderr"].endswith("\n") else "\n") return result.get("exit_code", 0) or 0 except asyncio.TimeoutError: print(f"TIMEOUT — {args.node} did not reply within {args.timeout + 10}s", file=sys.stderr) return 124 finally: await sub.unsubscribe() await nc.drain() # ----------------------------- status / where ----------------------------- async def cmd_status(args): nc = await connect() nodes = {} async def on_hb(msg): try: d = json.loads(msg.data.decode()) nodes[d["node"]] = d except Exception: pass sub = await nc.subscribe("atlas.heartbeat.>", cb=on_hb) window = 35 if not getattr(args, "fast", False) else 5 await asyncio.sleep(window) await sub.unsubscribe() await nc.drain() if not nodes: print(f"(no heartbeats heard in {window}s)") return 1 print(f"{'node':<20} {'platform':<10} {'last seen':<12} {'version'}") print("-" * 60) for n, d in sorted(nodes.items()): ago = time.time() - d.get("ts", 0) print(f"{n:<20} {d.get('platform','?'):<10} {ago:>4.0f}s ago {d.get('version','?')}") return 0 # ----------------------------- notify ----------------------------- async def cmd_notify(args): title = args.title or "Atlas" body = args.text target = args.target or HOSTNAME pushed = False if args.push: pushed = push_phone(title, body, priority=1 if args.urgent else 0) if target == HOSTNAME or target == "local": toast_local(title, body) print(f"toast: local{' + push' if pushed else ''}") return 0 # Remote target via NATS nc = await connect() try: cmd = ( "Import-Module BurntToast -ErrorAction SilentlyContinue; " f"New-BurntToastNotification -Text {json.dumps(title)},{json.dumps(body)}" ) job = { "id": str(uuid.uuid4()), "agent": "shell", "command": cmd, "timeout_s": 15, "originator": HOSTNAME, } await nc.publish(f"atlas.jobs.{target}", json.dumps(job).encode()) print(f"toast: dispatched to {target}{' + push' if pushed else ''}") finally: await nc.drain() return 0 # ----------------------------- todo ----------------------------- async def cmd_todo(args): today = dt.date.today().isoformat() f = DAILY_DIR / f"{today}.md" section = f"## [{dt.datetime.now().strftime('%H:%M')}] todo: {args.category or 'general'}\n" body = f"- [ ] {args.text}" if args.due: body += f" *(due {args.due})*" block = f"\n{section}\n{body}\n" if not f.exists(): f.write_text(f"# {today}\n") with f.open("a", encoding="utf-8") as fh: fh.write(block) print(f"appended to {f}") return 0 # ----------------------------- brain ----------------------------- async def cmd_brain(args): """One-shot ask: dispatch job to atlas-01 brain runner with a question.""" nc = await connect() try: job = { "id": str(uuid.uuid4()), "agent": "shell", "command": ( "set -a; . /etc/atlas/orchestrator.env 2>/dev/null; " "[ -f /etc/atlas/brain.env ] && . /etc/atlas/brain.env; set +a; " f"/opt/atlas/orchestrator/.venv/bin/python /opt/atlas/brain/atlas-brain.py --once-prompt {json.dumps(args.question)}" ), "timeout_s": 60, "originator": HOSTNAME, } inbox = "_INBOX." + uuid.uuid4().hex fut = asyncio.Future() async def on_reply(msg): if not fut.done(): fut.set_result(msg) sub = await nc.subscribe(inbox, cb=on_reply) await nc.publish("atlas.jobs.atlas-01", json.dumps(job).encode(), reply=inbox) try: m = await asyncio.wait_for(fut, timeout=70) r = json.loads(m.data.decode()) print(r.get("stdout", "(no output)")) return 0 except asyncio.TimeoutError: print("brain: timeout (atlas-01 did not reply in 70s)", file=sys.stderr) return 124 finally: await sub.unsubscribe() finally: await nc.drain() # ----------------------------- main ----------------------------- def main(): p = argparse.ArgumentParser(prog="atlas") sub = p.add_subparsers(dest="cmd", required=True) pr = sub.add_parser("run", help="run a command on a node") pr.add_argument("node") pr.add_argument("command") pr.add_argument("--agent", default="shell", choices=["shell", "claude", "claude-code", "python"]) pr.add_argument("--cwd") pr.add_argument("--timeout", type=int, default=300) pr.set_defaults(func=cmd_run) ps = sub.add_parser("status", help="show node heartbeats") ps.add_argument("--fast", action="store_true", help="only wait 5s instead of 35s") ps.set_defaults(func=cmd_status) pw = sub.add_parser("where", help="alias for status") pw.add_argument("--fast", action="store_true") pw.set_defaults(func=cmd_status) pn = sub.add_parser("nodes", help="alias for status") pn.add_argument("--fast", action="store_true") pn.set_defaults(func=cmd_status) pwk = sub.add_parser("wake", help="wake a node") pwk.add_argument("node") pwk.set_defaults(func=lambda a: cmd_run(argparse.Namespace( node=a.node, command="echo woke", agent="shell", cwd=None, timeout=10))) pno = sub.add_parser("notify", help="push a notification (toast + optional phone)") pno.add_argument("text") pno.add_argument("--title", default=None) pno.add_argument("--target", default=None, help="remote node hostname; default local") pno.add_argument("--push", action="store_true", help="also send Pushover phone alert") pno.add_argument("--urgent", action="store_true", help="priority=1 on phone push") pno.set_defaults(func=cmd_notify) pt = sub.add_parser("todo", help="append a todo item to today's daily.md") pt.add_argument("text") pt.add_argument("--due", default=None) pt.add_argument("--category", default=None) pt.set_defaults(func=cmd_todo) pb = sub.add_parser("brain", help="ask the brain a one-shot question") pb.add_argument("question") pb.set_defaults(func=cmd_brain) args = p.parse_args() rc = asyncio.run(args.func(args)) sys.exit(rc or 0) if __name__ == "__main__": main()