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/ssh_config.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Manage SSH config entries for agentworks VMs.
|
|
2
|
+
|
|
3
|
+
When ssh_config_dir is enabled (default), all VM Host blocks are written to
|
|
4
|
+
a single ~/.ssh/config.d/agentworks.conf file and an Include directive is
|
|
5
|
+
added to the top of ~/.ssh/config. When disabled, entries are kept in a
|
|
6
|
+
managed section at the end of ~/.ssh/config (legacy behavior).
|
|
7
|
+
|
|
8
|
+
On first run with ssh_config_dir enabled, any legacy managed section is
|
|
9
|
+
cleaned up automatically.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import tempfile
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
from agentworks import output
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from agentworks.config import Config
|
|
22
|
+
from agentworks.db import Database
|
|
23
|
+
|
|
24
|
+
_LEGACY_MARKER = "# --- Managed by agentworks. Do not edit below this line. ---"
|
|
25
|
+
_INCLUDE_COMMENT = "# Added by agentworks"
|
|
26
|
+
_CONFIG_DIR_NAME = "config.d"
|
|
27
|
+
_MANAGED_CONF = "agentworks.conf"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _to_ssh_path(path: Path) -> str:
|
|
31
|
+
"""Convert a Path to an SSH config-safe string.
|
|
32
|
+
|
|
33
|
+
Uses ~ for the home directory prefix and forward slashes on all platforms
|
|
34
|
+
since OpenSSH expects POSIX-style paths even on Windows.
|
|
35
|
+
"""
|
|
36
|
+
resolved = path.resolve()
|
|
37
|
+
posix = resolved.as_posix()
|
|
38
|
+
home = Path.home().resolve().as_posix()
|
|
39
|
+
# Check with trailing slash to avoid false matches (e.g. /home/user2)
|
|
40
|
+
if posix == home or posix.startswith(home + "/"):
|
|
41
|
+
posix = "~" + posix[len(home) :]
|
|
42
|
+
return posix
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _include_directive(ssh_config: Path) -> str:
|
|
46
|
+
"""Build the Include directive for config.d."""
|
|
47
|
+
config_d = ssh_config.parent / _CONFIG_DIR_NAME
|
|
48
|
+
return f"Include {_to_ssh_path(config_d)}/*"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def ssh_host_alias(vm_name: str, prefix: str = "awvm--") -> str:
|
|
52
|
+
"""Return the SSH host alias for a VM."""
|
|
53
|
+
return f"{prefix}{vm_name}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def sync_ssh_config(config: Config, db: Database) -> None:
|
|
57
|
+
"""Rebuild SSH config from current DB state."""
|
|
58
|
+
if config.operator.ssh_config_dir:
|
|
59
|
+
_rebuild_config_dir(config, db)
|
|
60
|
+
else:
|
|
61
|
+
_legacy_rebuild(config, db)
|
|
62
|
+
output.detail("SSH config synced")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _legacy_rebuild(config: Config, db: Database) -> None:
|
|
66
|
+
"""Legacy: rebuild the managed section from all VMs in DB."""
|
|
67
|
+
ssh_config = config.operator.ssh_config
|
|
68
|
+
user_section, _old_entries = _read_managed(ssh_config)
|
|
69
|
+
prefix = config.operator.ssh_host_prefix
|
|
70
|
+
|
|
71
|
+
entries: dict[str, str] = {}
|
|
72
|
+
for vm in db.list_vms():
|
|
73
|
+
if not vm.tailscale_host:
|
|
74
|
+
continue
|
|
75
|
+
alias = ssh_host_alias(vm.name, prefix)
|
|
76
|
+
entries[alias] = _format_entry(
|
|
77
|
+
alias=alias,
|
|
78
|
+
hostname=vm.tailscale_host,
|
|
79
|
+
user=vm.admin_username,
|
|
80
|
+
identity_file=config.operator.ssh_private_key,
|
|
81
|
+
)
|
|
82
|
+
_write_legacy(ssh_config, user_section, entries)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# -- config.d approach -----------------------------------------------------
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _rebuild_config_dir(config: Config, db: Database) -> None:
|
|
89
|
+
"""Declaratively rebuild ~/.ssh/config.d/agentworks.conf from DB state.
|
|
90
|
+
|
|
91
|
+
Writes a single file containing Host blocks for all VMs with Tailscale
|
|
92
|
+
hosts. Ensures the Include directive is present in ~/.ssh/config.
|
|
93
|
+
Also cleans up any legacy managed section on first encounter.
|
|
94
|
+
"""
|
|
95
|
+
ssh_config = config.operator.ssh_config
|
|
96
|
+
config_d = ssh_config.parent / _CONFIG_DIR_NAME
|
|
97
|
+
config_d.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
prefix = config.operator.ssh_host_prefix
|
|
99
|
+
|
|
100
|
+
# Ensure Include directive at top of ssh_config
|
|
101
|
+
_ensure_include(ssh_config)
|
|
102
|
+
|
|
103
|
+
# Clean up legacy managed section if present
|
|
104
|
+
_remove_legacy_section(ssh_config)
|
|
105
|
+
|
|
106
|
+
# Build all Host blocks from DB
|
|
107
|
+
blocks: list[str] = ["# Managed by agentworks -- do not edit.\n"]
|
|
108
|
+
for vm in db.list_vms():
|
|
109
|
+
if not vm.tailscale_host:
|
|
110
|
+
continue
|
|
111
|
+
alias = ssh_host_alias(vm.name, prefix)
|
|
112
|
+
blocks.append(
|
|
113
|
+
_format_entry(
|
|
114
|
+
alias=alias,
|
|
115
|
+
hostname=vm.tailscale_host,
|
|
116
|
+
user=vm.admin_username,
|
|
117
|
+
identity_file=config.operator.ssh_private_key,
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
conf_path = config_d / _MANAGED_CONF
|
|
122
|
+
if len(blocks) > 1:
|
|
123
|
+
_atomic_write(conf_path, "\n".join(blocks))
|
|
124
|
+
elif conf_path.exists():
|
|
125
|
+
conf_path.unlink()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _ensure_include(ssh_config: Path) -> None:
|
|
129
|
+
"""Ensure the Include directive is present in ssh_config.
|
|
130
|
+
|
|
131
|
+
Adds it at the top if not present. If the user has moved it elsewhere
|
|
132
|
+
in the file, their placement is respected.
|
|
133
|
+
"""
|
|
134
|
+
ssh_config.parent.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
directive = _include_directive(ssh_config)
|
|
136
|
+
|
|
137
|
+
include_block = f"{_INCLUDE_COMMENT}\n{directive}"
|
|
138
|
+
|
|
139
|
+
if not ssh_config.exists():
|
|
140
|
+
ssh_config.write_text(f"{include_block}\n")
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
content = ssh_config.read_text()
|
|
144
|
+
if directive in content:
|
|
145
|
+
return # already present (with or without comment)
|
|
146
|
+
|
|
147
|
+
# Insert at top
|
|
148
|
+
ssh_config.write_text(f"{include_block}\n\n{content}")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _remove_legacy_section(ssh_config: Path) -> None:
|
|
152
|
+
"""Remove the legacy managed section from ssh_config if present."""
|
|
153
|
+
if not ssh_config.exists():
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
content = ssh_config.read_text()
|
|
157
|
+
marker_idx = content.find(_LEGACY_MARKER)
|
|
158
|
+
if marker_idx == -1:
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
# Keep everything before the marker
|
|
162
|
+
user_section = content[:marker_idx].rstrip("\n")
|
|
163
|
+
directive = _include_directive(ssh_config)
|
|
164
|
+
if user_section:
|
|
165
|
+
ssh_config.write_text(user_section + "\n")
|
|
166
|
+
else:
|
|
167
|
+
ssh_config.write_text(f"{directive}\n")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# -- Legacy approach (managed section in ssh_config) -----------------------
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# -- Shared helpers --------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _format_entry(
|
|
177
|
+
alias: str,
|
|
178
|
+
hostname: str,
|
|
179
|
+
user: str,
|
|
180
|
+
identity_file: Path,
|
|
181
|
+
) -> str:
|
|
182
|
+
"""Format a single SSH config Host block."""
|
|
183
|
+
id_str = _to_ssh_path(identity_file)
|
|
184
|
+
if " " in id_str:
|
|
185
|
+
id_str = f'"{id_str}"'
|
|
186
|
+
return f"Host {alias}\n HostName {hostname}\n User {user}\n IdentityFile {id_str}\n"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _atomic_write(path: Path, content: str) -> None:
|
|
190
|
+
"""Write content to a file atomically via temp file + rename."""
|
|
191
|
+
fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
|
|
192
|
+
try:
|
|
193
|
+
with open(fd, "w", encoding="utf-8") as f:
|
|
194
|
+
f.write(content)
|
|
195
|
+
Path(tmp).replace(path)
|
|
196
|
+
except BaseException:
|
|
197
|
+
Path(tmp).unlink(missing_ok=True)
|
|
198
|
+
raise
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _read_managed(ssh_config: Path) -> tuple[str, dict[str, str]]:
|
|
202
|
+
"""Read the SSH config, splitting into operator-managed section and auto-managed entries."""
|
|
203
|
+
entries: dict[str, str] = {}
|
|
204
|
+
|
|
205
|
+
if not ssh_config.exists():
|
|
206
|
+
return "", entries
|
|
207
|
+
|
|
208
|
+
content = ssh_config.read_text()
|
|
209
|
+
marker_idx = content.find(_LEGACY_MARKER)
|
|
210
|
+
|
|
211
|
+
if marker_idx == -1:
|
|
212
|
+
return content, entries
|
|
213
|
+
|
|
214
|
+
user_section = content[:marker_idx]
|
|
215
|
+
managed_section = content[marker_idx + len(_LEGACY_MARKER) :]
|
|
216
|
+
|
|
217
|
+
current_alias = ""
|
|
218
|
+
current_lines: list[str] = []
|
|
219
|
+
|
|
220
|
+
for line in managed_section.splitlines():
|
|
221
|
+
if line.startswith("Host "):
|
|
222
|
+
if current_alias:
|
|
223
|
+
entries[current_alias] = "\n".join(current_lines) + "\n"
|
|
224
|
+
current_alias = line.split()[1] if len(line.split()) > 1 else ""
|
|
225
|
+
current_lines = [line]
|
|
226
|
+
elif current_alias:
|
|
227
|
+
current_lines.append(line)
|
|
228
|
+
|
|
229
|
+
if current_alias:
|
|
230
|
+
entries[current_alias] = "\n".join(current_lines) + "\n"
|
|
231
|
+
|
|
232
|
+
return user_section, entries
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _write_legacy(
|
|
236
|
+
ssh_config: Path,
|
|
237
|
+
user_section: str,
|
|
238
|
+
entries: dict[str, str],
|
|
239
|
+
) -> None:
|
|
240
|
+
"""Write the SSH config file with operator-managed section + agentworks-managed section."""
|
|
241
|
+
ssh_config.parent.mkdir(parents=True, exist_ok=True)
|
|
242
|
+
|
|
243
|
+
parts = [user_section.rstrip("\n")]
|
|
244
|
+
|
|
245
|
+
if entries:
|
|
246
|
+
parts.append("")
|
|
247
|
+
parts.append(_LEGACY_MARKER)
|
|
248
|
+
for block in entries.values():
|
|
249
|
+
parts.append(block.rstrip("\n"))
|
|
250
|
+
|
|
251
|
+
content = "\n".join(parts)
|
|
252
|
+
if not content.endswith("\n"):
|
|
253
|
+
content += "\n"
|
|
254
|
+
|
|
255
|
+
ssh_config.write_text(content)
|
|
File without changes
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""VM host management -- add, list, remove, OS detection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from agentworks import output
|
|
8
|
+
from agentworks.config import validate_name
|
|
9
|
+
from agentworks.output import VMError
|
|
10
|
+
from agentworks.ssh import SSHError, SSHTarget, run
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from agentworks.db import Database
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def detect_os(ssh_host: str) -> str | None:
|
|
17
|
+
"""Detect the OS of a remote host via SSH."""
|
|
18
|
+
try:
|
|
19
|
+
result = run(
|
|
20
|
+
SSHTarget(host=ssh_host, user=None, login_shell=True),
|
|
21
|
+
"uname -s",
|
|
22
|
+
timeout=15,
|
|
23
|
+
)
|
|
24
|
+
raw = result.stdout.strip().lower()
|
|
25
|
+
if "darwin" in raw:
|
|
26
|
+
return "darwin"
|
|
27
|
+
if "linux" in raw:
|
|
28
|
+
return "linux"
|
|
29
|
+
return raw or None
|
|
30
|
+
except (SSHError, TimeoutError):
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def add_vm_host(db: Database, name: str, ssh_host: str, platform: str = "lima") -> None:
|
|
35
|
+
"""Register a new VM host."""
|
|
36
|
+
validate_name(name)
|
|
37
|
+
|
|
38
|
+
if platform != "lima":
|
|
39
|
+
raise VMError(f"only 'lima' platform is supported for VM hosts, got: {platform}")
|
|
40
|
+
|
|
41
|
+
if db.get_vm_host(name) is not None:
|
|
42
|
+
raise VMError(f"VM host '{name}' already exists")
|
|
43
|
+
|
|
44
|
+
output.info(f"Detecting OS on {ssh_host}...")
|
|
45
|
+
detected_os = detect_os(ssh_host)
|
|
46
|
+
if detected_os:
|
|
47
|
+
output.info(f"Detected OS: {detected_os}")
|
|
48
|
+
else:
|
|
49
|
+
output.warn("could not detect OS (SSH connection may have failed)")
|
|
50
|
+
|
|
51
|
+
db.insert_vm_host(name, ssh_host, platform=platform, os=detected_os)
|
|
52
|
+
output.info(f"VM host '{name}' added ({ssh_host})")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def list_vm_hosts(db: Database) -> None:
|
|
56
|
+
"""List all registered VM hosts."""
|
|
57
|
+
hosts = db.list_vm_hosts()
|
|
58
|
+
if not hosts:
|
|
59
|
+
output.info("No VM hosts registered.")
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
output.info(f"{'NAME':<20} {'SSH HOST':<30} {'PLATFORM':<10} {'OS':<10} {'LAST SEEN'}")
|
|
63
|
+
output.info("-" * 90)
|
|
64
|
+
for h in hosts:
|
|
65
|
+
output.info(f"{h.name:<20} {h.ssh_host:<30} {h.platform:<10} {h.os or '-':<10} {h.last_seen_at or 'never'}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def remove_vm_host(db: Database, name: str, *, force: bool = False) -> None:
|
|
69
|
+
"""Remove a VM host. Refuses if VMs reference it unless --force."""
|
|
70
|
+
host = db.get_vm_host(name)
|
|
71
|
+
if host is None:
|
|
72
|
+
raise VMError(f"VM host '{name}' not found")
|
|
73
|
+
|
|
74
|
+
vm_count = db.count_vms_on_host(name)
|
|
75
|
+
if vm_count > 0 and not force:
|
|
76
|
+
raise VMError(f"VM host '{name}' has {vm_count} VM(s). Delete them first, or use --force.")
|
|
77
|
+
|
|
78
|
+
if vm_count > 0:
|
|
79
|
+
# Nullify vm_host_name on VMs referencing this host to prevent dangling FK
|
|
80
|
+
for vm in db.list_vms():
|
|
81
|
+
if vm.vm_host_name == name:
|
|
82
|
+
db.update_vm_host_ref(vm.name, None)
|
|
83
|
+
output.warn(f"cleared VM host reference on {vm_count} VM(s)")
|
|
84
|
+
|
|
85
|
+
db.delete_vm_host(name)
|
|
86
|
+
output.info(f"VM host '{name}' removed")
|
|
File without changes
|