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
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""VM template resolution and processing.
|
|
2
|
+
|
|
3
|
+
Handles inheritance (depth-first, left-to-right), merge rules, and the
|
|
4
|
+
built-in default template fallback. Follows the same pattern as workspace
|
|
5
|
+
templates.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from agentworks.config import Config, VMTemplate
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ResolvedVMTemplate:
|
|
21
|
+
"""A fully resolved VM template with all inheritance applied."""
|
|
22
|
+
|
|
23
|
+
name: str
|
|
24
|
+
# Provisioning
|
|
25
|
+
cpus: int = 4
|
|
26
|
+
memory: int = 8
|
|
27
|
+
disk: int = 50
|
|
28
|
+
azure_vm_size: str = "Standard_B2s"
|
|
29
|
+
swap: int = 4
|
|
30
|
+
# System-wide init
|
|
31
|
+
apt: list[str] = field(default_factory=list)
|
|
32
|
+
apt_packages: list[str] = field(default_factory=list)
|
|
33
|
+
snap: list[str] = field(default_factory=list)
|
|
34
|
+
system_install_commands: list[str] = field(default_factory=list)
|
|
35
|
+
# Nerf tools
|
|
36
|
+
nerf_build_claude_plugin: bool = False
|
|
37
|
+
skip_nerf_defaults: bool = False
|
|
38
|
+
nerf_addl_manifests: list[Path] = field(default_factory=list)
|
|
39
|
+
nerf_home_dir: str = "/opt/agentworks/nerf"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def resolve_from_dict(
|
|
43
|
+
templates: dict[str, VMTemplate],
|
|
44
|
+
template_name: str | None = None,
|
|
45
|
+
) -> ResolvedVMTemplate:
|
|
46
|
+
"""Resolve a VM template from a templates dict (no Config required).
|
|
47
|
+
|
|
48
|
+
Used during config loading to resolve the default template eagerly.
|
|
49
|
+
"""
|
|
50
|
+
if template_name is not None and template_name != "default":
|
|
51
|
+
if template_name not in templates:
|
|
52
|
+
msg = f"Unknown VM template: {template_name}"
|
|
53
|
+
raise ValueError(msg)
|
|
54
|
+
return _resolve_from_dict(templates, template_name)
|
|
55
|
+
|
|
56
|
+
if "default" in templates:
|
|
57
|
+
return _resolve_from_dict(templates, "default")
|
|
58
|
+
|
|
59
|
+
return ResolvedVMTemplate(name="default")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _resolve_from_dict(templates: dict[str, VMTemplate], name: str) -> ResolvedVMTemplate:
|
|
63
|
+
"""Depth-first resolution using a templates dict."""
|
|
64
|
+
if name not in templates:
|
|
65
|
+
# Implicit default: return built-in defaults
|
|
66
|
+
return ResolvedVMTemplate(name=name)
|
|
67
|
+
|
|
68
|
+
tmpl = templates[name]
|
|
69
|
+
result = ResolvedVMTemplate(name=name)
|
|
70
|
+
|
|
71
|
+
for parent_name in tmpl.inherits:
|
|
72
|
+
parent = _resolve_from_dict(templates, parent_name)
|
|
73
|
+
_merge(result, parent)
|
|
74
|
+
|
|
75
|
+
_merge_template(result, tmpl)
|
|
76
|
+
result.name = name
|
|
77
|
+
return result
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def resolve_template(config: Config, template_name: str | None = None) -> ResolvedVMTemplate:
|
|
81
|
+
"""Resolve a VM template by name, applying inheritance.
|
|
82
|
+
|
|
83
|
+
Selection order:
|
|
84
|
+
1. Explicit template_name
|
|
85
|
+
2. "default" template if it exists
|
|
86
|
+
3. Built-in default template
|
|
87
|
+
"""
|
|
88
|
+
return resolve_from_dict(config.vm_templates, template_name)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _append_dedupe(target: list[str], source: list[str]) -> list[str]:
|
|
92
|
+
"""Append source items to target, skipping dupes. Preserves order."""
|
|
93
|
+
seen = set(target)
|
|
94
|
+
result = list(target)
|
|
95
|
+
for item in source:
|
|
96
|
+
if item not in seen:
|
|
97
|
+
seen.add(item)
|
|
98
|
+
result.append(item)
|
|
99
|
+
return result
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _merge_map(target: dict[str, str], source: dict[str, str]) -> dict[str, str]:
|
|
103
|
+
"""Merge source map into target. Source wins on key collision."""
|
|
104
|
+
return {**target, **source}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _merge(target: ResolvedVMTemplate, source: ResolvedVMTemplate) -> None:
|
|
108
|
+
"""Merge source into target. Scalars: source wins. Lists: append with dedupe."""
|
|
109
|
+
target.cpus = source.cpus
|
|
110
|
+
target.memory = source.memory
|
|
111
|
+
target.disk = source.disk
|
|
112
|
+
target.azure_vm_size = source.azure_vm_size
|
|
113
|
+
target.swap = source.swap
|
|
114
|
+
target.apt = _append_dedupe(target.apt, source.apt)
|
|
115
|
+
target.apt_packages = _append_dedupe(target.apt_packages, source.apt_packages)
|
|
116
|
+
target.snap = _append_dedupe(target.snap, source.snap)
|
|
117
|
+
target.system_install_commands = _append_dedupe(target.system_install_commands, source.system_install_commands)
|
|
118
|
+
target.nerf_build_claude_plugin = source.nerf_build_claude_plugin
|
|
119
|
+
target.skip_nerf_defaults = source.skip_nerf_defaults
|
|
120
|
+
target.nerf_addl_manifests = list(source.nerf_addl_manifests)
|
|
121
|
+
target.nerf_home_dir = source.nerf_home_dir
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _merge_template(target: ResolvedVMTemplate, tmpl: VMTemplate) -> None:
|
|
125
|
+
"""Merge a raw VMTemplate into a ResolvedVMTemplate. None = not set, skip.
|
|
126
|
+
Scalars: child overrides. Lists: append with dedupe."""
|
|
127
|
+
if tmpl.cpus is not None:
|
|
128
|
+
target.cpus = tmpl.cpus
|
|
129
|
+
if tmpl.memory is not None:
|
|
130
|
+
target.memory = tmpl.memory
|
|
131
|
+
if tmpl.disk is not None:
|
|
132
|
+
target.disk = tmpl.disk
|
|
133
|
+
if tmpl.azure_vm_size is not None:
|
|
134
|
+
target.azure_vm_size = tmpl.azure_vm_size
|
|
135
|
+
if tmpl.swap is not None:
|
|
136
|
+
target.swap = tmpl.swap
|
|
137
|
+
if tmpl.apt is not None:
|
|
138
|
+
target.apt = _append_dedupe(target.apt, tmpl.apt)
|
|
139
|
+
if tmpl.apt_packages is not None:
|
|
140
|
+
target.apt_packages = _append_dedupe(target.apt_packages, tmpl.apt_packages)
|
|
141
|
+
if tmpl.snap is not None:
|
|
142
|
+
target.snap = _append_dedupe(target.snap, tmpl.snap)
|
|
143
|
+
if tmpl.system_install_commands is not None:
|
|
144
|
+
target.system_install_commands = _append_dedupe(target.system_install_commands, tmpl.system_install_commands)
|
|
145
|
+
if tmpl.nerf_build_claude_plugin is not None:
|
|
146
|
+
target.nerf_build_claude_plugin = tmpl.nerf_build_claude_plugin
|
|
147
|
+
if tmpl.skip_nerf_defaults is not None:
|
|
148
|
+
target.skip_nerf_defaults = tmpl.skip_nerf_defaults
|
|
149
|
+
if tmpl.nerf_addl_manifests is not None:
|
|
150
|
+
target.nerf_addl_manifests = list(tmpl.nerf_addl_manifests)
|
|
151
|
+
if tmpl.nerf_home_dir is not None:
|
|
152
|
+
target.nerf_home_dir = tmpl.nerf_home_dir
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Local workspace backend -- operations directly on the host filesystem."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from agentworks import output
|
|
12
|
+
from agentworks.workspaces.tmuxinator import generate_config
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from agentworks.config import Config
|
|
16
|
+
from agentworks.workspaces.templates import ResolvedTemplate
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def create_local_workspace(
|
|
20
|
+
config: Config,
|
|
21
|
+
ws_name: str,
|
|
22
|
+
template: ResolvedTemplate,
|
|
23
|
+
) -> str:
|
|
24
|
+
"""Create a local workspace. Returns the workspace path.
|
|
25
|
+
|
|
26
|
+
Errors if the workspace directory already exists.
|
|
27
|
+
"""
|
|
28
|
+
workspace_dir = config.paths.local_workspaces / ws_name
|
|
29
|
+
workspace_path = str(workspace_dir)
|
|
30
|
+
|
|
31
|
+
if workspace_dir.exists():
|
|
32
|
+
raise output.WorkspaceError(
|
|
33
|
+
f"directory {workspace_path} already exists.\nRemove it manually or choose a different name."
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Git clone or just create directory
|
|
37
|
+
if template.repo:
|
|
38
|
+
output.info(f"Cloning {template.repo}...")
|
|
39
|
+
try:
|
|
40
|
+
result = subprocess.run(
|
|
41
|
+
["git", "clone", template.repo, workspace_path],
|
|
42
|
+
capture_output=True,
|
|
43
|
+
text=True,
|
|
44
|
+
encoding="utf-8",
|
|
45
|
+
errors="replace",
|
|
46
|
+
timeout=300,
|
|
47
|
+
)
|
|
48
|
+
except subprocess.TimeoutExpired:
|
|
49
|
+
raise output.WorkspaceError("git clone timed out after 5 minutes") from None
|
|
50
|
+
if result.returncode != 0:
|
|
51
|
+
hint = ""
|
|
52
|
+
if template.repo.startswith("https://"):
|
|
53
|
+
hint = (
|
|
54
|
+
"\nHint: HTTPS repo URLs require credentials. "
|
|
55
|
+
"For private repos, use an SSH URL (git@...) so "
|
|
56
|
+
"your SSH key provides authentication."
|
|
57
|
+
)
|
|
58
|
+
raise output.WorkspaceError(f"git clone failed: {result.stderr.strip()}{hint}")
|
|
59
|
+
else:
|
|
60
|
+
workspace_dir.mkdir(parents=True)
|
|
61
|
+
|
|
62
|
+
# Tmuxinator config (no agents on local workspaces)
|
|
63
|
+
if template.tmuxinator:
|
|
64
|
+
tmux_config = generate_config(ws_name, workspace_path)
|
|
65
|
+
tmux_file = workspace_dir / ".tmuxinator.yml"
|
|
66
|
+
tmux_file.write_text(tmux_config)
|
|
67
|
+
|
|
68
|
+
# Symlink for tmuxinator to find it by console session name
|
|
69
|
+
from agentworks.workspaces.tmuxinator import console_session_name
|
|
70
|
+
|
|
71
|
+
session = console_session_name(ws_name)
|
|
72
|
+
tmux_config_dir = Path.home() / ".config" / "tmuxinator"
|
|
73
|
+
tmux_config_dir.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
link = tmux_config_dir / f"{session}.yml"
|
|
75
|
+
link.unlink(missing_ok=True)
|
|
76
|
+
link.symlink_to(tmux_file)
|
|
77
|
+
|
|
78
|
+
return workspace_path
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def shell_local_workspace(
|
|
82
|
+
workspace_path: str,
|
|
83
|
+
) -> None:
|
|
84
|
+
"""Open a plain shell into a local workspace."""
|
|
85
|
+
shell = os.environ.get("SHELL", "/bin/sh")
|
|
86
|
+
os.chdir(workspace_path)
|
|
87
|
+
os.execlp(shell, shell, "-l")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def console_local_workspace(
|
|
91
|
+
ws_name: str,
|
|
92
|
+
*,
|
|
93
|
+
recreate: bool = False,
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Open the workspace console (tmuxinator) for a local workspace."""
|
|
96
|
+
import subprocess
|
|
97
|
+
|
|
98
|
+
from agentworks.workspaces.tmuxinator import console_session_name
|
|
99
|
+
|
|
100
|
+
session = console_session_name(ws_name)
|
|
101
|
+
|
|
102
|
+
if recreate:
|
|
103
|
+
subprocess.run(["tmux", "kill-session", "-t", session], capture_output=True) # noqa: S603, S607
|
|
104
|
+
|
|
105
|
+
os.execlp("tmuxinator", "tmuxinator", "start", session)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def delete_local_workspace(ws_name: str, workspace_path: str) -> None:
|
|
109
|
+
"""Delete a local workspace directory."""
|
|
110
|
+
ws_dir = Path(workspace_path)
|
|
111
|
+
if ws_dir.exists():
|
|
112
|
+
shutil.rmtree(ws_dir)
|
|
113
|
+
|
|
114
|
+
# Remove tmuxinator symlink
|
|
115
|
+
from agentworks.workspaces.tmuxinator import console_session_name
|
|
116
|
+
|
|
117
|
+
session = console_session_name(ws_name)
|
|
118
|
+
link = Path.home() / ".config" / "tmuxinator" / f"{session}.yml"
|
|
119
|
+
link.unlink(missing_ok=True)
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""VM workspace backend -- operations via SSH to a VM."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from agentworks import output
|
|
10
|
+
from agentworks.ssh import admin_exec_target
|
|
11
|
+
from agentworks.workspaces.tmuxinator import console_session_name, generate_config
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from agentworks.config import Config
|
|
15
|
+
from agentworks.db import VMRow
|
|
16
|
+
from agentworks.ssh import SSHLogger
|
|
17
|
+
from agentworks.workspaces.templates import ResolvedTemplate
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def create_vm_workspace(
|
|
21
|
+
vm: VMRow,
|
|
22
|
+
config: Config,
|
|
23
|
+
ws_name: str,
|
|
24
|
+
template: ResolvedTemplate,
|
|
25
|
+
*,
|
|
26
|
+
logger: SSHLogger | None = None,
|
|
27
|
+
) -> str:
|
|
28
|
+
"""Create a workspace on a VM. Returns the remote workspace path.
|
|
29
|
+
|
|
30
|
+
Errors if the workspace directory already exists on the VM.
|
|
31
|
+
"""
|
|
32
|
+
assert vm.tailscale_host is not None
|
|
33
|
+
target = admin_exec_target(vm, config, logger=logger)
|
|
34
|
+
|
|
35
|
+
workspace_path = f"{config.paths.vm_workspaces}/{ws_name}"
|
|
36
|
+
ws_group = f"ws--{ws_name}"
|
|
37
|
+
|
|
38
|
+
# Refuse to create if directory already exists
|
|
39
|
+
exists = target.run(f"test -d {workspace_path}", check=False, timeout=10)
|
|
40
|
+
if exists.ok:
|
|
41
|
+
raise output.WorkspaceError(
|
|
42
|
+
f"directory {workspace_path} already exists on the VM. "
|
|
43
|
+
f"Remove it manually (ssh to the VM and 'sudo rm -rf {workspace_path}') "
|
|
44
|
+
"or choose a different name."
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# Create workspace group (idempotent), add admin, and set up directory with setgid
|
|
48
|
+
target.run(f"sh -c 'getent group {ws_group} >/dev/null 2>&1 || /usr/sbin/groupadd {ws_group}'", sudo=True)
|
|
49
|
+
target.run(f"usermod -aG {ws_group} {vm.admin_username}", sudo=True)
|
|
50
|
+
target.run(f"mkdir -p {workspace_path}", sudo=True)
|
|
51
|
+
target.run(f"chown {vm.admin_username}:{ws_group} {workspace_path}", sudo=True)
|
|
52
|
+
target.run(f"chmod 2770 {workspace_path}", sudo=True)
|
|
53
|
+
# Set default ACLs so files created inside are group-writable
|
|
54
|
+
target.run(f"setfacl -d -m g::rwx -m m::rwx {workspace_path}", sudo=True)
|
|
55
|
+
|
|
56
|
+
# Git clone if repo is set
|
|
57
|
+
if template.repo:
|
|
58
|
+
output.info(f"Cloning {template.repo}...")
|
|
59
|
+
try:
|
|
60
|
+
target.run(f"git clone {template.repo} {workspace_path}", timeout=300)
|
|
61
|
+
# Ensure cloned files inherit the workspace group and subdirectories
|
|
62
|
+
# have SGID so new files (including atomic writes) get the right group
|
|
63
|
+
target.run(f"chgrp -R {ws_group} {workspace_path}", sudo=True)
|
|
64
|
+
import shlex
|
|
65
|
+
|
|
66
|
+
sgid_cmd = f"find {shlex.quote(workspace_path)} -type d -exec chmod g+s {{}} +"
|
|
67
|
+
target.run(sgid_cmd, sudo=True, timeout=120)
|
|
68
|
+
except Exception:
|
|
69
|
+
if template.repo.startswith("git@"):
|
|
70
|
+
output.warn(
|
|
71
|
+
"Hint: SSH repo URLs are not supported. Use HTTPS URLs "
|
|
72
|
+
"and configure git credentials with 'vm add-git-credential'."
|
|
73
|
+
)
|
|
74
|
+
else:
|
|
75
|
+
output.warn(
|
|
76
|
+
"Hint: for private repos, ensure git credentials are "
|
|
77
|
+
"configured on the VM (see 'vm add-git-credential')."
|
|
78
|
+
)
|
|
79
|
+
raise
|
|
80
|
+
|
|
81
|
+
# Tmuxinator config (no tasks yet at workspace creation time)
|
|
82
|
+
if template.tmuxinator:
|
|
83
|
+
tmux_config = generate_config(ws_name, workspace_path)
|
|
84
|
+
target.write_file(f"{workspace_path}/.tmuxinator.yml", tmux_config)
|
|
85
|
+
# Symlink so tmuxinator can find it by console session name
|
|
86
|
+
session = console_session_name(ws_name)
|
|
87
|
+
target.run("mkdir -p ~/.config/tmuxinator", timeout=10)
|
|
88
|
+
target.run(
|
|
89
|
+
f"ln -sf {workspace_path}/.tmuxinator.yml ~/.config/tmuxinator/{session}.yml",
|
|
90
|
+
timeout=10,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
return workspace_path
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def shell_vm_workspace(
|
|
97
|
+
vm: VMRow,
|
|
98
|
+
config: Config,
|
|
99
|
+
workspace_path: str,
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Open a plain shell into a VM workspace."""
|
|
102
|
+
from agentworks.ssh import admin_exec_target, interactive
|
|
103
|
+
|
|
104
|
+
target = admin_exec_target(vm, config)
|
|
105
|
+
sys.exit(interactive(target, f"cd {workspace_path} && exec $SHELL -l"))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def console_vm_workspace(
|
|
109
|
+
vm: VMRow,
|
|
110
|
+
config: Config,
|
|
111
|
+
ws_name: str,
|
|
112
|
+
*,
|
|
113
|
+
recreate: bool = False,
|
|
114
|
+
) -> None:
|
|
115
|
+
"""Open the workspace console (tmuxinator) on a VM."""
|
|
116
|
+
from agentworks.ssh import admin_exec_target, interactive
|
|
117
|
+
|
|
118
|
+
session = console_session_name(ws_name)
|
|
119
|
+
target = admin_exec_target(vm, config)
|
|
120
|
+
|
|
121
|
+
if recreate:
|
|
122
|
+
target.run(f"tmux kill-session -t {session}", check=False, timeout=10)
|
|
123
|
+
|
|
124
|
+
sys.exit(interactive(target, f"tmuxinator start {session}"))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def delete_vm_workspace(
|
|
128
|
+
vm: VMRow,
|
|
129
|
+
config: Config,
|
|
130
|
+
ws_name: str,
|
|
131
|
+
workspace_path: str,
|
|
132
|
+
*,
|
|
133
|
+
logger: SSHLogger | None = None,
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Delete a workspace from a VM."""
|
|
136
|
+
from agentworks.ssh import SSHError
|
|
137
|
+
|
|
138
|
+
assert vm.tailscale_host is not None
|
|
139
|
+
target = admin_exec_target(vm, config, logger=logger)
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
target.run(f"rm -rf {workspace_path}", sudo=True, timeout=30)
|
|
143
|
+
session = console_session_name(ws_name)
|
|
144
|
+
target.run(f"rm -f ~/.config/tmuxinator/{session}.yml", check=False, timeout=10)
|
|
145
|
+
except SSHError as e:
|
|
146
|
+
output.warn(f"remote cleanup failed: {e}")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def generate_vscode_workspace(
|
|
150
|
+
vm: VMRow,
|
|
151
|
+
config: Config,
|
|
152
|
+
ws_name: str,
|
|
153
|
+
workspace_path: str,
|
|
154
|
+
) -> str:
|
|
155
|
+
"""Generate a .code-workspace file for VS Code SSH Remote."""
|
|
156
|
+
from agentworks.ssh_config import ssh_host_alias
|
|
157
|
+
|
|
158
|
+
# Use the SSH config alias so VS Code picks up the right host/user/key
|
|
159
|
+
ssh_host = ssh_host_alias(vm.name, config.operator.ssh_host_prefix)
|
|
160
|
+
|
|
161
|
+
ws_file = {
|
|
162
|
+
"folders": [
|
|
163
|
+
{
|
|
164
|
+
"uri": f"vscode-remote://ssh-remote+{ssh_host}{workspace_path}",
|
|
165
|
+
}
|
|
166
|
+
],
|
|
167
|
+
"remoteAuthority": f"ssh-remote+{ssh_host}",
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
vscode_dir = config.paths.vscode_workspaces
|
|
171
|
+
vscode_dir.mkdir(parents=True, exist_ok=True)
|
|
172
|
+
vscode_path = vscode_dir / f"{ws_name}.code-workspace"
|
|
173
|
+
vscode_path.write_text(json.dumps(ws_file, indent=2) + "\n")
|
|
174
|
+
|
|
175
|
+
return str(vscode_path)
|