agentpool-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. agentpool/__init__.py +3 -0
  2. agentpool/agent_io.py +134 -0
  3. agentpool/artifacts.py +151 -0
  4. agentpool/cli.py +1199 -0
  5. agentpool/config.py +373 -0
  6. agentpool/docs/agentpool-skill.md +85 -0
  7. agentpool/docs/onboarding.md +169 -0
  8. agentpool/event_detection.py +150 -0
  9. agentpool/fixtures/__init__.py +1 -0
  10. agentpool/fixtures/fake_agents/__init__.py +1 -0
  11. agentpool/fixtures/fake_agents/fake_approval_agent.py +16 -0
  12. agentpool/fixtures/fake_agents/fake_common.py +44 -0
  13. agentpool/fixtures/fake_agents/fake_completed_agent.py +13 -0
  14. agentpool/fixtures/fake_agents/fake_idle_agent.py +16 -0
  15. agentpool/fixtures/fake_agents/fake_limit_agent.py +14 -0
  16. agentpool/fixtures/fake_agents/fake_patch_agent.py +17 -0
  17. agentpool/fixtures/fake_agents/fake_question_agent.py +16 -0
  18. agentpool/git_worktree.py +144 -0
  19. agentpool/mcp/__init__.py +1 -0
  20. agentpool/mcp/resources.py +64 -0
  21. agentpool/mcp/tools.py +259 -0
  22. agentpool/mcp_server.py +487 -0
  23. agentpool/models.py +310 -0
  24. agentpool/onboarding.py +1279 -0
  25. agentpool/policy.py +63 -0
  26. agentpool/provider_model_catalog.json +997 -0
  27. agentpool/providers/__init__.py +3 -0
  28. agentpool/providers/base.py +411 -0
  29. agentpool/providers/registry.py +139 -0
  30. agentpool/redaction.py +30 -0
  31. agentpool/runtimes/__init__.py +3 -0
  32. agentpool/runtimes/base.py +36 -0
  33. agentpool/runtimes/tmux.py +133 -0
  34. agentpool/session_manager.py +1061 -0
  35. agentpool/stats/__init__.py +6 -0
  36. agentpool/stats/card.py +74 -0
  37. agentpool/stats/compute.py +496 -0
  38. agentpool/stats/queries.py +138 -0
  39. agentpool/stats/render.py +103 -0
  40. agentpool/stats/window.py +85 -0
  41. agentpool/store.py +478 -0
  42. agentpool/usage/__init__.py +1 -0
  43. agentpool/usage/_common.py +223 -0
  44. agentpool/usage/ccusage.py +130 -0
  45. agentpool/usage/claude.py +23 -0
  46. agentpool/usage/codex.py +210 -0
  47. agentpool/usage/codexbar.py +186 -0
  48. agentpool/usage/combine.py +71 -0
  49. agentpool/usage/copilot.py +146 -0
  50. agentpool/usage/devin.py +265 -0
  51. agentpool/usage/parsers.py +41 -0
  52. agentpool/usage/probes.py +52 -0
  53. agentpool/usage/provider_parsers.py +276 -0
  54. agentpool/usage/summary.py +166 -0
  55. agentpool/utils.py +59 -0
  56. agentpool_cli-0.1.0.dist-info/METADATA +292 -0
  57. agentpool_cli-0.1.0.dist-info/RECORD +60 -0
  58. agentpool_cli-0.1.0.dist-info/WHEEL +4 -0
  59. agentpool_cli-0.1.0.dist-info/entry_points.txt +2 -0
  60. agentpool_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
