agentworks-cli 0.2.1__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 (59) hide show
  1. agentworks/__init__.py +1 -0
  2. agentworks/agents/__init__.py +0 -0
  3. agentworks/agents/manager.py +1095 -0
  4. agentworks/agents/templates.py +145 -0
  5. agentworks/catalog.py +264 -0
  6. agentworks/catalog.toml +131 -0
  7. agentworks/cli.py +1462 -0
  8. agentworks/completions/__init__.py +33 -0
  9. agentworks/completions/bash.py +179 -0
  10. agentworks/completions/install.py +122 -0
  11. agentworks/completions/powershell.py +270 -0
  12. agentworks/completions/spec.py +216 -0
  13. agentworks/completions/zsh.py +256 -0
  14. agentworks/config.py +894 -0
  15. agentworks/db.py +1083 -0
  16. agentworks/doctor.py +430 -0
  17. agentworks/git_credentials/__init__.py +0 -0
  18. agentworks/git_credentials/azdo.py +29 -0
  19. agentworks/git_credentials/base.py +71 -0
  20. agentworks/git_credentials/github.py +22 -0
  21. agentworks/nerf-config.yaml +16 -0
  22. agentworks/output.py +296 -0
  23. agentworks/remote_exec.py +286 -0
  24. agentworks/sample-config.toml +289 -0
  25. agentworks/sessions/__init__.py +0 -0
  26. agentworks/sessions/console.py +164 -0
  27. agentworks/sessions/manager.py +1297 -0
  28. agentworks/sessions/templates.py +101 -0
  29. agentworks/sessions/tmux.py +503 -0
  30. agentworks/sources.py +303 -0
  31. agentworks/ssh.py +759 -0
  32. agentworks/ssh_config.py +255 -0
  33. agentworks/vm_hosts/__init__.py +0 -0
  34. agentworks/vm_hosts/manager.py +86 -0
  35. agentworks/vms/__init__.py +0 -0
  36. agentworks/vms/backup.py +409 -0
  37. agentworks/vms/base.py +56 -0
  38. agentworks/vms/bootstrap_script.py +185 -0
  39. agentworks/vms/cloud_init.py +55 -0
  40. agentworks/vms/initializer.py +1523 -0
  41. agentworks/vms/manager.py +1122 -0
  42. agentworks/vms/provisioners/__init__.py +0 -0
  43. agentworks/vms/provisioners/azure.py +602 -0
  44. agentworks/vms/provisioners/lima.py +295 -0
  45. agentworks/vms/provisioners/proxmox.py +279 -0
  46. agentworks/vms/provisioners/proxmox_api.py +261 -0
  47. agentworks/vms/provisioners/wsl2.py +340 -0
  48. agentworks/vms/templates.py +152 -0
  49. agentworks/workspaces/__init__.py +0 -0
  50. agentworks/workspaces/backends/__init__.py +0 -0
  51. agentworks/workspaces/backends/local.py +119 -0
  52. agentworks/workspaces/backends/vm.py +175 -0
  53. agentworks/workspaces/manager.py +1080 -0
  54. agentworks/workspaces/templates.py +76 -0
  55. agentworks/workspaces/tmuxinator.py +80 -0
  56. agentworks_cli-0.2.1.dist-info/METADATA +635 -0
  57. agentworks_cli-0.2.1.dist-info/RECORD +59 -0
  58. agentworks_cli-0.2.1.dist-info/WHEEL +4 -0
  59. agentworks_cli-0.2.1.dist-info/entry_points.txt +2 -0
