agent-container 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.
- agent_container/__init__.py +1327 -0
- agent_container/completions/agent-container.bash +172 -0
- agent_container/completions/agent-container.zsh +137 -0
- agent_container-0.1.0.dist-info/METADATA +753 -0
- agent_container-0.1.0.dist-info/RECORD +8 -0
- agent_container-0.1.0.dist-info/WHEEL +4 -0
- agent_container-0.1.0.dist-info/entry_points.txt +2 -0
- agent_container-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1327 @@
|
|
|
1
|
+
#!/usr/bin/env -S uv run --script
|
|
2
|
+
# /// script
|
|
3
|
+
# requires-python = ">=3.11"
|
|
4
|
+
# dependencies = [
|
|
5
|
+
# "typer>=0.12,<1",
|
|
6
|
+
# "questionary>=2.0,<3",
|
|
7
|
+
# "rich>=13,<15",
|
|
8
|
+
# ]
|
|
9
|
+
# ///
|
|
10
|
+
#
|
|
11
|
+
# agent-container — interactive wizard + CLI for agent-container containers.
|
|
12
|
+
#
|
|
13
|
+
# ============================================================================
|
|
14
|
+
# This script is the SINGLE SOURCE OF TRUTH for the agent-container on-disk contract.
|
|
15
|
+
# Everything below — container naming (agent-container-<name>), the port hash
|
|
16
|
+
# (2200 + sum-of-char-codes mod 100), the per-name state files
|
|
17
|
+
# ($XDG_STATE_HOME/agent-container/<name>.port), the env-file resolution order
|
|
18
|
+
# (./.env -> ~/.config/agent-container/<name>.env -> ~/.config/agent-container/.env), and
|
|
19
|
+
# the hosts.conf KEY=VALUE format (FOO_HOST / FOO_PORT) — is defined here and
|
|
20
|
+
# only here. The shell completions read the same state files directly; keep
|
|
21
|
+
# them in step with the constants in this file.
|
|
22
|
+
# ============================================================================
|
|
23
|
+
#
|
|
24
|
+
# hosts.conf handling: this tool parses hosts.conf line-by-line and NEVER
|
|
25
|
+
# executes/sources it. Quoting and unquoted trailing `# comment`s are handled
|
|
26
|
+
# like bash `source`, but values containing `$` or backticks are taken
|
|
27
|
+
# literally (no expansion), with a one-time warning.
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import re
|
|
34
|
+
import shutil
|
|
35
|
+
import signal
|
|
36
|
+
import socket
|
|
37
|
+
import subprocess
|
|
38
|
+
import sys
|
|
39
|
+
import termios
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
from typing import List
|
|
42
|
+
|
|
43
|
+
import questionary
|
|
44
|
+
import typer
|
|
45
|
+
from rich.console import Console
|
|
46
|
+
from rich.table import Table
|
|
47
|
+
|
|
48
|
+
# --- constants ---------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
IMAGE_NAME = "localhost/agent-container:latest"
|
|
51
|
+
CONTAINER_PREFIX = "agent-container-"
|
|
52
|
+
PORT_BASE = 2200
|
|
53
|
+
PORT_RANGE = 100 # 2200..2299
|
|
54
|
+
|
|
55
|
+
STATE_DIR = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local/state") / "agent-container"
|
|
56
|
+
CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "agent-container"
|
|
57
|
+
HOSTS_CONF = CONFIG_DIR / "hosts.conf"
|
|
58
|
+
|
|
59
|
+
def _is_repo_checkout(base: "Path") -> bool:
|
|
60
|
+
"""Best-effort test that `base` is an agent-container checkout.
|
|
61
|
+
|
|
62
|
+
Keyed on `completions/agent-container.bash` (plus a Dockerfile for the build
|
|
63
|
+
context): a repo-specific sentinel rather than the generic `Dockerfile` +
|
|
64
|
+
`completions/` dir pair, which is common to unrelated trees. Requiring the
|
|
65
|
+
exact file _completion_script reads (bin/agent-container: REPO_ROOT/completions/
|
|
66
|
+
agent-container.<shell>) also makes the marker and that consumed path the same
|
|
67
|
+
invariant — a recognized checkout can never then serve stale package data.
|
|
68
|
+
This is a heuristic, not a guaranteed false-positive-free identifier.
|
|
69
|
+
"""
|
|
70
|
+
return (base / "Dockerfile").is_file() and (base / "completions" / "agent-container.bash").is_file()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _find_repo_root() -> "Path | None":
|
|
74
|
+
"""Locate the repo checkout in a way that survives a non-editable install.
|
|
75
|
+
|
|
76
|
+
A wheel copies this module into site-packages, so Path(__file__) no longer
|
|
77
|
+
points inside the repo. Resolution order:
|
|
78
|
+
1. AGENT_CONTAINER_REPO (explicit operator override): trusted only when it
|
|
79
|
+
satisfies the checkout marker (see _is_repo_checkout); a wrong/typo'd
|
|
80
|
+
path yields None rather than a bogus root (build then dies actionably).
|
|
81
|
+
2. Walk up from this file, then from cwd, looking for a dir that matches
|
|
82
|
+
the marker.
|
|
83
|
+
Returns None when no checkout is reachable — client subcommands still work
|
|
84
|
+
(completions falls back to bundled package data; build fails actionably).
|
|
85
|
+
Note: this cannot die here — it runs at import, before Fatal/die exist.
|
|
86
|
+
"""
|
|
87
|
+
env = os.environ.get("AGENT_CONTAINER_REPO")
|
|
88
|
+
if env:
|
|
89
|
+
base = Path(env).expanduser().resolve()
|
|
90
|
+
return base if _is_repo_checkout(base) else None
|
|
91
|
+
here = Path(__file__).resolve()
|
|
92
|
+
for base in here.parents:
|
|
93
|
+
if _is_repo_checkout(base):
|
|
94
|
+
return base
|
|
95
|
+
for base in (Path.cwd(), *Path.cwd().parents):
|
|
96
|
+
if _is_repo_checkout(base):
|
|
97
|
+
return base
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
REPO_ROOT = _find_repo_root()
|
|
102
|
+
|
|
103
|
+
NAME_RE = re.compile(r"[a-z0-9][a-z0-9_-]*")
|
|
104
|
+
# ssh argv injection guard: a user/host that begins with '-' would be parsed
|
|
105
|
+
# by ssh as an option (e.g. -oProxyCommand=...). Conservative charsets that
|
|
106
|
+
# can never start with '-'.
|
|
107
|
+
SSH_USER_RE = re.compile(r"[A-Za-z0-9_][A-Za-z0-9._-]*")
|
|
108
|
+
SSH_HOST_RE = re.compile(r"[A-Za-z0-9_\[][A-Za-z0-9.:_\]-]*")
|
|
109
|
+
# tmux window name: embedded in the ssh remote command (attach --window), so it
|
|
110
|
+
# is charset-validated. Matches the entrypoint / agent-container window charset.
|
|
111
|
+
WINDOW_RE = re.compile(r"[A-Za-z0-9._-]+")
|
|
112
|
+
|
|
113
|
+
console = Console()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def eprint(msg: str) -> None:
|
|
117
|
+
# Plain stderr writer; rich would mangle "[agent-container]" as markup and wrap lines.
|
|
118
|
+
print(msg, file=sys.stderr)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class Fatal(Exception):
|
|
122
|
+
"""Fatal error; CLI exits 1, wizard prints and returns to the menu."""
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def die(msg: str) -> "typer.NoReturn": # cli() turns Fatal into exit 1
|
|
126
|
+
raise Fatal(msg)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# --- pure helpers ------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def validate_name(name: str) -> str:
|
|
133
|
+
"""Validate the container short-name charset: ^[a-z0-9][a-z0-9_-]*$.
|
|
134
|
+
|
|
135
|
+
>>> validate_name("acme")
|
|
136
|
+
'acme'
|
|
137
|
+
>>> validate_name("my-box")
|
|
138
|
+
'my-box'
|
|
139
|
+
>>> validate_name("Bad")
|
|
140
|
+
Traceback (most recent call last):
|
|
141
|
+
...
|
|
142
|
+
Fatal: invalid <name> 'Bad'; must match [a-z0-9][a-z0-9_-]*
|
|
143
|
+
"""
|
|
144
|
+
if not name:
|
|
145
|
+
die("missing required <name> argument")
|
|
146
|
+
if not NAME_RE.fullmatch(name):
|
|
147
|
+
die(f"invalid <name> '{name}'; must match [a-z0-9][a-z0-9_-]*")
|
|
148
|
+
return name
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def container_name(name: str) -> str:
|
|
152
|
+
"""
|
|
153
|
+
>>> container_name("acme")
|
|
154
|
+
'agent-container-acme'
|
|
155
|
+
"""
|
|
156
|
+
return f"{CONTAINER_PREFIX}{name}"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def volume_name(name: str) -> str:
|
|
160
|
+
"""
|
|
161
|
+
>>> volume_name("acme")
|
|
162
|
+
'agent-container-acme-workspace'
|
|
163
|
+
"""
|
|
164
|
+
return f"{CONTAINER_PREFIX}{name}-workspace"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def claude_volume_name(name: str) -> str:
|
|
168
|
+
"""
|
|
169
|
+
>>> claude_volume_name("acme")
|
|
170
|
+
'agent-container-acme-claude'
|
|
171
|
+
"""
|
|
172
|
+
return f"{CONTAINER_PREFIX}{name}-claude"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def codex_volume_name(name: str) -> str:
|
|
176
|
+
"""
|
|
177
|
+
>>> codex_volume_name("acme")
|
|
178
|
+
'agent-container-acme-codex'
|
|
179
|
+
"""
|
|
180
|
+
return f"{CONTAINER_PREFIX}{name}-codex"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def pi_volume_name(name: str) -> str:
|
|
184
|
+
"""
|
|
185
|
+
>>> pi_volume_name("acme")
|
|
186
|
+
'agent-container-acme-pi'
|
|
187
|
+
"""
|
|
188
|
+
return f"{CONTAINER_PREFIX}{name}-pi"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def shellenv_volume_name(name: str) -> str:
|
|
192
|
+
"""
|
|
193
|
+
>>> shellenv_volume_name("acme")
|
|
194
|
+
'agent-container-acme-shellenv'
|
|
195
|
+
"""
|
|
196
|
+
return f"{CONTAINER_PREFIX}{name}-shellenv"
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def tmux_volume_name(name: str) -> str:
|
|
200
|
+
"""Persists ~/.config/tmux (tmux.conf + tpm plugins) across down/up.
|
|
201
|
+
|
|
202
|
+
>>> tmux_volume_name("acme")
|
|
203
|
+
'agent-container-acme-tmux'
|
|
204
|
+
"""
|
|
205
|
+
return f"{CONTAINER_PREFIX}{name}-tmux"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def all_volume_mounts(name: str) -> list[str]:
|
|
209
|
+
"""The six per-container '-v' volume args, in the canonical fixed order
|
|
210
|
+
(workspace, claude, codex, pi, shellenv, tmux). pi-coding-agent's
|
|
211
|
+
config/auth dir is ~/.pi (verified from
|
|
212
|
+
the package: piConfig.configDir='.pi', getAgentDir()->~/.pi/agent).
|
|
213
|
+
|
|
214
|
+
>>> all_volume_mounts("acme")
|
|
215
|
+
['agent-container-acme-workspace:/workspace', 'agent-container-acme-claude:/home/dev/.claude', 'agent-container-acme-codex:/home/dev/.codex', 'agent-container-acme-pi:/home/dev/.pi', 'agent-container-acme-shellenv:/home/dev/.agent-container', 'agent-container-acme-tmux:/home/dev/.config/tmux']
|
|
216
|
+
"""
|
|
217
|
+
return [
|
|
218
|
+
f"{volume_name(name)}:/workspace",
|
|
219
|
+
f"{claude_volume_name(name)}:/home/dev/.claude",
|
|
220
|
+
f"{codex_volume_name(name)}:/home/dev/.codex",
|
|
221
|
+
f"{pi_volume_name(name)}:/home/dev/.pi",
|
|
222
|
+
f"{shellenv_volume_name(name)}:/home/dev/.agent-container",
|
|
223
|
+
f"{tmux_volume_name(name)}:/home/dev/.config/tmux",
|
|
224
|
+
]
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def per_container_volumes(name: str) -> list[str]:
|
|
228
|
+
"""All per-container volume NAMES, canonical order; used by --purge.
|
|
229
|
+
|
|
230
|
+
>>> per_container_volumes("acme")
|
|
231
|
+
['agent-container-acme-workspace', 'agent-container-acme-claude', 'agent-container-acme-codex', 'agent-container-acme-pi', 'agent-container-acme-shellenv', 'agent-container-acme-tmux']
|
|
232
|
+
"""
|
|
233
|
+
return [
|
|
234
|
+
volume_name(name),
|
|
235
|
+
claude_volume_name(name),
|
|
236
|
+
codex_volume_name(name),
|
|
237
|
+
pi_volume_name(name),
|
|
238
|
+
shellenv_volume_name(name),
|
|
239
|
+
tmux_volume_name(name),
|
|
240
|
+
]
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def resolve_bind_mount(spec: str) -> str:
|
|
244
|
+
"""Resolve a '--mount HOSTDIR[:CONTAINERPATH]' spec into an absolute,
|
|
245
|
+
read-write '-v' value.
|
|
246
|
+
|
|
247
|
+
HOSTDIR is resolved to an absolute real path (Path.resolve()) and must be
|
|
248
|
+
an existing directory. CONTAINERPATH defaults to /workspace/<basename>; if
|
|
249
|
+
given explicitly it must be absolute. No secret/env value ever reaches argv.
|
|
250
|
+
|
|
251
|
+
Lima prerequisite: the host dir must sit under a path the Lima VM exposes
|
|
252
|
+
WRITABLE (set `writable: true` on the relevant mount and restart Lima),
|
|
253
|
+
otherwise the bind is read-only inside the VM.
|
|
254
|
+
"""
|
|
255
|
+
host, sep, container = spec.partition(":")
|
|
256
|
+
if not host:
|
|
257
|
+
die(f"--mount: empty host directory in '{spec}'")
|
|
258
|
+
p = Path(host)
|
|
259
|
+
if not p.is_dir():
|
|
260
|
+
die(f"--mount: host path '{host}' does not exist or is not a directory")
|
|
261
|
+
abs_host = str(p.resolve())
|
|
262
|
+
if sep: # an explicit ':' was present
|
|
263
|
+
if not container:
|
|
264
|
+
die(f"--mount: empty container path in '{spec}'")
|
|
265
|
+
if not container.startswith("/"):
|
|
266
|
+
die(f"--mount: container path '{container}' must be absolute")
|
|
267
|
+
else:
|
|
268
|
+
container = f"/workspace/{Path(abs_host).name}"
|
|
269
|
+
return f"{abs_host}:{container}"
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def port_for_name(name: str) -> int:
|
|
273
|
+
"""Deterministic port: PORT_BASE + (sum of char codes mod PORT_RANGE).
|
|
274
|
+
|
|
275
|
+
Name charset is ASCII-only (enforced by NAME_RE), so ord() matches
|
|
276
|
+
bash's printf '%d' "'c" exactly.
|
|
277
|
+
|
|
278
|
+
>>> port_for_name("acme")
|
|
279
|
+
2206
|
|
280
|
+
>>> port_for_name("my-box")
|
|
281
|
+
2204
|
|
282
|
+
"""
|
|
283
|
+
return PORT_BASE + (sum(ord(c) for c in name) % PORT_RANGE)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def name_to_key(name: str) -> str:
|
|
287
|
+
"""hosts.conf key prefix: hyphens -> underscores, uppercased.
|
|
288
|
+
|
|
289
|
+
>>> name_to_key("my-box")
|
|
290
|
+
'MY_BOX'
|
|
291
|
+
>>> name_to_key("acme")
|
|
292
|
+
'ACME'
|
|
293
|
+
"""
|
|
294
|
+
return name.replace("-", "_").upper()
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def resolve_ssh_user(override: str | None = None) -> str:
|
|
298
|
+
"""SSH user from --user / AGENT_CONTAINER_USER / 'dev', charset-checked so it can
|
|
299
|
+
never be parsed by ssh as an option.
|
|
300
|
+
|
|
301
|
+
>>> resolve_ssh_user("dev")
|
|
302
|
+
'dev'
|
|
303
|
+
>>> resolve_ssh_user("-oProxyCommand=x")
|
|
304
|
+
Traceback (most recent call last):
|
|
305
|
+
...
|
|
306
|
+
Fatal: invalid ssh user '-oProxyCommand=x'
|
|
307
|
+
"""
|
|
308
|
+
user = override or os.environ.get("AGENT_CONTAINER_USER") or "dev"
|
|
309
|
+
if not SSH_USER_RE.fullmatch(user):
|
|
310
|
+
die(f"invalid ssh user '{user}'")
|
|
311
|
+
return user
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def validate_window(window: str) -> str:
|
|
315
|
+
"""tmux window name guard: it is embedded in the ssh remote shell command,
|
|
316
|
+
so reject anything outside the safe charset before building it.
|
|
317
|
+
|
|
318
|
+
>>> validate_window("agents")
|
|
319
|
+
'agents'
|
|
320
|
+
>>> validate_window("a; rm -rf ~")
|
|
321
|
+
Traceback (most recent call last):
|
|
322
|
+
...
|
|
323
|
+
Fatal: invalid tmux window 'a; rm -rf ~'; must match [A-Za-z0-9._-]+
|
|
324
|
+
"""
|
|
325
|
+
if not WINDOW_RE.fullmatch(window):
|
|
326
|
+
die(f"invalid tmux window '{window}'; must match [A-Za-z0-9._-]+")
|
|
327
|
+
return window
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def parse_kv_config(text: str) -> dict[str, str]:
|
|
331
|
+
"""Literal KEY=VALUE parser for hosts.conf. Never executes the file.
|
|
332
|
+
|
|
333
|
+
Skips blank lines and # comments, strips an optional leading 'export ',
|
|
334
|
+
splits on the first '=', honours one pair of matching quotes, and — like
|
|
335
|
+
bash `source` — drops unquoted trailing ' # comment's (a '#' only starts
|
|
336
|
+
a comment when preceded by whitespace; 'A=#lit' keeps the '#').
|
|
337
|
+
|
|
338
|
+
>>> parse_kv_config("# c\\n\\nA=1\\nexport B='two'\\nC=\\"three\\"\\nD=a=b\\n")
|
|
339
|
+
{'A': '1', 'B': 'two', 'C': 'three', 'D': 'a=b'}
|
|
340
|
+
>>> parse_kv_config('H=vps.example.com # primary box\\nQ="a # b" # c\\nR=#lit\\n')
|
|
341
|
+
{'H': 'vps.example.com', 'Q': 'a # b', 'R': '#lit'}
|
|
342
|
+
"""
|
|
343
|
+
out: dict[str, str] = {}
|
|
344
|
+
for line in text.splitlines():
|
|
345
|
+
line = line.strip()
|
|
346
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
347
|
+
continue
|
|
348
|
+
if line.startswith("export "):
|
|
349
|
+
line = line[len("export "):].lstrip()
|
|
350
|
+
key, _, value = line.partition("=")
|
|
351
|
+
key = key.strip()
|
|
352
|
+
value = value.strip()
|
|
353
|
+
if value[:1] in ("'", '"'):
|
|
354
|
+
# Quoted: take up to the matching close quote; anything after it
|
|
355
|
+
# (e.g. a trailing comment) is ignored, as bash would.
|
|
356
|
+
closing = value.find(value[0], 1)
|
|
357
|
+
if closing != -1:
|
|
358
|
+
value = value[1:closing]
|
|
359
|
+
else:
|
|
360
|
+
m = re.search(r"\s#", value)
|
|
361
|
+
if m:
|
|
362
|
+
value = value[: m.start()].rstrip()
|
|
363
|
+
if key:
|
|
364
|
+
out[key] = value
|
|
365
|
+
return out
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def env_file_candidates(name: str, cwd: Path) -> list[Path]:
|
|
369
|
+
"""Env-file resolution order (./.env, then per-name, then shared default).
|
|
370
|
+
|
|
371
|
+
>>> [str(p) for p in env_file_candidates("acme", Path("/w"))][0]
|
|
372
|
+
'/w/.env'
|
|
373
|
+
"""
|
|
374
|
+
return [cwd / ".env", CONFIG_DIR / f"{name}.env", CONFIG_DIR / ".env"]
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def resolve_env_file(name: str) -> Path | None:
|
|
378
|
+
# Secret hygiene: only existence checks; contents are never read.
|
|
379
|
+
for candidate in env_file_candidates(name, Path.cwd()):
|
|
380
|
+
if candidate.is_file():
|
|
381
|
+
return candidate
|
|
382
|
+
return None
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def state_file(name: str) -> Path:
|
|
386
|
+
return STATE_DIR / f"{name}.port"
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
# --- runtime -----------------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
_hosts_warned = False
|
|
392
|
+
|
|
393
|
+
# Captured at startup so an abnormal ssh exit can't leave the terminal raw.
|
|
394
|
+
try:
|
|
395
|
+
_ORIG_TERMIOS = termios.tcgetattr(sys.stdin.fileno()) if sys.stdin.isatty() else None
|
|
396
|
+
except Exception:
|
|
397
|
+
_ORIG_TERMIOS = None
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def detect_runtime() -> str:
|
|
401
|
+
"""Resolve the container runtime.
|
|
402
|
+
|
|
403
|
+
AGENT_CONTAINER_RUNTIME (validated docker|podman, must be on PATH) always wins.
|
|
404
|
+
Otherwise the default is platform-aware: on macOS the operator runs Lima +
|
|
405
|
+
docker-cli, so prefer docker then podman; on Linux (the VPS) prefer podman
|
|
406
|
+
then docker. Dies if neither is installed.
|
|
407
|
+
"""
|
|
408
|
+
forced = os.environ.get("AGENT_CONTAINER_RUNTIME")
|
|
409
|
+
if forced:
|
|
410
|
+
if forced not in ("podman", "docker"):
|
|
411
|
+
die(f"AGENT_CONTAINER_RUNTIME must be 'docker' or 'podman', got: {forced}")
|
|
412
|
+
if not shutil.which(forced):
|
|
413
|
+
die(f"AGENT_CONTAINER_RUNTIME={forced} but '{forced}' is not on PATH")
|
|
414
|
+
return forced
|
|
415
|
+
order = ("docker", "podman") if sys.platform.startswith("darwin") else ("podman", "docker")
|
|
416
|
+
for rt in order:
|
|
417
|
+
if shutil.which(rt):
|
|
418
|
+
return rt
|
|
419
|
+
die("neither 'podman' nor 'docker' on PATH (install one, or set AGENT_CONTAINER_RUNTIME)")
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def query(argv: list[str]) -> subprocess.CompletedProcess:
|
|
423
|
+
return subprocess.run(argv, capture_output=True, text=True)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def container_running(rt: str, cname: str) -> bool:
|
|
427
|
+
r = query([rt, "ps", "--filter", f"name=^{cname}$", "--format", "{{.Names}}"])
|
|
428
|
+
return cname in r.stdout.splitlines() # exact match, mirrors grep -qx
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def container_exists(rt: str, cname: str) -> bool:
|
|
432
|
+
r = query([rt, "ps", "-a", "--filter", f"name=^{cname}$", "--format", "{{.Names}}"])
|
|
433
|
+
return cname in r.stdout.splitlines()
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def ps_agent_container(rt: str, include_stopped: bool = False) -> list[tuple[str, str, str, str]]:
|
|
437
|
+
"""(cname, image, status, uptime) rows for agent-container-* containers.
|
|
438
|
+
|
|
439
|
+
Go-template + tab split, not --format json: podman and docker JSON
|
|
440
|
+
shapes are incompatible, the template output is not.
|
|
441
|
+
"""
|
|
442
|
+
argv = [rt, "ps"] + (["-a"] if include_stopped else [])
|
|
443
|
+
argv += ["--format", "{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.RunningFor}}"]
|
|
444
|
+
rows = []
|
|
445
|
+
for line in query(argv).stdout.splitlines():
|
|
446
|
+
parts = line.split("\t")
|
|
447
|
+
if len(parts) == 4 and parts[0].startswith(CONTAINER_PREFIX):
|
|
448
|
+
rows.append((parts[0], parts[1], parts[2], parts[3]))
|
|
449
|
+
return rows
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def image_exists(rt: str, tag: str) -> bool:
|
|
453
|
+
# `image inspect` works on both podman and docker (podman's `image exists` does not).
|
|
454
|
+
return query([rt, "image", "inspect", tag]).returncode == 0
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def port_free(port: int) -> bool:
|
|
458
|
+
# Wildcard bind: `-p {port}:22` publishes on ALL interfaces (parity with
|
|
459
|
+
# agent-container), so probe what will actually be bound, not just loopback.
|
|
460
|
+
try:
|
|
461
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
462
|
+
s.bind(("", port))
|
|
463
|
+
return True
|
|
464
|
+
except OSError:
|
|
465
|
+
return False
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def write_state(name: str, port: int) -> None:
|
|
469
|
+
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
470
|
+
state_file(name).write_text(f"{port}\n")
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def read_state_port(name: str) -> str | None:
|
|
474
|
+
f = state_file(name)
|
|
475
|
+
if not f.is_file():
|
|
476
|
+
return None
|
|
477
|
+
return f.read_text().strip() or None
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def clear_state(name: str) -> None:
|
|
481
|
+
state_file(name).unlink(missing_ok=True)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def load_hosts_conf() -> dict[str, str]:
|
|
485
|
+
global _hosts_warned
|
|
486
|
+
if not HOSTS_CONF.is_file():
|
|
487
|
+
return {}
|
|
488
|
+
conf = parse_kv_config(HOSTS_CONF.read_text())
|
|
489
|
+
if not _hosts_warned and any("$" in v or "`" in v for v in conf.values()):
|
|
490
|
+
eprint(
|
|
491
|
+
"[agent-container] WARNING: hosts.conf contains '$' or '`' values; "
|
|
492
|
+
"agent-container does not perform shell expansion (agent-container does)."
|
|
493
|
+
)
|
|
494
|
+
_hosts_warned = True
|
|
495
|
+
return conf
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def hosts_entry(name: str) -> tuple[str, str] | None:
|
|
499
|
+
"""(host, port) from hosts.conf, or None unless BOTH keys are present."""
|
|
500
|
+
conf = load_hosts_conf()
|
|
501
|
+
key = name_to_key(name)
|
|
502
|
+
host, port = conf.get(f"{key}_HOST"), conf.get(f"{key}_PORT")
|
|
503
|
+
if host and port:
|
|
504
|
+
return host, port
|
|
505
|
+
return None
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def quadlet_active(name: str) -> bool:
|
|
509
|
+
# Best-effort probe; systemctl is absent on macOS — ignore all failures.
|
|
510
|
+
if not shutil.which("systemctl"):
|
|
511
|
+
return False
|
|
512
|
+
try:
|
|
513
|
+
r = query(["systemctl", "--user", "is-active", f"{container_name(name)}.service"])
|
|
514
|
+
return r.returncode == 0 and r.stdout.strip() == "active"
|
|
515
|
+
except Exception:
|
|
516
|
+
return False
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def is_tty() -> bool:
|
|
520
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
# --- commands ----------------------------------------------------------------
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def log(msg: str) -> None:
|
|
527
|
+
eprint(f"[agent-container] {msg}")
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def warn(msg: str) -> None:
|
|
531
|
+
eprint(f"[agent-container] WARNING: {msg}")
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def hint(cmd: str) -> None:
|
|
535
|
+
console.print(f"hint: {cmd}", style="dim")
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def do_build(tag: str, context: "Path | None" = None) -> None:
|
|
539
|
+
# `build` is the one subcommand that needs a repo checkout: a docker build
|
|
540
|
+
# context IS a checkout. Resolve it explicitly (a PyPI install has REPO_ROOT
|
|
541
|
+
# is None) and fail actionably rather than with a traceback.
|
|
542
|
+
ctx = context
|
|
543
|
+
if ctx is None:
|
|
544
|
+
env = os.environ.get("AGENT_CONTAINER_REPO")
|
|
545
|
+
if env:
|
|
546
|
+
base = Path(env).expanduser().resolve()
|
|
547
|
+
if not _is_repo_checkout(base):
|
|
548
|
+
die(
|
|
549
|
+
f"AGENT_CONTAINER_REPO={env} is not an agent-container checkout "
|
|
550
|
+
"(missing Dockerfile/completions/agent-container.bash)"
|
|
551
|
+
)
|
|
552
|
+
ctx = base
|
|
553
|
+
else:
|
|
554
|
+
ctx = REPO_ROOT
|
|
555
|
+
if ctx is None:
|
|
556
|
+
die(
|
|
557
|
+
"no repo checkout for the docker build context; run from a checkout, "
|
|
558
|
+
"set AGENT_CONTAINER_REPO=<checkout>, or pass --context <dir>"
|
|
559
|
+
)
|
|
560
|
+
rt = detect_runtime()
|
|
561
|
+
log(f"building image '{tag}' with {rt} from {ctx}")
|
|
562
|
+
# Inherited stdio: layer progress must stream, never capture.
|
|
563
|
+
rc = subprocess.run([rt, "build", "-t", tag, str(ctx)]).returncode
|
|
564
|
+
if rc != 0:
|
|
565
|
+
die(f"build failed (exit {rc})")
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def launch_container(rt: str, name: str, env_file: Path, mounts: list[str] | None = None) -> None:
|
|
569
|
+
"""Build and run the container start argv.
|
|
570
|
+
|
|
571
|
+
`mounts` are already-resolved '<abs-host>:<container>' bind specs (from
|
|
572
|
+
resolve_bind_mount); they are appended after the standard per-container
|
|
573
|
+
volumes and before --restart.
|
|
574
|
+
"""
|
|
575
|
+
cname, vname, port = container_name(name), volume_name(name), port_for_name(name)
|
|
576
|
+
if not port_free(port):
|
|
577
|
+
die(
|
|
578
|
+
f"port {port} is already in use (port = 2200 + name hash; "
|
|
579
|
+
f"collisions are possible). Stop whatever holds it (agent-container list) "
|
|
580
|
+
f"or pick a different name."
|
|
581
|
+
)
|
|
582
|
+
log(f"name={name} port={port} env-file={env_file}")
|
|
583
|
+
log(f"image={IMAGE_NAME} volume={vname}")
|
|
584
|
+
argv = [
|
|
585
|
+
rt, "run", "-d",
|
|
586
|
+
"--name", cname,
|
|
587
|
+
"--env-file", str(env_file),
|
|
588
|
+
# Publishes sshd on ALL host interfaces (remote hosts.conf attach needs
|
|
589
|
+
# it). Firewall the VPS.
|
|
590
|
+
"-p", f"{port}:22",
|
|
591
|
+
]
|
|
592
|
+
# Standard per-container volumes (canonical order), then optional binds.
|
|
593
|
+
for vol in all_volume_mounts(name):
|
|
594
|
+
argv += ["-v", vol]
|
|
595
|
+
for m in (mounts or []):
|
|
596
|
+
argv += ["-v", m]
|
|
597
|
+
argv += ["--restart", "unless-stopped", IMAGE_NAME]
|
|
598
|
+
r = query(argv)
|
|
599
|
+
if r.returncode != 0:
|
|
600
|
+
die(f"{rt} run failed: {r.stderr.strip()}")
|
|
601
|
+
write_state(name, port)
|
|
602
|
+
log(f"started {cname}; attach with: agent-container attach {name}")
|
|
603
|
+
log(f"or directly: ssh dev@localhost -p {port} -t tmux attach -t main")
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def do_up(name: str, env_file_override: Path | None = None, mounts: list[str] | None = None) -> None:
|
|
607
|
+
validate_name(name)
|
|
608
|
+
# Resolve binds up front so a bad --mount aborts before any runtime call.
|
|
609
|
+
resolved_mounts = [resolve_bind_mount(m) for m in (mounts or [])]
|
|
610
|
+
rt = detect_runtime()
|
|
611
|
+
cname = container_name(name)
|
|
612
|
+
|
|
613
|
+
if container_running(rt, cname):
|
|
614
|
+
port = read_state_port(name)
|
|
615
|
+
log(f"container {cname} already running" + (f" on port {port}" if port else ""))
|
|
616
|
+
log(f"attach with: agent-container attach {name}")
|
|
617
|
+
return
|
|
618
|
+
|
|
619
|
+
if container_exists(rt, cname):
|
|
620
|
+
die(f"container {cname} exists but is not running. Run: agent-container down {name}")
|
|
621
|
+
|
|
622
|
+
env_file = env_file_override or resolve_env_file(name)
|
|
623
|
+
if env_file is None:
|
|
624
|
+
cands = ", ".join(str(c) for c in env_file_candidates(name, Path.cwd()))
|
|
625
|
+
die(f"no .env found. Looked in: {cands}")
|
|
626
|
+
|
|
627
|
+
if not image_exists(rt, IMAGE_NAME):
|
|
628
|
+
die(f"image {IMAGE_NAME} not found; run: agent-container build")
|
|
629
|
+
|
|
630
|
+
launch_container(rt, name, env_file, resolved_mounts)
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def down_container(rt: str, name: str, purge: bool) -> None:
|
|
634
|
+
cname = container_name(name)
|
|
635
|
+
if container_exists(rt, cname):
|
|
636
|
+
log(f"stopping {cname}")
|
|
637
|
+
query([rt, "stop", cname])
|
|
638
|
+
log(f"removing {cname}")
|
|
639
|
+
query([rt, "rm", "-f", cname])
|
|
640
|
+
else:
|
|
641
|
+
log(f"no container named {cname}")
|
|
642
|
+
|
|
643
|
+
# --purge drops ALL per-container volumes (workspace + claude + codex + pi +
|
|
644
|
+
# shellenv + tmux), tolerating absence; plain down preserves every one of them.
|
|
645
|
+
vols = per_container_volumes(name)
|
|
646
|
+
if purge:
|
|
647
|
+
for vname in vols:
|
|
648
|
+
log(f"purging volume {vname}")
|
|
649
|
+
if query([rt, "volume", "rm", vname]).returncode != 0:
|
|
650
|
+
warn(f"volume {vname} not removed (in use or absent)")
|
|
651
|
+
else:
|
|
652
|
+
log(f"volumes preserved (use --purge to remove): {', '.join(vols)}")
|
|
653
|
+
|
|
654
|
+
clear_state(name)
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def cli_down(name: str, purge: bool, yes: bool) -> None:
|
|
658
|
+
validate_name(name)
|
|
659
|
+
rt = detect_runtime()
|
|
660
|
+
|
|
661
|
+
if quadlet_active(name):
|
|
662
|
+
warn(
|
|
663
|
+
f"systemd user unit {container_name(name)}.service is active; "
|
|
664
|
+
f"systemd will restart the container after removal. Manage it via Quadlet instead."
|
|
665
|
+
)
|
|
666
|
+
if not yes:
|
|
667
|
+
eprint("[agent-container] re-run with -y/--yes to proceed despite the active unit")
|
|
668
|
+
raise typer.Exit(2)
|
|
669
|
+
|
|
670
|
+
if not yes:
|
|
671
|
+
if not is_tty():
|
|
672
|
+
eprint("[agent-container] refusing destructive 'down' without -y/--yes on a non-TTY")
|
|
673
|
+
raise typer.Exit(2)
|
|
674
|
+
what = f"stop and remove {container_name(name)}"
|
|
675
|
+
if purge:
|
|
676
|
+
what += f" and DELETE all per-container volumes ({', '.join(per_container_volumes(name))})"
|
|
677
|
+
if not questionary.confirm(f"{what}?", default=False).ask():
|
|
678
|
+
log("aborted")
|
|
679
|
+
return
|
|
680
|
+
|
|
681
|
+
down_container(rt, name, purge)
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def gather_rows(rt: str) -> list[dict[str, object]]:
|
|
685
|
+
rows: list[dict[str, object]] = []
|
|
686
|
+
running_short: set[str] = set()
|
|
687
|
+
for cname, image, status, uptime in ps_agent_container(rt):
|
|
688
|
+
short = cname[len(CONTAINER_PREFIX):]
|
|
689
|
+
running_short.add(short)
|
|
690
|
+
rows.append({
|
|
691
|
+
"name": cname,
|
|
692
|
+
"port": read_state_port(short) or "?",
|
|
693
|
+
"image": image,
|
|
694
|
+
"status": status,
|
|
695
|
+
"uptime": uptime,
|
|
696
|
+
"stale": False,
|
|
697
|
+
})
|
|
698
|
+
# Orphaned state files: a <name>.port with no running container.
|
|
699
|
+
if STATE_DIR.is_dir():
|
|
700
|
+
for f in sorted(STATE_DIR.glob("*.port")):
|
|
701
|
+
short = f.stem
|
|
702
|
+
if short in running_short:
|
|
703
|
+
continue
|
|
704
|
+
rows.append({
|
|
705
|
+
"name": container_name(short),
|
|
706
|
+
"port": read_state_port(short) or "?",
|
|
707
|
+
"image": "-",
|
|
708
|
+
"status": "stale",
|
|
709
|
+
"uptime": "-",
|
|
710
|
+
"stale": True,
|
|
711
|
+
})
|
|
712
|
+
return rows
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def do_list(as_json: bool) -> None:
|
|
716
|
+
rt = detect_runtime()
|
|
717
|
+
rows = gather_rows(rt)
|
|
718
|
+
if as_json:
|
|
719
|
+
print(json.dumps(rows, indent=2))
|
|
720
|
+
return
|
|
721
|
+
table = Table(show_header=True, header_style="bold", box=None, pad_edge=False)
|
|
722
|
+
for col in ("NAME", "PORT", "IMAGE", "STATUS", "UPTIME"):
|
|
723
|
+
table.add_column(col)
|
|
724
|
+
for row in rows:
|
|
725
|
+
style = "dim" if row["stale"] else None
|
|
726
|
+
table.add_row(
|
|
727
|
+
str(row["name"]), str(row["port"]), str(row["image"]),
|
|
728
|
+
str(row["status"]), str(row["uptime"]), style=style,
|
|
729
|
+
)
|
|
730
|
+
console.print(table)
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def ssh_argv(user: str, host: str, port: str | int, window: str | None = None) -> list[str]:
|
|
734
|
+
# The one true attach command; argv list, never a shell string. Without a
|
|
735
|
+
# window the argv is unchanged (agent-container parity). With one, the remote
|
|
736
|
+
# command is a SINGLE compound string that selects the window (tolerating
|
|
737
|
+
# its absence) THEN attaches — select-window works on a detached session.
|
|
738
|
+
base = ["ssh", f"{user}@{host}", "-p", str(port), "-t"]
|
|
739
|
+
if window:
|
|
740
|
+
return base + [
|
|
741
|
+
f"tmux select-window -t main:{window} 2>/dev/null; exec tmux attach -t main"
|
|
742
|
+
]
|
|
743
|
+
return base + ["tmux", "attach", "-t", "main"]
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def tmux_nest_warning() -> str | None:
|
|
747
|
+
if os.environ.get("TMUX"):
|
|
748
|
+
return "already inside tmux; detach the INNER session with Ctrl-B Ctrl-B d"
|
|
749
|
+
return None
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def resolve_attach_target(
|
|
753
|
+
name: str,
|
|
754
|
+
mode: str, # "auto" | "local" | "remote"
|
|
755
|
+
user_override: str | None = None,
|
|
756
|
+
host_override: str | None = None,
|
|
757
|
+
) -> tuple[str, str, str, str]:
|
|
758
|
+
"""Returns (user, host, port, kind). kind is 'local' or 'remote'."""
|
|
759
|
+
# Case-insensitive like agent-container: 'My-Box' resolves the same
|
|
760
|
+
# MY_BOX_* keys (and the same lowercase state file agent-container writes).
|
|
761
|
+
name = validate_name(name.lower())
|
|
762
|
+
user = resolve_ssh_user(user_override)
|
|
763
|
+
if host_override is not None and not SSH_HOST_RE.fullmatch(host_override):
|
|
764
|
+
die(f"invalid ssh host '{host_override}'")
|
|
765
|
+
key = name_to_key(name)
|
|
766
|
+
|
|
767
|
+
if mode == "remote" and not HOSTS_CONF.is_file():
|
|
768
|
+
die(f"no hosts config at {HOSTS_CONF} — create it (see docs/agent-container-hosts.example)")
|
|
769
|
+
|
|
770
|
+
entry = hosts_entry(name)
|
|
771
|
+
if mode == "remote" or (mode == "auto" and entry is not None):
|
|
772
|
+
if entry is None:
|
|
773
|
+
die(f"no host configured for {name} — add {key}_HOST and {key}_PORT to {HOSTS_CONF}")
|
|
774
|
+
host, port = entry
|
|
775
|
+
return user, host_override or host, port, "remote"
|
|
776
|
+
|
|
777
|
+
port = read_state_port(name)
|
|
778
|
+
if port is None:
|
|
779
|
+
if mode == "local":
|
|
780
|
+
die(
|
|
781
|
+
f"no local state for {name} at {state_file(name)} — is the container "
|
|
782
|
+
f"running? (try: agent-container up {name})"
|
|
783
|
+
)
|
|
784
|
+
die(
|
|
785
|
+
f"no attach target for {name}; checked {HOSTS_CONF} "
|
|
786
|
+
f"(no {key}_HOST/{key}_PORT) and {state_file(name)}"
|
|
787
|
+
)
|
|
788
|
+
host = host_override or os.environ.get("AGENT_CONTAINER_HOST") or "localhost"
|
|
789
|
+
return user, host, port, "local"
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def cli_attach(
|
|
793
|
+
name: str,
|
|
794
|
+
mode: str,
|
|
795
|
+
user_override: str | None,
|
|
796
|
+
host_override: str | None,
|
|
797
|
+
window: str | None = None,
|
|
798
|
+
) -> None:
|
|
799
|
+
# Validate the window BEFORE resolving/building the command (it is embedded
|
|
800
|
+
# in the remote shell string).
|
|
801
|
+
if window:
|
|
802
|
+
validate_window(window)
|
|
803
|
+
user, host, port, _ = resolve_attach_target(name, mode, user_override, host_override)
|
|
804
|
+
if w := tmux_nest_warning():
|
|
805
|
+
warn(w) # CLI mode: warn and proceed
|
|
806
|
+
session = f'"main"' + (f', window "{window}"' if window else "")
|
|
807
|
+
eprint(f"agent-container: {user}@{host}:{port} (tmux session {session})")
|
|
808
|
+
sys.stdout.flush()
|
|
809
|
+
sys.stderr.flush()
|
|
810
|
+
# Full handover: the process is replaced; signals, SIGWINCH, TTY ownership
|
|
811
|
+
# and the exit code belong to ssh — identical to bash `exec ssh`.
|
|
812
|
+
os.execvp("ssh", ssh_argv(user, host, port, window))
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def _child_default_sigint() -> None:
|
|
816
|
+
# SIG_IGN survives exec; without this reset a child spawned while the
|
|
817
|
+
# parent shields itself from Ctrl-C would ignore SIGINT too.
|
|
818
|
+
signal.signal(signal.SIGINT, signal.SIG_DFL)
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def wizard_handover(
|
|
822
|
+
user: str,
|
|
823
|
+
host: str,
|
|
824
|
+
port: str,
|
|
825
|
+
logs_hint: str = "agent-container logs <name>",
|
|
826
|
+
window: str | None = None,
|
|
827
|
+
) -> None:
|
|
828
|
+
"""Attach without killing the wizard: inherited-stdio subprocess + cleanup."""
|
|
829
|
+
target = f"{user}@{host}:{port}"
|
|
830
|
+
session = f'"main"' + (f', window "{window}"' if window else "")
|
|
831
|
+
eprint(f"agent-container: {target} (tmux session {session})")
|
|
832
|
+
sys.stdout.flush()
|
|
833
|
+
sys.stderr.flush()
|
|
834
|
+
old_int = signal.signal(signal.SIGINT, signal.SIG_IGN) # Ctrl-C goes to ssh only
|
|
835
|
+
try:
|
|
836
|
+
rc = subprocess.run(
|
|
837
|
+
ssh_argv(user, host, port, window), preexec_fn=_child_default_sigint
|
|
838
|
+
).returncode
|
|
839
|
+
finally:
|
|
840
|
+
signal.signal(signal.SIGINT, old_int)
|
|
841
|
+
if _ORIG_TERMIOS is not None:
|
|
842
|
+
try:
|
|
843
|
+
termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, _ORIG_TERMIOS)
|
|
844
|
+
except Exception:
|
|
845
|
+
pass
|
|
846
|
+
if rc == 0:
|
|
847
|
+
console.print(
|
|
848
|
+
f"Detached — tmux session 'main' is still running on {target}; agents keep working."
|
|
849
|
+
)
|
|
850
|
+
elif rc == 255:
|
|
851
|
+
warn(f"ssh could not connect to {target}; check hosts.conf or the container state.")
|
|
852
|
+
else:
|
|
853
|
+
warn(
|
|
854
|
+
f"remote command failed (exit {rc}); likely 'tmux attach' found no session "
|
|
855
|
+
f"'main'. Check the entrypoint via: {logs_hint}"
|
|
856
|
+
)
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def do_logs(name: str, follow: bool) -> int:
|
|
860
|
+
validate_name(name)
|
|
861
|
+
rt = detect_runtime()
|
|
862
|
+
argv = [rt, "logs"] + (["-f"] if follow else []) + [container_name(name)]
|
|
863
|
+
try:
|
|
864
|
+
return subprocess.run(argv).returncode # inherited stdio
|
|
865
|
+
except KeyboardInterrupt:
|
|
866
|
+
return 130
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
# --- wizard ------------------------------------------------------------------
|
|
870
|
+
# Lifecycle pickers (start/stop/purge/logs) list LOCAL containers only;
|
|
871
|
+
# remote (hosts.conf) targets appear exclusively in the Attach picker.
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def _validate_wiz_name(text: str): # questionary validator: True or error string
|
|
875
|
+
return True if NAME_RE.fullmatch(text) else "must match [a-z0-9][a-z0-9_-]*"
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def _short(cname: str) -> str:
|
|
879
|
+
return cname[len(CONTAINER_PREFIX):]
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def wiz_build(rt: str) -> None:
|
|
883
|
+
tag = questionary.text("image tag", default=IMAGE_NAME).ask()
|
|
884
|
+
if not tag:
|
|
885
|
+
return
|
|
886
|
+
do_build(tag)
|
|
887
|
+
hint("agent-container build" + (f" {tag}" if tag != IMAGE_NAME else ""))
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def wiz_up(rt: str) -> None:
|
|
891
|
+
name = questionary.text("container name", validate=_validate_wiz_name).ask()
|
|
892
|
+
if not name:
|
|
893
|
+
return
|
|
894
|
+
cname = container_name(name)
|
|
895
|
+
|
|
896
|
+
if container_running(rt, cname):
|
|
897
|
+
port = read_state_port(name)
|
|
898
|
+
log(f"container {cname} already running" + (f" on port {port}" if port else ""))
|
|
899
|
+
return
|
|
900
|
+
if container_exists(rt, cname):
|
|
901
|
+
log(f"container {cname} exists but is stopped — remove it first (Stop / remove)")
|
|
902
|
+
return
|
|
903
|
+
|
|
904
|
+
env_file = resolve_env_file(name)
|
|
905
|
+
if env_file is None:
|
|
906
|
+
eprint("[agent-container] no env file found; looked in:")
|
|
907
|
+
for c in env_file_candidates(name, Path.cwd()):
|
|
908
|
+
eprint(f" {c}")
|
|
909
|
+
target = CONFIG_DIR / f"{name}.env"
|
|
910
|
+
if questionary.confirm(f"create/edit {target} in $EDITOR now?", default=True).ask():
|
|
911
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
912
|
+
# $EDITOR gets the path only; agent-container never reads env contents.
|
|
913
|
+
subprocess.run([os.environ.get("EDITOR") or "vi", str(target)])
|
|
914
|
+
env_file = resolve_env_file(name)
|
|
915
|
+
if env_file is None:
|
|
916
|
+
log("still no env file; not starting")
|
|
917
|
+
return
|
|
918
|
+
|
|
919
|
+
if not image_exists(rt, IMAGE_NAME):
|
|
920
|
+
if not questionary.confirm(f"image {IMAGE_NAME} is missing — build it now?", default=True).ask():
|
|
921
|
+
return
|
|
922
|
+
do_build(IMAGE_NAME)
|
|
923
|
+
if not image_exists(rt, IMAGE_NAME):
|
|
924
|
+
log("image still missing; not starting")
|
|
925
|
+
return
|
|
926
|
+
|
|
927
|
+
# Optional single host-dir bind (empty = none). Resolved now so a bad path
|
|
928
|
+
# surfaces before the confirmation card; Fatal returns to the menu.
|
|
929
|
+
mounts: list[str] = []
|
|
930
|
+
mount_dir = questionary.text(
|
|
931
|
+
"host directory to mount read-write (empty = none)", default=""
|
|
932
|
+
).ask()
|
|
933
|
+
if mount_dir:
|
|
934
|
+
mounts = [resolve_bind_mount(mount_dir)]
|
|
935
|
+
|
|
936
|
+
card = Table(show_header=False, box=None, pad_edge=False)
|
|
937
|
+
card.add_column(style="bold")
|
|
938
|
+
card.add_column()
|
|
939
|
+
card.add_row("env-file", str(env_file)) # path only, never contents
|
|
940
|
+
card.add_row("port", str(port_for_name(name)))
|
|
941
|
+
card.add_row("volumes", ", ".join(per_container_volumes(name)))
|
|
942
|
+
if mounts:
|
|
943
|
+
card.add_row("bind", mounts[0])
|
|
944
|
+
card.add_row("image", IMAGE_NAME)
|
|
945
|
+
console.print(card)
|
|
946
|
+
|
|
947
|
+
if not questionary.confirm(f"start {cname}?", default=True).ask():
|
|
948
|
+
return
|
|
949
|
+
launch_container(rt, name, env_file, mounts) # port pre-check + actionable abort inside
|
|
950
|
+
hint(f"agent-container up {name}" + (f" --mount {mount_dir}" if mounts else ""))
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def wiz_attach(rt: str) -> None:
|
|
954
|
+
choices: list[questionary.Choice] = []
|
|
955
|
+
for cname, _image, _status, _uptime in ps_agent_container(rt):
|
|
956
|
+
short = _short(cname)
|
|
957
|
+
port = read_state_port(short) or "?"
|
|
958
|
+
choices.append(questionary.Choice(f"{short} [local :{port}]", value=("local", short, "", "")))
|
|
959
|
+
conf = load_hosts_conf()
|
|
960
|
+
for key in sorted(conf):
|
|
961
|
+
if not key.endswith("_HOST"):
|
|
962
|
+
continue
|
|
963
|
+
base = key[: -len("_HOST")]
|
|
964
|
+
host, port = conf[key], conf.get(f"{base}_PORT")
|
|
965
|
+
if not (host and port):
|
|
966
|
+
continue
|
|
967
|
+
rname = base.lower() # round-trips through name_to_key for the hint
|
|
968
|
+
choices.append(
|
|
969
|
+
questionary.Choice(f"{rname} [remote {host}:{port}]", value=("remote", rname, host, port))
|
|
970
|
+
)
|
|
971
|
+
if not choices:
|
|
972
|
+
log("nothing to attach to: no running local containers and no hosts.conf entries")
|
|
973
|
+
return
|
|
974
|
+
|
|
975
|
+
sel = questionary.select("attach to", choices=choices).ask()
|
|
976
|
+
if sel is None:
|
|
977
|
+
return
|
|
978
|
+
if w := tmux_nest_warning():
|
|
979
|
+
warn(w)
|
|
980
|
+
if not questionary.confirm("continue attaching?", default=True).ask():
|
|
981
|
+
return
|
|
982
|
+
|
|
983
|
+
# Optional window to select before attaching (empty = session default).
|
|
984
|
+
# Validated now so a bad name aborts back to the menu (Fatal is caught there).
|
|
985
|
+
window = questionary.text("window to select (empty = default)", default="").ask() or None
|
|
986
|
+
if window:
|
|
987
|
+
validate_window(window)
|
|
988
|
+
|
|
989
|
+
kind, name, host, port = sel
|
|
990
|
+
user = resolve_ssh_user()
|
|
991
|
+
if kind == "local":
|
|
992
|
+
port = read_state_port(name)
|
|
993
|
+
if port is None: # TOCTOU: state file vanished since the picker rendered
|
|
994
|
+
warn(f"state file for {name} disappeared; is the container still up?")
|
|
995
|
+
return
|
|
996
|
+
host = os.environ.get("AGENT_CONTAINER_HOST") or "localhost"
|
|
997
|
+
wizard_handover(user, host, port, logs_hint=f"agent-container logs {name}", window=window)
|
|
998
|
+
hint(f"agent-container attach {name} --{kind}" + (f" --window {window}" if window else ""))
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
def wiz_logs(rt: str) -> None:
|
|
1002
|
+
rows = ps_agent_container(rt)
|
|
1003
|
+
if not rows:
|
|
1004
|
+
log("no running agent-container containers")
|
|
1005
|
+
return
|
|
1006
|
+
sel = questionary.select("logs for", choices=[_short(r[0]) for r in rows]).ask()
|
|
1007
|
+
if sel is None:
|
|
1008
|
+
return
|
|
1009
|
+
log("streaming logs — Ctrl-C returns to the menu")
|
|
1010
|
+
old_int = signal.signal(signal.SIGINT, signal.SIG_IGN) # Ctrl-C stops the tail only
|
|
1011
|
+
try:
|
|
1012
|
+
subprocess.run([rt, "logs", "-f", container_name(sel)], preexec_fn=_child_default_sigint)
|
|
1013
|
+
finally:
|
|
1014
|
+
signal.signal(signal.SIGINT, old_int)
|
|
1015
|
+
hint(f"agent-container logs {sel}")
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
def wiz_down(rt: str) -> None:
|
|
1019
|
+
rows = ps_agent_container(rt, include_stopped=True)
|
|
1020
|
+
if not rows:
|
|
1021
|
+
log("no agent-container containers (running or stopped)")
|
|
1022
|
+
return
|
|
1023
|
+
choices = []
|
|
1024
|
+
for cname, _image, status, _uptime in rows:
|
|
1025
|
+
short = _short(cname)
|
|
1026
|
+
label = f"{short} [exited]" if status.lower().startswith("exited") else short
|
|
1027
|
+
choices.append(questionary.Choice(label, value=short))
|
|
1028
|
+
sel = questionary.select("stop / remove", choices=choices).ask()
|
|
1029
|
+
if sel is None:
|
|
1030
|
+
return
|
|
1031
|
+
cname = container_name(sel)
|
|
1032
|
+
|
|
1033
|
+
if not container_exists(rt, cname): # TOCTOU re-verify
|
|
1034
|
+
warn(f"container {cname} no longer exists")
|
|
1035
|
+
return
|
|
1036
|
+
if quadlet_active(sel):
|
|
1037
|
+
warn(
|
|
1038
|
+
f"systemd user unit {cname}.service is active; systemd will restart "
|
|
1039
|
+
f"the container after removal. Manage it via Quadlet instead."
|
|
1040
|
+
)
|
|
1041
|
+
if not questionary.confirm("proceed anyway?", default=False).ask():
|
|
1042
|
+
return
|
|
1043
|
+
if not questionary.confirm(f"stop and remove {cname}?", default=False).ask():
|
|
1044
|
+
log("aborted")
|
|
1045
|
+
return
|
|
1046
|
+
|
|
1047
|
+
purge = False
|
|
1048
|
+
vols = per_container_volumes(sel)
|
|
1049
|
+
if questionary.confirm(
|
|
1050
|
+
f"also delete ALL per-container volumes ({', '.join(vols)})?", default=False
|
|
1051
|
+
).ask():
|
|
1052
|
+
typed = questionary.text(f"type the container name ({cname}) to confirm volume deletion").ask()
|
|
1053
|
+
if typed == cname:
|
|
1054
|
+
purge = True
|
|
1055
|
+
else:
|
|
1056
|
+
log("name mismatch — volumes will be preserved")
|
|
1057
|
+
down_container(rt, sel, purge) # reports preserved vs purged, clears state
|
|
1058
|
+
hint(f"agent-container down {sel}" + (" --purge" if purge else ""))
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
def _volume_names(rt: str) -> list[str]:
|
|
1062
|
+
out = query([rt, "volume", "ls", "--format", "{{.Name}}"]).stdout.splitlines()
|
|
1063
|
+
return [v for v in out if re.fullmatch(re.escape(CONTAINER_PREFIX) + r".+-workspace", v)]
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
def wiz_purge_volume(rt: str) -> None:
|
|
1067
|
+
containers = {row[0] for row in ps_agent_container(rt, include_stopped=True)}
|
|
1068
|
+
orphans = []
|
|
1069
|
+
for vol in _volume_names(rt):
|
|
1070
|
+
short = vol[len(CONTAINER_PREFIX): -len("-workspace")]
|
|
1071
|
+
if container_name(short) not in containers:
|
|
1072
|
+
orphans.append(vol)
|
|
1073
|
+
if not orphans:
|
|
1074
|
+
log("no orphaned agent-container volumes")
|
|
1075
|
+
return
|
|
1076
|
+
sel = questionary.select("purge orphaned volume", choices=orphans).ask()
|
|
1077
|
+
if sel is None:
|
|
1078
|
+
return
|
|
1079
|
+
typed = questionary.text(f"type the volume name ({sel}) to confirm deletion").ask()
|
|
1080
|
+
if typed != sel:
|
|
1081
|
+
log("name mismatch — nothing removed")
|
|
1082
|
+
return
|
|
1083
|
+
if sel not in _volume_names(rt): # TOCTOU re-verify
|
|
1084
|
+
warn(f"volume {sel} no longer exists")
|
|
1085
|
+
return
|
|
1086
|
+
if query([rt, "volume", "rm", sel]).returncode != 0:
|
|
1087
|
+
warn(f"volume {sel} not removed (in use)")
|
|
1088
|
+
else:
|
|
1089
|
+
log(f"volume {sel} purged")
|
|
1090
|
+
hint(f"{rt} volume rm {sel}")
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
_MENU: list[tuple[str, "object"]] = [
|
|
1094
|
+
("Provision (build image)", wiz_build),
|
|
1095
|
+
("Start a container", wiz_up),
|
|
1096
|
+
("Attach", wiz_attach),
|
|
1097
|
+
("Logs", wiz_logs),
|
|
1098
|
+
("Stop / remove", wiz_down),
|
|
1099
|
+
("Purge orphaned volume", wiz_purge_volume),
|
|
1100
|
+
("Quit", None),
|
|
1101
|
+
]
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
def wizard_loop() -> int:
|
|
1105
|
+
if not is_tty():
|
|
1106
|
+
eprint("[agent-container] no TTY; interactive wizard unavailable. Use subcommands (agent-container --help).")
|
|
1107
|
+
return 2
|
|
1108
|
+
try:
|
|
1109
|
+
rt = detect_runtime()
|
|
1110
|
+
except Fatal as e:
|
|
1111
|
+
eprint(f"[agent-container] FATAL: {e}")
|
|
1112
|
+
return 1
|
|
1113
|
+
actions = dict(_MENU)
|
|
1114
|
+
while True:
|
|
1115
|
+
try:
|
|
1116
|
+
running = len(ps_agent_container(rt))
|
|
1117
|
+
hosts = "present" if HOSTS_CONF.is_file() else "absent"
|
|
1118
|
+
console.print(
|
|
1119
|
+
f"runtime: {rt} | running local containers: {running} | hosts.conf: {hosts}",
|
|
1120
|
+
style="dim",
|
|
1121
|
+
)
|
|
1122
|
+
choice = questionary.select("agent-container — what now?", choices=[t for t, _ in _MENU]).ask()
|
|
1123
|
+
if choice is None or choice == "Quit":
|
|
1124
|
+
return 0
|
|
1125
|
+
try:
|
|
1126
|
+
actions[choice](rt)
|
|
1127
|
+
except Fatal as e:
|
|
1128
|
+
eprint(f"[agent-container] ERROR: {e}") # back to the menu, not exit
|
|
1129
|
+
except (KeyboardInterrupt, EOFError):
|
|
1130
|
+
return 0
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
# --- entry -------------------------------------------------------------------
|
|
1134
|
+
|
|
1135
|
+
app = typer.Typer(
|
|
1136
|
+
add_completion=False,
|
|
1137
|
+
no_args_is_help=False,
|
|
1138
|
+
pretty_exceptions_enable=False,
|
|
1139
|
+
help="Interactive wizard + CLI for agent-container containers.",
|
|
1140
|
+
)
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
def fatal_exit(e: Fatal) -> "typer.NoReturn":
|
|
1144
|
+
eprint(f"[agent-container] FATAL: {e}")
|
|
1145
|
+
raise typer.Exit(1)
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def run_self_test() -> int:
|
|
1149
|
+
import doctest
|
|
1150
|
+
|
|
1151
|
+
failures, tests = doctest.testmod(
|
|
1152
|
+
sys.modules[__name__], optionflags=doctest.IGNORE_EXCEPTION_DETAIL
|
|
1153
|
+
)
|
|
1154
|
+
# Hard-coded port corpus: pins the deterministic port hash against regressions.
|
|
1155
|
+
corpus = {
|
|
1156
|
+
"acme": 2206,
|
|
1157
|
+
"blog": 2220,
|
|
1158
|
+
"scratch": 2244,
|
|
1159
|
+
"my-box": 2204,
|
|
1160
|
+
"a": 2297,
|
|
1161
|
+
"devbox123": 2298,
|
|
1162
|
+
}
|
|
1163
|
+
bad = {n: (port_for_name(n), want) for n, want in corpus.items() if port_for_name(n) != want}
|
|
1164
|
+
keys_ok = name_to_key("my-box") == "MY_BOX" and name_to_key("acme") == "ACME"
|
|
1165
|
+
ok = failures == 0 and not bad and keys_ok
|
|
1166
|
+
print(f"doctests: {tests - failures}/{tests} passed")
|
|
1167
|
+
print(f"port corpus: {'ok' if not bad else f'MISMATCH {bad}'}")
|
|
1168
|
+
print(f"key derivation: {'ok' if keys_ok else 'MISMATCH'}")
|
|
1169
|
+
print("self-test:", "PASS" if ok else "FAIL")
|
|
1170
|
+
return 0 if ok else 1
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
@app.command()
|
|
1174
|
+
def build(
|
|
1175
|
+
tag: str = typer.Argument(IMAGE_NAME, help="Image tag to build."),
|
|
1176
|
+
context: Path | None = typer.Option(
|
|
1177
|
+
None,
|
|
1178
|
+
"--context",
|
|
1179
|
+
help="Docker build context (repo checkout). Defaults to AGENT_CONTAINER_REPO "
|
|
1180
|
+
"or the auto-detected checkout; required when installed from PyPI.",
|
|
1181
|
+
),
|
|
1182
|
+
) -> None:
|
|
1183
|
+
"""Build the image from a repo checkout (streams build output)."""
|
|
1184
|
+
do_build(tag, context)
|
|
1185
|
+
|
|
1186
|
+
|
|
1187
|
+
@app.command()
|
|
1188
|
+
def up(
|
|
1189
|
+
name: str = typer.Argument(..., help="Container short name (agent-container-<name>)."),
|
|
1190
|
+
env_file: Path | None = typer.Option(
|
|
1191
|
+
None, "--env-file", help="Bypass env-file resolution; path must exist."
|
|
1192
|
+
),
|
|
1193
|
+
mount: List[str] = typer.Option(
|
|
1194
|
+
None,
|
|
1195
|
+
"--mount",
|
|
1196
|
+
help="Bind-mount a host dir read-write (repeatable): HOSTDIR[:CONTAINERPATH]. "
|
|
1197
|
+
"Default CONTAINERPATH is /workspace/<basename>. Lima: host dir must be "
|
|
1198
|
+
"under a writable Lima mount.",
|
|
1199
|
+
),
|
|
1200
|
+
) -> None:
|
|
1201
|
+
"""Start container agent-container-NAME (detached)."""
|
|
1202
|
+
if env_file is not None and not env_file.is_file():
|
|
1203
|
+
raise typer.BadParameter(f"--env-file {env_file} does not exist")
|
|
1204
|
+
do_up(name, env_file, mount or [])
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
@app.command()
|
|
1208
|
+
def down(
|
|
1209
|
+
name: str = typer.Argument(..., help="Container short name."),
|
|
1210
|
+
purge: bool = typer.Option(False, "--purge", help="Also delete the workspace volume."),
|
|
1211
|
+
yes: bool = typer.Option(False, "-y", "--yes", help="Skip confirmation."),
|
|
1212
|
+
) -> None:
|
|
1213
|
+
"""Stop and remove container agent-container-NAME."""
|
|
1214
|
+
cli_down(name, purge, yes)
|
|
1215
|
+
|
|
1216
|
+
|
|
1217
|
+
@app.command()
|
|
1218
|
+
def purge(
|
|
1219
|
+
name: str = typer.Argument(..., help="Container short name."),
|
|
1220
|
+
yes: bool = typer.Option(False, "-y", "--yes", help="Skip confirmation."),
|
|
1221
|
+
) -> None:
|
|
1222
|
+
"""Sugar for: down NAME --purge."""
|
|
1223
|
+
cli_down(name, purge=True, yes=yes)
|
|
1224
|
+
|
|
1225
|
+
|
|
1226
|
+
@app.command(name="list")
|
|
1227
|
+
def list_cmd(
|
|
1228
|
+
as_json: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
|
|
1229
|
+
) -> None:
|
|
1230
|
+
"""List running agent-container containers (plus stale state files)."""
|
|
1231
|
+
do_list(as_json)
|
|
1232
|
+
|
|
1233
|
+
|
|
1234
|
+
@app.command()
|
|
1235
|
+
def attach(
|
|
1236
|
+
name: str = typer.Argument(..., help="Container short name."),
|
|
1237
|
+
local: bool = typer.Option(False, "--local", help="Force local target (state file)."),
|
|
1238
|
+
remote: bool = typer.Option(False, "--remote", help="Force remote target (hosts.conf)."),
|
|
1239
|
+
user: str | None = typer.Option(None, "--user", help="SSH user (default: AGENT_CONTAINER_USER or dev)."),
|
|
1240
|
+
host: str | None = typer.Option(None, "--host", help="Override the resolved host."),
|
|
1241
|
+
window: str | None = typer.Option(
|
|
1242
|
+
None, "--window", "-w", help="Select tmux window NAME (session 'main') before attaching."
|
|
1243
|
+
),
|
|
1244
|
+
) -> None:
|
|
1245
|
+
"""ssh + tmux attach to container NAME (local state file or hosts.conf)."""
|
|
1246
|
+
if local and remote:
|
|
1247
|
+
raise typer.BadParameter("--local and --remote are mutually exclusive")
|
|
1248
|
+
mode = "local" if local else ("remote" if remote else "auto")
|
|
1249
|
+
cli_attach(name, mode, user, host, window)
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
@app.command()
|
|
1253
|
+
def logs(
|
|
1254
|
+
name: str = typer.Argument(..., help="Container short name."),
|
|
1255
|
+
no_follow: bool = typer.Option(False, "--no-follow", help="Print logs without following."),
|
|
1256
|
+
) -> None:
|
|
1257
|
+
"""Tail container logs."""
|
|
1258
|
+
raise typer.Exit(do_logs(name, follow=not no_follow))
|
|
1259
|
+
|
|
1260
|
+
|
|
1261
|
+
@app.command()
|
|
1262
|
+
def menu() -> None:
|
|
1263
|
+
"""Interactive wizard (same as bare agent-container)."""
|
|
1264
|
+
raise typer.Exit(wizard_loop())
|
|
1265
|
+
|
|
1266
|
+
|
|
1267
|
+
def _completion_script(shell: str) -> str:
|
|
1268
|
+
"""Return the bash/zsh completion script text.
|
|
1269
|
+
|
|
1270
|
+
Prefers the on-disk checkout (dev / `uv run --script`, where REPO_ROOT
|
|
1271
|
+
resolves via the __file__ marker); falls back to the completions bundled as
|
|
1272
|
+
package data in a non-editable PyPI install (where REPO_ROOT is None).
|
|
1273
|
+
"""
|
|
1274
|
+
if REPO_ROOT is not None:
|
|
1275
|
+
p = REPO_ROOT / "completions" / f"agent-container.{shell}"
|
|
1276
|
+
if p.is_file():
|
|
1277
|
+
return p.read_text()
|
|
1278
|
+
try:
|
|
1279
|
+
import importlib.resources as ir
|
|
1280
|
+
|
|
1281
|
+
res = ir.files("agent_container").joinpath(f"completions/agent-container.{shell}")
|
|
1282
|
+
if res.is_file():
|
|
1283
|
+
return res.read_text()
|
|
1284
|
+
except (ModuleNotFoundError, ImportError, FileNotFoundError, TypeError, AttributeError):
|
|
1285
|
+
pass
|
|
1286
|
+
die(f"completion script for '{shell}' not found (need a checkout or an installed package)")
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
@app.command()
|
|
1290
|
+
def completions(
|
|
1291
|
+
# Optional + manual validation so a bad/missing shell routes through die()
|
|
1292
|
+
# (exit 1, '[agent-container] FATAL:' style), not Typer's exit-2 required-arg error.
|
|
1293
|
+
shell: str = typer.Argument("", help="Shell to emit completion for: bash or zsh."),
|
|
1294
|
+
) -> None:
|
|
1295
|
+
"""Print the completion script for bash or zsh (from a checkout or package data)."""
|
|
1296
|
+
if shell not in ("bash", "zsh"):
|
|
1297
|
+
die("usage: agent-container completions <bash|zsh>")
|
|
1298
|
+
sys.stdout.write(_completion_script(shell))
|
|
1299
|
+
|
|
1300
|
+
|
|
1301
|
+
@app.callback(invoke_without_command=True)
|
|
1302
|
+
def main(
|
|
1303
|
+
ctx: typer.Context,
|
|
1304
|
+
self_test: bool = typer.Option(False, "--self-test", help="Run doctests + port-hash corpus checks."),
|
|
1305
|
+
) -> None:
|
|
1306
|
+
if self_test:
|
|
1307
|
+
raise typer.Exit(run_self_test())
|
|
1308
|
+
if ctx.invoked_subcommand is None:
|
|
1309
|
+
raise typer.Exit(wizard_loop())
|
|
1310
|
+
|
|
1311
|
+
|
|
1312
|
+
def cli() -> None:
|
|
1313
|
+
"""Console-script entry point for `uv tool install` (see pyproject.toml).
|
|
1314
|
+
|
|
1315
|
+
uv's generated launcher does `from agent_container import cli; sys.exit(cli())`,
|
|
1316
|
+
so the Fatal -> exit-1 handling that used to live only in the __main__ guard
|
|
1317
|
+
is hoisted here. NOT named `main` — that is already the Typer @app.callback.
|
|
1318
|
+
"""
|
|
1319
|
+
try:
|
|
1320
|
+
app()
|
|
1321
|
+
except Fatal as e:
|
|
1322
|
+
eprint(f"[agent-container] FATAL: {e}")
|
|
1323
|
+
sys.exit(1)
|
|
1324
|
+
|
|
1325
|
+
|
|
1326
|
+
if __name__ == "__main__":
|
|
1327
|
+
cli()
|