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/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}")
|