agent-manager-cli 0.1.7__tar.gz → 0.1.9__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-manager-cli
3
- Version: 0.1.7
3
+ Version: 0.1.9
4
4
  Summary: CLI для удалённого управления AI-агентами — Node Agent, daemon, сканер сессий
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.11
@@ -18,6 +18,7 @@ import argparse
18
18
  import asyncio
19
19
  import getpass
20
20
  import json
21
+ import os
21
22
  import subprocess
22
23
  import sys
23
24
  from pathlib import Path
@@ -259,6 +260,7 @@ def format_control_response_for_stdin(
259
260
  allow: bool,
260
261
  original_input: dict | None = None,
261
262
  deny_message: str = "Denied by user",
263
+ destination: str | None = None,
262
264
  ) -> str:
263
265
  """Build a stream-json control_response for Claude's can_use_tool prompt.
264
266
 
@@ -268,12 +270,18 @@ def format_control_response_for_stdin(
268
270
 
269
271
  When allowing, we must echo back the input unchanged in `updatedInput`
270
272
  (Claude uses that to build the actual tool invocation).
273
+
274
+ `destination` (optional) supports "sticky" allow — Claude will remember
275
+ the decision for the rest of the session (`"session"`) or persist it
276
+ to local settings (`"localSettings"`).
271
277
  """
272
278
  if allow:
