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