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
@@ -0,0 +1,261 @@
1
+ """Thin REST client for the Proxmox VE API.
2
+
3
+ Uses stdlib urllib.request -- no external dependencies. Authentication
4
+ is via PVEAPIToken (token ID + secret), not session cookies.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import ssl
11
+ import time
12
+ import urllib.error
13
+ import urllib.parse
14
+ import urllib.request
15
+ from typing import Any
16
+
17
+
18
+ class ProxmoxAPIError(RuntimeError):
19
+ """A Proxmox API call failed."""
20
+
21
+
22
+ class ProxmoxAPI:
23
+ """Minimal Proxmox VE REST client."""
24
+
25
+ def __init__(
26
+ self,
27
+ api_url: str,
28
+ token_id: str,
29
+ token_secret: str,
30
+ verify_ssl: bool = True,
31
+ ) -> None:
32
+ # Strip trailing slash for consistent URL building
33
+ self._base = api_url.rstrip("/") + "/api2/json"
34
+ self._auth = f"PVEAPIToken={token_id}={token_secret}"
35
+ self._ssl_ctx: ssl.SSLContext | None = None
36
+ if not verify_ssl:
37
+ self._ssl_ctx = ssl.create_default_context()
38
+ self._ssl_ctx.check_hostname = False
39
+ self._ssl_ctx.verify_mode = ssl.CERT_NONE
40
+
41
+ # -- Low-level request -----------------------------------------------------
42
+
43
+ def _request(
44
+ self,
45
+ method: str,
46
+ path: str,
47
+ data: dict[str, Any] | None = None,
48
+ *,
49
+ json_body: bool = False,
50
+ ) -> Any:
51
+ """Send an API request and return the parsed JSON ``data`` field.
52
+
53
+ Args:
54
+ json_body: If True, send data as JSON (required for guest agent
55
+ endpoints). Otherwise use form-urlencoded.
56
+ """
57
+ url = f"{self._base}{path}"
58
+
59
+ body: bytes | None = None
60
+ content_type = "application/x-www-form-urlencoded"
61
+ if data is not None:
62
+ if json_body:
63
+ body = json.dumps(data).encode()
64
+ content_type = "application/json"
65
+ else:
66
+ body = urllib.parse.urlencode(data).encode()
67
+
68
+ req = urllib.request.Request(url, data=body, method=method)
69
+ req.add_header("Authorization", self._auth)
70
+ if body is not None:
71
+ req.add_header("Content-Type", content_type)
72
+
73
+ try:
74
+ with urllib.request.urlopen(req, context=self._ssl_ctx) as resp:
75
+ resp_body = resp.read().decode()
76
+ except urllib.error.HTTPError as e:
77
+ err_body = e.read().decode() if e.fp else ""
78
+ raise ProxmoxAPIError(
79
+ f"Proxmox API {method} {path} failed ({e.code}): {err_body}"
80
+ ) from e
81
+
82
+ if not resp_body:
83
+ return None
84
+ parsed = json.loads(resp_body)
85
+ return parsed.get("data")
86
+
87
+ # -- Cluster ---------------------------------------------------------------
88
+
89
+ def next_id(self) -> int:
90
+ """Get the next available VMID."""
91
+ result = self._request("GET", "/cluster/nextid")
92
+ return int(result)
93
+
94
+ # -- VM operations ---------------------------------------------------------
95
+
96
+ def clone_vm(
97
+ self,
98
+ node: str,
99
+ template_vmid: int,
100
+ newid: int,
101
+ name: str,
102
+ *,
103
+ storage: str | None = None,
104
+ pool: str | None = None,
105
+ full: bool = True,
106
+ ) -> str:
107
+ """Clone a VM template. Returns the task UPID."""
108
+ params: dict[str, Any] = {
109
+ "newid": newid,
110
+ "name": name,
111
+ "full": int(full),
112
+ }
113
+ if storage:
114
+ params["storage"] = storage
115
+ if pool:
116
+ params["pool"] = pool
117
+ result = self._request(
118
+ "POST", f"/nodes/{node}/qemu/{template_vmid}/clone", params
119
+ )
120
+ return str(result)
121
+
122
+ def configure_vm(self, node: str, vmid: int, **params: Any) -> None:
123
+ """Update VM configuration."""
124
+ self._request("PUT", f"/nodes/{node}/qemu/{vmid}/config", params)
125
+
126
+ def resize_disk(
127
+ self, node: str, vmid: int, disk: str, size: str
128
+ ) -> None:
129
+ """Resize a VM disk (e.g. disk='scsi0', size='+20G')."""
130
+ self._request(
131
+ "PUT",
132
+ f"/nodes/{node}/qemu/{vmid}/resize",
133
+ {"disk": disk, "size": size},
134
+ )
135
+
136
+ def start_vm(self, node: str, vmid: int) -> str:
137
+ """Start a VM. Returns the task UPID."""
138
+ result = self._request(
139
+ "POST", f"/nodes/{node}/qemu/{vmid}/status/start"
140
+ )
141
+ return str(result)
142
+
143
+ def stop_vm(self, node: str, vmid: int) -> str:
144
+ """Stop a VM. Returns the task UPID."""
145
+ result = self._request(
146
+ "POST", f"/nodes/{node}/qemu/{vmid}/status/stop"
147
+ )
148
+ return str(result)
149
+
150
+ def delete_vm(self, node: str, vmid: int) -> str:
151
+ """Delete a VM. Returns the task UPID."""
152
+ result = self._request(
153
+ "DELETE", f"/nodes/{node}/qemu/{vmid}"
154
+ )
155
+ return str(result)
156
+
157
+ def vm_status(self, node: str, vmid: int) -> dict[str, Any]:
158
+ """Get current VM status."""
159
+ result = self._request(
160
+ "GET", f"/nodes/{node}/qemu/{vmid}/status/current"
161
+ )
162
+ return result # type: ignore[no-any-return]
163
+
164
+ # -- Tasks -----------------------------------------------------------------
165
+
166
+ def wait_for_task(
167
+ self,
168
+ node: str,
169
+ upid: str,
170
+ *,
171
+ timeout: int = 300,
172
+ poll_interval: float = 2.0,
173
+ ) -> None:
174
+ """Poll a task until it completes or times out."""
175
+ encoded_upid = urllib.parse.quote(upid, safe="")
176
+ deadline = time.monotonic() + timeout
177
+ while time.monotonic() < deadline:
178
+ result = self._request(
179
+ "GET", f"/nodes/{node}/tasks/{encoded_upid}/status"
180
+ )
181
+ if result and result.get("status") == "stopped":
182
+ if result.get("exitstatus") != "OK":
183
+ raise ProxmoxAPIError(
184
+ f"Task failed: {result.get('exitstatus')}"
185
+ )
186
+ return
187
+ time.sleep(poll_interval)
188
+ raise ProxmoxAPIError(f"Task timed out after {timeout}s: {upid}")
189
+
190
+ # -- Guest agent -----------------------------------------------------------
191
+
192
+ def guest_agent_network(
193
+ self, node: str, vmid: int
194
+ ) -> list[dict[str, Any]]:
195
+ """Get network interfaces from the QEMU guest agent."""
196
+ result = self._request(
197
+ "GET", f"/nodes/{node}/qemu/{vmid}/agent/network-get-interfaces"
198
+ )
199
+ if result and "result" in result:
200
+ data = result["result"]
201
+ if isinstance(data, list):
202
+ return data
203
+ raise ProxmoxAPIError(f"unexpected network-get-interfaces shape: {type(data).__name__}")
204
+ return []
205
+
206
+ def guest_agent_exec_wait(
207
+ self,
208
+ node: str,
209
+ vmid: int,
210
+ command: str,
211
+ args: list[str] | None = None,
212
+ *,
213
+ timeout: int = 60,
214
+ ) -> dict[str, Any] | None:
215
+ """Run a command via the guest agent and wait for completion.
216
+
217
+ Uses exec then polls exec-status until finished or timeout.
218
+ Returns the result dict with exitcode, out-data, err-data.
219
+
220
+ Proxmox 8 requires the command as a JSON array sent with
221
+ Content-Type: application/json.
222
+ """
223
+ cmd_array = [command] + (args or [])
224
+
225
+ result = self._request(
226
+ "POST",
227
+ f"/nodes/{node}/qemu/{vmid}/agent/exec",
228
+ {"command": cmd_array},
229
+ json_body=True,
230
+ )
231
+ pid = result.get("pid") if result else None
232
+ if pid is None:
233
+ return None
234
+
235
+ # Poll for completion
236
+ deadline = time.monotonic() + timeout
237
+ while time.monotonic() < deadline:
238
+ status = self._request(
239
+ "GET",
240
+ f"/nodes/{node}/qemu/{vmid}/agent/exec-status?pid={pid}",
241
+ )
242
+ if status and status.get("exited"):
243
+ return status # type: ignore[no-any-return]
244
+ time.sleep(2)
245
+
246
+ return None
247
+
248
+ def guest_agent_file_write(
249
+ self, node: str, vmid: int, path: str, content: str
250
+ ) -> None:
251
+ """Write a file inside the VM via the guest agent.
252
+
253
+ Sends raw content and lets Proxmox handle base64 encoding
254
+ for the guest agent.
255
+ """
256
+ self._request(
257
+ "POST",
258
+ f"/nodes/{node}/qemu/{vmid}/agent/file-write",
259
+ {"file": path, "content": content},
260
+ json_body=True,
261
+ )
@@ -0,0 +1,340 @@
1
+ """WSL2 provisioner -- imports Debian distros on Windows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import platform
7
+ import subprocess
8
+ import urllib.request
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING
11
+
12
+ from agentworks import output
13
+ from agentworks.db import VMStatus
14
+ from agentworks.ssh import ExecTarget, WSL2Target
15
+ from agentworks.vms.base import ProvisionResult, VMProvisioner
16
+
17
+ if TYPE_CHECKING:
18
+ from agentworks.config import Config
19
+ from agentworks.db import VMRow
20
+
21
+ # Default install path for WSL2 distros
22
+ WSL_BASE_PATH = "%LOCALAPPDATA%\\agentworks\\wsl"
23
+
24
+ # Docker Hub OCI registry endpoints for the official Debian image
25
+ _DOCKER_AUTH_URL = "https://auth.docker.io/token?service=registry.docker.io&scope=repository:library/debian:pull"
26
+ _DOCKER_MANIFESTS_URL = "https://registry-1.docker.io/v2/library/debian/manifests/bookworm"
27
+ _DOCKER_BLOBS_URL = "https://registry-1.docker.io/v2/library/debian/blobs"
28
+
29
+ # Map Python's platform.machine() to OCI architecture names
30
+ _ARCH_MAP = {"x86_64": "amd64", "amd64": "amd64", "aarch64": "arm64", "arm64": "arm64"}
31
+
32
+
33
+ def _oci_arch() -> str:
34
+ """Return the OCI architecture name for the host machine."""
35
+ machine = platform.machine().lower()
36
+ arch = _ARCH_MAP.get(machine)
37
+ if arch is None:
38
+ raise RuntimeError(f"Unsupported architecture: {machine}")
39
+ return arch
40
+
41
+
42
+ class _StripAuthRedirectHandler(urllib.request.HTTPRedirectHandler):
43
+ """Strip Authorization header when following redirects to a different host.
44
+
45
+ Docker Hub blob requests return a 302 to a CDN. The CDN rejects the
46
+ Bearer token with 400 Bad Request, so we must drop it on redirect.
47
+ """
48
+
49
+ def redirect_request(
50
+ self,
51
+ req: urllib.request.Request,
52
+ fp: object,
53
+ code: int,
54
+ msg: str,
55
+ headers: object,
56
+ newurl: str,
57
+ ) -> urllib.request.Request | None:
58
+ new_req = super().redirect_request(req, fp, code, msg, headers, newurl) # type: ignore[arg-type]
59
+ if new_req is not None:
60
+ new_req.remove_header("Authorization")
61
+ return new_req
62
+
63
+
64
+ _blob_opener = urllib.request.build_opener(_StripAuthRedirectHandler)
65
+
66
+
67
+ def _wsl(args: list[str], *, check: bool = True, timeout: int = 300) -> str:
68
+ """Run a wsl.exe command and return stdout."""
69
+ result = subprocess.run(
70
+ ["wsl", *args], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout
71
+ )
72
+ if check and result.returncode != 0:
73
+ raise RuntimeError(f"wsl command failed: {result.stderr.strip()}")
74
+ return result.stdout
75
+
76
+
77
+ def _powershell(script: str, *, check: bool = True, timeout: int = 120) -> str:
78
+ """Run a PowerShell command and return stdout."""
79
+ result = subprocess.run(
80
+ ["powershell", "-NoProfile", "-Command", script],
81
+ capture_output=True,
82
+ text=True,
83
+ encoding="utf-8",
84
+ errors="replace",
85
+ timeout=timeout,
86
+ )
87
+ if check and result.returncode != 0:
88
+ raise RuntimeError(f"PowerShell failed: {result.stderr.strip()}")
89
+ return result.stdout
90
+
91
+
92
+ def _download_debian_rootfs(tarball_path: str) -> None:
93
+ """Download the official Debian rootfs from Docker Hub OCI registry.
94
+
95
+ Pulls the rootfs layer from the official debian:bookworm image without
96
+ requiring Docker to be installed. The layer is a tar.gz that works
97
+ directly with ``wsl --import``.
98
+ """
99
+ # 1. Get anonymous pull token
100
+ output.detail("Authenticating with Docker Hub...")
101
+ with urllib.request.urlopen(_DOCKER_AUTH_URL) as resp:
102
+ token = json.loads(resp.read())["token"]
103
+
104
+ # 2. Fetch image manifest to find the rootfs layer digest.
105
+ # debian:bookworm is multi-arch, so we first get the manifest list
106
+ # and resolve the platform-specific manifest for the host architecture.
107
+ output.detail("Fetching Debian bookworm image manifest...")
108
+ auth_header = {"Authorization": f"Bearer {token}"}
109
+
110
+ req = urllib.request.Request(
111
+ _DOCKER_MANIFESTS_URL,
112
+ headers={
113
+ **auth_header,
114
+ "Accept": (
115
+ "application/vnd.docker.distribution.manifest.list.v2+json, "
116
+ "application/vnd.docker.distribution.manifest.v2+json"
117
+ ),
118
+ },
119
+ )
120
+ with urllib.request.urlopen(req) as resp:
121
+ manifest = json.loads(resp.read())
122
+
123
+ # If it's a manifest list, resolve the entry for the host architecture
124
+ if "manifests" in manifest:
125
+ arch = _oci_arch()
126
+ match = next(
127
+ (
128
+ m
129
+ for m in manifest["manifests"]
130
+ if m.get("platform", {}).get("architecture") == arch and m.get("platform", {}).get("os") == "linux"
131
+ ),
132
+ None,
133
+ )
134
+ if match is None:
135
+ raise RuntimeError(f"No {arch}/linux manifest found for debian:bookworm")
136
+ platform_digest = match["digest"]
137
+ manifest_url = f"https://registry-1.docker.io/v2/library/debian/manifests/{platform_digest}"
138
+ req = urllib.request.Request(
139
+ manifest_url,
140
+ headers={
141
+ **auth_header,
142
+ "Accept": "application/vnd.docker.distribution.manifest.v2+json",
143
+ },
144
+ )
145
+ with urllib.request.urlopen(req) as resp:
146
+ manifest = json.loads(resp.read())
147
+
148
+ digest = manifest["layers"][0]["digest"]
149
+ total_bytes = manifest["layers"][0].get("size", 0)
150
+
151
+ # 3. Download the rootfs layer with progress
152
+ blob_url = f"{_DOCKER_BLOBS_URL}/{digest}"
153
+ req = urllib.request.Request(blob_url, headers=auth_header)
154
+ p = output.progress("Downloading Debian rootfs", total=total_bytes or None)
155
+
156
+ dest = Path(tarball_path)
157
+ with _blob_opener.open(req) as resp, dest.open("wb") as f:
158
+ downloaded = 0
159
+ chunk_size = 256 * 1024
160
+ last_update = 0
161
+ while True:
162
+ chunk = resp.read(chunk_size)
163
+ if not chunk:
164
+ break
165
+ f.write(chunk)
166
+ downloaded += len(chunk)
167
+ # Update every ~1MB to avoid flooding
168
+ if downloaded - last_update >= 1024 * 1024:
169
+ p.update(downloaded)
170
+ last_update = downloaded
171
+
172
+ p.done()
173
+
174
+
175
+ class WSL2Provisioner(VMProvisioner):
176
+ """Provisions WSL2 Debian distributions on Windows."""
177
+
178
+ def create(
179
+ self,
180
+ vm_name: str,
181
+ config: Config,
182
+ *,
183
+ admin_username: str = "agentworks",
184
+ ) -> ProvisionResult:
185
+ output.info(f"Provisioning WSL2 VM '{vm_name}'...")
186
+
187
+ install_path = f"{WSL_BASE_PATH}\\{vm_name}"
188
+ _powershell(f"New-Item -ItemType Directory -Force -Path '{install_path}'")
189
+
190
+ # Download Debian rootfs if not cached
191
+ cache_dir = "%LOCALAPPDATA%\\agentworks\\cache"
192
+ tarball = f"{cache_dir}\\debian-bookworm-{_oci_arch()}-rootfs.tar.gz"
193
+ _powershell(f"New-Item -ItemType Directory -Force -Path '{cache_dir}'")
194
+
195
+ check = _powershell(f"Test-Path '{tarball}'").strip()
196
+ if check.lower() != "true":
197
+ _download_debian_rootfs(tarball)
198
+ else:
199
+ output.detail("Using cached Debian rootfs.")
200
+
201
+ # Import and configure the distro
202
+ output.detail("Importing rootfs into WSL2...")
203
+ _wsl(["--import", vm_name, install_path, tarball])
204
+
205
+ # The Docker rootfs is minimal. Install packages to bring it up to
206
+ # parity with the Lima/Azure cloud images.
207
+ output.detail("Installing base packages...")
208
+ _wsl(
209
+ [
210
+ "--distribution",
211
+ vm_name,
212
+ "--user",
213
+ "root",
214
+ "--",
215
+ "bash",
216
+ "-c",
217
+ "DEBIAN_FRONTEND=noninteractive apt-get update -qq"
218
+ " && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq -o Dpkg::Options::=--force-confnew"
219
+ " bash bash-completion sudo passwd"
220
+ " openssh-server curl git ca-certificates"
221
+ " tmux tmuxinator"
222
+ " locales procps iproute2 iputils-ping"
223
+ " less vim-tiny man-db"
224
+ " > /dev/null",
225
+ ]
226
+ )
227
+
228
+ # Configure swap file
229
+ if config.vm.swap > 0:
230
+ swap_mb = config.vm.swap * 1024
231
+ output.detail(f"Setting up {config.vm.swap} GiB swap file...")
232
+ _wsl(
233
+ [
234
+ "--distribution",
235
+ vm_name,
236
+ "--user",
237
+ "root",
238
+ "--",
239
+ "bash",
240
+ "-c",
241
+ f"fallocate -l {swap_mb}M /swapfile"
242
+ " && chmod 600 /swapfile"
243
+ " && mkswap /swapfile"
244
+ " && swapon /swapfile"
245
+ " && echo '/swapfile none swap sw 0 0' >> /etc/fstab",
246
+ ]
247
+ )
248
+
249
+ # Create user account
250
+ output.detail(f"Creating user '{admin_username}'...")
251
+ _wsl(["--distribution", vm_name, "--user", "root", "--", "useradd", "-m", "-s", "/bin/bash", admin_username])
252
+ _wsl(["--distribution", vm_name, "--user", "root", "--", "usermod", "-aG", "sudo", admin_username])
253
+ import shlex
254
+
255
+ _wsl(
256
+ [
257
+ "--distribution",
258
+ vm_name,
259
+ "--user",
260
+ "root",
261
+ "--",
262
+ "bash",
263
+ "-c",
264
+ f"echo {shlex.quote(f'{admin_username} ALL=(ALL) NOPASSWD:ALL')}"
265
+ f" > /etc/sudoers.d/{shlex.quote(admin_username)}",
266
+ ]
267
+ )
268
+
269
+ # Configure wsl.conf: default user + systemd
270
+ output.detail("Enabling systemd...")
271
+ _wsl(
272
+ [
273
+ "--distribution",
274
+ vm_name,
275
+ "--user",
276
+ "root",
277
+ "--",
278
+ "bash",
279
+ "-c",
280
+ f"printf '[user]\\ndefault={shlex.quote(admin_username)}"
281
+ f"\\n\\n[boot]\\nsystemd=true\\n' > /etc/wsl.conf",
282
+ ]
283
+ )
284
+
285
+ # Restart the distro so systemd takes effect
286
+ output.detail("Restarting distro...")
287
+ _wsl(["--terminate", vm_name])
288
+ # Run a command to trigger the distro to start with systemd
289
+ _wsl(["--distribution", vm_name, "--user", "root", "--", "bash", "-c", "echo ok"])
290
+
291
+ output.detail(f"WSL2 VM '{vm_name}' provisioned.")
292
+ return ProvisionResult(
293
+ admin_exec_target=ExecTarget(wsl2=WSL2Target(distro_name=vm_name, user=admin_username)),
294
+ wsl_distro_name=vm_name,
295
+ )
296
+
297
+ def start(self, vm: VMRow) -> None:
298
+ output.info(f"Starting WSL2 distro '{vm.name}'...")
299
+ _wsl(["--distribution", vm.name, "--", "echo", "started"])
300
+ output.info(f"WSL2 distro '{vm.name}' started")
301
+
302
+ def stop(self, vm: VMRow) -> None:
303
+ output.info(f"Terminating WSL2 distro '{vm.name}'...")
304
+ _wsl(["--terminate", vm.name])
305
+ output.info(f"WSL2 distro '{vm.name}' terminated")
306
+
307
+ def delete(self, vm: VMRow) -> None:
308
+ output.info(f"Unregistering WSL2 distro '{vm.name}'...")
309
+ _wsl(["--unregister", vm.name], check=False)
310
+ # Clean up install directory
311
+ install_path = f"{WSL_BASE_PATH}\\{vm.name}"
312
+ _powershell(
313
+ f"Remove-Item -Recurse -Force -Path '{install_path}' -ErrorAction SilentlyContinue",
314
+ check=False,
315
+ )
316
+ output.info(f"WSL2 distro '{vm.name}' deleted")
317
+
318
+ def admin_exec_target(self, vm: VMRow, *, config: object | None = None) -> ExecTarget:
319
+ return ExecTarget(wsl2=WSL2Target(distro_name=vm.name, user=vm.admin_username))
320
+
321
+ def status(self, vm: VMRow) -> VMStatus:
322
+ try:
323
+ output = _wsl(["--list", "--verbose"], check=False)
324
+ except RuntimeError:
325
+ return VMStatus.UNKNOWN
326
+
327
+ for line in output.strip().splitlines():
328
+ parts = line.split()
329
+ # WSL --list --verbose output: [*] NAME STATE VERSION
330
+ # Filter to find our distro
331
+ name_candidates = [p for p in parts if p == vm.name]
332
+ if not name_candidates:
333
+ continue
334
+ state_str = parts[-2].lower() if len(parts) >= 3 else ""
335
+ if state_str == "running":
336
+ return VMStatus.RUNNING
337
+ if state_str == "stopped":
338
+ return VMStatus.STOPPED
339
+ return VMStatus.UNKNOWN
340
+ return VMStatus.UNKNOWN