cli-agent-runner 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.
@@ -0,0 +1,111 @@
1
+ """Dataclasses for the Python API state tree.
2
+
3
+ These are the public types that ``agent_runner.api`` returns and that
4
+ ``cli/`` formats. Phase 3 LLM/Critic will read these structures.
5
+
6
+ All frozen — state is immutable, no in-place mutation.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from enum import StrEnum
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+
17
+ class ServiceMode(StrEnum):
18
+ SYSTEMD_USER = "systemd_user"
19
+ PID_FILE = "pid_file"
20
+ NONE = "none"
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class ServiceStatus:
25
+ mode: ServiceMode
26
+ active: bool
27
+ pid: int | None = None
28
+ uptime_s: float | None = None
29
+ unit_file: Path | None = None
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class SystemMetrics:
34
+ mem_total_mb: int
35
+ mem_available_mb: int
36
+ disk_used_pct: float
37
+ disk_free_gb: float = 0.0
38
+ load_1m: float | None = None
39
+ cpu_pct: float | None = None
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class RoundView:
44
+ round_num: int
45
+ phase: str | None
46
+ started_at: str
47
+ duration_so_far_s: float | None
48
+ pid: int | None
49
+ exit_code: int | None
50
+ timed_out: bool | None
51
+ log_path: Path
52
+ log_tail: str | None = None
53
+ recent_events: list[dict[str, Any]] = field(default_factory=list)
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class ProjectState:
58
+ project: str
59
+ status: dict[str, Any]
60
+ defenses: list[dict[str, Any]]
61
+ current_round: RoundView | None
62
+ recent_rounds: list[RoundView]
63
+ orphan: dict[str, Any] | None
64
+ system: SystemMetrics
65
+ service: ServiceStatus
66
+ recent_events: list[dict[str, Any]] = field(default_factory=list)
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class Alert:
71
+ severity: str
72
+ detector: str
73
+ message: str
74
+ context: dict[str, Any]
75
+ ts: str
76
+ auto_action: str = "none"
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class InitResult:
81
+ work_dir: Path
82
+ files_created: list[Path]
83
+ committed: bool
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class InstallResult:
88
+ unit_path: Path
89
+ monitor_unit_path: Path | None
90
+ enabled: bool
91
+ started: bool
92
+
93
+
94
+ def select_path(tree: Any, path: str) -> Any:
95
+ """Resolve dot-notation path into a nested dataclass / dict / list tree.
96
+
97
+ Numeric segments index into lists. Raises KeyError naming the failed
98
+ segment so the caller (CLI) can give a precise error to the user.
99
+ """
100
+ cur = tree
101
+ for part in path.split("."):
102
+ try:
103
+ if part.isdigit():
104
+ cur = cur[int(part)]
105
+ elif isinstance(cur, dict):
106
+ cur = cur[part]
107
+ else:
108
+ cur = getattr(cur, part)
109
+ except (AttributeError, IndexError, KeyError) as e:
110
+ raise KeyError(f"path segment {part!r} not found in select path {path!r}") from e
111
+ return cur
@@ -0,0 +1,76 @@
1
+ """CLI entry — argparse subcommand dispatcher.
2
+
3
+ Each subcommand lives in its own ``*_cmd.py`` file; this module just routes.
4
+ The ``--config`` and ``--json`` flags can be placed before OR after the
5
+ subcommand verb. To make this work without the subparser's defaults clobbering
6
+ values supplied to the main parser, the main parser owns the real defaults
7
+ while the subparser-shared parent declares the same flags with
8
+ ``argparse.SUPPRESS`` so they only mutate the namespace when explicitly
9
+ supplied after the verb.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ from agent_runner.cli import (
19
+ init_cmd,
20
+ install_cmd,
21
+ monitor_cmd,
22
+ peek_cmd,
23
+ round_cmd,
24
+ serve_cmd,
25
+ service_cmd,
26
+ )
27
+
28
+
29
+ def _build_parser() -> argparse.ArgumentParser:
30
+ parser = argparse.ArgumentParser(
31
+ prog="agent-runner",
32
+ description="Restart-on-exit supervisor for autonomous CLI agents.",
33
+ )
34
+ parser.add_argument(
35
+ "--config",
36
+ type=Path,
37
+ default=Path("./agent-runner.toml"),
38
+ help="Path to agent-runner.toml (default: ./agent-runner.toml)",
39
+ )
40
+ parser.add_argument(
41
+ "--json", action="store_true", help="Machine-readable JSON output (where supported)"
42
+ )
43
+
44
+ # Parent parser shared by every subparser so the same flags can also be
45
+ # placed AFTER the verb. SUPPRESS keeps the subparser from overwriting
46
+ # values supplied to the main parser when the flag is omitted.
47
+ parent = argparse.ArgumentParser(add_help=False)
48
+ parent.add_argument("--config", type=Path, default=argparse.SUPPRESS, help=argparse.SUPPRESS)
49
+ parent.add_argument(
50
+ "--json", action="store_true", default=argparse.SUPPRESS, help=argparse.SUPPRESS
51
+ )
52
+
53
+ sub = parser.add_subparsers(dest="command", required=False)
54
+
55
+ init_cmd.add_parser(sub, parent)
56
+ install_cmd.add_parser(sub, parent)
57
+ service_cmd.add_parser(sub, parent)
58
+ peek_cmd.add_parser(sub, parent)
59
+ monitor_cmd.add_parser(sub, parent)
60
+ serve_cmd.add_parser(sub, parent)
61
+ round_cmd.add_parser(sub, parent)
62
+
63
+ return parser
64
+
65
+
66
+ def main(argv: list[str] | None = None) -> int:
67
+ parser = _build_parser()
68
+ args = parser.parse_args(argv)
69
+ if not args.command:
70
+ parser.print_help()
71
+ return 1
72
+ return args.func(args)
73
+
74
+
75
+ if __name__ == "__main__": # pragma: no cover
76
+ sys.exit(main())
@@ -0,0 +1,3 @@
1
+ from agent_runner.cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,78 @@
1
+ """Shared CLI helpers — config loading + JSON formatting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import json
7
+ import sys
8
+ from enum import Enum
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from agent_runner.config import Config, load_config
13
+
14
+
15
+ def cfg_from_args(args) -> Config:
16
+ return load_config(args.config)
17
+
18
+
19
+ def work_dir_from_args(args) -> Path:
20
+ """Resolve the project work_dir from --config's parent, falling back to cwd.
21
+
22
+ The api functions hardcode ``work_dir / "agent-runner.toml"`` for config loading,
23
+ so callers cannot rename the toml. Reject a mismatching filename loudly here
24
+ instead of letting api read the wrong file (or a missing file) silently.
25
+ """
26
+ cfg = getattr(args, "config", None)
27
+ if cfg is None:
28
+ return Path.cwd().resolve()
29
+ cfg_path = Path(cfg).resolve()
30
+ if cfg_path.name != "agent-runner.toml":
31
+ raise ValueError(
32
+ f"--config must point at a file named 'agent-runner.toml', got {cfg_path.name!r}"
33
+ )
34
+ return cfg_path.parent
35
+
36
+
37
+ def emit(value: Any, *, json_mode: bool) -> None:
38
+ if json_mode:
39
+ print(json.dumps(_to_jsonable(value), indent=2, default=str))
40
+ else:
41
+ print(_pretty(value))
42
+
43
+
44
+ def _to_jsonable(value: Any) -> Any:
45
+ if dataclasses.is_dataclass(value) and not isinstance(value, type):
46
+ return {f.name: _to_jsonable(getattr(value, f.name)) for f in dataclasses.fields(value)}
47
+ if isinstance(value, Enum):
48
+ return value.value
49
+ if isinstance(value, Path):
50
+ return str(value)
51
+ if isinstance(value, (list, tuple)):
52
+ return [_to_jsonable(x) for x in value]
53
+ if isinstance(value, dict):
54
+ return {k: _to_jsonable(v) for k, v in value.items()}
55
+ return value
56
+
57
+
58
+ def _pretty(value: Any) -> str:
59
+ if dataclasses.is_dataclass(value) and not isinstance(value, type):
60
+ lines: list[str] = [type(value).__name__ + ":"]
61
+ for f in dataclasses.fields(value):
62
+ v = getattr(value, f.name)
63
+ lines.append(f" {f.name}: {_pretty_inline(v)}")
64
+ return "\n".join(lines)
65
+ return _pretty_inline(value)
66
+
67
+
68
+ def _pretty_inline(value: Any) -> str:
69
+ if isinstance(value, Enum):
70
+ return value.value
71
+ if isinstance(value, Path):
72
+ return str(value)
73
+ return repr(value)
74
+
75
+
76
+ def fail(msg: str, *, code: int = 1) -> int:
77
+ print(f"agent-runner: {msg}", file=sys.stderr)
78
+ return code
@@ -0,0 +1,31 @@
1
+ """init subcommand — scaffold project."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from agent_runner import api
6
+ from agent_runner.cli.common import emit, fail, work_dir_from_args
7
+
8
+
9
+ def add_parser(sub, parent) -> None:
10
+ p = sub.add_parser("init", parents=[parent], help="Scaffold agent-runner project files")
11
+ p.add_argument("--force", action="store_true", help="Overwrite existing toml")
12
+ g = p.add_mutually_exclusive_group()
13
+ g.add_argument(
14
+ "--commit",
15
+ dest="commit",
16
+ action="store_true",
17
+ default=True,
18
+ help="git commit the new files (default)",
19
+ )
20
+ g.add_argument("--no-commit", dest="commit", action="store_false", help="Skip git commit")
21
+ p.set_defaults(func=cmd)
22
+
23
+
24
+ def cmd(args) -> int:
25
+ work_dir = work_dir_from_args(args)
26
+ try:
27
+ result = api.init(work_dir, force=args.force, commit=args.commit)
28
+ except (FileExistsError, RuntimeError) as e:
29
+ return fail(str(e))
30
+ emit(result, json_mode=getattr(args, "json", False))
31
+ return 0
@@ -0,0 +1,44 @@
1
+ """install / uninstall subcommands — manage systemd user units."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from agent_runner import api
6
+ from agent_runner.cli.common import emit, fail, work_dir_from_args
7
+
8
+
9
+ def add_parser(sub, parent) -> None:
10
+ p = sub.add_parser(
11
+ "install", parents=[parent], help="Generate systemd user unit, enable + start"
12
+ )
13
+ p.add_argument(
14
+ "--system",
15
+ action="store_true",
16
+ help="Install at system level (not yet supported in Phase 2)",
17
+ )
18
+ p.add_argument(
19
+ "--monitor",
20
+ action="store_true",
21
+ help="Also install monitor sidekick service for auto-stop on critical alerts",
22
+ )
23
+ p.set_defaults(func=cmd_install)
24
+
25
+ u = sub.add_parser(
26
+ "uninstall", parents=[parent], help="Stop, disable, and remove systemd user unit(s)"
27
+ )
28
+ u.set_defaults(func=cmd_uninstall)
29
+
30
+
31
+ def cmd_install(args) -> int:
32
+ work_dir = work_dir_from_args(args)
33
+ try:
34
+ result = api.install(work_dir, system=args.system, with_monitor=args.monitor)
35
+ except (NotImplementedError, FileNotFoundError) as e:
36
+ return fail(str(e))
37
+ emit(result, json_mode=getattr(args, "json", False))
38
+ return 0
39
+
40
+
41
+ def cmd_uninstall(args) -> int:
42
+ work_dir = work_dir_from_args(args)
43
+ api.uninstall(work_dir)
44
+ return 0
@@ -0,0 +1,48 @@
1
+ """monitor subcommand — anomaly detection daemon (local or remote)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+
8
+ from agent_runner import api
9
+ from agent_runner.cli.common import _to_jsonable, work_dir_from_args
10
+
11
+
12
+ def add_parser(sub, parent) -> None:
13
+ p = sub.add_parser(
14
+ "monitor", parents=[parent], help="Anomaly detection daemon (local or remote via --host)"
15
+ )
16
+ p.add_argument(
17
+ "--host",
18
+ type=str,
19
+ default=None,
20
+ metavar="SSH-ALIAS",
21
+ help="Watch a remote agent-runner via ssh",
22
+ )
23
+ p.add_argument(
24
+ "--interval",
25
+ type=int,
26
+ default=None,
27
+ metavar="SECONDS",
28
+ help="Poll interval (default 30s, 60s for remote)",
29
+ )
30
+ p.set_defaults(func=cmd)
31
+
32
+
33
+ def cmd(args) -> int:
34
+ interval = args.interval if args.interval is not None else (60 if args.host else 30)
35
+ json_mode = getattr(args, "json", False)
36
+ try:
37
+ work_dir = work_dir_from_args(args)
38
+ for alert in api.monitor_loop(work_dir, host=args.host, interval_s=interval):
39
+ if json_mode:
40
+ print(json.dumps(_to_jsonable(alert)))
41
+ sys.stdout.flush()
42
+ else:
43
+ tag = {"info": "[OK]", "warning": "[WARN]", "critical": "[CRIT]"}[alert.severity]
44
+ print(f"{tag} {alert.detector} — {alert.message}")
45
+ sys.stdout.flush()
46
+ except KeyboardInterrupt:
47
+ return 0
48
+ return 0
@@ -0,0 +1,81 @@
1
+ """peek and watch subcommands — snapshot + auto-refresh."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ import time
8
+
9
+ from agent_runner import api
10
+ from agent_runner.cli.common import emit, fail, work_dir_from_args
11
+
12
+
13
+ def _round_arg(s: str) -> int | str:
14
+ if s == "latest":
15
+ return s
16
+ try:
17
+ return int(s)
18
+ except ValueError as e:
19
+ raise argparse.ArgumentTypeError(f"--round expects int or 'latest', got {s!r}") from e
20
+
21
+
22
+ def add_parser(sub, parent) -> None:
23
+ for verb, fn in (("peek", cmd_peek), ("watch", cmd_watch)):
24
+ p = sub.add_parser(
25
+ verb, parents=[parent], help=f"{verb} project state with optional drill-down"
26
+ )
27
+ p.add_argument(
28
+ "--round",
29
+ type=_round_arg,
30
+ default=None,
31
+ metavar="N",
32
+ help="Drill into round N (int or 'latest')",
33
+ )
34
+ p.add_argument("--log", action="store_true", help="Include current round's log tail")
35
+ p.add_argument(
36
+ "--events", type=int, default=None, metavar="N", help="Include last N events"
37
+ )
38
+ p.add_argument(
39
+ "--select",
40
+ type=str,
41
+ default=None,
42
+ help="Dot-path subtree to extract (e.g. system.disk_used_pct)",
43
+ )
44
+ if verb == "watch":
45
+ p.add_argument(
46
+ "--interval",
47
+ type=int,
48
+ default=2,
49
+ metavar="SECONDS",
50
+ help="Refresh interval (default 2)",
51
+ )
52
+ p.set_defaults(func=fn)
53
+
54
+
55
+ def cmd_peek(args) -> int:
56
+ try:
57
+ result = api.peek(
58
+ work_dir_from_args(args),
59
+ round=args.round,
60
+ log=args.log,
61
+ events=args.events,
62
+ select=args.select,
63
+ )
64
+ except KeyError as e:
65
+ return fail(str(e))
66
+ except FileNotFoundError as e:
67
+ return fail(f"config not found: {e}")
68
+ emit(result, json_mode=getattr(args, "json", False))
69
+ return 0
70
+
71
+
72
+ def cmd_watch(args) -> int:
73
+ while True:
74
+ sys.stdout.write("\x1b[2J\x1b[H")
75
+ rc = cmd_peek(args)
76
+ if rc != 0:
77
+ return rc
78
+ try:
79
+ time.sleep(args.interval)
80
+ except KeyboardInterrupt:
81
+ return 0
@@ -0,0 +1,17 @@
1
+ """round subcommand — runs one supervisor round (used by serve and systemd)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from agent_runner.cli.common import cfg_from_args
6
+ from agent_runner.runner import run_one_round
7
+
8
+
9
+ def add_parser(sub, parent) -> None:
10
+ p = sub.add_parser("round", parents=[parent], help="Run one round and exit")
11
+ p.set_defaults(func=cmd)
12
+
13
+
14
+ def cmd(args) -> int:
15
+ cfg = cfg_from_args(args)
16
+ run_one_round(cfg)
17
+ return 0
@@ -0,0 +1,60 @@
1
+ """serve subcommand — long-running supervisor loop. THIN: <=60 LOC.
2
+
3
+ Trap signals, write/cleanup PID files, run `round` subprocess in a loop.
4
+ All real work delegated to `agent-runner round` (fresh import per round).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import signal
11
+ import subprocess # noqa: TID251
12
+ import sys
13
+ import time
14
+
15
+ from agent_runner.cli.common import cfg_from_args
16
+ from agent_runner.lifecycle import PIDFile, send_signal_to_pid
17
+
18
+
19
+ def add_parser(sub, parent) -> None:
20
+ p = sub.add_parser("serve", parents=[parent], help="Long-running supervisor loop")
21
+ p.add_argument("--once", action="store_true", help="Run a single round then exit (debug)")
22
+ p.set_defaults(func=cmd)
23
+
24
+
25
+ def cmd(args) -> int:
26
+ cfg = cfg_from_args(args)
27
+ pid_file = PIDFile(cfg.runtime.log_dir / "serve.pid")
28
+ pid_file.write(os.getpid())
29
+ stop = {"requested": False}
30
+ round_pid_file = PIDFile(cfg.runtime.log_dir / "round.pid")
31
+
32
+ def graceful(_sig, _frame):
33
+ stop["requested"] = True
34
+
35
+ def cancel(_sig, _frame):
36
+ stop["requested"] = True
37
+ rp = round_pid_file.read()
38
+ if rp is not None:
39
+ send_signal_to_pid(-rp, signal.SIGINT)
40
+
41
+ signal.signal(signal.SIGTERM, graceful)
42
+ signal.signal(signal.SIGINT, graceful)
43
+ signal.signal(signal.SIGUSR1, cancel)
44
+
45
+ try:
46
+ while not stop["requested"]:
47
+ r = subprocess.run(
48
+ [sys.executable, "-m", "agent_runner.cli", "--config", str(args.config), "round"],
49
+ )
50
+ if args.once or stop["requested"]:
51
+ break
52
+ delay = (
53
+ cfg.runtime.restart_delay_s
54
+ if r.returncode == 0
55
+ else cfg.runtime.restart_delay_s * 2
56
+ )
57
+ time.sleep(delay)
58
+ finally:
59
+ pid_file.unlink()
60
+ return 0
@@ -0,0 +1,54 @@
1
+ """service subcommands — start / stop / kill / cancel / restart / status."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from agent_runner import api
6
+ from agent_runner.cli.common import emit, work_dir_from_args
7
+
8
+
9
+ def add_parser(sub, parent) -> None:
10
+ for verb, fn, help_text in (
11
+ ("start", cmd_start, "Start the service"),
12
+ ("stop", cmd_stop, "Graceful stop (waits for current round)"),
13
+ ("kill", cmd_kill, "Force terminate (5s grace then SIGKILL)"),
14
+ ("cancel", cmd_cancel, "Best-effort: SIGINT to claude (commit-and-exit hint)"),
15
+ ("restart", cmd_restart, "stop + start (use --force for kill semantics)"),
16
+ ("status", cmd_status, "Show current service state"),
17
+ ):
18
+ p = sub.add_parser(verb, parents=[parent], help=help_text)
19
+ if verb == "restart":
20
+ p.add_argument("--force", action="store_true", help="Use kill instead of stop")
21
+ p.set_defaults(func=fn)
22
+
23
+
24
+ def cmd_start(args) -> int:
25
+ emit(api.start(work_dir_from_args(args)), json_mode=getattr(args, "json", False))
26
+ return 0
27
+
28
+
29
+ def cmd_stop(args) -> int:
30
+ emit(api.stop(work_dir_from_args(args)), json_mode=getattr(args, "json", False))
31
+ return 0
32
+
33
+
34
+ def cmd_kill(args) -> int:
35
+ emit(api.kill(work_dir_from_args(args)), json_mode=getattr(args, "json", False))
36
+ return 0
37
+
38
+
39
+ def cmd_cancel(args) -> int:
40
+ api.cancel(work_dir_from_args(args))
41
+ return 0
42
+
43
+
44
+ def cmd_restart(args) -> int:
45
+ emit(
46
+ api.restart(work_dir_from_args(args), force=args.force),
47
+ json_mode=getattr(args, "json", False),
48
+ )
49
+ return 0
50
+
51
+
52
+ def cmd_status(args) -> int:
53
+ emit(api.status(work_dir_from_args(args)), json_mode=getattr(args, "json", False))
54
+ return 0