273
- response_body = {
279
+ response_body: dict = {
274
280
  "behavior": "allow",
275
281
  "updatedInput": original_input or {},
276
282
  }
283
+ if destination in ("session", "localSettings"):
284
+ response_body["destination"] = destination
277
285
  else:
278
286
  response_body = {
279
287
  "behavior": "deny",
@@ -291,6 +299,21 @@ def format_control_response_for_stdin(
291
299
  )
292
300
 
293
301
 
302
+ def format_control_request_for_stdin(subtype: str, **fields) -> str:
303
+ """Build a client→claude control_request with the given subtype and
304
+ payload fields. Handles `interrupt`, `set_model`, `set_permission_mode`,
305
+ and other control subtypes Claude supports over stream-json stdin."""
306
+ import uuid as _u
307
+
308
+ return json.dumps(
309
+ {
310
+ "type": "control_request",
311
+ "request_id": f"{subtype}-{_u.uuid4()}",
312
+ "request": {"subtype": subtype, **fields},
313
+ }
314
+ )
315
+
316
+
294
317
  def _log(prefix: str, msg: str) -> None:
295
318
  print(f"[{prefix}] {msg}", file=sys.stderr)
296
319
 
@@ -324,10 +347,39 @@ CLAUDE_PERMISSION_MODES_ALL = (
324
347
  )
325
348
 
326
349
 
350
+ def _build_claude_hook_settings() -> str:
351
+ """Inline JSON payload for `claude --settings` that registers our
352
+ PreToolUse hook. The hook is `am permission-hook` — a subcommand of
353
+ this very CLI that forwards each permission request to the backend
354
+ and routes the user's answer back to Claude."""
355
+ # Quote sys.executable properly in case of spaces in path.
356
+ import shlex
357
+
358
+ cmd_str = f"{shlex.quote(sys.executable)} -m am permission-hook"
359
+ return json.dumps(
360
+ {
361
+ "hooks": {
362
+ "PreToolUse": [
363
+ {
364
+ "matcher": "*",
365
+ "hooks": [
366
+ {
367
+ "type": "command",
368
+ "command": cmd_str,
369
+ }
370
+ ],
371
+ }
372
+ ]
373
+ }
374
+ }
375
+ )
376
+
377
+
327
378
  def _build_cli_command(
328
379
  cli: str,
329
380
  session_id: str,
330
381
  permission_mode: str | None = None,
382
+ with_permission_hook: bool = True,
331
383
  ) -> list[str]:
332
384
  """Build CLI-specific resume command.
333
385
 
@@ -350,6 +402,8 @@ def _build_cli_command(
350
402
  ]
351
403
  if permission_mode and permission_mode in CLAUDE_PERMISSION_MODES_ALL:
352
404
  cmd.extend(["--permission-mode", permission_mode])
405
+ if with_permission_hook:
406
+ cmd.extend(["--settings", _build_claude_hook_settings()])
353
407
  return cmd
354
408
  if cli == "codex":
355
409
  return ["codex", "resume", session_id, "--json"]
@@ -530,6 +584,7 @@ async def run_daemon_bidirectional(ws_url: str, ws_token: str, command: list[str
530
584
  elif msg_type == "permission_response":
531
585
  data = msg.get("data", {}) or {}
532
586
  allow = bool(data.get("allow", False))
587
+ destination = data.get("destination")
533
588
  request_id = msg.get("request_id") or data.get("request_id")
534
589
  if not request_id:
535
590
  _log(
@@ -542,13 +597,38 @@ async def run_daemon_bidirectional(ws_url: str, ws_token: str, command: list[str
542
597
  request_id=request_id,
543
598
  allow=allow,
544
599
  original_input=original_input,
600
+ destination=destination,
545
601
  )
546
602
  proc.stdin.write(ctrl + "\n")
547
603
  proc.stdin.flush()
604
+ sticky = f" sticky={destination}" if destination else ""
548
605
  _log(
549
606
  "stdin",
550
- f"permission: {'allow' if allow else 'deny'} id={request_id[-8:]}",
607
+ f"permission: {'allow' if allow else 'deny'}"
608
+ f" id={request_id[-8:]}{sticky}",
551
609
  )
610
+ elif msg_type == "interrupt":
611
+ # Cancel whatever Claude is currently doing without
612
+ # killing the process. Equivalent to pressing Esc in
613
+ # the TUI. Claude stops the in-flight tool and
614
+ # returns control.
615
+ if proc.stdin and not proc.stdin.closed:
616
+ ctrl = format_control_request_for_stdin("interrupt")
617
+ proc.stdin.write(ctrl + "\n")
618
+ proc.stdin.flush()
619
+ _log("stdin", "interrupt")
620
+ elif msg_type == "set_model":
621
+ model = msg.get("data", {}).get("model")
622
+ if not model:
623
+ _log("stdin", "set_model: missing model, ignoring")
624
+ elif proc.stdin and not proc.stdin.closed:
625
+ ctrl = format_control_request_for_stdin(
626
+ "set_model",
627
+ model=model,
628
+ )
629
+ proc.stdin.write(ctrl + "\n")
630
+ proc.stdin.flush()
631
+ _log("stdin", f"set_model: {model}")
552
632
  elif msg_type == "set_permission_mode":
553
633
  # Shift+Tab equivalent — send a control_request to
554
634
  # Claude CLI so it updates its session permission
@@ -557,17 +637,11 @@ async def run_daemon_bidirectional(ws_url: str, ws_token: str, command: list[str
557
637
  if mode not in CLAUDE_PERMISSION_MODES_ALL:
558
638
  _log("stdin", f"set_permission_mode: ignoring unknown mode {mode!r}")
559
639
  elif proc.stdin and not proc.stdin.closed:
560
- import uuid as _u
561
-
562
- ctrl = {
563
- "type": "control_request",
564
- "request_id": f"set-mode-{_u.uuid4()}",
565
- "request": {
566
- "subtype": "set_permission_mode",
567
- "mode": mode,
568
- },
569
- }
570
- proc.stdin.write(json.dumps(ctrl) + "\n")
640
+ ctrl = format_control_request_for_stdin(
641
+ "set_permission_mode",
642
+ mode=mode,
643
+ )
644
+ proc.stdin.write(ctrl + "\n")
571
645
  proc.stdin.flush()
572
646
  _log("stdin", f"set_permission_mode: {mode}")
573
647
  elif msg_type == "stop":
@@ -582,8 +656,39 @@ async def run_daemon_bidirectional(ws_url: str, ws_token: str, command: list[str
582
656
 
583
657
  stdout_task = asyncio.create_task(_read_stdout())
584
658
  ws_task = asyncio.create_task(_read_ws())
585
- await stdout_task
586
- ws_task.cancel()
659
+
660
+ # Run both in parallel. Normally stdout_task finishes first
661
+ # (Claude exits naturally at end of task). If ws_task finishes
662
+ # first it means the backend connection died — in that case we
663
+ # have no way to recover the session, so we terminate Claude
664
+ # so the daemon subprocess exits cleanly. The node agent will
665
+ # detect the dead subprocess and spawn a fresh one on the next
666
+ # attach command (with --continue --resume so history is kept).
667
+ done, _pending = await asyncio.wait(
668
+ [stdout_task, ws_task],
669
+ return_when=asyncio.FIRST_COMPLETED,
670
+ )
671
+
672
+ if ws_task in done and proc.returncode is None:
673
+ _log(
674
+ "daemon",
675
+ "WS closed while Claude is still running — terminating CLI "
676
+ "(node agent will respawn on next attach)",
677
+ )
678
+ try:
679
+ proc.terminate()
680
+ except ProcessLookupError:
681
+ pass
682
+
683
+ # Let remaining tasks clean up (stdout_task should exit once
684
+ # claude's stdout closes after terminate).
685
+ for task in (stdout_task, ws_task):
686
+ if not task.done():
687
+ task.cancel()
688
+ try:
689
+ await task
690
+ except (asyncio.CancelledError, Exception):
691
+ pass
587
692
 
588
693
  proc.wait()
589
694
  _log("daemon", f"Process exited with code {proc.returncode}")
@@ -814,8 +919,34 @@ async def _run_node_agent(
814
919
  async def _heartbeat():
815
920
  while True:
816
921
  await asyncio.sleep(25)
922
+ # Reap any daemon subprocesses that have exited so we
923
+ # don't report them as alive.
924
+ dead = [
925
+ aid for aid, p in daemon_procs.items()
926
+ if p.returncode is not None
927
+ ]
928
+ for aid in dead:
929
+ daemon_procs.pop(aid, None)
930
+
931
+ # Report live daemons to the backend so it can refresh
932
+ # `daemon:live:{id}` heartbeats. This is the source of
933
+ # truth for the UI's `is_daemon_alive` check — without
934
+ # it the backend has no way to know when a daemon
935
+ # subprocess is alive but its own ws to the backend is
936
+ # dead (orphaned after backend restart, etc.).
937
+ alive_ids = [
938
+ aid for aid, p in daemon_procs.items()
939
+ if p.returncode is None
940
+ ]
817
941
  try:
818
- await ws.send(json.dumps({"type": "heartbeat"}))
942
+ await ws.send(
943
+ json.dumps(
944
+ {
945
+ "type": "heartbeat",
946
+ "daemons": alive_ids,
947
+ }
948
+ )
949
+ )
819
950
  except websockets.exceptions.ConnectionClosed as e:
820
951
  _log("node", f"heartbeat: connection closed ({e.code} {e.reason or '-'})")
821
952
  break
@@ -902,11 +1033,21 @@ async def _handle_attach(
902
1033
  *cli_cmd,
903
1034
  ]
904
1035
 
1036
+ # Propagate backend connection info + daemon token to the child
1037
+ # subprocess tree. Claude CLI will spawn PreToolUse hooks (via
1038
+ # `am permission-hook`) that need to contact the backend, so these
1039
+ # env vars must reach the grandchild process.
1040
+ env = dict(os.environ)
1041
+ env["AM_DAEMON_TOKEN"] = daemon_token
1042
+ env["AM_BACKEND_HOST"] = host
1043
+ env["AM_BACKEND_SECURE"] = "1" if secure else "0"
1044
+
905
1045
  proc = await asyncio.create_subprocess_exec(
906
1046
  *daemon_cmd,
907
1047
  cwd=project_path or None,
908
1048
  stdout=asyncio.subprocess.DEVNULL,
909
1049
  stderr=asyncio.subprocess.PIPE,
1050
+ env=env,
910
1051
  )
911
1052
  daemon_procs[agent_id] = proc
912
1053
  _log("node", f"Spawned daemon PID {proc.pid} for agent {agent_id[:8]}...")
@@ -1039,6 +1180,103 @@ def cmd_daemon(args: argparse.Namespace) -> None:
1039
1180
  asyncio.run(run_daemon_pipe(ws_url, args.token, sys.stdin))
1040
1181
 
1041
1182
 
1183
+ # ---------------------------------------------------------------------------
1184
+ # PreToolUse permission hook — spawned by Claude CLI for every tool call.
1185
+ # ---------------------------------------------------------------------------
1186
+
1187
+
1188
+ def cmd_permission_hook(args: argparse.Namespace) -> None: # noqa: ARG001
1189
+ """Handle a single Claude Code PreToolUse hook invocation.
1190
+
1191
+ Claude spawns this subprocess per tool call with a JSON payload on
1192
+ stdin describing the tool use. We forward it to the backend, wait
1193
+ for the user's decision (long-poll HTTP), and write Claude's
1194
+ expected response shape to stdout.
1195
+
1196
+ Environment (populated by the parent am daemon subprocess):
1197
+ AM_DAEMON_TOKEN — daemon JWT (identifies user+agent)
1198
+ AM_BACKEND_HOST — "host:port"
1199
+ AM_BACKEND_SECURE — "1" for https/wss, "0" for plain
1200
+
1201
+ Fail-safe: on any error (network, missing token, timeout) we default
1202
+ to `ask` with a reason. This causes Claude to use its normal
1203
+ permission-mode flow instead of silently allowing or blocking.
1204
+ """
1205
+ import urllib.error
1206
+ import urllib.request
1207
+
1208
+ def _emit(decision: str, reason: str = "") -> None:
1209
+ """Write the PreToolUse hook response and exit cleanly."""
1210
+ out = {
1211
+ "hookSpecificOutput": {
1212
+ "hookEventName": "PreToolUse",
1213
+ "permissionDecision": decision,
1214
+ "permissionDecisionReason": reason,
1215
+ }
1216
+ }
1217
+ sys.stdout.write(json.dumps(out))
1218
+ sys.stdout.flush()
1219
+ sys.exit(0)
1220
+
1221
+ try:
1222
+ raw_input_payload = sys.stdin.read()
1223
+ except Exception as e:
1224
+ _emit("ask", f"Hook: failed to read stdin: {e}")
1225
+ return
1226
+
1227
+ try:
1228
+ hook_input = json.loads(raw_input_payload) if raw_input_payload.strip() else {}
1229
+ except json.JSONDecodeError as e:
1230
+ _emit("ask", f"Hook: bad JSON on stdin: {e}")
1231
+ return
1232
+
1233
+ token = os.environ.get("AM_DAEMON_TOKEN")
1234
+ host = os.environ.get("AM_BACKEND_HOST", "localhost:8000")
1235
+ secure = os.environ.get("AM_BACKEND_SECURE") == "1"
1236
+ if not token:
1237
+ _emit("ask", "Hook: AM_DAEMON_TOKEN is not set")
1238
+ return
1239
+
1240
+ proto = "https" if secure else "http"
1241
+ url = f"{proto}://{host}/api/hooks/permission/ask"
1242
+
1243
+ body = {
1244
+ "tool_name": hook_input.get("tool_name"),
1245
+ "tool_input": hook_input.get("tool_input"),
1246
+ "hook_event_name": hook_input.get("hook_event_name"),
1247
+ "session_id": hook_input.get("session_id"),
1248
+ "transcript_path": hook_input.get("transcript_path"),
1249
+ }
1250
+
1251
+ req = urllib.request.Request(
1252
+ url,
1253
+ data=json.dumps(body).encode(),
1254
+ headers={
1255
+ "Authorization": f"Bearer {token}",
1256
+ "Content-Type": "application/json",
1257
+ },
1258
+ method="POST",
1259
+ )
1260
+
1261
+ try:
1262
+ # Long-poll: backend blocks until user answers or 5-min timeout.
1263
+ # Give urllib a slightly longer ceiling to avoid races.
1264
+ with _safe_urlopen(req, timeout=310) as resp:
1265
+ payload = json.loads(resp.read())
1266
+ except urllib.error.HTTPError as e:
1267
+ _emit("ask", f"Hook: backend HTTP {e.code}")
1268
+ return
1269
+ except Exception as e:
1270
+ _emit("ask", f"Hook: backend error: {e}")
1271
+ return
1272
+
1273
+ decision = payload.get("decision", "ask")
1274
+ reason = payload.get("reason", "")
1275
+ if decision not in ("allow", "deny", "ask"):
1276
+ decision = "ask"
1277
+ _emit(decision, reason)
1278
+
1279
+
1042
1280
  # ---------------------------------------------------------------------------
1043
1281
  # Main entry point
1044
1282
  # ---------------------------------------------------------------------------
@@ -1094,6 +1332,12 @@ def main() -> None:
1094
1332
  daemon_p.add_argument("--no-followup", action="store_true", help="Disable follow-up mode")
1095
1333
  daemon_p.add_argument("cli_command", nargs="*", help="CLI command to run")
1096
1334
 
1335
+ # --- permission-hook (spawned by Claude CLI as PreToolUse hook) ---
1336
+ subparsers.add_parser(
1337
+ "permission-hook",
1338
+ help="Internal: Claude Code PreToolUse hook handler",
1339
+ )
1340
+
1097
1341
  args = parser.parse_args()
1098
1342
 
1099
1343
  if args.command == "login":
@@ -1102,6 +1346,8 @@ def main() -> None:
1102
1346
  cmd_connect(args)
1103
1347
  elif args.command == "daemon":
1104
1348
  cmd_daemon(args)
1349
+ elif args.command == "permission-hook":
1350
+ cmd_permission_hook(args)
1105
1351
  else:
1106
1352
  parser.print_help()
1107
1353
  sys.exit(1)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agent-manager-cli"
3
- version = "0.1.7"
3
+ version = "0.1.9"
4
4
  description = "CLI для удалённого управления AI-агентами — Node Agent, daemon, сканер сессий"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -0,0 +1,387 @@
1
+ """Tests for CLI config management and helpers."""
2
+
3
+ import json
4
+ import subprocess
5
+ import sys
6
+
7
+ from am.cli import (
8
+ _build_claude_hook_settings,
9
+ _build_cli_command,
10
+ _build_continue_command,
11
+ format_control_request_for_stdin,
12
+ format_control_response_for_stdin,
13
+ format_permission_response_for_stdin,
14
+ format_user_message_for_stdin,
15
+ load_config,
16
+ save_config,
17
+ )
18
+
19
+
20
+ class TestConfig:
21
+ def test_load_config_missing(self, tmp_path, monkeypatch):
22
+ monkeypatch.setattr("am.cli.CONFIG_FILE", tmp_path / "missing.json")
23
+ assert load_config() == {}
24
+
25
+ def test_save_and_load(self, tmp_path, monkeypatch):
26
+ config_file = tmp_path / "sub" / "config.json"
27
+ monkeypatch.setattr("am.cli.CONFIG_FILE", config_file)
28
+ monkeypatch.setattr("am.cli.CONFIG_DIR", tmp_path / "sub")
29
+
30
+ save_config({"host": "example.com", "secure": True})
31
+ result = load_config()
32
+ assert result["host"] == "example.com"
33
+ assert result["secure"] is True
34
+
35
+
36
+ class TestFormatMessages:
37
+ def test_user_message(self):
38
+ result = json.loads(format_user_message_for_stdin("hello"))
39
+ assert result["type"] == "user"
40
+ assert result["message"]["role"] == "user"
41
+ content = result["message"]["content"]
42
+ assert len(content) == 1
43
+ assert content[0]["type"] == "text"
44
+ assert content[0]["text"] == "hello"
45
+
46
+ def test_permission_allow(self):
47
+ result = json.loads(format_permission_response_for_stdin(True))
48
+ assert result["type"] == "permission_response"
49
+ assert result["permission"] == "allow"
50
+
51
+ def test_permission_deny(self):
52
+ result = json.loads(format_permission_response_for_stdin(False))
53
+ assert result["permission"] == "deny"
54
+
55
+
56
+ class TestBuildCommands:
57
+ def test_claude_command(self):
58
+ cmd = _build_cli_command("claude", "sess-123")
59
+ assert cmd[0] == "claude"
60
+ assert "--continue" in cmd
61
+ assert "--resume" in cmd
62
+ assert "sess-123" in cmd
63
+ assert "--output-format" in cmd
64
+ assert "stream-json" in cmd
65
+ # No permission mode → flag should NOT be present
66
+ assert "--permission-mode" not in cmd
67
+ # Permission hook is enabled by default for claude
68
+ assert "--settings" in cmd
69
+ # The inline settings JSON must declare a PreToolUse hook pointing
70
+ # to `am permission-hook` so Claude calls our bridge on every tool.
71
+ idx = cmd.index("--settings")
72
+ settings = json.loads(cmd[idx + 1])
73
+ assert "hooks" in settings
74
+ assert "PreToolUse" in settings["hooks"]
75
+ hook_cmd = settings["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
76
+ assert "am permission-hook" in hook_cmd
77
+
78
+ def test_claude_command_without_hook(self):
79
+ cmd = _build_cli_command("claude", "sess-123", with_permission_hook=False)
80
+ assert "--settings" not in cmd
81
+
82
+ def test_claude_command_with_permission_mode(self):
83
+ cmd = _build_cli_command("claude", "sess-123", permission_mode="acceptEdits")
84
+ assert "--permission-mode" in cmd
85
+ idx = cmd.index("--permission-mode")
86
+ assert cmd[idx + 1] == "acceptEdits"
87
+
88
+ def test_claude_command_rejects_unknown_permission_mode(self):
89
+ # Unknown modes are silently dropped (we don't want to break
90
+ # spawn just because the UI sent a garbage value).
91
+ cmd = _build_cli_command("claude", "sess-123", permission_mode="nonsense")
92
+ assert "--permission-mode" not in cmd
93
+
94
+ def test_codex_command(self):
95
+ cmd = _build_cli_command("codex", "rollout-abc")
96
+ assert cmd == ["codex", "resume", "rollout-abc", "--json"]
97
+
98
+ def test_gemini_command(self):
99
+ cmd = _build_cli_command("gemini", "anything")
100
+ assert cmd == ["gemini", "--resume"]
101
+
102
+ def test_unsupported_cli(self):
103
+ try:
104
+ _build_cli_command("unknown", "x")
105
+ assert False, "Should have raised ValueError"
106
+ except ValueError as e:
107
+ assert "unknown" in str(e)
108
+
109
+ def test_continue_command(self):
110
+ original = ["claude", "-p", "do stuff", "--output-format", "stream-json"]
111
+ cmd = _build_continue_command(original, "sess-1", "next task")
112
+ assert cmd[0] == "claude"
113
+ assert "-p" in cmd
114
+ assert "next task" in cmd
115
+ assert "--continue" in cmd
116
+ assert "--resume" in cmd
117
+ assert "sess-1" in cmd
118
+
119
+
120
+ class TestControlProtocol:
121
+ """Tests for Claude Code's stream-json control_request/response helpers."""
122
+
123
+ def test_control_request_shape(self):
124
+ raw = format_control_request_for_stdin("set_permission_mode", mode="plan")
125
+ msg = json.loads(raw)
126
+ assert msg["type"] == "control_request"
127
+ assert msg["request_id"].startswith("set_permission_mode-")
128
+ assert msg["request"] == {"subtype": "set_permission_mode", "mode": "plan"}
129
+
130
+ def test_control_request_empty_payload(self):
131
+ raw = format_control_request_for_stdin("interrupt")
132
+ msg = json.loads(raw)
133
+ assert msg["request"]["subtype"] == "interrupt"
134
+ assert len(msg["request"]) == 1 # only subtype
135
+
136
+ def test_control_response_allow(self):
137
+ raw = format_control_response_for_stdin(
138
+ request_id="req-1",
139
+ allow=True,
140
+ original_input={"command": "ls"},
141
+ )
142
+ msg = json.loads(raw)
143
+ assert msg["type"] == "control_response"
144
+ assert msg["response"]["subtype"] == "success"
145
+ assert msg["response"]["request_id"] == "req-1"
146
+ assert msg["response"]["response"]["behavior"] == "allow"
147
+ assert msg["response"]["response"]["updatedInput"] == {"command": "ls"}
148
+ assert "destination" not in msg["response"]["response"]
149
+
150
+ def test_control_response_allow_sticky(self):
151
+ raw = format_control_response_for_stdin(
152
+ request_id="req-2",
153
+ allow=True,
154
+ original_input={"x": 1},
155
+ destination="session",
156
+ )
157
+ msg = json.loads(raw)
158
+ assert msg["response"]["response"]["destination"] == "session"
159
+
160
+ def test_control_response_deny(self):
161
+ raw = format_control_response_for_stdin(
162
+ request_id="req-3",
163
+ allow=False,
164
+ deny_message="nope",
165
+ )
166
+ msg = json.loads(raw)
167
+ assert msg["response"]["response"]["behavior"] == "deny"
168
+ assert msg["response"]["response"]["message"] == "nope"
169
+ assert "updatedInput" not in msg["response"]["response"]
170
+
171
+
172
+ class TestHookSettings:
173
+ def test_build_claude_hook_settings(self):
174
+ raw = _build_claude_hook_settings()
175
+ settings = json.loads(raw)
176
+ # Registers exactly one PreToolUse hook matching all tools.
177
+ assert list(settings.keys()) == ["hooks"]
178
+ assert list(settings["hooks"].keys()) == ["PreToolUse"]
179
+ group = settings["hooks"]["PreToolUse"][0]
180
+ assert group["matcher"] == "*"
181
+ hooks = group["hooks"]
182
+ assert len(hooks) == 1
183
+ assert hooks[0]["type"] == "command"
184
+ # Points at `sys.executable -m am permission-hook`
185
+ assert "am permission-hook" in hooks[0]["command"]
186
+
187
+
188
+ class TestCLIEntryPoint:
189
+ def test_help(self):
190
+ result = subprocess.run(
191
+ [sys.executable, "-m", "am", "--help"],
192
+ capture_output=True,
193
+ text=True,
194
+ )
195
+ assert result.returncode == 0
196
+ assert "login" in result.stdout
197
+ assert "connect" in result.stdout
198
+ assert "daemon" in result.stdout
199
+
200
+ def test_login_help(self):
201
+ result = subprocess.run(
202
+ [sys.executable, "-m", "am", "login", "--help"],
203
+ capture_output=True,
204
+ text=True,
205
+ )
206
+ assert result.returncode == 0
207
+ assert "--host" in result.stdout
208
+ assert "--email" in result.stdout
209
+
210
+ def test_connect_help(self):
211
+ result = subprocess.run(
212
+ [sys.executable, "-m", "am", "connect", "--help"],
213
+ capture_output=True,
214
+ text=True,
215
+ )
216
+ assert result.returncode == 0
217
+ assert "--host" in result.stdout
218
+
219
+ def test_daemon_help(self):
220
+ result = subprocess.run(
221
+ [sys.executable, "-m", "am", "daemon", "--help"],
222
+ capture_output=True,
223
+ text=True,
224
+ )
225
+ assert result.returncode == 0
226
+ assert "--token" in result.stdout
227
+
228
+ def test_no_command_shows_help(self):
229
+ result = subprocess.run(
230
+ [sys.executable, "-m", "am"],
231
+ capture_output=True,
232
+ text=True,
233
+ )
234
+ assert result.returncode == 1
235
+
236
+
237
+ class TestPermissionHook:
238
+ """Tests for `am permission-hook` subcommand.
239
+
240
+ The hook reads a Claude-style JSON payload on stdin, POSTs to the
241
+ backend, waits for the user, and writes the hookSpecificOutput shape
242
+ to stdout. We mock the backend via a monkey-patched urlopen.
243
+ """
244
+
245
+ def _run_hook(self, stdin_json, env, monkeypatch, mock_response):
246
+ """Invoke cmd_permission_hook() inline, capturing stdout/exit."""
247
+ import contextlib
248
+ import io
249
+
250
+ from am import cli as cli_mod
251
+
252
+ class _FakeResponse:
253
+ def __init__(self, body):
254
+ self._body = json.dumps(body).encode()
255
+
256
+ def read(self):
257
+ return self._body
258
+
259
+ def __enter__(self):
260
+ return self
261
+
262
+ def __exit__(self, *a):
263
+ return False
264
+
265
+ def _fake_urlopen(req, *args, **kwargs):
266
+ # Exceptions (e.g. HTTPError) can be injected by passing them
267
+ # in as `mock_response`.
268
+ if isinstance(mock_response, Exception):
269
+ raise mock_response
270
+ return _FakeResponse(mock_response)
271
+
272
+ monkeypatch.setattr(cli_mod, "_safe_urlopen", _fake_urlopen)
273
+ for k, v in env.items():
274
+ monkeypatch.setenv(k, v)
275
+ monkeypatch.setattr("sys.stdin", io.StringIO(json.dumps(stdin_json)))
276
+
277
+ buf = io.StringIO()
278
+ exit_code = None
279
+ try:
280
+ with contextlib.redirect_stdout(buf):
281
+ cli_mod.cmd_permission_hook(type("A", (), {"command": "permission-hook"})())
282
+ except SystemExit as e:
283
+ exit_code = e.code
284
+ return exit_code, buf.getvalue()
285
+
286
+ def test_allow_decision(self, monkeypatch):
287
+ code, out = self._run_hook(
288
+ stdin_json={
289
+ "tool_name": "Bash",
290
+ "tool_input": {"command": "ls"},
291
+ "hook_event_name": "PreToolUse",
292
+ "session_id": "s1",
293
+ },
294
+ env={
295
+ "AM_DAEMON_TOKEN": "fake-token",
296
+ "AM_BACKEND_HOST": "localhost:8000",
297
+ "AM_BACKEND_SECURE": "0",
298
+ },
299
+ monkeypatch=monkeypatch,
300
+ mock_response={"decision": "allow", "reason": "user approved"},
301
+ )
302
+ assert code == 0
303
+ parsed = json.loads(out)
304
+ assert parsed["hookSpecificOutput"]["hookEventName"] == "PreToolUse"
305
+ assert parsed["hookSpecificOutput"]["permissionDecision"] == "allow"
306
+ assert parsed["hookSpecificOutput"]["permissionDecisionReason"] == "user approved"
307
+
308
+ def test_deny_decision(self, monkeypatch):
309
+ code, out = self._run_hook(
310
+ stdin_json={"tool_name": "Bash", "tool_input": {"command": "rm -rf /"}},
311
+ env={
312
+ "AM_DAEMON_TOKEN": "fake",
313
+ "AM_BACKEND_HOST": "localhost:8000",
314
+ },
315
+ monkeypatch=monkeypatch,
316
+ mock_response={"decision": "deny", "reason": "nope"},
317
+ )
318
+ assert code == 0
319
+ parsed = json.loads(out)
320
+ assert parsed["hookSpecificOutput"]["permissionDecision"] == "deny"
321
+
322
+ def test_missing_token_defaults_to_ask(self, monkeypatch):
323
+ # Without AM_DAEMON_TOKEN the hook can't talk to the backend, so
324
+ # it must emit "ask" so Claude falls back to permission-mode logic.
325
+ monkeypatch.delenv("AM_DAEMON_TOKEN", raising=False)
326
+ code, out = self._run_hook(
327
+ stdin_json={"tool_name": "Bash"},
328
+ env={}, # no token
329
+ monkeypatch=monkeypatch,
330
+ mock_response={"decision": "allow"}, # never called
331
+ )
332
+ assert code == 0
333
+ parsed = json.loads(out)
334
+ assert parsed["hookSpecificOutput"]["permissionDecision"] == "ask"
335
+ assert "AM_DAEMON_TOKEN" in parsed["hookSpecificOutput"]["permissionDecisionReason"]
336
+
337
+ def test_backend_error_defaults_to_ask(self, monkeypatch):
338
+ code, out = self._run_hook(
339
+ stdin_json={"tool_name": "Bash"},
340
+ env={"AM_DAEMON_TOKEN": "fake"},
341
+ monkeypatch=monkeypatch,
342
+ mock_response=ConnectionRefusedError("backend down"),
343
+ )
344
+ assert code == 0
345
+ parsed = json.loads(out)
346
+ assert parsed["hookSpecificOutput"]["permissionDecision"] == "ask"
347
+ assert "backend error" in parsed["hookSpecificOutput"]["permissionDecisionReason"]
348
+
349
+ def test_garbage_decision_coerced_to_ask(self, monkeypatch):
350
+ code, out = self._run_hook(
351
+ stdin_json={"tool_name": "Bash"},
352
+ env={"AM_DAEMON_TOKEN": "fake"},
353
+ monkeypatch=monkeypatch,
354
+ mock_response={"decision": "something-weird"},
355
+ )
356
+ assert code == 0
357
+ parsed = json.loads(out)
358
+ assert parsed["hookSpecificOutput"]["permissionDecision"] == "ask"
359
+
360
+ def test_bad_stdin_json_defaults_to_ask(self, monkeypatch):
361
+ import contextlib
362
+ import io
363
+
364
+ from am import cli as cli_mod
365
+
366
+ monkeypatch.setenv("AM_DAEMON_TOKEN", "fake")
367
+ monkeypatch.setattr("sys.stdin", io.StringIO("not json {{"))
368
+ buf = io.StringIO()
369
+ code = None
370
+ try:
371
+ with contextlib.redirect_stdout(buf):
372
+ cli_mod.cmd_permission_hook(type("A", (), {"command": "permission-hook"})())
373
+ except SystemExit as e:
374
+ code = e.code
375
+ assert code == 0
376
+ parsed = json.loads(buf.getvalue())
377
+ assert parsed["hookSpecificOutput"]["permissionDecision"] == "ask"
378
+ assert "bad JSON" in parsed["hookSpecificOutput"]["permissionDecisionReason"]
379
+
380
+ def test_cli_registers_subcommand(self):
381
+ result = subprocess.run(
382
+ [sys.executable, "-m", "am", "--help"],
383
+ capture_output=True,
384
+ text=True,
385
+ )
386
+ assert result.returncode == 0
387
+ assert "permission-hook" in result.stdout
@@ -1,149 +0,0 @@
1
- """Tests for CLI config management and helpers."""
2
-
3
- import json
4
- import subprocess
5
- import sys
6
-
7
- from am.cli import (
8
- _build_cli_command,
9
- _build_continue_command,
10
- format_permission_response_for_stdin,
11
- format_user_message_for_stdin,
12
- load_config,
13
- save_config,
14
- )
15
-
16
-
17
- class TestConfig:
18
- def test_load_config_missing(self, tmp_path, monkeypatch):
19
- monkeypatch.setattr("am.cli.CONFIG_FILE", tmp_path / "missing.json")
20
- assert load_config() == {}
21
-
22
- def test_save_and_load(self, tmp_path, monkeypatch):
23
- config_file = tmp_path / "sub" / "config.json"
24
- monkeypatch.setattr("am.cli.CONFIG_FILE", config_file)
25
- monkeypatch.setattr("am.cli.CONFIG_DIR", tmp_path / "sub")
26
-
27
- save_config({"host": "example.com", "secure": True})
28
- result = load_config()
29
- assert result["host"] == "example.com"
30
- assert result["secure"] is True
31
-
32
-
33
- class TestFormatMessages:
34
- def test_user_message(self):
35
- result = json.loads(format_user_message_for_stdin("hello"))
36
- assert result["type"] == "user"
37
- assert result["message"]["role"] == "user"
38
- content = result["message"]["content"]
39
- assert len(content) == 1
40
- assert content[0]["type"] == "text"
41
- assert content[0]["text"] == "hello"
42
-
43
- def test_permission_allow(self):
44
- result = json.loads(format_permission_response_for_stdin(True))
45
- assert result["type"] == "permission_response"
46
- assert result["permission"] == "allow"
47
-
48
- def test_permission_deny(self):
49
- result = json.loads(format_permission_response_for_stdin(False))
50
- assert result["permission"] == "deny"
51
-
52
-
53
- class TestBuildCommands:
54
- def test_claude_command(self):
55
- cmd = _build_cli_command("claude", "sess-123")
56
- assert cmd[0] == "claude"
57
- assert "--continue" in cmd
58
- assert "--resume" in cmd
59
- assert "sess-123" in cmd
60
- assert "--output-format" in cmd
61
- assert "stream-json" in cmd
62
- # No permission mode → flag should NOT be present
63
- assert "--permission-mode" not in cmd
64
-
65
- def test_claude_command_with_permission_mode(self):
66
- cmd = _build_cli_command("claude", "sess-123", permission_mode="acceptEdits")
67
- assert "--permission-mode" in cmd
68
- idx = cmd.index("--permission-mode")
69
- assert cmd[idx + 1] == "acceptEdits"
70
-
71
- def test_claude_command_rejects_unknown_permission_mode(self):
72
- # Unknown modes are silently dropped (we don't want to break
73
- # spawn just because the UI sent a garbage value).
74
- cmd = _build_cli_command("claude", "sess-123", permission_mode="nonsense")
75
- assert "--permission-mode" not in cmd
76
-
77
- def test_codex_command(self):
78
- cmd = _build_cli_command("codex", "rollout-abc")
79
- assert cmd == ["codex", "resume", "rollout-abc", "--json"]
80
-
81
- def test_gemini_command(self):
82
- cmd = _build_cli_command("gemini", "anything")
83
- assert cmd == ["gemini", "--resume"]
84
-
85
- def test_unsupported_cli(self):
86
- try:
87
- _build_cli_command("unknown", "x")
88
- assert False, "Should have raised ValueError"
89
- except ValueError as e:
90
- assert "unknown" in str(e)
91
-
92
- def test_continue_command(self):
93
- original = ["claude", "-p", "do stuff", "--output-format", "stream-json"]
94
- cmd = _build_continue_command(original, "sess-1", "next task")
95
- assert cmd[0] == "claude"
96
- assert "-p" in cmd
97
- assert "next task" in cmd
98
- assert "--continue" in cmd
99
- assert "--resume" in cmd
100
- assert "sess-1" in cmd
101
-
102
-
103
- class TestCLIEntryPoint:
104
- def test_help(self):
105
- result = subprocess.run(
106
- [sys.executable, "-m", "am", "--help"],
107
- capture_output=True,
108
- text=True,
109
- )
110
- assert result.returncode == 0
111
- assert "login" in result.stdout
112
- assert "connect" in result.stdout
113
- assert "daemon" in result.stdout
114
-
115
- def test_login_help(self):
116
- result = subprocess.run(
117
- [sys.executable, "-m", "am", "login", "--help"],
118
- capture_output=True,
119
- text=True,
120
- )
121
- assert result.returncode == 0
122
- assert "--host" in result.stdout
123
- assert "--email" in result.stdout
124
-
125
- def test_connect_help(self):
126
- result = subprocess.run(
127
- [sys.executable, "-m", "am", "connect", "--help"],
128
- capture_output=True,
129
- text=True,
130
- )
131
- assert result.returncode == 0
132
- assert "--host" in result.stdout
133
-
134
- def test_daemon_help(self):
135
- result = subprocess.run(
136
- [sys.executable, "-m", "am", "daemon", "--help"],
137
- capture_output=True,
138
- text=True,
139
- )
140
- assert result.returncode == 0
141
- assert "--token" in result.stdout
142
-
143
- def test_no_command_shows_help(self):
144
- result = subprocess.run(
145
- [sys.executable, "-m", "am"],
146
- capture_output=True,
147
- text=True,
148
- )
149
- assert result.returncode == 1