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
agentworks/sources.py ADDED
@@ -0,0 +1,303 @@
1
+ """Source reference resolution for fetching files or directories from local or remote sources.
2
+
3
+ Supports Terraform-style source references:
4
+ - Local path: ~/.config/agentworks/mise.lock (or file::~/.config/...)
5
+ - Git repo: git::https://github.com/user/repo.git
6
+ - Git + path: git::https://github.com/user/repo.git//path/to/file
7
+ - Git + ref: git::https://github.com/user/repo.git//path/to/file?ref=main
8
+
9
+ Two fetch modes:
10
+ - fetch_file: fetches a single file (e.g., mise lockfile)
11
+ - fetch_dir: fetches a directory (e.g., dotfiles repo); for git sources,
12
+ clones the repo to the destination with pull-on-reinit support
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ import shlex
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from typing import TYPE_CHECKING
22
+
23
+ if TYPE_CHECKING:
24
+ from agentworks.ssh import ExecTarget, SSHLogger
25
+
26
+
27
+ class SourceRefError(Exception):
28
+ """Raised when a source reference is invalid."""
29
+
30
+
31
+ # Ref must be safe for shell and git: alphanumeric, hyphens, dots, underscores, slashes
32
+ _SAFE_REF_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._/-]*$")
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class SourceRef:
37
+ """Parsed source reference."""
38
+
39
+ kind: str # "file" or "git"
40
+ path: str # local path (for file) or repo URL (for git)
41
+ subpath: str # file within repo (git only), empty for file sources
42
+ ref: str # git ref (branch/tag/commit), empty string = default branch
43
+
44
+
45
+ def parse_source_ref(source: str, *, default_filename: str = "") -> SourceRef:
46
+ """Parse a source reference string into a SourceRef.
47
+
48
+ Args:
49
+ source: The source reference string.
50
+ default_filename: Default filename when git source has no subpath.
51
+ If empty and no subpath is provided, the subpath will be empty.
52
+
53
+ Raises:
54
+ SourceRefError: If the source reference is malformed.
55
+ """
56
+ if not source:
57
+ raise SourceRefError("source reference cannot be empty")
58
+
59
+ # Strip file:: prefix
60
+ if source.startswith("file::"):
61
+ return SourceRef(kind="file", path=source[6:], subpath="", ref="")
62
+
63
+ # Git source
64
+ if source.startswith("git::"):
65
+ return _parse_git_source(source[5:], default_filename)
66
+
67
+ # Default: local file
68
+ return SourceRef(kind="file", path=source, subpath="", ref="")
69
+
70
+
71
+ def _parse_git_source(url_str: str, default_filename: str) -> SourceRef:
72
+ """Parse the URL portion of a git:: source reference."""
73
+ if not url_str:
74
+ raise SourceRefError("git source URL cannot be empty")
75
+
76
+ # Split on // to separate URL from subpath
77
+ subpath = ""
78
+ if "//" in url_str:
79
+ # Find the first // that is NOT part of https://
80
+ # Strategy: split on //, skip the protocol portion
81
+ parts = url_str.split("//")
82
+ if len(parts) >= 3:
83
+ # e.g. ["https:", "github.com/user/repo.git", "path/to/file?ref=main"]
84
+ # Rejoin protocol + host, take the rest as subpath
85
+ url_str = parts[0] + "//" + parts[1]
86
+ subpath = "/".join(parts[2:])
87
+ # If only 2 parts, it's just a URL with protocol (https://...) and no subpath
88
+
89
+ # Extract query params from either the URL or the subpath
90
+ ref = ""
91
+ # Check subpath first for ?ref=
92
+ if subpath and "?" in subpath:
93
+ subpath, query = subpath.rsplit("?", 1)
94
+ ref = _extract_ref(query)
95
+ elif "?" in url_str:
96
+ url_str, query = url_str.rsplit("?", 1)
97
+ ref = _extract_ref(query)
98
+
99
+ # Validate
100
+ if not (url_str.startswith("https://") or url_str.startswith("git@")):
101
+ raise SourceRefError(f"git source URL must start with https:// or git@, got: {url_str}")
102
+
103
+ if ".." in subpath:
104
+ raise SourceRefError(f"git source subpath must not contain '..': {subpath}")
105
+
106
+ if ref and not _SAFE_REF_RE.match(ref):
107
+ raise SourceRefError(f"git source ref contains unsafe characters: {ref}")
108
+
109
+ # Apply default filename if no subpath
110
+ if not subpath and default_filename:
111
+ subpath = default_filename
112
+
113
+ return SourceRef(kind="git", path=url_str, subpath=subpath, ref=ref)
114
+
115
+
116
+ def _extract_ref(query: str) -> str:
117
+ """Extract ref= value from a query string."""
118
+ for param in query.split("&"):
119
+ if param.startswith("ref="):
120
+ return param[4:]
121
+ return ""
122
+
123
+
124
+ def fetch_file(
125
+ source: SourceRef,
126
+ target: ExecTarget,
127
+ dest: str,
128
+ *,
129
+ logger: SSHLogger | None = None,
130
+ ) -> None:
131
+ """Fetch a file from a source reference to a destination on the target.
132
+
133
+ Args:
134
+ source: Parsed source reference.
135
+ target: SSH execution target.
136
+ dest: Destination file path on the target.
137
+ logger: Optional SSH logger.
138
+ """
139
+ if source.kind == "file":
140
+ _fetch_local(source, target, dest, logger=logger)
141
+ elif source.kind == "git":
142
+ _fetch_git(source, target, dest, logger=logger)
143
+ else:
144
+ raise SourceRefError(f"unknown source kind: {source.kind}")
145
+
146
+
147
+ def _fetch_local(
148
+ source: SourceRef,
149
+ target: ExecTarget,
150
+ dest: str,
151
+ *,
152
+ logger: SSHLogger | None = None,
153
+ ) -> None:
154
+ """Copy a local file to the target."""
155
+ local_path = Path(source.path).expanduser()
156
+ if not local_path.exists():
157
+ raise SourceRefError(f"local source file does not exist: {local_path}")
158
+
159
+ target.copy_to(local_path, dest)
160
+ if logger:
161
+ logger.output(f"Copied {local_path} to {dest}")
162
+
163
+
164
+ def _fetch_git(
165
+ source: SourceRef,
166
+ target: ExecTarget,
167
+ dest: str,
168
+ *,
169
+ logger: SSHLogger | None = None,
170
+ ) -> None:
171
+ """Clone a git repo on the target and copy a file from it."""
172
+ from agentworks.ssh import SSHError
173
+
174
+ tmp_dir = "/tmp/agentworks-source-ref"
175
+
176
+ try:
177
+ # Clean up any previous clone
178
+ target.run(f"rm -rf {tmp_dir}", check=False)
179
+
180
+ # Shallow clone
181
+ clone_cmd = "git clone --depth 1"
182
+ if source.ref:
183
+ clone_cmd += f" --branch {shlex.quote(source.ref)}"
184
+ clone_cmd += f" {shlex.quote(source.path)} {tmp_dir}"
185
+
186
+ target.run(clone_cmd, timeout=60)
187
+ if logger:
188
+ logger.output(f"Cloned {source.path} (ref: {source.ref or 'default'})")
189
+
190
+ # Copy the file
191
+ src_file = f"{tmp_dir}/{source.subpath}" if source.subpath else tmp_dir
192
+ target.run(f"test -f {shlex.quote(src_file)}", timeout=10)
193
+
194
+ # Use cp on the target (file is already there from clone)
195
+ inner = f"cp {shlex.quote(src_file)} {shlex.quote(dest)}"
196
+ target.run(f"sh -c {shlex.quote(inner)}", timeout=10)
197
+ if logger:
198
+ logger.output(f"Copied {source.subpath or '(root)'} to {dest}")
199
+
200
+ except SSHError as e:
201
+ raise SourceRefError(f"failed to fetch from git source: {e}") from e
202
+ finally:
203
+ target.run(f"rm -rf {tmp_dir}", check=False)
204
+
205
+
206
+ def fetch_dir(
207
+ source: SourceRef,
208
+ target: ExecTarget,
209
+ dest: str,
210
+ *,
211
+ logger: SSHLogger | None = None,
212
+ ) -> None:
213
+ """Fetch a directory from a source reference to a destination on the target.
214
+
215
+ For file sources, copies a local directory to the target.
216
+ For git sources, clones the repo to the destination (or pulls if the
217
+ destination is already a matching clone). The subpath is ignored for
218
+ directory fetches (the whole repo is cloned).
219
+
220
+ Args:
221
+ source: Parsed source reference.
222
+ target: SSH execution target.
223
+ dest: Destination directory path on the target.
224
+ logger: Optional SSH logger.
225
+ """
226
+ if source.kind == "file":
227
+ _fetch_local_dir(source, target, dest, logger=logger)
228
+ elif source.kind == "git":
229
+ _fetch_git_dir(source, target, dest, logger=logger)
230
+ else:
231
+ raise SourceRefError(f"unknown source kind: {source.kind}")
232
+
233
+
234
+ def _fetch_local_dir(
235
+ source: SourceRef,
236
+ target: ExecTarget,
237
+ dest: str,
238
+ *,
239
+ logger: SSHLogger | None = None,
240
+ ) -> None:
241
+ """Copy a local directory to the target."""
242
+ local_path = Path(source.path).expanduser()
243
+ if not local_path.exists():
244
+ raise SourceRefError(f"local source directory does not exist: {local_path}")
245
+ if not local_path.is_dir():
246
+ raise SourceRefError(f"local source is not a directory: {local_path}")
247
+
248
+ # Check if destination exists and warn
249
+ if target.run(f"test -d {shlex.quote(dest)}", check=False).ok:
250
+ if logger:
251
+ logger.warning(f"overwriting existing {dest}")
252
+ target.run(f"rm -rf {shlex.quote(dest)}", check=False)
253
+
254
+ target.copy_dir_to(local_path, dest)
255
+ if logger:
256
+ logger.output(f"Copied {local_path} to {dest}")
257
+
258
+
259
+ def _fetch_git_dir(
260
+ source: SourceRef,
261
+ target: ExecTarget,
262
+ dest: str,
263
+ *,
264
+ logger: SSHLogger | None = None,
265
+ ) -> None:
266
+ """Clone a git repo to the destination, or pull if already cloned."""
267
+ from agentworks.ssh import SSHError
268
+
269
+ # Check if destination is already a matching clone
270
+ is_git = target.run(f"test -d {shlex.quote(dest)}/.git", check=False)
271
+ if is_git.ok:
272
+ remote = target.run(
273
+ f"git -C {shlex.quote(dest)} remote get-url origin",
274
+ check=False,
275
+ )
276
+ if remote.ok and remote.stdout.strip() == source.path:
277
+ if logger:
278
+ logger.output(f"Pulling {source.path} in {dest}")
279
+ try:
280
+ target.run(f"git -C {shlex.quote(dest)} pull", timeout=120)
281
+ except SSHError as e:
282
+ raise SourceRefError(f"git pull failed in {dest}: {e}") from e
283
+ return
284
+ # Different repo at the destination
285
+ raise SourceRefError(f"{dest} exists but is a clone of {remote.stdout.strip()}, not {source.path}")
286
+
287
+ # Check if destination exists but is not a git repo
288
+ if target.run(f"test -d {shlex.quote(dest)}", check=False).ok:
289
+ raise SourceRefError(f"{dest} exists but is not a git repo")
290
+
291
+ # Fresh clone
292
+ clone_cmd = "git clone"
293
+ if source.ref:
294
+ clone_cmd += f" --branch {shlex.quote(source.ref)}"
295
+ clone_cmd += f" {shlex.quote(source.path)} {shlex.quote(dest)}"
296
+
297
+ try:
298
+ target.run(clone_cmd, timeout=120)
299
+ except SSHError as e:
300
+ raise SourceRefError(f"git clone failed: {e}") from e
301
+
302
+ if logger:
303
+ logger.output(f"Cloned {source.path} to {dest}")