agentpool/cli.py ADDED
@@ -0,0 +1,1199 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import shutil
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Annotated
8
+
9
+ import typer
10
+ import yaml
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+
14
+ from agentpool import __version__
15
+ from agentpool.agent_io import collect_payload, observe_payload, parse_detail, read_stdin_text
16
+ from agentpool.config import (
17
+ DEFAULT_CONFIG_PATH,
18
+ DEFAULT_MODEL_CATALOG_PATH,
19
+ load_config,
20
+ validate_config,
21
+ validate_model_catalog_path,
22
+ )
23
+ from agentpool.mcp_server import run_mcp_server
24
+ from agentpool.models import SpawnWorkerRequest, ToolError
25
+ from agentpool.onboarding import (
26
+ command_path,
27
+ deep_doctor,
28
+ default_onboarding_nudges,
29
+ format_mcp_install,
30
+ init_config,
31
+ mcp_client_config,
32
+ mcp_host_config,
33
+ privacy_doctor,
34
+ run_fake_smoke,
35
+ run_real_read_only_smoke,
36
+ setup_all_providers,
37
+ setup_provider,
38
+ )
39
+ from agentpool.mcp import tools as mcp_tools
40
+ from agentpool.session_manager import SessionManager
41
+ from agentpool.stats.card import render_stats_card
42
+ from agentpool.stats.render import render_stats_panel, render_stats_plain
43
+ from agentpool.usage.probes import detect_codexbar
44
+
45
+
46
+ app = typer.Typer(
47
+ help="AgentPool local coding-agent control plane.",
48
+ invoke_without_command=True,
49
+ no_args_is_help=True,
50
+ )
51
+ config_app = typer.Typer(help="Inspect AgentPool config.")
52
+ leases_app = typer.Typer(help="Manage advisory file leases.")
53
+ worktrees_app = typer.Typer(help="Inspect and clean AgentPool-created worktrees.")
54
+ app.add_typer(config_app, name="config")
55
+ app.add_typer(leases_app, name="leases")
56
+ app.add_typer(worktrees_app, name="worktrees")
57
+ console = Console()
58
+
59
+
60
+ @app.callback()
61
+ def root(
62
+ version: Annotated[bool, typer.Option("--version", help="Show AgentPool version.")] = False,
63
+ ) -> None:
64
+ if version:
65
+ console.print(f"agentpool {__version__}")
66
+ raise typer.Exit()
67
+
68
+
69
+ def print_data(data: object, json_output: bool) -> None:
70
+ if json_output:
71
+ console.print_json(json.dumps(data, default=str))
72
+ else:
73
+ console.print(data)
74
+
75
+
76
+ def manager() -> SessionManager:
77
+ return SessionManager(load_config())
78
+
79
+
80
+ def handle_tool_error(exc: ToolError, json_output: bool = False) -> None:
81
+ data = {"error": exc.error.model_dump(mode="json")}
82
+ next_command = _next_command_for_error(exc)
83
+ if next_command:
84
+ data["error"]["details"] = {**(data["error"].get("details") or {}), "example": next_command}
85
+ if json_output:
86
+ console.print_json(json.dumps(data))
87
+ else:
88
+ console.print(f"[red]{exc.error.code}[/red]: {exc.error.message}")
89
+ if next_command:
90
+ console.print(f"try: {next_command}")
91
+ raise typer.Exit(1)
92
+
93
+
94
+ def _next_command_for_error(exc: ToolError) -> str | None:
95
+ code = exc.error.code
96
+ details = exc.error.details or {}
97
+ if code == "PROVIDER_NOT_FOUND":
98
+ return "agentpool inventory --json"
99
+ if code == "PROVIDER_NOT_INSTALLED":
100
+ provider_id = details.get("provider_id") or "<provider-id>"
101
+ return f"agentpool setup {provider_id}"
102
+ if code == "POLICY_BLOCKED" and details.get("policy") in {
103
+ "require_explicit_provider",
104
+ "denied_providers",
105
+ "allowed_providers",
106
+ }:
107
+ return "agentpool inventory --json"
108
+ if code == "POLICY_BLOCKED" and "max_parallel_sessions" in details:
109
+ return "agentpool sessions --json"
110
+ if code == "USAGE_POLICY_BLOCKED":
111
+ provider_id = details.get("provider_id") or "<provider-id>"
112
+ return f"agentpool usage-summary --provider {provider_id} --refresh --json"
113
+ if code in {"INVALID_REQUEST", "INVALID_STDIN"}:
114
+ return str(details.get("example") or "agentpool spawn --provider <provider-id> --repo . --task \"Inspect this repo.\"")
115
+ if code == "INVALID_DETAIL":
116
+ return "agentpool observe <session-id> --detail excerpt"
117
+ if code == "INVALID_SESSION_PAGE":
118
+ return "agentpool sessions --limit 50 --offset 0 --json"
119
+ return None
120
+
121
+
122
+ @app.command()
123
+ def doctor(
124
+ json_output: Annotated[bool, typer.Option("--json", help="Emit JSON.")] = False,
125
+ deep: Annotated[bool, typer.Option("--deep", help="Run tmux/sqlite/artifact/cache checks.")] = False,
126
+ privacy: Annotated[
127
+ bool,
128
+ typer.Option("--privacy", help="Show local storage and usage-probe privacy posture."),
129
+ ] = False,
130
+ ) -> None:
131
+ mgr = manager()
132
+ tmux_path = shutil.which("tmux")
133
+ inventory = mgr.inventory(include_usage=True)
134
+ data = {
135
+ "tmux": {"installed": bool(tmux_path), "path": tmux_path},
136
+ "config_path": str(DEFAULT_CONFIG_PATH),
137
+ "db_path": str(mgr.config.storage.db),
138
+ "artifact_root": str(mgr.config.storage.artifacts),
139
+ "inventory": inventory,
140
+ }
141
+ if deep:
142
+ data["deep"] = deep_doctor(mgr)
143
+ if privacy:
144
+ data["privacy"] = privacy_doctor(mgr)
145
+ if json_output:
146
+ console.print_json(json.dumps(data, default=str))
147
+ return
148
+ table = Table("Provider", "Installed", "Auth", "Usage")
149
+ for provider in inventory["providers"]:
150
+ table.add_row(
151
+ provider["id"],
152
+ "yes" if provider["installed"] else "no",
153
+ provider["auth"]["status"],
154
+ provider["usage"]["status"] if provider.get("usage") else "unknown",
155
+ )
156
+ console.print(f"tmux: {tmux_path or 'missing'}")
157
+ if deep:
158
+ deep_data = data["deep"]
159
+ console.print(f"deep checks: {'ok' if deep_data['ok'] else 'failed'}")
160
+ for check in deep_data["checks"]:
161
+ console.print(f" {check['name']}: {'ok' if check['ok'] else 'failed'}")
162
+ if privacy:
163
+ privacy_data = data["privacy"]
164
+ console.print("privacy:")
165
+ console.print(
166
+ " credential storage: "
167
+ f"{'yes' if privacy_data['credential_storage']['agentpool_stores_provider_credentials'] else 'no'}"
168
+ )
169
+ console.print(
170
+ " browser scraping by default: "
171
+ f"{'yes' if privacy_data['credential_storage']['browser_scraping_enabled_by_default'] else 'no'}"
172
+ )
173
+ console.print(f" sqlite db: {privacy_data['local_storage']['sqlite_db']}")
174
+ console.print(f" artifacts: {privacy_data['local_storage']['artifact_root']}")
175
+ console.print(" live usage probes run only on explicit refresh: yes")
176
+ console.print(table)
177
+ console.print("\nWire AgentPool into Cursor:")
178
+ for command in default_onboarding_nudges():
179
+ console.print(f" {command}")
180
+
181
+
182
+ @app.command("init")
183
+ def init_command(
184
+ path: Annotated[Path, typer.Option("--path", help="Config path to initialize.")] = DEFAULT_CONFIG_PATH,
185
+ force: Annotated[bool, typer.Option("--force", help="Back up and overwrite existing config.")] = False,
186
+ json_output: Annotated[bool, typer.Option("--json", help="Emit JSON.")] = False,
187
+ ) -> None:
188
+ data = init_config(path, force=force)
189
+ if json_output:
190
+ console.print_json(json.dumps(data, default=str))
191
+ return
192
+ status = "wrote" if data["changed"] else "exists"
193
+ console.print(f"config {status}: {data['config_path']}")
194
+ if data.get("backup_path"):
195
+ console.print(f"backup: {data['backup_path']}")
196
+ console.print("next:")
197
+ for command in data.get("next_commands") or default_onboarding_nudges():
198
+ console.print(f" {command}")
199
+
200
+
201
+ @app.command("mcp-config")
202
+ def mcp_config(
203
+ client: Annotated[
204
+ str,
205
+ typer.Option(
206
+ "--client",
207
+ help=(
208
+ "MCP host: generic, claude-code, claude-desktop, codex, cursor, or copilot-cli."
209
+ ),
210
+ ),
211
+ ] = "generic",
212
+ absolute_command: Annotated[
213
+ bool, typer.Option("--absolute-command", help="Use the current resolved agentpool command.")
214
+ ] = False,
215
+ install: Annotated[
216
+ bool,
217
+ typer.Option(
218
+ "--install",
219
+ help="Print one-click install helpers (deeplink, shell command, or Copilot CLI steps).",
220
+ ),
221
+ ] = False,
222
+ json_output: Annotated[bool, typer.Option("--json", help="Emit JSON.")] = False,
223
+ ) -> None:
224
+ """Print MCP host configuration.
225
+
226
+ Examples:
227
+ agentpool mcp-config --client codex --absolute-command --install
228
+ agentpool mcp-config --client claude-code --json
229
+ agentpool mcp-config --client generic
230
+ """
231
+ command = command_path(absolute=absolute_command) if absolute_command else "agentpool"
232
+ data = mcp_client_config(client, command)
233
+ if json_output:
234
+ console.print_json(json.dumps(data, default=str))
235
+ if not data.get("ok", True):
236
+ raise typer.Exit(1)
237
+ return
238
+ if not data.get("ok", True):
239
+ console.print(f"[red]{data['error']}[/red]")
240
+ console.print(f"supported: {', '.join(data['supported_clients'])}")
241
+ raise typer.Exit(1)
242
+ if install:
243
+ console.print(format_mcp_install(data), markup=False)
244
+ return
245
+ if data.get("format") == "toml":
246
+ console.print(data["config"], end="", markup=False)
247
+ else:
248
+ console.print_json(json.dumps(data["config"]))
249
+
250
+
251
+ @app.command()
252
+ def inventory(json_output: Annotated[bool, typer.Option("--json", help="Emit JSON.")] = False) -> None:
253
+ data = manager().inventory(include_usage=True)
254
+ if json_output:
255
+ console.print_json(json.dumps(data, default=str))
256
+ else:
257
+ table = Table("Provider", "Installed", "Binary", "Auth", "Usage")
258
+ for provider in data["providers"]:
259
+ table.add_row(
260
+ provider["id"],
261
+ "yes" if provider["installed"] else "no",
262
+ provider.get("binary_path") or "",
263
+ provider["auth"]["status"],
264
+ provider["usage"]["status"] if provider.get("usage") else "unknown",
265
+ )
266
+ console.print(table)
267
+
268
+
269
+ @app.command()
270
+ def usage(
271
+ provider: Annotated[str | None, typer.Option("--provider", help="Provider id.")] = None,
272
+ backend: Annotated[
273
+ str,
274
+ typer.Option("--backend", help="Usage backend: native, codexbar, ccusage, or combined."),
275
+ ] = "combined",
276
+ cached: Annotated[bool, typer.Option("--cached", help="Read latest persisted snapshot without probing.")] = False,
277
+ json_output: Annotated[bool, typer.Option("--json", help="Emit JSON.")] = False,
278
+ ) -> None:
279
+ try:
280
+ data = manager().cached_usage_snapshot(provider) if cached else manager().usage_snapshot(provider, backend=backend)
281
+ print_data(data, json_output)
282
+ except ToolError as exc:
283
+ handle_tool_error(exc, json_output)
284
+
285
+
286
+ @app.command("usage-summary")
287
+ def usage_summary(
288
+ provider: Annotated[str | None, typer.Option("--provider", help="Provider id.")] = None,
289
+ refresh: Annotated[bool, typer.Option("--refresh", help="Run live probes before summarizing.")] = False,
290
+ backend: Annotated[
291
+ str,
292
+ typer.Option("--backend", help="Live usage backend: native, codexbar, ccusage, or combined."),
293
+ ] = "combined",
294
+ json_output: Annotated[bool, typer.Option("--json", help="Emit JSON.")] = False,
295
+ ) -> None:
296
+ """Summarize provider usage.
297
+
298
+ Examples:
299
+ agentpool usage-summary --json
300
+ agentpool usage-summary --provider codex-cli --refresh --json
301
+ agentpool usage-summary --backend codexbar --json
302
+ """
303
+ try:
304
+ data = manager().usage_summary(provider_id=provider, refresh=refresh, backend=backend)
305
+ if json_output:
306
+ console.print_json(json.dumps(data, default=str))
307
+ return
308
+ table = Table("Provider", "Status", "Confidence", "Summary", "Checked")
309
+ for row in data["providers"].values():
310
+ table.add_row(
311
+ row["provider_id"],
312
+ row["status"],
313
+ row["confidence"],
314
+ row["summary"],
315
+ row["checked_at"],
316
+ )
317
+ console.print(f"source: {data['source']}")
318
+ console.print(table)
319
+ except ToolError as exc:
320
+ handle_tool_error(exc, json_output)
321
+
322
+
323
+ @app.command("capacity-summary")
324
+ def capacity_summary(
325
+ provider: Annotated[str | None, typer.Option("--provider", help="Provider id.")] = None,
326
+ refresh: Annotated[bool, typer.Option("--refresh", help="Run live probes before summarizing.")] = False,
327
+ backend: Annotated[
328
+ str,
329
+ typer.Option("--backend", help="Live usage backend: native, codexbar, ccusage, or combined."),
330
+ ] = "combined",
331
+ json_output: Annotated[bool, typer.Option("--json", help="Emit JSON.")] = False,
332
+ ) -> None:
333
+ usage_summary(provider=provider, refresh=refresh, backend=backend, json_output=json_output)
334
+
335
+
336
+ @app.command("setup")
337
+ def setup_command(
338
+ target: Annotated[
339
+ str,
340
+ typer.Argument(help="Setup target, for example: cursor, codex, claude-code, droid-cli."),
341
+ ],
342
+ backend: Annotated[
343
+ str | None,
344
+ typer.Option("--backend", help="Usage backend override: native, codexbar, ccusage, or combined."),
345
+ ] = None,
346
+ skip_usage: Annotated[
347
+ bool,
348
+ typer.Option("--skip-usage", help="Do not run live usage probes during setup."),
349
+ ] = False,
350
+ relative_command: Annotated[
351
+ bool,
352
+ typer.Option("--relative-command", help="Use 'agentpool' instead of an absolute path in MCP config."),
353
+ ] = False,
354
+ json_output: Annotated[bool, typer.Option("--json", help="Emit JSON.")] = False,
355
+ ) -> None:
356
+ if target.strip().lower() == "all":
357
+ data = setup_all_providers(
358
+ manager(),
359
+ backend=backend,
360
+ run_usage=not skip_usage,
361
+ absolute_command=not relative_command,
362
+ )
363
+ if json_output:
364
+ console.print_json(json.dumps(data, default=str))
365
+ return
366
+ console.print("[bold]AgentPool setup: all providers[/bold]")
367
+ table = Table("Provider", "Installed", "Usage", "MCP", "Details", "Next action")
368
+ for row in data["rows"]:
369
+ installed = "yes" if row["installed"] else "no"
370
+ if row["installed"] is None:
371
+ installed = "n/a"
372
+ usage = "skip" if row["usage_ok"] is None else ("ok" if row["usage_ok"] else "needs action")
373
+ table.add_row(
374
+ row["provider_id"],
375
+ installed,
376
+ usage,
377
+ row["mcp_config"],
378
+ str(row.get("usage_message") or ""),
379
+ str(row.get("action") or ""),
380
+ )
381
+ console.print(table)
382
+ console.print("\nRun a focused setup for details:")
383
+ for target_name in data["targets"]:
384
+ console.print(f" agentpool setup {target_name}")
385
+ return
386
+
387
+ data = setup_provider(
388
+ manager(),
389
+ target,
390
+ backend=backend,
391
+ run_usage=not skip_usage,
392
+ absolute_command=not relative_command,
393
+ )
394
+ if json_output:
395
+ console.print_json(json.dumps(data, default=str))
396
+ if not data["ok"]:
397
+ raise typer.Exit(1)
398
+ return
399
+ if data.get("error"):
400
+ console.print(f"[red]{data['error']}[/red]")
401
+ console.print(f"supported: {', '.join(data.get('supported_targets', []))}")
402
+ if data.get("action"):
403
+ console.print(f"next: {data['action']}")
404
+ raise typer.Exit(1)
405
+ console.print(f"[bold]AgentPool setup: {data['display_name']}[/bold]")
406
+ table = Table("Check", "Status", "Details", "Next action")
407
+ for check in data["checks"]:
408
+ status = "skip" if check["ok"] is None else ("ok" if check["ok"] else "needs action")
409
+ table.add_row(check["name"], status, str(check.get("message") or ""), str(check.get("action") or ""))
410
+ console.print(table)
411
+ if data.get("mcp_config"):
412
+ config = data["mcp_config"]
413
+ install_text = format_mcp_install(config)
414
+ if install_text:
415
+ console.print(f"\n{install_text}")
416
+ console.print(f"\nMCP config for {config['path']}:")
417
+ if config.get("format") == "toml":
418
+ console.print(config["config"], end="", markup=False)
419
+ else:
420
+ console.print_json(json.dumps(config["config"], default=str))
421
+ console.print("\nManual steps:")
422
+ for step in data["manual_steps"]:
423
+ console.print(f" - {step}")
424
+ if data.get("actions"):
425
+ console.print("\nActions to resolve setup issues:")
426
+ for action in data["actions"]:
427
+ console.print(f" - {action}")
428
+ if data.get("setup_doc"):
429
+ console.print(f"\nGuide: {data['setup_doc']}")
430
+ console.print("\nUseful next commands:")
431
+ for command in data["next_commands"]:
432
+ console.print(f" {command}")
433
+ if not data["ok"]:
434
+ raise typer.Exit(1)
435
+
436
+
437
+ @app.command()
438
+ def onboard(json_output: Annotated[bool, typer.Option("--json", help="Emit JSON.")] = False) -> None:
439
+ mgr = manager()
440
+ data = {
441
+ "config_path": str(DEFAULT_CONFIG_PATH),
442
+ "db_path": str(mgr.config.storage.db),
443
+ "artifact_root": str(mgr.config.storage.artifacts),
444
+ "usage_backends": {
445
+ "default": "combined",
446
+ "available": ["native", "codexbar", "ccusage", "combined"],
447
+ "codexbar": detect_codexbar(),
448
+ "web_sources_enabled_by_default": False,
449
+ },
450
+ "first_commands": [
451
+ "agentpool init",
452
+ "agentpool doctor --deep",
453
+ "agentpool usage-summary --refresh",
454
+ "agentpool usage-summary --refresh --backend codexbar",
455
+ "agentpool providers",
456
+ "agentpool models",
457
+ "agentpool smoke --provider fake-question --repo .",
458
+ ],
459
+ "mcp_resources": [
460
+ "agentpool://onboarding",
461
+ "agentpool://skill.md",
462
+ "agentpool://sessions/{session_id}/transcript",
463
+ "agentpool://sessions/{session_id}/events",
464
+ "agentpool://artifacts/{session_id}",
465
+ ],
466
+ "mcp_host_config": {
467
+ "mcpServers": {"agentpool": {"command": "agentpool", "args": ["mcp"]}},
468
+ },
469
+ "rules": [
470
+ "Select providers explicitly; provider=auto is rejected.",
471
+ "Use usage-summary before delegating when possible.",
472
+ "Use read_only for exploration; choose worktree isolation explicitly when AgentPool should create one.",
473
+ "Observe and collect workers deliberately; terminate when done.",
474
+ ],
475
+ }
476
+ if json_output:
477
+ console.print_json(json.dumps(data, default=str))
478
+ return
479
+ console.print("[bold]AgentPool Onboarding[/bold]")
480
+ console.print(f"config: {data['config_path']}")
481
+ console.print(f"db: {data['db_path']}")
482
+ console.print(f"artifacts: {data['artifact_root']}")
483
+ codexbar = data["usage_backends"]["codexbar"]
484
+ console.print(f"codexbar: {'installed' if codexbar['installed'] else 'not installed'}")
485
+ console.print("\nFirst commands:")
486
+ for command in data["first_commands"]:
487
+ console.print(f" {command}")
488
+ console.print("\nMCP resources agents may read on demand:")
489
+ for resource in data["mcp_resources"]:
490
+ console.print(f" {resource}")
491
+ console.print("\nHost config:")
492
+ console.print_json(json.dumps(data["mcp_host_config"]))
493
+
494
+
495
+ @app.command()
496
+ def smoke(
497
+ provider: Annotated[str, typer.Option("--provider", help="Provider id to smoke test.")] = "fake-question",
498
+ repo: Annotated[Path, typer.Option("--repo", help="Repository path.")] = Path("."),
499
+ model: Annotated[
500
+ str | None,
501
+ typer.Option("--model", help="Explicit model id for real-provider smoke. Defaults to provider smoke_model."),
502
+ ] = None,
503
+ real_read_only: Annotated[
504
+ bool,
505
+ typer.Option("--real-read-only", help="Allow a guarded read-only smoke for a real provider."),
506
+ ] = False,
507
+ timeout: Annotated[int, typer.Option("--timeout", help="Real-provider observe timeout in seconds.")] = 60,
508
+ no_accept_startup_trust: Annotated[
509
+ bool,
510
+ typer.Option("--no-accept-startup-trust", help="Do not answer known startup trust prompts."),
511
+ ] = False,
512
+ json_output: Annotated[bool, typer.Option("--json", help="Emit JSON.")] = False,
513
+ ) -> None:
514
+ try:
515
+ if provider.startswith("fake-"):
516
+ data = run_fake_smoke(manager(), repo=repo.expanduser().resolve(), provider_id=provider)
517
+ elif real_read_only:
518
+ data = run_real_read_only_smoke(
519
+ manager(),
520
+ repo=repo.expanduser().resolve(),
521
+ provider_id=provider,
522
+ model=model,
523
+ timeout_seconds=timeout,
524
+ accept_startup_trust=not no_accept_startup_trust,
525
+ )
526
+ else:
527
+ raise ToolError(
528
+ "POLICY_BLOCKED",
529
+ "Real-provider smoke requires --real-read-only.",
530
+ {"provider_id": provider, "isolation": "read_only"},
531
+ )
532
+ if json_output:
533
+ console.print_json(json.dumps(data, default=str))
534
+ return
535
+ console.print(f"smoke {'ok' if data['ok'] else 'failed'}: {data['session_id']}")
536
+ console.print(f"artifacts: {data.get('artifact_dir')}")
537
+ except ToolError as exc:
538
+ handle_tool_error(exc, json_output)
539
+
540
+
541
+ @app.command()
542
+ def providers(json_output: Annotated[bool, typer.Option("--json", help="Emit JSON.")] = False) -> None:
543
+ data = manager().inventory(include_usage=False)
544
+ if json_output:
545
+ console.print_json(json.dumps({"providers": data["providers"]}, default=str))
546
+ else:
547
+ for provider in data["providers"]:
548
+ console.print(provider["id"])
549
+
550
+
551
+ @app.command("models")
552
+ def models_command(
553
+ action: Annotated[
554
+ str | None,
555
+ typer.Argument(help="Use 'validate' to validate a JSON model catalog."),
556
+ ] = None,
557
+ provider: Annotated[str | None, typer.Option("--provider", help="Provider id.")] = None,
558
+ path: Annotated[
559
+ Path | None,
560
+ typer.Option("--path", help="JSON model catalog path. Defaults to the embedded catalog."),
561
+ ] = None,
562
+ json_output: Annotated[bool, typer.Option("--json", help="Emit JSON.")] = False,
563
+ ) -> None:
564
+ mgr = manager()
565
+ if action:
566
+ if action != "validate":
567
+ raise typer.BadParameter("Only supported models action is 'validate'.")
568
+ data = validate_model_catalog_path(
569
+ path or DEFAULT_MODEL_CATALOG_PATH,
570
+ known_provider_ids=set(mgr.config.providers),
571
+ )
572
+ if json_output:
573
+ console.print_json(json.dumps(data, default=str))
574
+ else:
575
+ console.print(f"catalog {'ok' if data['ok'] else 'failed'}: {data['path']}")
576
+ for warning in data["warnings"]:
577
+ console.print(f"[yellow]warning[/yellow]: {warning}")
578
+ for error in data["errors"]:
579
+ console.print(f"[red]error[/red]: {error}")
580
+ if not data["ok"]:
581
+ raise typer.Exit(1)
582
+ return
583
+ rows = mgr.provider_models(provider)["providers"]
584
+ if json_output:
585
+ console.print_json(json.dumps({"providers": rows}, default=str))
586
+ return
587
+ if provider:
588
+ row = rows[0]
589
+ console.print(f"[bold]{row['provider_id']}[/bold]")
590
+ console.print(f"default: {row['default_model'] or ''}")
591
+ console.print(f"smoke: {row['smoke_model'] or ''}")
592
+ console.print(f"selection: {row['model_selection'] or ''}")
593
+ console.print(f"catalog: {row['catalog_completeness'] or ''}")
594
+ if row["quirks"]:
595
+ console.print("quirks:")
596
+ for quirk in row["quirks"]:
597
+ console.print(f" {quirk}")
598
+ table = Table("Model", "Display", "Confidence", "Reasoning")
599
+ for model in row["models"]:
600
+ metadata = model.get("metadata") or {}
601
+ reasoning = metadata.get("reasoning") or {}
602
+ supported = ", ".join(reasoning.get("supported") or [])
603
+ default = reasoning.get("default")
604
+ reasoning_text = f"{supported}; default {default}" if supported and default else supported
605
+ table.add_row(
606
+ model["id"],
607
+ model.get("display_name") or "",
608
+ model.get("confidence") or "",
609
+ reasoning_text,
610
+ )
611
+ console.print(table)
612
+ return
613
+ table = Table("Provider", "Default", "Smoke", "Selection", "Models", "Catalog")
614
+ for row in rows:
615
+ table.add_row(
616
+ row["provider_id"],
617
+ str(row["default_model"] or ""),
618
+ str(row["smoke_model"] or ""),
619
+ str(row["model_selection"] or ""),
620
+ str(len(row["models"])),
621
+ str(row["catalog_completeness"] or ""),
622
+ )
623
+ console.print(table)
624
+
625
+
626
+ @app.command()
627
+ def stats(
628
+ since: Annotated[
629
+ str | None,
630
+ typer.Option("--since", help="Window spec: 7d, 30d, 12h, 1w, ISO date, or all."),
631
+ ] = None,
632
+ window_from: Annotated[
633
+ str | None,
634
+ typer.Option("--from", help="Window start ISO timestamp. Mutually exclusive with --since."),
635
+ ] = None,
636
+ window_to: Annotated[
637
+ str | None,
638
+ typer.Option("--to", help="Window end ISO timestamp. Requires --from."),
639
+ ] = None,
640
+ provider: Annotated[str | None, typer.Option("--provider", help="Filter by provider id.")] = None,
641
+ scope: Annotated[
642
+ str,
643
+ typer.Option("--scope", help="Session scope: mine or all."),
644
+ ] = "all",
645
+ sections: Annotated[
646
+ list[str] | None,
647
+ typer.Option("--sections", help="Limit output sections. Repeat for multiple."),
648
+ ] = None,
649
+ share: Annotated[
650
+ Path | None,
651
+ typer.Option(
652
+ "--share",
653
+ help="Render a PNG share card. Optional output path.",
654
+ file_okay=True,
655
+ dir_okay=False,
656
+ ),
657
+ ] = None,
658
+ json_output: Annotated[bool, typer.Option("--json", help="Emit JSON.")] = False,
659
+ plain: Annotated[bool, typer.Option("--plain", help="Emit grep-friendly key=value lines.")] = False,
660
+ ) -> None:
661
+ """Report pool stats for a time window. Defaults to the last 7 days."""
662
+ if json_output and plain:
663
+ handle_tool_error(
664
+ ToolError("INVALID_OUTPUT", "Choose either --json or --plain, not both."),
665
+ json_output,
666
+ )
667
+ if window_from or window_to:
668
+ if since is not None:
669
+ handle_tool_error(
670
+ ToolError("INVALID_WINDOW", "Use either --since or --from/--to, not both."),
671
+ json_output,
672
+ )
673
+ if not window_from or not window_to:
674
+ handle_tool_error(
675
+ ToolError("INVALID_WINDOW", "--from and --to must be provided together."),
676
+ json_output,
677
+ )
678
+ window_spec = f"{window_from}/{window_to}"
679
+ else:
680
+ window_spec = since or "7d"
681
+
682
+ try:
683
+ data = mcp_tools.get_stats(
684
+ manager(),
685
+ window=window_spec,
686
+ provider_id=provider,
687
+ sections=sections,
688
+ scope=scope,
689
+ )
690
+ if share is not None:
691
+ card = render_stats_card(data, str(share))
692
+ if json_output:
693
+ data = {**data, "share_card": card}
694
+ elif not plain:
695
+ console.print(f"share card: {card['path']} ({card['bytes']} bytes)")
696
+ if json_output:
697
+ console.print_json(json.dumps(data, default=str))
698
+ return
699
+ if plain:
700
+ console.print(render_stats_plain(data))
701
+ return
702
+ console.print(render_stats_panel(data))
703
+ except ToolError as exc:
704
+ handle_tool_error(exc, json_output)
705
+
706
+
707
+ @app.command()
708
+ def sessions(
709
+ state: Annotated[
710
+ str | None,
711
+ typer.Option("--state", help="Comma-separated states such as running,completed."),
712
+ ] = None,
713
+ provider: Annotated[str | None, typer.Option("--provider", help="Filter by provider id.")] = None,
714
+ limit: Annotated[int, typer.Option("--limit", help="Maximum sessions to return.")] = 50,
715
+ offset: Annotated[int, typer.Option("--offset", help="Zero-based session page offset.")] = 0,
716
+ recent: Annotated[int | None, typer.Option("--recent", help="Return the N most recent sessions.")] = None,
717
+ all_rows: Annotated[bool, typer.Option("--all", help="Return all matching sessions.")] = False,
718
+ json_output: Annotated[bool, typer.Option("--json", help="Emit JSON.")] = False,
719
+ ) -> None:
720
+ """List sessions with bounded output by default.
721
+
722
+ Examples:
723
+ agentpool sessions --json
724
+ agentpool sessions --limit 25 --offset 25 --json
725
+ agentpool sessions --state running,awaiting_user_input --json
726
+ agentpool sessions --recent 10 --json
727
+ """
728
+ try:
729
+ if recent is not None and all_rows:
730
+ raise ToolError(
731
+ "INVALID_SESSION_PAGE",
732
+ "Use either --recent or --all, not both.",
733
+ {"example": "agentpool sessions --recent 10 --json"},
734
+ )
735
+ page_limit: int | None = None if all_rows else (recent if recent is not None else limit)
736
+ page_offset = 0 if recent is not None else offset
737
+ states = [part.strip() for part in state.split(",") if part.strip()] if state else None
738
+ data = manager().list_sessions(
739
+ states=states,
740
+ provider_id=provider,
741
+ limit=page_limit,
742
+ offset=page_offset,
743
+ )
744
+ except ToolError as exc:
745
+ handle_tool_error(exc, json_output)
746
+ return
747
+ print_data(data, json_output)
748
+
749
+
750
+ @app.command()
751
+ def spawn(
752
+ provider: Annotated[str, typer.Option("--provider", help="Explicit provider id.")],
753
+ task: Annotated[str | None, typer.Option("--task", help="Worker task.")] = None,
754
+ task_stdin: Annotated[bool, typer.Option("--task-stdin", help="Read worker task from stdin.")] = False,
755
+ repo: Annotated[Path, typer.Option("--repo", help="Repository path.")] = Path("."),
756
+ role: Annotated[
757
+ str,
758
+ typer.Option("--role", help="Worker role: explorer, reviewer, implementer, tester, or custom."),
759
+ ] = "explorer",
760
+ runtime: Annotated[str, typer.Option("--runtime", help="Runtime. v0.1 supports tmux only.")] = "tmux",
761
+ isolation: Annotated[
762
+ str,
763
+ typer.Option(
764
+ "--isolation",
765
+ help="Isolation: read_only, shared, or worktree. Worktree is explicit, not the default.",
766
+ ),
767
+ ] = "read_only",
768
+ model: Annotated[
769
+ str | None,
770
+ typer.Option("--model", help="Explicit model id. Defaults to the selected provider's configured default_model."),
771
+ ] = None,
772
+ account: Annotated[
773
+ str | None,
774
+ typer.Option("--account", help="Optional account label/id to persist with the session."),
775
+ ] = None,
776
+ allowed_file: Annotated[
777
+ list[str] | None,
778
+ typer.Option("--allowed-file", help="Advisory allowed file path. Repeat for multiple paths."),
779
+ ] = None,
780
+ max_runtime_seconds: Annotated[
781
+ int | None,
782
+ typer.Option("--max-runtime-seconds", help="Terminate on the next control operation after this runtime."),
783
+ ] = None,
784
+ max_turns: Annotated[
785
+ int | None,
786
+ typer.Option("--max-turns", help="Maximum number of send-message turns AgentPool will allow."),
787
+ ] = None,
788
+ supervision: Annotated[
789
+ str,
790
+ typer.Option("--supervision", help="Supervision: interactive, autonomous, or human_visible."),
791
+ ] = "interactive",
792
+ initial_prompt_mode: Annotated[
793
+ str,
794
+ typer.Option("--initial-prompt-mode", help="Initial prompt mode: provider_default, send_after_launch, arg, or stdin."),
795
+ ] = "provider_default",
796
+ reasoning_effort: Annotated[
797
+ str | None,
798
+ typer.Option("--reasoning-effort", help="Provider reasoning effort override when supported, for example high."),
799
+ ] = None,
800
+ service_tier: Annotated[
801
+ str | None,
802
+ typer.Option("--service-tier", help="Provider service tier override when supported, for example fast."),
803
+ ] = None,
804
+ json_output: Annotated[bool, typer.Option("--json", help="Emit JSON.")] = False,
805
+ ) -> None:
806
+ """Spawn one explicitly selected worker.
807
+
808
+ Examples:
809
+ agentpool spawn --provider codex-cli --repo . --task "Review the auth module read-only." --isolation read_only
810
+ cat task.md | agentpool spawn --provider fake-question --repo . --task-stdin --json
811
+ agentpool spawn --provider codex-cli --repo . --task "Make the narrow patch." --isolation worktree
812
+ """
813
+ try:
814
+ if task_stdin and task:
815
+ raise ToolError(
816
+ "INVALID_REQUEST",
817
+ "Use either --task or --task-stdin, not both.",
818
+ {"example": "cat task.md | agentpool spawn --provider <provider-id> --repo . --task-stdin"},
819
+ )
820
+ if task_stdin:
821
+ task = read_stdin_text(
822
+ sys.stdin.read(),
823
+ "task",
824
+ "cat task.md | agentpool spawn --provider <provider-id> --repo . --task-stdin",
825
+ )
826
+ if not task:
827
+ raise ToolError(
828
+ "INVALID_REQUEST",
829
+ "Missing worker task.",
830
+ {"example": "agentpool spawn --provider <provider-id> --repo . --task \"Inspect this repo read-only.\""},
831
+ )
832
+ data = manager().spawn_worker(
833
+ SpawnWorkerRequest(
834
+ provider_id=provider,
835
+ task=task,
836
+ repo_path=str(repo),
837
+ role=role, # type: ignore[arg-type]
838
+ runtime=runtime, # type: ignore[arg-type]
839
+ isolation=isolation, # type: ignore[arg-type]
840
+ model=model,
841
+ account=account,
842
+ allowed_files=allowed_file or [],
843
+ max_runtime_seconds=max_runtime_seconds,
844
+ max_turns=max_turns,
845
+ supervision=supervision, # type: ignore[arg-type]
846
+ initial_prompt_mode=initial_prompt_mode, # type: ignore[arg-type]
847
+ reasoning_effort=reasoning_effort,
848
+ service_tier=service_tier,
849
+ )
850
+ )
851
+ if json_output:
852
+ console.print_json(json.dumps(data, default=str))
853
+ else:
854
+ console.print(data["session"]["id"])
855
+ console.print(data["attach_command"])
856
+ except ToolError as exc:
857
+ handle_tool_error(exc, json_output)
858
+
859
+
860
+ @app.command()
861
+ def observe(
862
+ session_id: str,
863
+ wait_for: Annotated[str | None, typer.Option("--wait-for", help="Comma-separated events.")] = None,
864
+ timeout: Annotated[int, typer.Option("--timeout")] = 0,
865
+ detail: Annotated[str, typer.Option("--detail", help="Output detail: summary, excerpt, or full.")] = "summary",
866
+ max_lines: Annotated[int | None, typer.Option("--max-lines", help="tmux capture line limit.")] = None,
867
+ output: Annotated[Path | None, typer.Option("--output", help="Write JSON observe payload to this path.")] = None,
868
+ json_output: Annotated[bool, typer.Option("--json", help="Emit JSON.")] = False,
869
+ ) -> None:
870
+ """Observe worker state without dumping large transcripts by default.
871
+
872
+ Examples:
873
+ agentpool observe <session-id> --wait-for completed,error,question,approval_prompt --timeout 60 --json
874
+ agentpool observe <session-id> --detail excerpt --json
875
+ agentpool observe <session-id> --detail full --output /tmp/observe.json
876
+ """
877
+ try:
878
+ parsed_detail = parse_detail(detail)
879
+ mgr = manager()
880
+ observed = mgr.observe_worker(
881
+ session_id,
882
+ wait_for=wait_for.split(",") if wait_for else None,
883
+ timeout_seconds=timeout,
884
+ include_screen=parsed_detail != "summary",
885
+ include_recent_log=False,
886
+ max_lines=max_lines,
887
+ ).model_dump(mode="json")
888
+ data = observe_payload(observed, mgr.artifact_manifest(session_id), parsed_detail)
889
+ if output:
890
+ output.parent.mkdir(parents=True, exist_ok=True)
891
+ output.write_text(json.dumps(data, indent=2, default=str), encoding="utf-8")
892
+ if json_output:
893
+ console.print_json(json.dumps({"output_path": str(output), **data}, default=str))
894
+ else:
895
+ console.print(str(output))
896
+ return
897
+ print_data(data, json_output)
898
+ except ToolError as exc:
899
+ handle_tool_error(exc, json_output)
900
+
901
+
902
+ @app.command()
903
+ def send(
904
+ session_id: str,
905
+ message: Annotated[str | None, typer.Argument(help="Message to send. Omit with --stdin.")] = None,
906
+ stdin: Annotated[bool, typer.Option("--stdin", help="Read message from stdin.")] = False,
907
+ json_output: Annotated[bool, typer.Option("--json", help="Emit JSON.")] = False,
908
+ ) -> None:
909
+ """Send one message to a worker.
910
+
911
+ Examples:
912
+ agentpool send <session-id> "Continue with the review." --json
913
+ cat reply.md | agentpool send <session-id> --stdin --json
914
+ agentpool send <session-id> "" --json
915
+ """
916
+ try:
917
+ if stdin and message:
918
+ raise ToolError(
919
+ "INVALID_REQUEST",
920
+ "Use either a message argument or --stdin, not both.",
921
+ {"example": "cat reply.md | agentpool send <session-id> --stdin"},
922
+ )
923
+ if stdin:
924
+ message = read_stdin_text(
925
+ sys.stdin.read(),
926
+ "message",
927
+ "cat reply.md | agentpool send <session-id> --stdin",
928
+ )
929
+ if message is None:
930
+ raise ToolError(
931
+ "INVALID_REQUEST",
932
+ "Missing message.",
933
+ {"example": "agentpool send <session-id> \"Continue.\""},
934
+ )
935
+ print_data(manager().send_worker_message(session_id, message), json_output)
936
+ except ToolError as exc:
937
+ handle_tool_error(exc, json_output)
938
+
939
+
940
+ @app.command()
941
+ def keys(
942
+ session_id: str,
943
+ key: list[str],
944
+ json_output: Annotated[bool, typer.Option("--json", help="Emit JSON.")] = False,
945
+ ) -> None:
946
+ """Send raw keys when policy allows it.
947
+
948
+ Examples:
949
+ agentpool keys <session-id> C-c --json
950
+ agentpool keys <session-id> Enter --json
951
+ """
952
+ try:
953
+ print_data(manager().send_worker_keys(session_id, key), json_output)
954
+ except ToolError as exc:
955
+ handle_tool_error(exc, json_output)
956
+
957
+
958
+ @app.command()
959
+ def interrupt(
960
+ session_id: str,
961
+ json_output: Annotated[bool, typer.Option("--json", help="Emit JSON.")] = False,
962
+ ) -> None:
963
+ """Interrupt a worker.
964
+
965
+ Examples:
966
+ agentpool interrupt <session-id> --json
967
+ """
968
+ try:
969
+ print_data(manager().interrupt_worker(session_id), json_output)
970
+ except ToolError as exc:
971
+ handle_tool_error(exc, json_output)
972
+
973
+
974
+ @app.command()
975
+ def attach(
976
+ session_id: str,
977
+ json_output: Annotated[bool, typer.Option("--json", help="Emit JSON.")] = False,
978
+ ) -> None:
979
+ """Print attach information for a worker.
980
+
981
+ Examples:
982
+ agentpool attach <session-id>
983
+ agentpool attach <session-id> --json
984
+ """
985
+ try:
986
+ data = manager().attach_info(session_id)
987
+ if json_output:
988
+ console.print_json(json.dumps(data, default=str))
989
+ else:
990
+ console.print(data["attach_command"])
991
+ except ToolError as exc:
992
+ handle_tool_error(exc, json_output)
993
+
994
+
995
+ @app.command()
996
+ def collect(
997
+ session_id: str,
998
+ detail: Annotated[str, typer.Option("--detail", help="Output detail: summary, excerpt, or full.")] = "summary",
999
+ json_output: Annotated[bool, typer.Option("--json", help="Emit JSON.")] = False,
1000
+ ) -> None:
1001
+ """Collect worker artifacts and return paths by default.
1002
+
1003
+ Examples:
1004
+ agentpool collect <session-id> --json
1005
+ agentpool collect <session-id> --detail excerpt --json
1006
+ agentpool collect <session-id> --detail full
1007
+ """
1008
+ try:
1009
+ parsed_detail = parse_detail(detail)
1010
+ print_data(collect_payload(manager().collect_worker_artifacts(session_id), parsed_detail), json_output)
1011
+ except ToolError as exc:
1012
+ handle_tool_error(exc, json_output)
1013
+
1014
+
1015
+ @app.command("artifacts")
1016
+ def artifacts_command(
1017
+ session_id: str,
1018
+ json_output: Annotated[bool, typer.Option("--json", help="Emit JSON.")] = False,
1019
+ ) -> None:
1020
+ """List artifact paths for a worker.
1021
+
1022
+ Examples:
1023
+ agentpool artifacts <session-id> --json
1024
+ agentpool artifacts <session-id>
1025
+ """
1026
+ try:
1027
+ data = manager().artifact_manifest(session_id)
1028
+ print_data(data, json_output)
1029
+ except ToolError as exc:
1030
+ handle_tool_error(exc, json_output)
1031
+
1032
+
1033
+ @app.command()
1034
+ def transcript(
1035
+ session_id: str,
1036
+ offset: Annotated[int, typer.Option("--offset", help="Zero-based byte offset.")] = 0,
1037
+ limit: Annotated[int, typer.Option("--limit", help="Maximum bytes to read.")] = 4000,
1038
+ tail_lines: Annotated[int | None, typer.Option("--tail-lines", help="Read the last N transcript lines.")] = None,
1039
+ json_output: Annotated[bool, typer.Option("--json", help="Emit JSON.")] = False,
1040
+ ) -> None:
1041
+ """Read a bounded transcript page or tail.
1042
+
1043
+ Examples:
1044
+ agentpool transcript <session-id> --offset 0 --limit 4000 --json
1045
+ agentpool transcript <session-id> --offset 4000 --limit 4000 --json
1046
+ agentpool transcript <session-id> --tail-lines 80
1047
+ """
1048
+ try:
1049
+ data = manager().read_transcript(session_id, offset=offset, limit=limit, tail_lines=tail_lines)
1050
+ if json_output:
1051
+ console.print_json(json.dumps(data, default=str))
1052
+ else:
1053
+ console.print(data["text"], end="")
1054
+ except ToolError as exc:
1055
+ handle_tool_error(exc, json_output)
1056
+
1057
+
1058
+ @app.command()
1059
+ def terminate(
1060
+ session_id: str,
1061
+ json_output: Annotated[bool, typer.Option("--json", help="Emit JSON.")] = False,
1062
+ ) -> None:
1063
+ """Terminate a worker.
1064
+
1065
+ Examples:
1066
+ agentpool terminate <session-id> --json
1067
+ """
1068
+ try:
1069
+ print_data(manager().terminate_worker(session_id), json_output)
1070
+ except ToolError as exc:
1071
+ handle_tool_error(exc, json_output)
1072
+
1073
+
1074
+ @app.command()
1075
+ def mcp(
1076
+ toolsets: Annotated[
1077
+ str | None,
1078
+ typer.Option("--toolsets", help="Comma-separated MCP toolsets. Defaults to env or default."),
1079
+ ] = None,
1080
+ tools: Annotated[
1081
+ str | None,
1082
+ typer.Option("--tools", help="Comma-separated extra MCP tool names to expose."),
1083
+ ] = None,
1084
+ lockdown: Annotated[bool, typer.Option("--lockdown", help="Suppress inline untrusted worker output.")] = False,
1085
+ ) -> None:
1086
+ """Start the AgentPool MCP server.
1087
+
1088
+ Examples:
1089
+ agentpool mcp
1090
+ agentpool mcp --toolsets default,stats
1091
+ AGENTPOOL_MCP_LOCKDOWN=1 agentpool mcp --toolsets default
1092
+ """
1093
+ run_mcp_server(toolsets=toolsets, tools=tools, lockdown=lockdown)
1094
+
1095
+
1096
+ @config_app.command("path")
1097
+ def config_path() -> None:
1098
+ console.print(str(DEFAULT_CONFIG_PATH))
1099
+
1100
+
1101
+ @config_app.command("print")
1102
+ def config_print() -> None:
1103
+ console.print(yaml.safe_dump(load_config().model_dump(mode="json"), sort_keys=False))
1104
+
1105
+
1106
+ @config_app.command("validate")
1107
+ def config_validate(
1108
+ path: Annotated[Path | None, typer.Option("--path", help="Config path. Defaults to ~/.agentpool/config.yaml.")] = None,
1109
+ json_output: Annotated[bool, typer.Option("--json", help="Emit JSON.")] = False,
1110
+ ) -> None:
1111
+ try:
1112
+ config = load_config(path)
1113
+ data = validate_config(config)
1114
+ if json_output:
1115
+ console.print_json(json.dumps(data, default=str))
1116
+ return
1117
+ console.print(f"config {'ok' if data['ok'] else 'failed'}")
1118
+ for warning in data["warnings"]:
1119
+ console.print(f"[yellow]warning[/yellow]: {warning}")
1120
+ for error in data["errors"]:
1121
+ console.print(f"[red]error[/red]: {error}")
1122
+ if not data["ok"]:
1123
+ raise typer.Exit(1)
1124
+ except Exception as exc:
1125
+ if json_output:
1126
+ console.print_json(json.dumps({"ok": False, "errors": [str(exc)], "warnings": []}, default=str))
1127
+ else:
1128
+ console.print(f"[red]error[/red]: {exc}")
1129
+ raise typer.Exit(1)
1130
+
1131
+
1132
+ @leases_app.command("list")
1133
+ def leases_list(
1134
+ session_id: Annotated[str | None, typer.Option("--session-id", help="Filter by session id.")] = None,
1135
+ repo: Annotated[Path | None, typer.Option("--repo", help="Filter by repository path.")] = None,
1136
+ all_leases: Annotated[bool, typer.Option("--all", help="Include released and expired leases.")] = False,
1137
+ json_output: Annotated[bool, typer.Option("--json", help="Emit JSON.")] = False,
1138
+ ) -> None:
1139
+ try:
1140
+ data = manager().list_file_leases(
1141
+ session_id=session_id,
1142
+ repo_path=str(repo) if repo else None,
1143
+ active_only=not all_leases,
1144
+ )
1145
+ print_data(data, json_output)
1146
+ except ToolError as exc:
1147
+ handle_tool_error(exc, json_output)
1148
+
1149
+
1150
+ @leases_app.command("acquire")
1151
+ def leases_acquire(
1152
+ session_id: Annotated[str, typer.Option("--session-id", help="Owning session id.")],
1153
+ file_path: Annotated[str, typer.Option("--file", help="File path to lease.")],
1154
+ mode: Annotated[str, typer.Option("--mode", help="Lease mode: read or write.")] = "write",
1155
+ ttl_seconds: Annotated[int | None, typer.Option("--ttl-seconds", help="Optional lease TTL.")] = None,
1156
+ json_output: Annotated[bool, typer.Option("--json", help="Emit JSON.")] = False,
1157
+ ) -> None:
1158
+ try:
1159
+ print_data(manager().acquire_file_lease(session_id, file_path, mode=mode, ttl_seconds=ttl_seconds), json_output)
1160
+ except ToolError as exc:
1161
+ handle_tool_error(exc, json_output)
1162
+
1163
+
1164
+ @leases_app.command("release")
1165
+ def leases_release(
1166
+ lease_id: Annotated[int | None, typer.Option("--lease-id", help="Lease id to release.")] = None,
1167
+ session_id: Annotated[str | None, typer.Option("--session-id", help="Release leases for this session.")] = None,
1168
+ file_path: Annotated[str | None, typer.Option("--file", help="Optional file path filter with --session-id.")] = None,
1169
+ json_output: Annotated[bool, typer.Option("--json", help="Emit JSON.")] = False,
1170
+ ) -> None:
1171
+ try:
1172
+ print_data(manager().release_file_lease(lease_id=lease_id, session_id=session_id, file_path=file_path), json_output)
1173
+ except ToolError as exc:
1174
+ handle_tool_error(exc, json_output)
1175
+ except ValueError as exc:
1176
+ handle_tool_error(ToolError("INVALID_LEASE_RELEASE", str(exc)), json_output)
1177
+
1178
+
1179
+ @worktrees_app.command("list")
1180
+ def worktrees_list(
1181
+ repo: Annotated[Path, typer.Option("--repo", help="Repository path.")] = Path("."),
1182
+ json_output: Annotated[bool, typer.Option("--json", help="Emit JSON.")] = False,
1183
+ ) -> None:
1184
+ try:
1185
+ print_data(manager().list_worktrees(str(repo)), json_output)
1186
+ except ToolError as exc:
1187
+ handle_tool_error(exc, json_output)
1188
+
1189
+
1190
+ @worktrees_app.command("cleanup")
1191
+ def worktrees_cleanup(
1192
+ session_id: Annotated[str, typer.Option("--session-id", help="Session whose AgentPool worktree should be removed.")],
1193
+ force: Annotated[bool, typer.Option("--force", help="Remove even if active or dirty.")] = False,
1194
+ json_output: Annotated[bool, typer.Option("--json", help="Emit JSON.")] = False,
1195
+ ) -> None:
1196
+ try:
1197
+ print_data(manager().cleanup_worktree(session_id, force=force), json_output)
1198
+ except ToolError as exc:
1199
+ handle_tool_error(exc, json_output)