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.
- {agent_manager_cli-0.1.7 → agent_manager_cli-0.1.9}/PKG-INFO +1 -1
- {agent_manager_cli-0.1.7 → agent_manager_cli-0.1.9}/am/cli.py +262 -16
- {agent_manager_cli-0.1.7 → agent_manager_cli-0.1.9}/pyproject.toml +1 -1
- agent_manager_cli-0.1.9/tests/test_cli.py +387 -0
- agent_manager_cli-0.1.7/tests/test_cli.py +0 -149
- {agent_manager_cli-0.1.7 → agent_manager_cli-0.1.9}/.gitignore +0 -0
- {agent_manager_cli-0.1.7 → agent_manager_cli-0.1.9}/README.md +0 -0
- {agent_manager_cli-0.1.7 → agent_manager_cli-0.1.9}/am/__init__.py +0 -0
- {agent_manager_cli-0.1.7 → agent_manager_cli-0.1.9}/am/__main__.py +0 -0
- {agent_manager_cli-0.1.7 → agent_manager_cli-0.1.9}/am/scanners/__init__.py +0 -0
- {agent_manager_cli-0.1.7 → agent_manager_cli-0.1.9}/am/scanners/_util.py +0 -0
- {agent_manager_cli-0.1.7 → agent_manager_cli-0.1.9}/am/scanners/claude.py +0 -0
- {agent_manager_cli-0.1.7 → agent_manager_cli-0.1.9}/am/scanners/codex.py +0 -0
- {agent_manager_cli-0.1.7 → agent_manager_cli-0.1.9}/am/scanners/gemini.py +0 -0
- {agent_manager_cli-0.1.7 → agent_manager_cli-0.1.9}/am/schemas/__init__.py +0 -0
- {agent_manager_cli-0.1.7 → agent_manager_cli-0.1.9}/am/schemas/session.py +0 -0
- {agent_manager_cli-0.1.7 → agent_manager_cli-0.1.9}/tests/__init__.py +0 -0
- {agent_manager_cli-0.1.7 → agent_manager_cli-0.1.9}/tests/test_event_mapping.py +0 -0
- {agent_manager_cli-0.1.7 → agent_manager_cli-0.1.9}/tests/test_scanners.py +0 -0
|
@@ -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'}
|
|
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
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
-
|
|
586
|
-
|
|
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(
|
|
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)
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|