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.
- agentworks/__init__.py +1 -0
- agentworks/agents/__init__.py +0 -0
- agentworks/agents/manager.py +1095 -0
- agentworks/agents/templates.py +145 -0
- agentworks/catalog.py +264 -0
- agentworks/catalog.toml +131 -0
- agentworks/cli.py +1462 -0
- agentworks/completions/__init__.py +33 -0
- agentworks/completions/bash.py +179 -0
- agentworks/completions/install.py +122 -0
- agentworks/completions/powershell.py +270 -0
- agentworks/completions/spec.py +216 -0
- agentworks/completions/zsh.py +256 -0
- agentworks/config.py +894 -0
- agentworks/db.py +1083 -0
- agentworks/doctor.py +430 -0
- agentworks/git_credentials/__init__.py +0 -0
- agentworks/git_credentials/azdo.py +29 -0
- agentworks/git_credentials/base.py +71 -0
- agentworks/git_credentials/github.py +22 -0
- agentworks/nerf-config.yaml +16 -0
- agentworks/output.py +296 -0
- agentworks/remote_exec.py +286 -0
- agentworks/sample-config.toml +289 -0
- agentworks/sessions/__init__.py +0 -0
- agentworks/sessions/console.py +164 -0
- agentworks/sessions/manager.py +1297 -0
- agentworks/sessions/templates.py +101 -0
- agentworks/sessions/tmux.py +503 -0
- agentworks/sources.py +303 -0
- agentworks/ssh.py +759 -0
- agentworks/ssh_config.py +255 -0
- agentworks/vm_hosts/__init__.py +0 -0
- agentworks/vm_hosts/manager.py +86 -0
- agentworks/vms/__init__.py +0 -0
- agentworks/vms/backup.py +409 -0
- agentworks/vms/base.py +56 -0
- agentworks/vms/bootstrap_script.py +185 -0
- agentworks/vms/cloud_init.py +55 -0
- agentworks/vms/initializer.py +1523 -0
- agentworks/vms/manager.py +1122 -0
- agentworks/vms/provisioners/__init__.py +0 -0
- agentworks/vms/provisioners/azure.py +602 -0
- agentworks/vms/provisioners/lima.py +295 -0
- agentworks/vms/provisioners/proxmox.py +279 -0
- agentworks/vms/provisioners/proxmox_api.py +261 -0
- agentworks/vms/provisioners/wsl2.py +340 -0
- agentworks/vms/templates.py +152 -0
- agentworks/workspaces/__init__.py +0 -0
- agentworks/workspaces/backends/__init__.py +0 -0
- agentworks/workspaces/backends/local.py +119 -0
- agentworks/workspaces/backends/vm.py +175 -0
- agentworks/workspaces/manager.py +1080 -0
- agentworks/workspaces/templates.py +76 -0
- agentworks/workspaces/tmuxinator.py +80 -0
- agentworks_cli-0.2.1.dist-info/METADATA +635 -0
- agentworks_cli-0.2.1.dist-info/RECORD +59 -0
- agentworks_cli-0.2.1.dist-info/WHEEL +4 -0
- 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
|
+
|