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,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)