agent-manager-cli 0.1.0__py3-none-any.whl

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.
am/cli.py ADDED
@@ -0,0 +1,817 @@
1
+ """
2
+ Agent Manager CLI — daemon, login, and node agent.
3
+
4
+ Modes:
5
+ 1. daemon — bridges Claude Code CLI to the backend via bidirectional WebSocket
6
+ 2. login — authenticate with the server and save token locally
7
+ 3. connect — run as a persistent node agent: scan sessions, receive attach commands
8
+
9
+ Usage:
10
+ am login --host agent-manager.space --email user@mail.com
11
+ am connect
12
+ am daemon --token <TOKEN> -- claude -p "task" --output-format stream-json
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import asyncio
19
+ import getpass
20
+ import json
21
+ import subprocess
22
+ import sys
23
+ from pathlib import Path
24
+
25
+ import websockets
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Config file management (~/.agentmanager/config.json)
29
+ # ---------------------------------------------------------------------------
30
+
31
+ CONFIG_DIR = Path.home() / ".agentmanager"
32
+ CONFIG_FILE = CONFIG_DIR / "config.json"
33
+
34
+
35
+ def load_config() -> dict:
36
+ """Load config from ~/.agentmanager/config.json."""
37
+ if CONFIG_FILE.exists():
38
+ return json.loads(CONFIG_FILE.read_text())
39
+ return {}
40
+
41
+
42
+ def save_config(data: dict) -> None:
43
+ """Save config to ~/.agentmanager/config.json with restricted permissions."""
44
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
45
+ CONFIG_DIR.chmod(0o700)
46
+ CONFIG_FILE.write_text(json.dumps(data, indent=2))
47
+ CONFIG_FILE.chmod(0o600)
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Stream event mapping (daemon mode)
52
+ # ---------------------------------------------------------------------------
53
+
54
+
55
+ def map_stream_event(line: str) -> list[dict]:
56
+ """Map a Claude Code stream-json line to our StreamEvent format(s).
57
+
58
+ Returns a list of event dicts (may be empty).
59
+ """
60
+ try:
61
+ msg = json.loads(line)
62
+ except json.JSONDecodeError:
63
+ return []
64
+
65
+ msg_type = msg.get("type")
66
+
67
+ if msg_type == "system":
68
+ if msg.get("subtype") == "init":
69
+ return [
70
+ {
71
+ "event_type": "status",
72
+ "data": {
73
+ "status": "running",
74
+ "message": "Session started",
75
+ "session_id": msg.get("session_id", ""),
76
+ },
77
+ }
78
+ ]
79
+ return []
80
+
81
+ if msg_type == "assistant":
82
+ content_blocks = msg.get("message", {}).get("content", [])
83
+ events = []
84
+ for block in content_blocks:
85
+ block_type = block.get("type")
86
+ if block_type == "thinking":
87
+ events.append(
88
+ {
89
+ "event_type": "thinking",
90
+ "data": {"text": block.get("thinking", "")},
91
+ }
92
+ )
93
+ elif block_type == "text":
94
+ events.append(
95
+ {
96
+ "event_type": "text",
97
+ "data": {"text": block.get("text", "")},
98
+ }
99
+ )
100
+ elif block_type == "tool_use":
101
+ events.append(
102
+ {
103
+ "event_type": "tool_use",
104
+ "data": {
105
+ "tool": block.get("name", ""),
106
+ "input": block.get("input", {}),
107
+ "tool_use_id": block.get("id", ""),
108
+ },
109
+ }
110
+ )
111
+ return events
112
+
113
+ if msg_type == "user":
114
+ content_blocks = msg.get("message", {}).get("content", [])
115
+ for block in content_blocks:
116
+ if block.get("type") == "tool_result":
117
+ content = block.get("content", "")
118
+ if isinstance(content, list):
119
+ content = "\n".join(
120
+ b.get("text", "") for b in content if b.get("type") == "text"
121
+ )
122
+ return [
123
+ {
124
+ "event_type": "tool_result",
125
+ "data": {
126
+ "tool_use_id": block.get("tool_use_id", ""),
127
+ "content": str(content)[:2000],
128
+ "is_error": block.get("is_error", False),
129
+ },
130
+ }
131
+ ]
132
+ return []
133
+
134
+ if msg_type == "result":
135
+ subtype = msg.get("subtype", "")
136
+ if subtype == "success":
137
+ return [
138
+ {
139
+ "event_type": "status",
140
+ "data": {
141
+ "status": "completed",
142
+ "message": "Task completed",
143
+ "cost_usd": msg.get("total_cost_usd", 0),
144
+ "duration_ms": msg.get("duration_ms", 0),
145
+ "num_turns": msg.get("num_turns", 0),
146
+ "session_id": msg.get("session_id", ""),
147
+ },
148
+ }
149
+ ]
150
+ return [
151
+ {
152
+ "event_type": "error",
153
+ "data": {
154
+ "message": f"Task ended: {subtype}",
155
+ "cost_usd": msg.get("total_cost_usd", 0),
156
+ },
157
+ }
158
+ ]
159
+
160
+ if msg_type == "stream_event":
161
+ delta = msg.get("event", {}).get("delta", {})
162
+ delta_type = delta.get("type")
163
+ if delta_type == "text_delta":
164
+ return [
165
+ {
166
+ "event_type": "text",
167
+ "data": {"text": delta.get("text", ""), "partial": True},
168
+ }
169
+ ]
170
+ if delta_type == "thinking_delta":
171
+ return [
172
+ {
173
+ "event_type": "thinking",
174
+ "data": {"text": delta.get("thinking", ""), "partial": True},
175
+ }
176
+ ]
177
+
178
+ if msg_type == "input_request":
179
+ request_type = msg.get("request_type", "text")
180
+ event_data: dict = {"request_type": request_type}
181
+
182
+ if request_type == "permission":
183
+ event_data["tool_name"] = msg.get("tool_name", "")
184
+ event_data["tool_input"] = msg.get("tool_input", {})
185
+ elif request_type == "question":
186
+ event_data["questions"] = msg.get("questions", [])
187
+
188
+ return [
189
+ {
190
+ "event_type": "input_request",
191
+ "data": event_data,
192
+ }
193
+ ]
194
+
195
+ return []
196
+
197
+
198
+ # ---------------------------------------------------------------------------
199
+ # Helpers
200
+ # ---------------------------------------------------------------------------
201
+
202
+
203
+ async def read_lines(stream) -> asyncio.Queue:
204
+ """Read lines from a sync stream in a thread, put them into an async queue."""
205
+ queue: asyncio.Queue[str | None] = asyncio.Queue()
206
+ loop = asyncio.get_event_loop()
207
+
208
+ def _reader():
209
+ try:
210
+ for line in stream:
211
+ loop.call_soon_threadsafe(queue.put_nowait, line.strip())
212
+ finally:
213
+ loop.call_soon_threadsafe(queue.put_nowait, None)
214
+
215
+ loop.run_in_executor(None, _reader)
216
+ return queue
217
+
218
+
219
+ def format_user_message_for_stdin(text: str) -> str:
220
+ """Format a user message as stream-json for Claude CLI stdin."""
221
+ return json.dumps(
222
+ {
223
+ "type": "user",
224
+ "message": {
225
+ "role": "user",
226
+ "content": [{"type": "text", "text": text}],
227
+ },
228
+ }
229
+ )
230
+
231
+
232
+ def format_permission_response_for_stdin(allow: bool) -> str:
233
+ """Format a permission response as stream-json for Claude CLI stdin."""
234
+ return json.dumps(
235
+ {
236
+ "type": "permission_response",
237
+ "permission": "allow" if allow else "deny",
238
+ }
239
+ )
240
+
241
+
242
+ def _log(prefix: str, msg: str) -> None:
243
+ print(f"[{prefix}] {msg}", file=sys.stderr)
244
+
245
+
246
+ def _safe_urlopen(url: str, **kwargs):
247
+ """urlopen wrapper that only allows http/https schemes."""
248
+ import urllib.request
249
+
250
+ if not url.startswith(("http://", "https://")):
251
+ raise ValueError(f"Unsafe URL scheme: {url}")
252
+ return urllib.request.urlopen(url, **kwargs) # nosec B310 — scheme validated above
253
+
254
+
255
+ # Allowed CLI executables for subprocess calls
256
+ _ALLOWED_EXECUTABLES = frozenset({"claude", "codex", "gemini"})
257
+
258
+
259
+ def _build_cli_command(cli: str, session_id: str) -> list[str]:
260
+ """Build CLI-specific resume command.
261
+
262
+ Only allows known CLI executables to prevent arbitrary command execution.
263
+ """
264
+ if cli not in _ALLOWED_EXECUTABLES:
265
+ allowed = ", ".join(sorted(_ALLOWED_EXECUTABLES))
266
+ raise ValueError(f"Unsupported CLI: {cli!r} (allowed: {allowed})")
267
+ if cli == "claude":
268
+ return [
269
+ "claude",
270
+ "--continue",
271
+ "--resume",
272
+ session_id,
273
+ "--output-format",
274
+ "stream-json",
275
+ "--input-format",
276
+ "stream-json",
277
+ "--verbose",
278
+ ]
279
+ if cli == "codex":
280
+ return ["codex", "resume", session_id, "--json"]
281
+ # gemini
282
+ return ["gemini", "--resume"]
283
+
284
+
285
+ def _build_continue_command(
286
+ original_cmd: list[str],
287
+ session_id: str,
288
+ prompt: str,
289
+ ) -> list[str]:
290
+ """Build a --continue --resume command from the original command."""
291
+ exe = original_cmd[0]
292
+ return [
293
+ exe,
294
+ "-p",
295
+ prompt,
296
+ "--continue",
297
+ "--resume",
298
+ session_id,
299
+ "--output-format",
300
+ "stream-json",
301
+ "--input-format",
302
+ "stream-json",
303
+ "--verbose",
304
+ ]
305
+
306
+
307
+ # ---------------------------------------------------------------------------
308
+ # Daemon mode
309
+ # ---------------------------------------------------------------------------
310
+
311
+
312
+ def _log_event(etype: str, data: dict) -> None:
313
+ """Log a mapped event to stderr."""
314
+ if etype == "text":
315
+ _log(etype, data.get("text", "")[:80])
316
+ elif etype == "tool_use":
317
+ _log(etype, data.get("tool", ""))
318
+ elif etype == "tool_result":
319
+ _log(etype, data.get("content", "")[:60])
320
+ elif etype in ("status", "error"):
321
+ _log(etype, data.get("message", ""))
322
+ elif etype == "thinking":
323
+ _log(etype, "...")
324
+ elif etype == "input_request":
325
+ _log(etype, data.get("request_type", ""))
326
+
327
+
328
+ async def run_daemon_bidirectional(ws_url: str, ws_token: str, command: list[str]):
329
+ """Run Claude CLI with bidirectional pipes and forward via WebSocket."""
330
+ _log("daemon", f"Running: {' '.join(command)}")
331
+ proc = subprocess.Popen(
332
+ command,
333
+ stdin=subprocess.PIPE,
334
+ stdout=subprocess.PIPE,
335
+ stderr=sys.stderr,
336
+ text=True,
337
+ )
338
+
339
+ session_id = None
340
+
341
+ async with websockets.connect(ws_url, ping_interval=20, ping_timeout=10) as ws:
342
+ await ws.send(json.dumps({"token": ws_token}))
343
+ _log("daemon", "Connected (bidirectional mode)")
344
+
345
+ stdout_queue = await read_lines(proc.stdout)
346
+ sent_count = 0
347
+
348
+ async def _read_stdout():
349
+ nonlocal sent_count, session_id
350
+ line_num = 0
351
+ while True:
352
+ line = await stdout_queue.get()
353
+ if line is None:
354
+ break
355
+ if not line:
356
+ continue
357
+ line_num += 1
358
+
359
+ try:
360
+ raw = json.loads(line)
361
+ if raw.get("type") == "system" and raw.get("subtype") == "init":
362
+ session_id = raw.get("session_id")
363
+ elif raw.get("type") == "result":
364
+ sid = raw.get("session_id")
365
+ if sid:
366
+ session_id = sid
367
+ except (json.JSONDecodeError, KeyError):
368
+ pass
369
+
370
+ events = map_stream_event(line)
371
+ if not events:
372
+ continue
373
+
374
+ for event in events:
375
+ try:
376
+ await ws.send(json.dumps(event))
377
+ sent_count += 1
378
+ except Exception as e:
379
+ _log("send error", str(e))
380
+ continue
381
+ _log_event(event["event_type"], event.get("data", {}))
382
+
383
+ _log("daemon", f"stdout closed. Sent {sent_count} events.")
384
+
385
+ async def _read_ws():
386
+ try:
387
+ async for raw_msg in ws:
388
+ try:
389
+ msg = json.loads(raw_msg)
390
+ except json.JSONDecodeError:
391
+ continue
392
+
393
+ msg_type = msg.get("type", "")
394
+ if msg_type == "user_input":
395
+ text = msg.get("data", {}).get("text", "")
396
+ if text and proc.stdin and not proc.stdin.closed:
397
+ proc.stdin.write(format_user_message_for_stdin(text) + "\n")
398
+ proc.stdin.flush()
399
+ _log("stdin", f"user_input: {text[:60]}")
400
+ elif msg_type == "permission_response":
401
+ allow = msg.get("data", {}).get("allow", False)
402
+ if proc.stdin and not proc.stdin.closed:
403
+ proc.stdin.write(format_permission_response_for_stdin(allow) + "\n")
404
+ proc.stdin.flush()
405
+ _log("stdin", f"permission: {'allow' if allow else 'deny'}")
406
+ except websockets.exceptions.ConnectionClosed:
407
+ pass
408
+
409
+ stdout_task = asyncio.create_task(_read_stdout())
410
+ ws_task = asyncio.create_task(_read_ws())
411
+ await stdout_task
412
+ ws_task.cancel()
413
+
414
+ proc.wait()
415
+ _log("daemon", f"Process exited with code {proc.returncode}")
416
+ return proc.returncode, session_id
417
+
418
+
419
+ async def run_daemon_pipe(ws_url: str, ws_token: str, input_stream):
420
+ """Forward mapped events from stdin (pipe mode, read-only)."""
421
+ async with websockets.connect(ws_url, ping_interval=20, ping_timeout=10) as ws:
422
+ await ws.send(json.dumps({"token": ws_token}))
423
+ _log("daemon", "Connected (pipe mode)")
424
+
425
+ queue = await read_lines(input_stream)
426
+ sent_count = 0
427
+
428
+ while True:
429
+ line = await queue.get()
430
+ if line is None:
431
+ break
432
+ if not line:
433
+ continue
434
+
435
+ events = map_stream_event(line)
436
+ for event in events:
437
+ try:
438
+ await ws.send(json.dumps(event))
439
+ sent_count += 1
440
+ except Exception as e:
441
+ _log("send error", str(e))
442
+ continue
443
+ _log_event(event["event_type"], event.get("data", {}))
444
+
445
+ _log("daemon", f"Done. Sent {sent_count} events.")
446
+
447
+
448
+ async def run_with_followup(ws_url: str, ws_token: str, command: list[str]):
449
+ """Run Claude CLI, then listen for follow-up messages and restart."""
450
+ returncode, session_id = await run_daemon_bidirectional(ws_url, ws_token, command)
451
+
452
+ if returncode != 0 or not session_id:
453
+ return returncode
454
+
455
+ _log("daemon", f"Task completed (session: {session_id}). Listening for follow-ups...")
456
+
457
+ async with websockets.connect(ws_url, ping_interval=20, ping_timeout=10) as ws:
458
+ await ws.send(json.dumps({"token": ws_token}))
459
+ await ws.send(
460
+ json.dumps(
461
+ {
462
+ "event_type": "status",
463
+ "data": {
464
+ "status": "waiting_followup",
465
+ "message": "Ready for follow-up",
466
+ "session_id": session_id,
467
+ },
468
+ }
469
+ )
470
+ )
471
+
472
+ try:
473
+ async for raw_msg in ws:
474
+ try:
475
+ msg = json.loads(raw_msg)
476
+ except json.JSONDecodeError:
477
+ continue
478
+
479
+ if msg.get("type") == "user_input":
480
+ text = msg.get("data", {}).get("text", "")
481
+ if not text:
482
+ continue
483
+ _log("daemon", f"Follow-up: {text[:60]}")
484
+ continue_cmd = _build_continue_command(command, session_id, text)
485
+ break
486
+ elif msg.get("type") == "stop":
487
+ _log("daemon", "Stop received.")
488
+ return 0
489
+ else:
490
+ return 0
491
+ except websockets.exceptions.ConnectionClosed:
492
+ return 0
493
+
494
+ return await run_with_followup(ws_url, ws_token, continue_cmd)
495
+
496
+
497
+ # ---------------------------------------------------------------------------
498
+ # Login command
499
+ # ---------------------------------------------------------------------------
500
+
501
+
502
+ def cmd_login(args: argparse.Namespace) -> None:
503
+ """Authenticate with the server and save tokens locally."""
504
+ import urllib.error
505
+ import urllib.request
506
+
507
+ host = args.host
508
+ email = args.email or input("Email: ")
509
+ password = args.password or getpass.getpass("Password: ")
510
+
511
+ proto = "https" if args.secure else "http"
512
+ url = f"{proto}://{host}/api/auth/login"
513
+
514
+ payload = json.dumps({"email": email, "password": password}).encode()
515
+ req = urllib.request.Request(
516
+ url,
517
+ data=payload,
518
+ headers={"Content-Type": "application/json"},
519
+ method="POST",
520
+ )
521
+
522
+ try:
523
+ with _safe_urlopen(req) as resp:
524
+ data = json.loads(resp.read())
525
+ except urllib.error.HTTPError as e:
526
+ body = e.read().decode()
527
+ _log("login", f"Error {e.code}: {body}")
528
+ sys.exit(1)
529
+ except urllib.error.URLError as e:
530
+ _log("login", f"Connection error: {e.reason}")
531
+ sys.exit(1)
532
+
533
+ config = load_config()
534
+ config["host"] = host
535
+ config["secure"] = args.secure
536
+ config["access_token"] = data["access_token"]
537
+ config["refresh_token"] = data["refresh_token"]
538
+ save_config(config)
539
+
540
+ _log("login", f"Logged in as {email}")
541
+ _log("login", f"Config saved to {CONFIG_FILE}")
542
+
543
+
544
+ # ---------------------------------------------------------------------------
545
+ # Connect command (Node Agent)
546
+ # ---------------------------------------------------------------------------
547
+
548
+
549
+ def _refresh_token(host: str, secure: bool, refresh_token: str) -> dict | None:
550
+ """Try to refresh the access token. Returns new tokens or None."""
551
+ import urllib.request
552
+
553
+ proto = "https" if secure else "http"
554
+ url = f"{proto}://{host}/api/auth/refresh"
555
+ payload = json.dumps({"refresh_token": refresh_token}).encode()
556
+ req = urllib.request.Request(
557
+ url,
558
+ data=payload,
559
+ headers={"Content-Type": "application/json"},
560
+ method="POST",
561
+ )
562
+ try:
563
+ with _safe_urlopen(req) as resp:
564
+ return json.loads(resp.read())
565
+ except Exception:
566
+ return None
567
+
568
+
569
+ async def _run_node_agent(host: str, secure: bool, access_token: str) -> None:
570
+ """Run the node agent: connect, scan sessions, handle attach commands."""
571
+ from am.scanners import scan_all_sessions
572
+
573
+ ws_proto = "wss" if secure else "ws"
574
+ ws_url = f"{ws_proto}://{host}/ws/node"
575
+
576
+ _log("node", f"Connecting to {ws_url}...")
577
+
578
+ daemon_procs: dict[str, asyncio.subprocess.Process] = {}
579
+
580
+ async with websockets.connect(ws_url, ping_interval=20, ping_timeout=10) as ws:
581
+ await ws.send(json.dumps({"token": access_token}))
582
+
583
+ raw = await ws.recv()
584
+ msg = json.loads(raw)
585
+ if msg.get("type") != "connected":
586
+ _log("node", f"Unexpected response: {msg}")
587
+ return
588
+ _log("node", f"Connected as user {msg.get('user_id', '?')[:8]}...")
589
+
590
+ async def _scan_and_send():
591
+ while True:
592
+ try:
593
+ loop = asyncio.get_event_loop()
594
+ sessions = await loop.run_in_executor(None, scan_all_sessions)
595
+ sessions_data = [s.model_dump(mode="json") for s in sessions]
596
+ await ws.send(
597
+ json.dumps(
598
+ {
599
+ "type": "sessions",
600
+ "data": sessions_data,
601
+ }
602
+ )
603
+ )
604
+ _log("node", f"Sent {len(sessions)} sessions")
605
+ except websockets.exceptions.ConnectionClosed:
606
+ break
607
+ except Exception as e:
608
+ _log("node", f"Scan error: {e}")
609
+ await asyncio.sleep(30)
610
+
611
+ async def _heartbeat():
612
+ while True:
613
+ await asyncio.sleep(25)
614
+ try:
615
+ await ws.send(json.dumps({"type": "heartbeat"}))
616
+ except websockets.exceptions.ConnectionClosed:
617
+ break
618
+
619
+ async def _listen_commands():
620
+ try:
621
+ async for raw_msg in ws:
622
+ try:
623
+ cmd = json.loads(raw_msg)
624
+ except json.JSONDecodeError:
625
+ continue
626
+
627
+ if cmd.get("type") == "attach":
628
+ await _handle_attach(cmd, host, secure, daemon_procs)
629
+ except websockets.exceptions.ConnectionClosed:
630
+ pass
631
+
632
+ scan_task = asyncio.create_task(_scan_and_send())
633
+ hb_task = asyncio.create_task(_heartbeat())
634
+ cmd_task = asyncio.create_task(_listen_commands())
635
+
636
+ try:
637
+ await asyncio.wait(
638
+ [scan_task, hb_task, cmd_task],
639
+ return_when=asyncio.FIRST_COMPLETED,
640
+ )
641
+ finally:
642
+ scan_task.cancel()
643
+ hb_task.cancel()
644
+ cmd_task.cancel()
645
+ for aid, proc in daemon_procs.items():
646
+ if proc.returncode is None:
647
+ proc.terminate()
648
+ _log("node", f"Terminated daemon for agent {aid[:8]}...")
649
+
650
+
651
+ async def _handle_attach(
652
+ cmd: dict,
653
+ host: str,
654
+ secure: bool,
655
+ daemon_procs: dict[str, asyncio.subprocess.Process],
656
+ ) -> None:
657
+ """Handle an attach command from the server."""
658
+ agent_id = cmd["agent_id"]
659
+ session_id = cmd["session_id"]
660
+ cli = cmd.get("cli", "claude")
661
+ project_path = cmd.get("project_path", "")
662
+ daemon_token = cmd["daemon_token"]
663
+
664
+ _log("node", f"Attach: {cli} session {session_id[:8]}... agent {agent_id[:8]}...")
665
+
666
+ cli_cmd = _build_cli_command(cli, session_id)
667
+ daemon_cmd = [
668
+ sys.executable,
669
+ "-m",
670
+ "am",
671
+ "daemon",
672
+ "--token",
673
+ daemon_token,
674
+ "--host",
675
+ host,
676
+ *(["--secure"] if secure else []),
677
+ "--",
678
+ *cli_cmd,
679
+ ]
680
+
681
+ proc = await asyncio.create_subprocess_exec(
682
+ *daemon_cmd,
683
+ cwd=project_path or None,
684
+ stdout=asyncio.subprocess.DEVNULL,
685
+ stderr=asyncio.subprocess.PIPE,
686
+ )
687
+ daemon_procs[agent_id] = proc
688
+ _log("node", f"Spawned daemon PID {proc.pid} for agent {agent_id[:8]}...")
689
+
690
+ asyncio.create_task(_log_daemon_stderr(agent_id, proc, daemon_procs))
691
+
692
+
693
+ async def _log_daemon_stderr(
694
+ agent_id: str,
695
+ proc: asyncio.subprocess.Process,
696
+ daemon_procs: dict[str, asyncio.subprocess.Process],
697
+ ) -> None:
698
+ """Read daemon stderr and log it."""
699
+ try:
700
+ while True:
701
+ line = await proc.stderr.readline()
702
+ if not line:
703
+ break
704
+ print(f" [daemon:{agent_id[:8]}] {line.decode().rstrip()}", file=sys.stderr)
705
+ except Exception:
706
+ pass
707
+ await proc.wait()
708
+ _log("node", f"Daemon {agent_id[:8]}... exited with code {proc.returncode}")
709
+ daemon_procs.pop(agent_id, None)
710
+
711
+
712
+ def cmd_connect(args: argparse.Namespace) -> None:
713
+ """Run the node agent."""
714
+ config = load_config()
715
+
716
+ host = args.host or config.get("host")
717
+ secure = args.secure or config.get("secure", False)
718
+ access_token = config.get("access_token")
719
+ refresh_token = config.get("refresh_token")
720
+
721
+ if not host:
722
+ _log("connect", "No host configured. Run `am login` first.")
723
+ sys.exit(1)
724
+ if not access_token:
725
+ _log("connect", "No access token. Run `am login` first.")
726
+ sys.exit(1)
727
+
728
+ # Refresh token before connecting
729
+ if refresh_token:
730
+ tokens = _refresh_token(host, secure, refresh_token)
731
+ if tokens:
732
+ access_token = tokens["access_token"]
733
+ config["access_token"] = tokens["access_token"]
734
+ config["refresh_token"] = tokens["refresh_token"]
735
+ save_config(config)
736
+ _log("connect", "Token refreshed.")
737
+
738
+ try:
739
+ asyncio.run(_run_node_agent(host, secure, access_token))
740
+ except KeyboardInterrupt:
741
+ print("\n[connect] Stopped.", file=sys.stderr)
742
+ except websockets.exceptions.InvalidStatusCode as e:
743
+ _log("connect", f"WebSocket error: {e}")
744
+ if e.status_code in (401, 403):
745
+ _log("connect", "Token may be expired. Run `am login` again.")
746
+ sys.exit(1)
747
+ except Exception as e:
748
+ _log("connect", f"Error: {e}")
749
+ sys.exit(1)
750
+
751
+
752
+ # ---------------------------------------------------------------------------
753
+ # Daemon subcommand
754
+ # ---------------------------------------------------------------------------
755
+
756
+
757
+ def cmd_daemon(args: argparse.Namespace) -> None:
758
+ """Run daemon for a CLI session."""
759
+ proto = "wss" if args.secure else "ws"
760
+ ws_url = f"{proto}://{args.host}/ws/daemon"
761
+
762
+ if args.cli_command:
763
+ if args.no_followup:
764
+ returncode, _ = asyncio.run(
765
+ run_daemon_bidirectional(ws_url, args.token, args.cli_command)
766
+ )
767
+ else:
768
+ returncode = asyncio.run(run_with_followup(ws_url, args.token, args.cli_command))
769
+ sys.exit(returncode or 0)
770
+ else:
771
+ _log("daemon", "Reading from stdin (pipe mode)...")
772
+ asyncio.run(run_daemon_pipe(ws_url, args.token, sys.stdin))
773
+
774
+
775
+ # ---------------------------------------------------------------------------
776
+ # Main entry point
777
+ # ---------------------------------------------------------------------------
778
+
779
+
780
+ def main() -> None:
781
+ parser = argparse.ArgumentParser(
782
+ prog="am",
783
+ description="Agent Manager — daemon, login, and node agent",
784
+ )
785
+ subparsers = parser.add_subparsers(dest="command")
786
+
787
+ # --- login ---
788
+ login_p = subparsers.add_parser("login", help="Authenticate with the server")
789
+ login_p.add_argument("--host", required=True, help="Server host:port")
790
+ login_p.add_argument("--email", help="Email (will prompt if not provided)")
791
+ login_p.add_argument("--password", help="Password (will prompt if not provided)")
792
+ login_p.add_argument("--secure", action="store_true", help="Use HTTPS/WSS")
793
+
794
+ # --- connect ---
795
+ connect_p = subparsers.add_parser("connect", help="Run node agent")
796
+ connect_p.add_argument("--host", help="Override saved host")
797
+ connect_p.add_argument("--secure", action="store_true", help="Use HTTPS/WSS")
798
+
799
+ # --- daemon ---
800
+ daemon_p = subparsers.add_parser("daemon", help="Run daemon for a CLI session")
801
+ daemon_p.add_argument("--token", required=True, help="Daemon JWT token")
802
+ daemon_p.add_argument("--host", default="localhost:8000", help="Backend host:port")
803
+ daemon_p.add_argument("--secure", action="store_true", help="Use WSS")
804
+ daemon_p.add_argument("--no-followup", action="store_true", help="Disable follow-up mode")
805
+ daemon_p.add_argument("cli_command", nargs="*", help="CLI command to run")
806
+
807
+ args = parser.parse_args()
808
+
809
+ if args.command == "login":
810
+ cmd_login(args)
811
+ elif args.command == "connect":
812
+ cmd_connect(args)
813
+ elif args.command == "daemon":
814
+ cmd_daemon(args)
815
+ else:
816
+ parser.print_help()
817
+ sys.exit(1)