agentworks/cli.py ADDED
@@ -0,0 +1,1462 @@
1
+ """Typer CLI entrypoint for Agentworks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import secrets
6
+ from typing import TYPE_CHECKING, Annotated, Protocol
7
+
8
+ import click
9
+ import typer
10
+
11
+ from agentworks.db import Database
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Mapping
15
+
16
+ app = typer.Typer(
17
+ name="agentworks",
18
+ help="Orchestrate workspace lifecycle across multiple compute targets.",
19
+ no_args_is_help=True,
20
+ )
21
+
22
+ # -- Command groups --------------------------------------------------------
23
+
24
+ vm_host_app = typer.Typer(
25
+ name="vm-host",
26
+ help="Manage VM hosts (machines that run VMs).",
27
+ no_args_is_help=True,
28
+ )
29
+ app.add_typer(vm_host_app)
30
+
31
+ vm_app = typer.Typer(
32
+ name="vm",
33
+ help="Manage virtual machines.",
34
+ no_args_is_help=True,
35
+ )
36
+ app.add_typer(vm_app)
37
+
38
+ workspace_app = typer.Typer(
39
+ name="workspace",
40
+ help="Manage workspaces.",
41
+ no_args_is_help=True,
42
+ )
43
+ app.add_typer(workspace_app)
44
+
45
+ agent_app = typer.Typer(
46
+ name="agent",
47
+ help="Manage agents (isolated users on VMs).",
48
+ no_args_is_help=True,
49
+ )
50
+ app.add_typer(agent_app)
51
+
52
+ agent_grants_app = typer.Typer(
53
+ name="workspace-grants",
54
+ help="Manage agent workspace access grants.",
55
+ no_args_is_help=True,
56
+ )
57
+ agent_app.add_typer(agent_grants_app)
58
+
59
+ session_app = typer.Typer(
60
+ name="session",
61
+ help="Manage sessions.",
62
+ no_args_is_help=True,
63
+ )
64
+ app.add_typer(session_app)
65
+
66
+ installer_app = typer.Typer(
67
+ name="installer",
68
+ help="List and inspect available installers from the catalog.",
69
+ no_args_is_help=True,
70
+ )
71
+ app.add_typer(installer_app)
72
+
73
+ config_app = typer.Typer(
74
+ name="config",
75
+ help="Configuration utilities.",
76
+ no_args_is_help=True,
77
+ )
78
+ app.add_typer(config_app)
79
+
80
+
81
+ # -- Global options --------------------------------------------------------
82
+
83
+ _non_interactive = False
84
+
85
+
86
+ @app.callback()
87
+ def _global_options(
88
+ non_interactive: Annotated[
89
+ bool,
90
+ typer.Option("--non-interactive", help="Disable interactive prompts"),
91
+ ] = False,
92
+ ) -> None:
93
+ """Global options for all commands."""
94
+ global _non_interactive # noqa: PLW0603
95
+ _non_interactive = non_interactive
96
+
97
+
98
+ # -- Helpers ---------------------------------------------------------------
99
+
100
+
101
+ def _get_db() -> Database:
102
+ return Database()
103
+
104
+
105
+ def _generate_name() -> str:
106
+ return secrets.token_hex(4)
107
+
108
+
109
+ def _is_interactive() -> bool:
110
+ """Check if stdin is a TTY and --non-interactive was not passed."""
111
+ import sys
112
+
113
+ if _non_interactive:
114
+ return False
115
+
116
+ return sys.stdin.isatty()
117
+
118
+
119
+ def _require_interactive(what: str) -> None:
120
+ """Raise if not interactive and a prompt would be needed."""
121
+ if not _is_interactive():
122
+ typer.echo(f"Error: {what} is required in non-interactive mode", err=True)
123
+ raise typer.Exit(1)
124
+
125
+
126
+ def _prompt_name(label: str, name: str | None) -> str:
127
+ """Prompt for a name if not provided via --name, showing a random default."""
128
+ from agentworks import output
129
+
130
+ if name is not None:
131
+ return name
132
+ _require_interactive("--name")
133
+ default = _generate_name()
134
+ return output.prompt(f"{label} name", default=default)
135
+
136
+
137
+ def _prompt_workspace(db: Database, workspace: str | None) -> str:
138
+ """Prompt for a workspace if not provided, listing available workspaces."""
139
+ from agentworks import output
140
+
141
+ if workspace is not None:
142
+ return workspace
143
+
144
+ workspaces = db.list_workspaces()
145
+ if not workspaces:
146
+ typer.echo("Error: no workspaces found. Create one with 'agentworks workspace create'.", err=True)
147
+ raise typer.Exit(1)
148
+
149
+ if len(workspaces) == 1:
150
+ output.info(f"Using workspace '{workspaces[0].name}'")
151
+ return workspaces[0].name
152
+
153
+ _require_interactive("--workspace")
154
+
155
+ options = []
156
+ for ws in workspaces:
157
+ label = ws.name
158
+ if ws.vm_name:
159
+ label += f" (vm: {ws.vm_name})"
160
+ elif ws.type == "local":
161
+ label += " (local)"
162
+ options.append(label)
163
+
164
+ idx = output.choose("Select a workspace:", options)
165
+ return workspaces[idx].name
166
+
167
+
168
+ def _prompt_vm(db: Database, vm_name: str | None) -> str:
169
+ """Prompt for a VM if not provided, listing available VMs."""
170
+ from agentworks import output
171
+
172
+ if vm_name is not None:
173
+ return vm_name
174
+
175
+ vms = db.list_vms()
176
+ if not vms:
177
+ typer.echo("Error: no VMs found. Create one with 'agentworks vm create'.", err=True)
178
+ raise typer.Exit(1)
179
+
180
+ if len(vms) == 1:
181
+ output.info(f"Using VM '{vms[0].name}'")
182
+ return vms[0].name
183
+
184
+ _require_interactive("--vm")
185
+
186
+ options = [f"{v.name} ({v.platform})" for v in vms]
187
+ idx = output.choose("Select a VM:", options)
188
+ return vms[idx].name
189
+
190
+
191
+ class _HasDescription(Protocol):
192
+ """Structural protocol for catalog entries that have a description."""
193
+
194
+ @property
195
+ def description(self) -> str: ...
196
+
197
+
198
+ # -- Top-level commands ----------------------------------------------------
199
+
200
+
201
+ _SHELL_CHOICES = click.Choice(["bash", "zsh", "powershell"])
202
+
203
+
204
+ @app.command("completion")
205
+ def completion(
206
+ shell: Annotated[str, typer.Argument(help="Shell type", click_type=_SHELL_CHOICES)] = "zsh",
207
+ install: Annotated[bool, typer.Option("--install", help="Install completions to the default location")] = False,
208
+ ) -> None:
209
+ """Output shell completion script (or install it with --install)."""
210
+ from agentworks.completions import SUPPORTED_SHELLS, generate
211
+ from agentworks.completions.install import install_completions
212
+
213
+ if shell not in SUPPORTED_SHELLS:
214
+ typer.echo(f"Error: unsupported shell '{shell}'. Supported: {', '.join(SUPPORTED_SHELLS)}", err=True)
215
+ raise typer.Exit(1)
216
+
217
+ script = generate(shell)
218
+
219
+ if install:
220
+ install_completions(shell, script)
221
+ else:
222
+ typer.echo(script, nl=False)
223
+
224
+
225
+ @app.command("doctor")
226
+ def doctor() -> None:
227
+ """Check environment, config, and dependencies."""
228
+ from agentworks.completions.spec import build_spec, completion_version
229
+ from agentworks.doctor import Status, run_checks
230
+
231
+ report = run_checks(completion_version=completion_version(build_spec(app)))
232
+
233
+ typer.echo("Checking environment...\n")
234
+ for group in report.groups:
235
+ typer.echo(f"{group.name}:")
236
+ for check in group.checks:
237
+ label = {
238
+ Status.OK: "[ok]",
239
+ Status.INFO: "[info]",
240
+ Status.WARN: "[warn]",
241
+ Status.FAIL: "[FAIL]",
242
+ }[check.status].ljust(6)
243
+ msg = check.name
244
+ if check.message is not None:
245
+ msg += f" ({check.message})"
246
+ typer.echo(f" {label} {msg}")
247
+ typer.echo()
248
+
249
+ c = report.counts()
250
+ typer.echo(
251
+ f"Results: {c[Status.OK]} ok, {c[Status.INFO]} info, "
252
+ f"{c[Status.WARN]} warn, {c[Status.FAIL]} fail"
253
+ )
254
+ if c[Status.FAIL] > 0:
255
+ raise typer.Exit(1)
256
+
257
+
258
+ # -- VM Host commands ------------------------------------------------------
259
+
260
+
261
+ @vm_host_app.command("add")
262
+ def vm_host_add(
263
+ name: Annotated[str, typer.Argument(help="Name for this VM host")],
264
+ ssh_host: Annotated[str, typer.Argument(help="SSH address (hostname or IP)")],
265
+ ) -> None:
266
+ """Register a new VM host."""
267
+ from agentworks.vm_hosts.manager import add_vm_host
268
+
269
+ add_vm_host(_get_db(), name, ssh_host)
270
+
271
+
272
+ @vm_host_app.command("list")
273
+ def vm_host_list() -> None:
274
+ """List registered VM hosts."""
275
+ from agentworks.vm_hosts.manager import list_vm_hosts
276
+
277
+ list_vm_hosts(_get_db())
278
+
279
+
280
+ @vm_host_app.command("remove")
281
+ def vm_host_remove(
282
+ name: Annotated[str, typer.Argument(help="Name of the VM host to remove")],
283
+ force: Annotated[bool, typer.Option("--force", help="Remove even if VMs reference this host")] = False,
284
+ ) -> None:
285
+ """Remove a VM host."""
286
+ from agentworks.vm_hosts.manager import remove_vm_host
287
+
288
+ remove_vm_host(_get_db(), name, force=force)
289
+
290
+
291
+ # -- VM commands -----------------------------------------------------------
292
+
293
+
294
+ @vm_app.command("create")
295
+ def vm_create(
296
+ name: Annotated[str | None, typer.Option("--name", help="VM name (prompted if omitted)")] = None,
297
+ template: Annotated[str | None, typer.Option("--template", help="VM template")] = None,
298
+ platform: Annotated[
299
+ str | None,
300
+ typer.Option("--platform", help="Platform", click_type=click.Choice(["lima", "azure", "wsl2", "proxmox"])),
301
+ ] = None,
302
+ vm_host: Annotated[str | None, typer.Option("--vm-host", help="VM host for Lima")] = None,
303
+ cpus: Annotated[int | None, typer.Option("--cpus", help="Number of CPUs")] = None,
304
+ memory: Annotated[int | None, typer.Option("--memory", help="Memory in GiB")] = None,
305
+ disk: Annotated[int | None, typer.Option("--disk", help="Disk size in GiB")] = None,
306
+ azure_vm_size: Annotated[str | None, typer.Option("--azure-vm-size", help="Azure VM size")] = None,
307
+ admin_username: Annotated[str | None, typer.Option("--admin-username", help="Admin username on the VM")] = None,
308
+ ) -> None:
309
+ """Create a new VM (provision + initialize)."""
310
+ from agentworks.config import load_config
311
+ from agentworks.vms.manager import create_vm
312
+
313
+ resolved_name = _prompt_name("VM", name)
314
+ config = load_config()
315
+ create_vm(
316
+ _get_db(),
317
+ config,
318
+ name=resolved_name,
319
+ template=template,
320
+ platform=platform,
321
+ vm_host=vm_host,
322
+ cpus=cpus,
323
+ memory=memory,
324
+ disk=disk,
325
+ azure_vm_size=azure_vm_size,
326
+ admin_username=admin_username,
327
+ )
328
+
329
+
330
+ @vm_app.command("list")
331
+ def vm_list() -> None:
332
+ """List VMs."""
333
+ from agentworks.vms.manager import list_vms
334
+
335
+ list_vms(_get_db())
336
+
337
+
338
+ @vm_app.command("backup")
339
+ def vm_backup(
340
+ name: Annotated[str, typer.Argument(help="VM name")],
341
+ ) -> None:
342
+ """Create a full backup of a VM: metadata, agents, workspaces, and files."""
343
+ from agentworks.config import load_config
344
+ from agentworks.vms.backup import backup_vm
345
+
346
+ backup_vm(_get_db(), load_config(), name)
347
+
348
+
349
+ @vm_app.command("describe")
350
+ def vm_describe(
351
+ name: Annotated[str, typer.Argument(help="VM name")],
352
+ ) -> None:
353
+ """Show detailed information about a VM."""
354
+ from agentworks.config import load_config
355
+ from agentworks.vms.manager import describe_vm
356
+
357
+ describe_vm(_get_db(), load_config(), name)
358
+
359
+
360
+ @vm_app.command("start")
361
+ def vm_start(
362
+ name: Annotated[str, typer.Argument(help="VM name")],
363
+ ) -> None:
364
+ """Start a stopped VM."""
365
+ from agentworks.config import load_config
366
+ from agentworks.vms.manager import start_vm
367
+
368
+ start_vm(_get_db(), load_config(), name)
369
+
370
+
371
+ @vm_app.command("stop")
372
+ def vm_stop(
373
+ name: Annotated[str, typer.Argument(help="VM name")],
374
+ ) -> None:
375
+ """Stop a running VM."""
376
+ from agentworks.config import load_config
377
+ from agentworks.vms.manager import stop_vm
378
+
379
+ stop_vm(_get_db(), load_config(), name)
380
+
381
+
382
+ @vm_app.command("delete")
383
+ def vm_delete(
384
+ name: Annotated[str, typer.Argument(help="VM name")],
385
+ force: Annotated[bool, typer.Option("--force", help="Force delete even with workspaces")] = False,
386
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation")] = False,
387
+ ) -> None:
388
+ """Delete a VM and clean up all resources."""
389
+ from agentworks.config import load_config
390
+ from agentworks.vms.manager import delete_vm
391
+
392
+ delete_vm(_get_db(), load_config(), name, force=force, yes=yes)
393
+
394
+
395
+ @vm_app.command("rekey")
396
+ def vm_rekey(
397
+ name: Annotated[str, typer.Argument(help="VM name")],
398
+ wait_for_share: Annotated[
399
+ bool, typer.Option("--wait-for-share", help="Wait for operator to share VM back to their tailnet")
400
+ ] = False,
401
+ ignore_env: Annotated[
402
+ bool, typer.Option("--ignore-env", help="Ignore TAILSCALE_AUTH_KEY env var and prompt for key")
403
+ ] = False,
404
+ ) -> None:
405
+ """Assign a new Tailscale auth key to a VM (logout + rejoin)."""
406
+ from agentworks.config import load_config
407
+ from agentworks.vms.manager import rekey_vm
408
+
409
+ rekey_vm(_get_db(), load_config(), name, wait_for_share=wait_for_share, ignore_env=ignore_env)
410
+
411
+
412
+ @vm_app.command("reinit")
413
+ def vm_reinit(
414
+ name: Annotated[str, typer.Argument(help="VM name")],
415
+ ) -> None:
416
+ """Re-run initialization on a provisioned VM."""
417
+ from agentworks.config import load_config
418
+ from agentworks.vms.manager import reinit_vm
419
+
420
+ reinit_vm(_get_db(), load_config(), name)
421
+
422
+
423
+ @vm_app.command("exec", context_settings={"allow_extra_args": True, "allow_interspersed_args": False})
424
+ def vm_exec(
425
+ ctx: typer.Context,
426
+ name: Annotated[str, typer.Argument(help="VM name")],
427
+ ) -> None:
428
+ """Execute a command on a VM."""
429
+ from agentworks.config import load_config
430
+ from agentworks.vms.manager import exec_vm
431
+
432
+ if not ctx.args:
433
+ typer.echo("Error: missing command", err=True)
434
+ raise typer.Exit(1)
435
+ raise typer.Exit(exec_vm(_get_db(), load_config(), name, ctx.args))
436
+
437
+
438
+ @vm_app.command("shell")
439
+ def vm_shell(
440
+ name: Annotated[str, typer.Argument(help="VM name")],
441
+ ) -> None:
442
+ """Open a shell on a VM (home directory)."""
443
+ from agentworks.config import load_config
444
+ from agentworks.vms.manager import shell_vm
445
+
446
+ shell_vm(_get_db(), load_config(), name)
447
+
448
+
449
+ @vm_app.command("port-forward")
450
+ def vm_port_forward(
451
+ name: Annotated[str, typer.Argument(help="VM name")],
452
+ ports: Annotated[list[str], typer.Argument(help="Port specs: [LOCAL_PORT:]REMOTE_PORT")],
453
+ address: Annotated[str, typer.Option("--address", help="Local address to bind to")] = "localhost",
454
+ verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Verbose SSH output")] = False,
455
+ ) -> None:
456
+ """Forward local port(s) to a VM (like kubectl port-forward)."""
457
+ from agentworks.config import load_config
458
+ from agentworks.vms.manager import port_forward_vm
459
+
460
+ port_forward_vm(_get_db(), load_config(), name, ports, address=address, verbose=verbose)
461
+
462
+
463
+ @vm_app.command("add-git-credential")
464
+ def vm_add_git_credential(
465
+ name: Annotated[str, typer.Argument(help="VM name")],
466
+ credential: Annotated[str, typer.Argument(help="Git credential name from config")],
467
+ ) -> None:
468
+ """Add or update a git credential on a VM."""
469
+ from agentworks.config import load_config
470
+ from agentworks.vms.manager import add_git_credential
471
+
472
+ add_git_credential(_get_db(), load_config(), name, credential)
473
+
474
+
475
+ @vm_app.command("logs")
476
+ def vm_logs(
477
+ name: Annotated[str, typer.Argument(help="VM name")],
478
+ show_all: Annotated[bool, typer.Option("--all", help="Show all logs instead of only the latest")] = False,
479
+ ) -> None:
480
+ """Show SSH logs for a VM."""
481
+ from agentworks.ssh import LOG_DIR
482
+
483
+ if not LOG_DIR.exists():
484
+ typer.echo("No logs found.")
485
+ return
486
+
487
+ # Collect all logs for this VM -- filename is <vm>-<timestamp>-<cmd>.log
488
+ all_logs = sorted(LOG_DIR.glob(f"{name}-*.log"), reverse=True)
489
+ logs = [(str(p), p.name) for p in all_logs]
490
+
491
+ if not logs:
492
+ typer.echo(f"No SSH logs found for VM '{name}'.")
493
+ return
494
+
495
+ from pathlib import Path
496
+
497
+ display = logs if show_all else logs[:1]
498
+ for log_path, log_name in display:
499
+ typer.echo(f"--- {log_name} ---")
500
+ typer.echo(Path(log_path).read_text(), nl=False)
501
+ typer.echo("")
502
+
503
+
504
+ @vm_app.command("console")
505
+ def vm_console(
506
+ name: Annotated[str, typer.Argument(help="VM name")],
507
+ recreate: Annotated[bool, typer.Option("--recreate", help="Kill and rebuild the console")] = False,
508
+ allow_nesting: Annotated[bool, typer.Option("--allow-nesting", help="Allow running inside tmux")] = False,
509
+ ) -> None:
510
+ """Attach to the VM console (creates it if needed)."""
511
+ from agentworks.config import load_config
512
+ from agentworks.sessions.console import attach_console
513
+
514
+ attach_console(
515
+ _get_db(),
516
+ load_config(),
517
+ vm_name=name,
518
+ recreate=recreate,
519
+ allow_nesting=allow_nesting,
520
+ )
521
+
522
+
523
+ # -- Workspace commands ----------------------------------------------------
524
+
525
+
526
+ @workspace_app.command("create")
527
+ def workspace_create(
528
+ name: Annotated[str | None, typer.Option("--name", help="Workspace name (prompted if omitted)")] = None,
529
+ vm: Annotated[str | None, typer.Option("--vm", help="Target VM")] = None,
530
+ local: Annotated[bool, typer.Option("--local", help="Create a local workspace (no VM)")] = False,
531
+ template: Annotated[str | None, typer.Option("--template", help="Workspace template")] = None,
532
+ open_vscode: Annotated[bool, typer.Option("--open-vscode", help="Open in VS Code")] = False,
533
+ ) -> None:
534
+ """Create a workspace on a VM or locally."""
535
+ from agentworks.config import load_config
536
+ from agentworks.workspaces.manager import create_workspace
537
+
538
+ if local and vm:
539
+ typer.echo("Error: --local and --vm are mutually exclusive", err=True)
540
+ raise typer.Exit(1)
541
+
542
+ db = _get_db()
543
+
544
+ # 1. Select target (VM), 2. Name
545
+ if not local:
546
+ vm = _prompt_vm(db, vm)
547
+ resolved_name = _prompt_name("Workspace", name)
548
+
549
+ create_workspace(
550
+ db,
551
+ load_config(),
552
+ name=resolved_name,
553
+ vm_name=vm,
554
+ local=local,
555
+ template_name=template,
556
+ open_vscode=open_vscode,
557
+ )
558
+
559
+
560
+ @workspace_app.command("shell")
561
+ def workspace_shell(
562
+ name: Annotated[str, typer.Argument(help="Workspace name")],
563
+ ) -> None:
564
+ """Open a plain shell into a workspace."""
565
+ from agentworks.config import load_config
566
+ from agentworks.workspaces.manager import shell_workspace
567
+
568
+ shell_workspace(_get_db(), load_config(), name)
569
+
570
+
571
+ @workspace_app.command("console")
572
+ def workspace_console(
573
+ name: Annotated[str, typer.Argument(help="Workspace name")],
574
+ recreate: Annotated[bool, typer.Option("--recreate", help="Kill and rebuild the console")] = False,
575
+ allow_nesting: Annotated[bool, typer.Option("--allow-nesting", help="Allow running inside tmux")] = False,
576
+ ) -> None:
577
+ """Open the workspace console (tmux session with sessions)."""
578
+ from agentworks.config import load_config
579
+ from agentworks.workspaces.manager import console_workspace
580
+
581
+ console_workspace(
582
+ _get_db(),
583
+ load_config(),
584
+ name,
585
+ allow_nesting=allow_nesting,
586
+ recreate=recreate,
587
+ )
588
+
589
+
590
+ @workspace_app.command("list")
591
+ def workspace_list(
592
+ vm: Annotated[str | None, typer.Option("--vm", help="Filter by VM")] = None,
593
+ local: Annotated[bool, typer.Option("--local", help="Show only local workspaces")] = False,
594
+ ) -> None:
595
+ """List workspaces."""
596
+ from agentworks.workspaces.manager import list_workspaces
597
+
598
+ if local and vm:
599
+ typer.echo("Error: --local and --vm are mutually exclusive", err=True)
600
+ raise typer.Exit(1)
601
+
602
+ ws_type = "local" if local else None
603
+ list_workspaces(_get_db(), vm_name=vm, ws_type=ws_type)
604
+
605
+
606
+ @workspace_app.command("describe")
607
+ def workspace_describe(
608
+ name: Annotated[str, typer.Argument(help="Workspace name")],
609
+ ) -> None:
610
+ """Show workspace details, sessions, and agent access."""
611
+ from agentworks.workspaces.manager import describe_workspace
612
+
613
+ describe_workspace(_get_db(), name)
614
+
615
+
616
+ @workspace_app.command("rehome")
617
+ def workspace_rehome(
618
+ name: Annotated[str, typer.Argument(help="Workspace name")],
619
+ target: Annotated[
620
+ str | None, typer.Option("--target", help="Target path (default: configured workspace dir)")
621
+ ] = None,
622
+ remove_old: Annotated[
623
+ bool, typer.Option("--remove-old", help="Remove the old directory after verified copy")
624
+ ] = False,
625
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation")] = False,
626
+ ) -> None:
627
+ """Move a workspace to a new directory path."""
628
+ from agentworks.config import load_config
629
+ from agentworks.workspaces.manager import rehome_workspace
630
+
631
+ rehome_workspace(_get_db(), load_config(), name, target_path=target, remove_old=remove_old, yes=yes)
632
+
633
+
634
+ @workspace_app.command("repair")
635
+ def workspace_repair(
636
+ name: Annotated[str, typer.Argument(help="Workspace name")],
637
+ ) -> None:
638
+ """Repair workspace infrastructure: group, permissions, ACLs, agent access."""
639
+ from agentworks.config import load_config
640
+ from agentworks.workspaces.manager import repair_workspace
641
+
642
+ repair_workspace(_get_db(), load_config(), name)
643
+
644
+
645
+ @workspace_app.command("delete")
646
+ def workspace_delete(
647
+ name: Annotated[str, typer.Argument(help="Workspace name")],
648
+ force: Annotated[bool, typer.Option("--force", help="Force delete even with sessions")] = False,
649
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation")] = False,
650
+ ) -> None:
651
+ """Delete a workspace."""
652
+ from agentworks.config import load_config
653
+ from agentworks.workspaces.manager import delete_workspace
654
+
655
+ delete_workspace(_get_db(), load_config(), name, force=force, yes=yes)
656
+
657
+
658
+ @workspace_app.command("copy")
659
+ def workspace_copy(
660
+ source: Annotated[str, typer.Argument(help="Source workspace name")],
661
+ name: Annotated[str | None, typer.Option("--name", help="New workspace name (prompted if omitted)")] = None,
662
+ vm: Annotated[str | None, typer.Option("--vm", help="Target VM")] = None,
663
+ local: Annotated[bool, typer.Option("--local", help="Copy to a local workspace")] = False,
664
+ ) -> None:
665
+ """Copy a workspace to a new location (VM or local)."""
666
+ from agentworks.config import load_config
667
+ from agentworks.workspaces.manager import copy_workspace
668
+
669
+ if local and vm:
670
+ typer.echo("Error: --local and --vm are mutually exclusive", err=True)
671
+ raise typer.Exit(1)
672
+
673
+ resolved_name = _prompt_name("Workspace", name)
674
+ copy_workspace(
675
+ _get_db(),
676
+ load_config(),
677
+ source,
678
+ dest_name=resolved_name,
679
+ vm_name=vm,
680
+ local=local,
681
+ )
682
+
683
+
684
+ # -- Agent commands --------------------------------------------------------
685
+
686
+
687
+ @agent_app.command("create")
688
+ def agent_create(
689
+ name: Annotated[str | None, typer.Option("--name", help="Agent name (prompted if omitted)")] = None,
690
+ vm: Annotated[str | None, typer.Option("--vm", help="Target VM")] = None,
691
+ template: Annotated[str | None, typer.Option("--template", help="Agent template")] = None,
692
+ grant_all_workspaces: Annotated[
693
+ bool,
694
+ typer.Option("--grant-all-workspaces", help="Grant access to all workspaces"),
695
+ ] = False,
696
+ ) -> None:
697
+ """Create an agent (isolated Linux user) on a VM."""
698
+ from agentworks.agents.manager import create_agent
699
+ from agentworks.config import load_config
700
+
701
+ db = _get_db()
702
+
703
+ # 1. Select target (VM), 2. Name
704
+ resolved_vm = _prompt_vm(db, vm)
705
+ resolved_name = _prompt_name("Agent", name)
706
+
707
+ create_agent(
708
+ db,
709
+ load_config(),
710
+ name=resolved_name,
711
+ vm_name=resolved_vm,
712
+ template=template,
713
+ grant_all_workspaces=grant_all_workspaces,
714
+ )
715
+
716
+
717
+ @agent_app.command("list")
718
+ def agent_list(
719
+ vm: Annotated[str | None, typer.Option("--vm", help="Filter by VM")] = None,
720
+ ) -> None:
721
+ """List agents."""
722
+ from agentworks.agents.manager import list_agents
723
+
724
+ list_agents(_get_db(), vm_name=vm)
725
+
726
+
727
+ @agent_app.command("describe")
728
+ def agent_describe(
729
+ name: Annotated[str, typer.Argument(help="Agent name")],
730
+ ) -> None:
731
+ """Show detailed information about an agent."""
732
+ from agentworks.agents.manager import describe_agent
733
+
734
+ describe_agent(_get_db(), name=name)
735
+
736
+
737
+ @agent_app.command("reinit")
738
+ def agent_reinit(
739
+ name: Annotated[str, typer.Argument(help="Agent name")],
740
+ ) -> None:
741
+ """Re-run agent setup using the stored template."""
742
+ from agentworks.agents.manager import reinit_agent
743
+ from agentworks.config import load_config
744
+
745
+ reinit_agent(_get_db(), load_config(), name=name)
746
+
747
+
748
+ @agent_grants_app.command("grant")
749
+ def agent_grants_grant(
750
+ name: Annotated[str, typer.Argument(help="Agent name")],
751
+ workspaces: Annotated[str | None, typer.Argument(help="Workspace names (comma-separated)")] = None,
752
+ all_workspaces: Annotated[bool, typer.Option("--all", help="Grant access to all workspaces")] = False,
753
+ ) -> None:
754
+ """Grant an agent explicit access to workspaces."""
755
+ from agentworks.agents.manager import grant_workspaces
756
+ from agentworks.config import load_config
757
+
758
+ if not all_workspaces and not workspaces:
759
+ typer.echo("Error: specify workspace names or --all", err=True)
760
+ raise typer.Exit(1)
761
+
762
+ ws_list = [w.strip() for w in workspaces.split(",")] if workspaces else []
763
+ grant_workspaces(_get_db(), load_config(), agent_name=name, workspace_names=ws_list, grant_all=all_workspaces)
764
+
765
+
766
+ @agent_grants_app.command("deny")
767
+ def agent_grants_deny(
768
+ name: Annotated[str, typer.Argument(help="Agent name")],
769
+ workspaces: Annotated[str | None, typer.Argument(help="Workspace names (comma-separated)")] = None,
770
+ all_workspaces: Annotated[bool, typer.Option("--all", help="Remove all explicit grants")] = False,
771
+ ) -> None:
772
+ """Remove explicit workspace grants from an agent."""
773
+ from agentworks.agents.manager import deny_workspaces
774
+ from agentworks.config import load_config
775
+
776
+ if not all_workspaces and not workspaces:
777
+ typer.echo("Error: specify workspace names or --all", err=True)
778
+ raise typer.Exit(1)
779
+
780
+ ws_list = [w.strip() for w in workspaces.split(",")] if workspaces else []
781
+ deny_workspaces(_get_db(), load_config(), agent_name=name, workspace_names=ws_list, deny_all=all_workspaces)
782
+
783
+
784
+ @agent_grants_app.command("list")
785
+ def agent_grants_list(
786
+ name: Annotated[str, typer.Argument(help="Agent name")],
787
+ ) -> None:
788
+ """List workspace grants for an agent."""
789
+ from agentworks.agents.manager import list_grants
790
+
791
+ list_grants(_get_db(), agent_name=name)
792
+
793
+
794
+ @agent_app.command("exec", context_settings={"allow_extra_args": True, "allow_interspersed_args": False})
795
+ def agent_exec(
796
+ ctx: typer.Context,
797
+ name: Annotated[str, typer.Argument(help="Agent name")],
798
+ ) -> None:
799
+ """Execute a command as an agent user."""
800
+ from agentworks.agents.manager import exec_agent
801
+ from agentworks.config import load_config
802
+
803
+ if not ctx.args:
804
+ typer.echo("Error: missing command", err=True)
805
+ raise typer.Exit(1)
806
+ raise typer.Exit(exec_agent(_get_db(), load_config(), name=name, command=ctx.args))
807
+
808
+
809
+ @agent_app.command("shell")
810
+ def agent_shell(
811
+ name: Annotated[str, typer.Argument(help="Agent name")],
812
+ workspace: Annotated[str | None, typer.Option("--workspace", help="cd into a workspace")] = None,
813
+ ) -> None:
814
+ """Open a shell as an agent user."""
815
+ from agentworks.agents.manager import shell_agent
816
+ from agentworks.config import load_config
817
+
818
+ shell_agent(_get_db(), load_config(), name=name, workspace_name=workspace)
819
+
820
+
821
+ @agent_app.command("delete")
822
+ def agent_delete(
823
+ name: Annotated[str, typer.Argument(help="Agent name")],
824
+ force: Annotated[bool, typer.Option("--force", help="Force delete even with sessions")] = False,
825
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation")] = False,
826
+ ) -> None:
827
+ """Delete an agent."""
828
+ from agentworks.agents.manager import delete_agent
829
+ from agentworks.config import load_config
830
+
831
+ delete_agent(_get_db(), load_config(), name=name, force=force, yes=yes)
832
+
833
+
834
+ # -- Session commands ---------------------------------------------------------
835
+
836
+
837
+ @session_app.command("create")
838
+ def session_create(
839
+ name: Annotated[str | None, typer.Option("--name", help="Session name (prompted if omitted)")] = None,
840
+ workspace: Annotated[str | None, typer.Option("--workspace", help="Existing workspace")] = None,
841
+ template: Annotated[str | None, typer.Option("--template", help="Session template")] = None,
842
+ admin: Annotated[bool, typer.Option("--admin", help="Run as the VM admin user")] = False,
843
+ agent: Annotated[str | None, typer.Option("--agent", help="Agent name (agent mode)")] = None,
844
+ new_workspace: Annotated[bool, typer.Option("--new-workspace", help="Create a new workspace")] = False,
845
+ workspace_name: Annotated[str | None, typer.Option("--workspace-name", help="Name for new workspace")] = None,
846
+ workspace_template: Annotated[
847
+ str | None, typer.Option("--workspace-template", help="Template for new workspace")
848
+ ] = None,
849
+ vm: Annotated[str | None, typer.Option("--vm", help="VM for new workspace")] = None,
850
+ ) -> None:
851
+ """Create and start a session in a workspace."""
852
+ from agentworks.config import load_config
853
+ from agentworks.sessions.manager import create_session
854
+ from agentworks.workspaces.manager import create_workspace
855
+
856
+ # Validate flag combinations before any prompts
857
+ if admin and agent:
858
+ typer.echo("Error: --admin and --agent are mutually exclusive", err=True)
859
+ raise typer.Exit(1)
860
+ if workspace and new_workspace:
861
+ typer.echo("Error: --workspace and --new-workspace are mutually exclusive", err=True)
862
+ raise typer.Exit(1)
863
+ if not new_workspace and (workspace_name or workspace_template or vm):
864
+ typer.echo(
865
+ "Error: --workspace-name, --workspace-template, and --vm require --new-workspace",
866
+ err=True,
867
+ )
868
+ raise typer.Exit(1)
869
+
870
+ db = _get_db()
871
+ config = load_config()
872
+
873
+ if new_workspace:
874
+ resolved_vm = _prompt_vm(db, vm)
875
+ resolved_workspace = workspace_name # may be None, resolved after session name
876
+
877
+ # Resolve mode (need VM name for agent lookup)
878
+ resolved_agent: str | None = agent
879
+ if not admin and agent is None:
880
+ # Look up agents on the target VM
881
+ vm_agents = db.list_agents(vm_name=resolved_vm)
882
+ if vm_agents:
883
+ _require_interactive("--admin or --agent")
884
+ from agentworks import output
885
+
886
+ options = ["admin"]
887
+ for a in vm_agents:
888
+ label = f"agent: {a.name}"
889
+ if a.template:
890
+ label += f" [{a.template}]"
891
+ options.append(label)
892
+ idx = output.choose("Run session as:", options)
893
+ resolved_agent = None if idx == 0 else vm_agents[idx - 1].name
894
+
895
+ resolved_name = _prompt_name("Session", name)
896
+ resolved_ws_name = resolved_workspace or f"ws-{resolved_name}"
897
+
898
+ create_workspace(
899
+ db,
900
+ config,
901
+ name=resolved_ws_name,
902
+ vm_name=resolved_vm,
903
+ template_name=workspace_template,
904
+ )
905
+ resolved_workspace = resolved_ws_name
906
+ else:
907
+ resolved_workspace = _prompt_workspace(db, workspace)
908
+
909
+ # Resolve mode
910
+ resolved_agent: str | None = agent # type: ignore[no-redef]
911
+ if not admin and agent is None:
912
+ resolved_agent = _prompt_session_mode(db, resolved_workspace)
913
+
914
+ resolved_name = _prompt_name("Session", name)
915
+
916
+ create_session(
917
+ db,
918
+ config,
919
+ name=resolved_name,
920
+ workspace_name=resolved_workspace,
921
+ template_name=template,
922
+ agent_name=resolved_agent,
923
+ created_workspace=new_workspace,
924
+ )
925
+
926
+
927
+ def _prompt_session_mode(db: Database, workspace_name: str) -> str | None:
928
+ """Prompt for admin vs agent mode. Returns agent name or None for admin."""
929
+ ws = db.get_workspace(workspace_name)
930
+ if ws is None or ws.vm_name is None:
931
+ return None
932
+
933
+ from agentworks import output
934
+
935
+ agents = db.list_agents(vm_name=ws.vm_name)
936
+ if not agents:
937
+ # No agents on this VM, default to admin
938
+ return None
939
+
940
+ _require_interactive("--admin or --agent")
941
+
942
+ options = ["admin"]
943
+ for a in agents:
944
+ label = f"agent: {a.name}"
945
+ if a.template:
946
+ label += f" [{a.template}]"
947
+ options.append(label)
948
+
949
+ idx = output.choose("Run session as:", options)
950
+ if idx == 0:
951
+ return None
952
+ return agents[idx - 1].name
953
+
954
+
955
+ @session_app.command("describe")
956
+ def session_describe(
957
+ name: Annotated[str, typer.Argument(help="Session name")],
958
+ ) -> None:
959
+ """Show session details."""
960
+ from agentworks.config import load_config
961
+ from agentworks.sessions.manager import describe_session
962
+
963
+ describe_session(_get_db(), load_config(), name=name)
964
+
965
+
966
+ @session_app.command("list")
967
+ def session_list(
968
+ workspace: Annotated[str | None, typer.Option("--workspace", help="Filter by workspace")] = None,
969
+ no_status: Annotated[bool, typer.Option("--no-status", help="Skip SSH status check (faster)")] = False,
970
+ ) -> None:
971
+ """List sessions."""
972
+ from agentworks.config import load_config
973
+ from agentworks.sessions.manager import list_sessions
974
+
975
+ list_sessions(_get_db(), load_config(), workspace_name=workspace, no_status=no_status)
976
+
977
+
978
+ @session_app.command("stop")
979
+ def session_stop(
980
+ name: Annotated[str | None, typer.Argument(help="Session name")] = None,
981
+ all_sessions: Annotated[bool, typer.Option("--all", help="Stop all running sessions")] = False,
982
+ vm: Annotated[str | None, typer.Option("--vm", help="Filter by VM (with --all)")] = None,
983
+ workspace: Annotated[str | None, typer.Option("--workspace", help="Filter by workspace (with --all)")] = None,
984
+ force: Annotated[bool, typer.Option("--force", help="Force-stop broken sessions via PID kill")] = False,
985
+ ) -> None:
986
+ """Stop a running session, or all running sessions with --all."""
987
+ from agentworks.config import load_config
988
+ from agentworks.sessions.manager import stop_all_sessions, stop_session
989
+
990
+ if name and all_sessions:
991
+ raise typer.BadParameter("provide a session name or --all, not both")
992
+ if (vm or workspace) and not all_sessions:
993
+ raise typer.BadParameter("--vm and --workspace require --all")
994
+ if all_sessions:
995
+ stop_all_sessions(_get_db(), load_config(), vm_name=vm, workspace_name=workspace, force=force)
996
+ elif name:
997
+ stop_session(_get_db(), load_config(), name=name, force=force)
998
+ else:
999
+ raise typer.BadParameter("provide a session name or use --all")
1000
+
1001
+
1002
+ @session_app.command("restart")
1003
+ def session_restart(
1004
+ name: Annotated[str | None, typer.Argument(help="Session name")] = None,
1005
+ all_stopped: Annotated[bool, typer.Option("--all-stopped", help="Restart all stopped sessions")] = False,
1006
+ all_sessions: Annotated[bool, typer.Option("--all", help="Restart all sessions (prompts for running)")] = False,
1007
+ vm: Annotated[str | None, typer.Option("--vm", help="Filter by VM (with --all/--all-stopped)")] = None,
1008
+ workspace: Annotated[str | None, typer.Option("--workspace", help="Filter by workspace")] = None,
1009
+ force: Annotated[bool, typer.Option("--force", help="Force-kill broken sessions via PID")] = False,
1010
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompts")] = False,
1011
+ ) -> None:
1012
+ """Restart a session, or batch restart with --all-stopped / --all."""
1013
+ from agentworks.config import load_config
1014
+ from agentworks.sessions.manager import restart_all_sessions, restart_session
1015
+
1016
+ if name and (all_stopped or all_sessions):
1017
+ raise typer.BadParameter("provide a session name or a batch flag (--all/--all-stopped), not both")
1018
+ if all_stopped and all_sessions:
1019
+ raise typer.BadParameter("use --all or --all-stopped, not both")
1020
+ if (vm or workspace) and not (all_stopped or all_sessions):
1021
+ raise typer.BadParameter("--vm and --workspace require --all or --all-stopped")
1022
+ if all_stopped or all_sessions:
1023
+ db = _get_db()
1024
+ config = load_config()
1025
+ include_running = all_sessions
1026
+
1027
+ # --all without --yes: prompt if there are running sessions
1028
+ if include_running and not yes:
1029
+ from agentworks import output
1030
+ from agentworks.sessions.manager import (
1031
+ batch_check_all_sessions,
1032
+ ensure_pids_batch,
1033
+ filter_sessions,
1034
+ )
1035
+
1036
+ sessions = filter_sessions(db, workspace_name=workspace, vm_name=vm)
1037
+ sessions = ensure_pids_batch(sessions, db=db, config=config)
1038
+ from agentworks.db import SessionStatus
1039
+
1040
+ status_map = batch_check_all_sessions(sessions, db=db, config=config)
1041
+ running = [s for s in sessions if status_map.get(s.name) == SessionStatus.OK]
1042
+ if running:
1043
+ names = ", ".join(s.name for s in running[:5])
1044
+ suffix = f" (and {len(running) - 5} more)" if len(running) > 5 else ""
1045
+ output.warn(
1046
+ f"{len(running)} session(s) are running and will be restarted ({names}{suffix}).\n"
1047
+ "Hint: use --all-stopped to restart only stopped sessions."
1048
+ )
1049
+ if not output.confirm("Continue?"):
1050
+ raise output.UserAbort("restart cancelled")
1051
+
1052
+ restart_all_sessions(
1053
+ db, config, vm_name=vm, workspace_name=workspace, include_running=include_running, force=force,
1054
+ )
1055
+ elif name:
1056
+ restart_session(_get_db(), load_config(), name=name, force=force, yes=yes)
1057
+ else:
1058
+ raise typer.BadParameter("provide a session name, --all-stopped, or --all")
1059
+
1060
+
1061
+
1062
+ @session_app.command("attach")
1063
+ def session_attach(
1064
+ name: Annotated[str, typer.Argument(help="Session name")],
1065
+ ) -> None:
1066
+ """Attach to a session."""
1067
+ from agentworks.config import load_config
1068
+ from agentworks.sessions.manager import attach_session
1069
+
1070
+ attach_session(_get_db(), load_config(), name=name)
1071
+
1072
+
1073
+ @session_app.command("delete")
1074
+ def session_delete(
1075
+ name: Annotated[str, typer.Argument(help="Session name")],
1076
+ force: Annotated[bool, typer.Option("--force", help="Force-kill broken sessions via PID")] = False,
1077
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation")] = False,
1078
+ ) -> None:
1079
+ """Delete a session."""
1080
+ from agentworks.config import load_config
1081
+ from agentworks.sessions.manager import delete_session
1082
+
1083
+ delete_session(_get_db(), load_config(), name=name, force=force, yes=yes)
1084
+
1085
+
1086
+ @session_app.command("logs")
1087
+ def session_logs(
1088
+ name: Annotated[str, typer.Argument(help="Session name")],
1089
+ lines: Annotated[int | None, typer.Option("--lines", "-n", help="Number of lines")] = None,
1090
+ ) -> None:
1091
+ """Dump the scrollback buffer for a session."""
1092
+ from agentworks.config import load_config
1093
+ from agentworks.sessions.manager import session_logs as _session_logs
1094
+
1095
+ _session_logs(_get_db(), load_config(), name=name, lines=lines)
1096
+
1097
+
1098
+
1099
+ # -- Installer catalog commands --------------------------------------------
1100
+
1101
+ _TYPE_CHOICES = click.Choice(["apt-source", "apt-package", "system-install-cmd", "user-install-cmd"])
1102
+
1103
+
1104
+ @installer_app.command("list")
1105
+ def installer_list(
1106
+ type_filter: Annotated[str | None, typer.Option("--type", help="Filter by type", click_type=_TYPE_CHOICES)] = None,
1107
+ source_filter: Annotated[
1108
+ str | None, typer.Option("--source", help="Filter by source", click_type=click.Choice(["builtin", "custom"]))
1109
+ ] = None,
1110
+ ) -> None:
1111
+ """List available installers from the built-in and custom catalog."""
1112
+ from agentworks.catalog import load_builtin_catalog, load_catalog
1113
+ from agentworks.config import load_config
1114
+
1115
+ config = load_config()
1116
+ builtin = load_builtin_catalog()
1117
+ merged = load_catalog(config)
1118
+
1119
+ rows: list[tuple[str, str, str, str]] = [] # (type, name, source, description)
1120
+
1121
+ def _add_entries(
1122
+ type_label: str,
1123
+ merged_entries: Mapping[str, _HasDescription],
1124
+ builtin_entries: Mapping[str, _HasDescription],
1125
+ ) -> None:
1126
+ for name, entry in sorted(merged_entries.items()):
1127
+ is_builtin = name in builtin_entries
1128
+ is_custom = name in getattr(config, _CONFIG_ATTR[type_label], {})
1129
+ if is_custom:
1130
+ source = "custom"
1131
+ elif is_builtin:
1132
+ source = "built-in"
1133
+ else:
1134
+ source = "built-in"
1135
+ if source_filter == "builtin" and source != "built-in":
1136
+ continue
1137
+ if source_filter == "custom" and source != "custom":
1138
+ continue
1139
+ rows.append((type_label, name, source, entry.description))
1140
+
1141
+ if type_filter is None or type_filter == "apt-source":
1142
+ _add_entries("apt-source", merged.apt_sources, builtin.apt_sources)
1143
+ if type_filter is None or type_filter == "apt-package":
1144
+ _add_entries("apt-package", merged.apt_packages, builtin.apt_packages)
1145
+ if type_filter is None or type_filter == "system-install-cmd":
1146
+ _add_entries("system-install-cmd", merged.system_install_commands, builtin.system_install_commands)
1147
+ if type_filter is None or type_filter == "user-install-cmd":
1148
+ _add_entries("user-install-cmd", merged.user_install_commands, builtin.user_install_commands)
1149
+
1150
+ if not rows:
1151
+ typer.echo("No entries found.")
1152
+ return
1153
+
1154
+ # Calculate column widths
1155
+ type_w = max(len(r[0]) for r in rows)
1156
+ name_w = max(len(r[1]) for r in rows)
1157
+ src_w = max(len(r[2]) for r in rows)
1158
+
1159
+ header = f"{'TYPE':<{type_w}} {'NAME':<{name_w}} {'SOURCE':<{src_w}} DESCRIPTION"
1160
+ typer.echo(header)
1161
+ typer.echo("-" * len(header))
1162
+ for type_label, name, source, desc in rows:
1163
+ typer.echo(f"{type_label:<{type_w}} {name:<{name_w}} {source:<{src_w}} {desc}")
1164
+
1165
+
1166
+ # Maps type labels to config attributes for source detection
1167
+ _CONFIG_ATTR = {
1168
+ "apt-source": "apt_sources",
1169
+ "apt-package": "apt_packages",
1170
+ "system-install-cmd": "system_install_commands",
1171
+ "user-install-cmd": "user_install_commands",
1172
+ }
1173
+
1174
+
1175
+ @installer_app.command("describe")
1176
+ def installer_describe(
1177
+ name: Annotated[str, typer.Argument(help="Entry name")],
1178
+ ) -> None:
1179
+ """Show details of a catalog entry."""
1180
+ from agentworks.catalog import (
1181
+ AptPackageEntry,
1182
+ AptSourceEntry,
1183
+ SystemInstallCommandEntry,
1184
+ UserInstallCommandEntry,
1185
+ load_builtin_catalog,
1186
+ load_catalog,
1187
+ )
1188
+ from agentworks.config import load_config
1189
+
1190
+ config = load_config()
1191
+ builtin = load_builtin_catalog()
1192
+ merged = load_catalog(config)
1193
+
1194
+ # Search all four pools; Mapping[str, _HasDescription] covers all catalog entry types
1195
+ # (all have description: str) and allows covariant use of the concrete dict types.
1196
+ pools: list[tuple[str, Mapping[str, _HasDescription], Mapping[str, _HasDescription], str]] = [
1197
+ ("apt-source", merged.apt_sources, builtin.apt_sources, "apt_sources"),
1198
+ ("apt-package", merged.apt_packages, builtin.apt_packages, "apt_packages"),
1199
+ (
1200
+ "system-install-cmd",
1201
+ merged.system_install_commands,
1202
+ builtin.system_install_commands,
1203
+ "system_install_commands",
1204
+ ),
1205
+ (
1206
+ "user-install-cmd",
1207
+ merged.user_install_commands,
1208
+ builtin.user_install_commands,
1209
+ "user_install_commands",
1210
+ ),
1211
+ ]
1212
+ for type_label, merged_entries, builtin_entries, config_attr in pools:
1213
+ if name not in merged_entries:
1214
+ continue
1215
+
1216
+ entry = merged_entries[name]
1217
+ is_custom = name in getattr(config, config_attr, {})
1218
+ source = "custom" if is_custom else "built-in"
1219
+ overrides = name in builtin_entries and is_custom
1220
+
1221
+ typer.echo(f"Name: {name}")
1222
+ typer.echo(f"Type: {type_label}")
1223
+ typer.echo(f"Source: {source}")
1224
+ if overrides:
1225
+ typer.echo(" (overrides built-in)")
1226
+ typer.echo(f"Description: {entry.description}")
1227
+
1228
+ if isinstance(entry, AptSourceEntry):
1229
+ typer.echo(f"Key URL: {entry.key_url}")
1230
+ typer.echo(f"Key path: {entry.key_path}")
1231
+ typer.echo(f"Source: {entry.source}")
1232
+ typer.echo(f"Source file: {entry.source_file}")
1233
+ if entry.key_dearmor:
1234
+ typer.echo("Key dearmor: yes")
1235
+ elif isinstance(entry, AptPackageEntry):
1236
+ if entry.apt_sources:
1237
+ typer.echo(f"Apt sources: {', '.join(entry.apt_sources)}")
1238
+ typer.echo(f"Apt: {', '.join(entry.apt)}")
1239
+ elif isinstance(entry, (SystemInstallCommandEntry, UserInstallCommandEntry)):
1240
+ typer.echo(f"Command: {entry.command}")
1241
+ if entry.test_exec:
1242
+ typer.echo(f"Test exec: {entry.test_exec}")
1243
+ if entry.test_file:
1244
+ typer.echo(f"Test file: {entry.test_file}")
1245
+ if entry.test_dir:
1246
+ typer.echo(f"Test dir: {entry.test_dir}")
1247
+ if entry.path:
1248
+ typer.echo(f"PATH: {', '.join(entry.path)}")
1249
+ return
1250
+
1251
+ typer.echo(f"Error: '{name}' not found in catalog", err=True)
1252
+ raise typer.Exit(1)
1253
+
1254
+
1255
+ # -- Config commands -------------------------------------------------------
1256
+
1257
+
1258
+ @config_app.command("init")
1259
+ def config_init() -> None:
1260
+ """Create a sample config file at ~/.config/agentworks/config.toml."""
1261
+ import shutil
1262
+ from importlib.resources import files
1263
+
1264
+ from agentworks.config import CONFIG_DIR, CONFIG_PATH
1265
+
1266
+ if CONFIG_PATH.exists():
1267
+ typer.echo(f"Config already exists: {CONFIG_PATH}")
1268
+ typer.echo("Edit it directly, or remove it and run 'agentworks config init' again.")
1269
+ return
1270
+
1271
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
1272
+ sample = files("agentworks").joinpath("sample-config.toml")
1273
+ shutil.copy2(str(sample), CONFIG_PATH)
1274
+ typer.echo(f"Sample config written to {CONFIG_PATH}")
1275
+ typer.echo("Edit it to match your setup, then run 'agentworks vm create' to get started.")
1276
+
1277
+
1278
+ @config_app.command("edit")
1279
+ def config_edit() -> None:
1280
+ """Open the config file in your editor ($EDITOR)."""
1281
+ import os
1282
+ import subprocess
1283
+ import sys
1284
+
1285
+ from agentworks.config import CONFIG_PATH
1286
+
1287
+ editor = os.environ.get("EDITOR") or os.environ.get("VISUAL")
1288
+ if not editor:
1289
+ typer.echo("Error: $EDITOR is not set. Set it to your preferred editor.", err=True)
1290
+ raise typer.Exit(1)
1291
+
1292
+ if not CONFIG_PATH.exists():
1293
+ typer.echo(f"Error: config file not found at {CONFIG_PATH}", err=True)
1294
+ typer.echo("Run 'agentworks config init' to create one.", err=True)
1295
+ raise typer.Exit(1)
1296
+
1297
+ sys.exit(subprocess.call([editor, str(CONFIG_PATH)]))
1298
+
1299
+
1300
+ @config_app.command("sample")
1301
+ def config_sample() -> None:
1302
+ """Print the sample config to stdout."""
1303
+ from importlib.resources import files
1304
+
1305
+ sample = files("agentworks").joinpath("sample-config.toml")
1306
+ typer.echo(sample.read_text(), nl=False)
1307
+
1308
+
1309
+ @config_app.command("sync-vscode-workspaces")
1310
+ def config_sync_vscode_workspaces() -> None:
1311
+ """Regenerate .code-workspace files for all VM workspaces."""
1312
+ from agentworks.config import load_config
1313
+ from agentworks.workspaces.backends.vm import generate_vscode_workspace
1314
+
1315
+ config = load_config()
1316
+ db = _get_db()
1317
+
1318
+ workspaces = db.list_workspaces(ws_type="vm")
1319
+ if not workspaces:
1320
+ typer.echo("No VM workspaces found.")
1321
+ return
1322
+
1323
+ count = 0
1324
+ for ws in workspaces:
1325
+ if ws.vm_name is None:
1326
+ continue
1327
+ vm = db.get_vm(ws.vm_name)
1328
+ if vm is None:
1329
+ typer.echo(f" Skipping '{ws.name}': VM '{ws.vm_name}' not found", err=True)
1330
+ continue
1331
+ path = generate_vscode_workspace(vm, config, ws.name, ws.workspace_path)
1332
+ typer.echo(f" {ws.name} -> {path}")
1333
+ count += 1
1334
+
1335
+ typer.echo(f"Regenerated {count} VS Code workspace file(s) in {config.paths.vscode_workspaces}")
1336
+
1337
+
1338
+ @config_app.command("sync-ssh-config")
1339
+ def config_sync_ssh_config() -> None:
1340
+ """Rebuild SSH config entries for all VMs from current state."""
1341
+ from agentworks.config import load_config
1342
+ from agentworks.ssh_config import sync_ssh_config
1343
+
1344
+ sync_ssh_config(load_config(), _get_db())
1345
+
1346
+
1347
+
1348
+ # -- Entrypoint ------------------------------------------------------------
1349
+
1350
+
1351
+ def main() -> None:
1352
+ """CLI entrypoint. Sets up output handler and catches business logic errors."""
1353
+ import time
1354
+
1355
+ from agentworks.config import ConfigError
1356
+ from agentworks.output import AgentworksError, Progress, UserAbort, set_handler
1357
+
1358
+ # -- Typer output handler --------------------------------------------------
1359
+
1360
+ class _TyperProgress:
1361
+ def __init__(self, label: str, total: int | None = None) -> None:
1362
+ self._label = label
1363
+ self._total = total
1364
+ self._start = time.monotonic()
1365
+
1366
+ def update(self, current: int | None = None, message: str | None = None) -> None:
1367
+ parts = [f" {self._label}..."]
1368
+ if current is not None and self._total is not None and self._total > 0:
1369
+ pct = current / self._total * 100
1370
+ parts.append(f" {pct:.0f}% ({current}/{self._total})")
1371
+ if message:
1372
+ parts.append(f" {message}")
1373
+ typer.echo("".join(parts))
1374
+
1375
+ def done(self, message: str | None = None) -> None:
1376
+ elapsed = time.monotonic() - self._start
1377
+ suffix = f" {message}" if message else ""
1378
+ typer.echo(f" {self._label} done ({elapsed:.0f}s){suffix}")
1379
+
1380
+ class _TyperHandler:
1381
+ def info(self, message: str) -> None:
1382
+ typer.echo(message)
1383
+
1384
+ def detail(self, message: str, indent: int = 1) -> None:
1385
+ typer.echo(f"{' ' * indent}{message}")
1386
+
1387
+ def warn(self, message: str) -> None:
1388
+ typer.echo(f"Warning: {message}", err=True)
1389
+
1390
+ def confirm(self, message: str, default: bool = False) -> bool:
1391
+ try:
1392
+ return typer.confirm(message, default=default)
1393
+ except click.exceptions.Abort:
1394
+ from agentworks.output import UserAbort
1395
+
1396
+ raise UserAbort("interrupted") from None
1397
+
1398
+ def choose(self, message: str, options: list[str]) -> int:
1399
+ typer.echo(message)
1400
+ for i, option in enumerate(options, 1):
1401
+ typer.echo(f" {i}) {option}")
1402
+ while True:
1403
+ try:
1404
+ choice = int(typer.prompt("Choice", type=int))
1405
+ if 1 <= choice <= len(options):
1406
+ return choice - 1
1407
+ except click.exceptions.Abort:
1408
+ from agentworks.output import UserAbort
1409
+
1410
+ raise UserAbort("interrupted") from None
1411
+ except ValueError:
1412
+ pass
1413
+ typer.echo(f"Invalid choice. Enter 1-{len(options)}.")
1414
+
1415
+ def pause(self, message: str) -> None:
1416
+ try:
1417
+ input(message)
1418
+ except (EOFError, KeyboardInterrupt):
1419
+ from agentworks.output import UserAbort
1420
+
1421
+ raise UserAbort("interrupted") from None
1422
+
1423
+ def prompt(self, label: str, default: str | None = None) -> str:
1424
+ return str(typer.prompt(label, default=default or ""))
1425
+
1426
+ def prompt_secret(self, label: str, hint: str | None = None) -> str:
1427
+ import click
1428
+
1429
+ try:
1430
+ if hint:
1431
+ typer.echo(f" {hint}", err=True)
1432
+ while True:
1433
+ value = str(click.prompt(label, err=True, default="", hide_input=True))
1434
+ if value.strip():
1435
+ break
1436
+ typer.echo("(empty, try again)", err=True)
1437
+ return value
1438
+ except click.exceptions.Abort:
1439
+ from agentworks.output import UserAbort
1440
+
1441
+ raise UserAbort("interrupted") from None
1442
+
1443
+ def progress(self, label: str, total: int | None = None) -> Progress:
1444
+ typer.echo(f" {label}...")
1445
+ return _TyperProgress(label, total)
1446
+
1447
+ set_handler(_TyperHandler())
1448
+
1449
+ # -- Run app ---------------------------------------------------------------
1450
+
1451
+ try:
1452
+ app()
1453
+ except ConfigError as e:
1454
+ typer.echo(f"Configuration error: {e}", err=True)
1455
+ raise SystemExit(1) from None
1456
+ except UserAbort:
1457
+ typer.echo("Aborted.", err=True)
1458
+ raise SystemExit(1) from None
1459
+ except AgentworksError as e:
1460
+ typer.echo(f"Error: {e}", err=True)
1461
+ raise SystemExit(1) from None
1462
+