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/doctor.py
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
"""Health checks for the agentworks environment.
|
|
2
|
+
|
|
3
|
+
Returns structured results. The presentation layer decides rendering.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from agentworks.config import Config
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Status(Enum):
|
|
22
|
+
OK = "ok"
|
|
23
|
+
INFO = "info"
|
|
24
|
+
WARN = "warn"
|
|
25
|
+
FAIL = "fail"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class HealthCheck:
|
|
30
|
+
name: str
|
|
31
|
+
status: Status
|
|
32
|
+
message: str | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class HealthGroup:
|
|
37
|
+
name: str
|
|
38
|
+
checks: list[HealthCheck] = field(default_factory=list)
|
|
39
|
+
|
|
40
|
+
def ok(self, name: str, message: str | None = None) -> None:
|
|
41
|
+
self.checks.append(HealthCheck(name=name, status=Status.OK, message=message))
|
|
42
|
+
|
|
43
|
+
def info(self, name: str, message: str | None = None) -> None:
|
|
44
|
+
self.checks.append(HealthCheck(name=name, status=Status.INFO, message=message))
|
|
45
|
+
|
|
46
|
+
def warn(self, name: str, message: str | None = None) -> None:
|
|
47
|
+
self.checks.append(HealthCheck(name=name, status=Status.WARN, message=message))
|
|
48
|
+
|
|
49
|
+
def fail(self, name: str, message: str | None = None) -> None:
|
|
50
|
+
self.checks.append(HealthCheck(name=name, status=Status.FAIL, message=message))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class HealthReport:
|
|
55
|
+
groups: list[HealthGroup] = field(default_factory=list)
|
|
56
|
+
|
|
57
|
+
def counts(self) -> dict[Status, int]:
|
|
58
|
+
"""Compute all status counts in a single pass."""
|
|
59
|
+
result = {s: 0 for s in Status}
|
|
60
|
+
for g in self.groups:
|
|
61
|
+
for c in g.checks:
|
|
62
|
+
result[c.status] += 1
|
|
63
|
+
return result
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def ok_count(self) -> int:
|
|
67
|
+
return self.counts()[Status.OK]
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def info_count(self) -> int:
|
|
71
|
+
return self.counts()[Status.INFO]
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def warn_count(self) -> int:
|
|
75
|
+
return self.counts()[Status.WARN]
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def fail_count(self) -> int:
|
|
79
|
+
return self.counts()[Status.FAIL]
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def has_failures(self) -> bool:
|
|
83
|
+
return self.counts()[Status.FAIL] > 0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def run_checks(*, completion_version: str | None = None) -> HealthReport:
|
|
87
|
+
"""Run all health checks and return structured results.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
completion_version: current completion spec version for staleness check.
|
|
91
|
+
Computed by the CLI layer and passed in to avoid coupling doctor
|
|
92
|
+
to the CLI module. Omit to skip completion checks.
|
|
93
|
+
"""
|
|
94
|
+
report = HealthReport()
|
|
95
|
+
|
|
96
|
+
report.groups.append(_check_python())
|
|
97
|
+
report.groups.append(_check_required_tools())
|
|
98
|
+
report.groups.append(_check_vm_platforms())
|
|
99
|
+
report.groups.append(_check_tailscale())
|
|
100
|
+
|
|
101
|
+
config_group, config = _check_config()
|
|
102
|
+
report.groups.append(config_group)
|
|
103
|
+
|
|
104
|
+
if config is not None and config.git_credentials:
|
|
105
|
+
report.groups.append(_check_git_credentials(config))
|
|
106
|
+
|
|
107
|
+
report.groups.append(_check_database())
|
|
108
|
+
|
|
109
|
+
if completion_version is not None:
|
|
110
|
+
report.groups.append(_check_completions(completion_version))
|
|
111
|
+
|
|
112
|
+
return report
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
# Individual check groups
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _check_python() -> HealthGroup:
|
|
121
|
+
g = HealthGroup("Python")
|
|
122
|
+
v = sys.version_info
|
|
123
|
+
if v >= (3, 12):
|
|
124
|
+
g.ok(f"Python {v.major}.{v.minor}.{v.micro}")
|
|
125
|
+
else:
|
|
126
|
+
g.fail(f"Python {v.major}.{v.minor}.{v.micro}", "3.12+ required")
|
|
127
|
+
return g
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _check_required_tools() -> HealthGroup:
|
|
131
|
+
g = HealthGroup("Required tools")
|
|
132
|
+
for tool in ("ssh", "scp", "tailscale"):
|
|
133
|
+
if shutil.which(tool):
|
|
134
|
+
g.ok(tool)
|
|
135
|
+
else:
|
|
136
|
+
g.fail(tool, "not found")
|
|
137
|
+
return g
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _check_vm_platforms() -> HealthGroup:
|
|
141
|
+
g = HealthGroup("VM platforms")
|
|
142
|
+
|
|
143
|
+
# VM hosts (remote Lima)
|
|
144
|
+
try:
|
|
145
|
+
from agentworks.db import Database
|
|
146
|
+
|
|
147
|
+
db_exists, _, _ = Database.check_schema()
|
|
148
|
+
if db_exists:
|
|
149
|
+
_db = Database()
|
|
150
|
+
hosts = _db.list_vm_hosts()
|
|
151
|
+
if hosts:
|
|
152
|
+
for h in hosts:
|
|
153
|
+
os_info = f", {h.os}" if h.os else ""
|
|
154
|
+
g.ok(f"VM host: {h.name}", f"{h.ssh_host}{os_info}")
|
|
155
|
+
else:
|
|
156
|
+
g.info("VM hosts", "none configured")
|
|
157
|
+
else:
|
|
158
|
+
g.info("VM hosts", "database not yet created")
|
|
159
|
+
except Exception:
|
|
160
|
+
g.warn("VM hosts", "could not check")
|
|
161
|
+
|
|
162
|
+
# Local platform tools
|
|
163
|
+
for tool, label in [
|
|
164
|
+
("limactl", "Local Lima (limactl)"),
|
|
165
|
+
("wsl", "WSL2 (wsl)"),
|
|
166
|
+
]:
|
|
167
|
+
if shutil.which(tool):
|
|
168
|
+
g.ok(label)
|
|
169
|
+
else:
|
|
170
|
+
g.info(label, "not available")
|
|
171
|
+
return g
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _check_tailscale() -> HealthGroup:
|
|
175
|
+
g = HealthGroup("Tailscale")
|
|
176
|
+
ts = shutil.which("tailscale")
|
|
177
|
+
if not ts:
|
|
178
|
+
g.fail("tailscale", "not installed")
|
|
179
|
+
return g
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
result = subprocess.run(
|
|
183
|
+
["tailscale", "status"],
|
|
184
|
+
capture_output=True,
|
|
185
|
+
text=True,
|
|
186
|
+
encoding="utf-8",
|
|
187
|
+
errors="replace",
|
|
188
|
+
timeout=10,
|
|
189
|
+
)
|
|
190
|
+
if result.returncode == 0:
|
|
191
|
+
if os.environ.get("TAILSCALE_AUTH_KEY"):
|
|
192
|
+
g.ok("Connected to tailnet", "auth key env var set")
|
|
193
|
+
else:
|
|
194
|
+
g.ok("Connected to tailnet", "will prompt for auth key during VM init")
|
|
195
|
+
else:
|
|
196
|
+
g.fail("Not connected", "run 'tailscale up'")
|
|
197
|
+
except subprocess.TimeoutExpired:
|
|
198
|
+
g.fail("tailscale status", "timed out")
|
|
199
|
+
return g
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _check_config() -> tuple[HealthGroup, Config | None]:
|
|
203
|
+
"""Returns (group, config_or_none)."""
|
|
204
|
+
from agentworks.config import CONFIG_PATH, ConfigError
|
|
205
|
+
|
|
206
|
+
g = HealthGroup("Configuration")
|
|
207
|
+
config = None
|
|
208
|
+
|
|
209
|
+
if not CONFIG_PATH.exists():
|
|
210
|
+
g.fail("Config file", f"not found: {CONFIG_PATH}. Run 'agentworks config init' to create one.")
|
|
211
|
+
return g, None
|
|
212
|
+
|
|
213
|
+
g.ok("Config file", str(CONFIG_PATH))
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
from agentworks.config import load_config
|
|
217
|
+
|
|
218
|
+
config = load_config(warn_issues=False)
|
|
219
|
+
except ConfigError as e:
|
|
220
|
+
g.fail("Config", str(e))
|
|
221
|
+
return g, None
|
|
222
|
+
except SystemExit:
|
|
223
|
+
g.fail("Config", "failed to load")
|
|
224
|
+
return g, None
|
|
225
|
+
|
|
226
|
+
for issue in config.config_issues:
|
|
227
|
+
g.warn("Config", issue)
|
|
228
|
+
if not config.config_issues:
|
|
229
|
+
g.ok("Config is valid")
|
|
230
|
+
|
|
231
|
+
# SSH keys
|
|
232
|
+
_check_ssh_key(g, config.operator.ssh_public_key, "public")
|
|
233
|
+
_check_ssh_key(g, config.operator.ssh_private_key, "private")
|
|
234
|
+
|
|
235
|
+
# Dotfiles
|
|
236
|
+
if config.admin.dotfiles_source:
|
|
237
|
+
from agentworks.sources import parse_source_ref
|
|
238
|
+
|
|
239
|
+
ref = parse_source_ref(config.admin.dotfiles_source)
|
|
240
|
+
if ref.kind == "git" or Path(ref.path).expanduser().exists():
|
|
241
|
+
g.ok("Admin dotfiles", config.admin.dotfiles_source)
|
|
242
|
+
else:
|
|
243
|
+
g.warn("Admin dotfiles", f"source missing: {config.admin.dotfiles_source}")
|
|
244
|
+
|
|
245
|
+
return g, config
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _check_ssh_key(g: HealthGroup, path: object, label: str) -> None:
|
|
249
|
+
"""Check that an SSH key file exists and has correct permissions."""
|
|
250
|
+
if not isinstance(path, Path):
|
|
251
|
+
g.fail(f"SSH {label} key", "invalid path")
|
|
252
|
+
return
|
|
253
|
+
if not path.exists():
|
|
254
|
+
g.fail(f"SSH {label} key", f"not found: {path}")
|
|
255
|
+
return
|
|
256
|
+
if not os.access(path, os.R_OK):
|
|
257
|
+
g.fail(f"SSH {label} key", f"not readable: {path}")
|
|
258
|
+
return
|
|
259
|
+
|
|
260
|
+
g.ok(f"SSH {label} key", str(path))
|
|
261
|
+
|
|
262
|
+
# Check permissions on private key. Skipped on Windows: st_mode there is
|
|
263
|
+
# synthesized from the read-only attribute (typically reports 0o666) and
|
|
264
|
+
# doesn't reflect the NTFS ACLs that actually gate access.
|
|
265
|
+
if label == "private" and sys.platform != "win32":
|
|
266
|
+
mode = path.stat().st_mode & 0o777
|
|
267
|
+
if mode & 0o077:
|
|
268
|
+
g.warn("SSH private key permissions", f"{oct(mode)}, recommend 600")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _check_git_credentials(config: Config) -> HealthGroup:
|
|
272
|
+
"""Check git credential providers."""
|
|
273
|
+
from agentworks.vms.initializer import resolve_git_credential_providers
|
|
274
|
+
|
|
275
|
+
g = HealthGroup("Git credentials")
|
|
276
|
+
|
|
277
|
+
# Collect all credential names from admin and agent templates
|
|
278
|
+
all_cred_names: list[str] = list(config.admin.git_credentials)
|
|
279
|
+
for tmpl in config.agent_templates.values():
|
|
280
|
+
if tmpl.git_credentials is not None:
|
|
281
|
+
for name in tmpl.git_credentials:
|
|
282
|
+
if name not in all_cred_names:
|
|
283
|
+
all_cred_names.append(name)
|
|
284
|
+
|
|
285
|
+
try:
|
|
286
|
+
providers = resolve_git_credential_providers(config, all_cred_names)
|
|
287
|
+
except Exception as e:
|
|
288
|
+
g.warn("Git credentials", f"could not resolve providers: {e}")
|
|
289
|
+
return g
|
|
290
|
+
|
|
291
|
+
from agentworks.git_credentials.base import env_var_for_credential
|
|
292
|
+
|
|
293
|
+
for name, provider in providers.items():
|
|
294
|
+
label = provider.display_name
|
|
295
|
+
try:
|
|
296
|
+
if not provider.verify_auth():
|
|
297
|
+
g.warn(label, f"auth check failed ({provider.auth_hint()})")
|
|
298
|
+
continue
|
|
299
|
+
if os.environ.get(env_var_for_credential(name)):
|
|
300
|
+
g.ok(label, "ready (token set via environment)")
|
|
301
|
+
else:
|
|
302
|
+
g.ok(label, "ready (will prompt for token during VM init)")
|
|
303
|
+
except Exception as e:
|
|
304
|
+
g.warn(label, f"auth check error: {e}")
|
|
305
|
+
|
|
306
|
+
return g
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _check_database() -> HealthGroup:
|
|
310
|
+
from agentworks.db import Database
|
|
311
|
+
|
|
312
|
+
g = HealthGroup("Database")
|
|
313
|
+
|
|
314
|
+
try:
|
|
315
|
+
exists, current, latest = Database.check_schema()
|
|
316
|
+
if not exists:
|
|
317
|
+
g.ok("Database", "does not exist yet (will be created on first use)")
|
|
318
|
+
elif current == latest:
|
|
319
|
+
g.ok("Schema", f"up to date (version {current})")
|
|
320
|
+
db = Database()
|
|
321
|
+
_report_db_contents(g, db)
|
|
322
|
+
elif current < latest:
|
|
323
|
+
g.warn("Schema", f"at version {current}, latest is {latest}. Migrating...")
|
|
324
|
+
db = Database() # auto-migrates
|
|
325
|
+
g.ok("Schema", f"migrated to version {latest}")
|
|
326
|
+
_report_db_contents(g, db)
|
|
327
|
+
else:
|
|
328
|
+
g.fail("Schema", f"version {current} is newer than latest {latest} (downgrade?)")
|
|
329
|
+
except Exception as e:
|
|
330
|
+
g.fail("Database", str(e))
|
|
331
|
+
|
|
332
|
+
return g
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _report_db_contents(g: HealthGroup, db: object) -> None:
|
|
336
|
+
"""Report DB contents and flag VMs in non-complete states."""
|
|
337
|
+
from agentworks.db import Database, InitStatus
|
|
338
|
+
from agentworks.ssh import LOG_DIR
|
|
339
|
+
|
|
340
|
+
assert isinstance(db, Database)
|
|
341
|
+
|
|
342
|
+
vms = db.list_vms()
|
|
343
|
+
ws_count = len(db.list_workspaces())
|
|
344
|
+
g.ok("Contents", f"{len(vms)} VMs, {ws_count} workspaces")
|
|
345
|
+
|
|
346
|
+
def _log_hint(vm_name: str) -> str:
|
|
347
|
+
if not LOG_DIR.exists():
|
|
348
|
+
return ""
|
|
349
|
+
logs = sorted(LOG_DIR.glob(f"{vm_name}-*.log"), reverse=True)
|
|
350
|
+
return f" Log: {logs[0]}" if logs else ""
|
|
351
|
+
|
|
352
|
+
for vm in vms:
|
|
353
|
+
if vm.init_status == InitStatus.FAILED.value:
|
|
354
|
+
g.warn(f"VM '{vm.name}'", f"failed state (only delete supported).{_log_hint(vm.name)}")
|
|
355
|
+
elif vm.init_status == InitStatus.PARTIAL.value:
|
|
356
|
+
g.warn(f"VM '{vm.name}'", f"initialized with warnings.{_log_hint(vm.name)}")
|
|
357
|
+
elif vm.init_status not in (InitStatus.COMPLETE.value, InitStatus.PENDING.value):
|
|
358
|
+
g.warn(f"VM '{vm.name}'", f"unexpected init status: {vm.init_status}")
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _check_completions(current_version: str) -> HealthGroup:
|
|
362
|
+
g = HealthGroup("Shell completions")
|
|
363
|
+
|
|
364
|
+
shells = _get_completion_paths()
|
|
365
|
+
|
|
366
|
+
any_found = False
|
|
367
|
+
for shell_name, candidate_paths in shells:
|
|
368
|
+
for path in candidate_paths:
|
|
369
|
+
if not path.exists():
|
|
370
|
+
continue
|
|
371
|
+
any_found = True
|
|
372
|
+
installed_version = _read_completion_version(path)
|
|
373
|
+
if installed_version == current_version:
|
|
374
|
+
g.ok(shell_name, "up to date")
|
|
375
|
+
elif installed_version is None:
|
|
376
|
+
g.warn(shell_name, f"no version stamp. See: agentworks completion {shell_name} --help")
|
|
377
|
+
else:
|
|
378
|
+
g.warn(shell_name, f"stale. See: agentworks completion {shell_name} --help")
|
|
379
|
+
if not any_found:
|
|
380
|
+
g.ok("Completions", "none installed (install with: agentworks completion <shell> --install)")
|
|
381
|
+
|
|
382
|
+
return g
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _get_completion_paths() -> list[tuple[str, list[Path]]]:
|
|
386
|
+
"""Return (shell_name, candidate_paths) for all shells."""
|
|
387
|
+
home = Path.home()
|
|
388
|
+
shells: list[tuple[str, list[Path]]] = []
|
|
389
|
+
|
|
390
|
+
# Bash
|
|
391
|
+
shells.append((
|
|
392
|
+
"bash",
|
|
393
|
+
[home / ".local" / "share" / "bash-completion" / "completions" / "agentworks"],
|
|
394
|
+
))
|
|
395
|
+
|
|
396
|
+
# Zsh
|
|
397
|
+
zsh_paths: list[Path] = [home / ".zfunc" / "_agentworks"]
|
|
398
|
+
zsh_custom = os.environ.get("ZSH_CUSTOM")
|
|
399
|
+
if zsh_custom:
|
|
400
|
+
zsh_paths.append(Path(zsh_custom) / "completions" / "_agentworks")
|
|
401
|
+
omz_default = home / ".oh-my-zsh" / "custom" / "completions" / "_agentworks"
|
|
402
|
+
if omz_default not in zsh_paths:
|
|
403
|
+
zsh_paths.append(omz_default)
|
|
404
|
+
shells.append(("zsh", zsh_paths))
|
|
405
|
+
|
|
406
|
+
# PowerShell
|
|
407
|
+
from agentworks.completions.install import _query_powershell_profile
|
|
408
|
+
|
|
409
|
+
profile = _query_powershell_profile()
|
|
410
|
+
if profile is not None:
|
|
411
|
+
shells.append((
|
|
412
|
+
"powershell",
|
|
413
|
+
[profile.parent / "Completions" / "agentworks.ps1"],
|
|
414
|
+
))
|
|
415
|
+
|
|
416
|
+
return shells
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _read_completion_version(path: Path) -> str | None:
|
|
420
|
+
"""Read the version stamp from a completion file."""
|
|
421
|
+
try:
|
|
422
|
+
with path.open() as f:
|
|
423
|
+
for line in f:
|
|
424
|
+
if line.startswith("# agentworks-completion-version:"):
|
|
425
|
+
return line.split(":", 1)[1].strip()
|
|
426
|
+
if not line.startswith("#") and line.strip():
|
|
427
|
+
break
|
|
428
|
+
except OSError:
|
|
429
|
+
pass
|
|
430
|
+
return None
|
|
File without changes
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Azure DevOps git credential provider -- prompt for a personal access token."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from agentworks import output
|
|
6
|
+
from agentworks.git_credentials.base import GitCredentialProvider
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AzDOCredentialProvider(GitCredentialProvider):
|
|
10
|
+
"""Configures git credentials for Azure DevOps via a personal access token."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, config_name: str, org: str, description: str | None = None) -> None:
|
|
13
|
+
super().__init__(config_name, description=description)
|
|
14
|
+
self._org = org
|
|
15
|
+
|
|
16
|
+
def verify_auth(self) -> bool:
|
|
17
|
+
return True
|
|
18
|
+
|
|
19
|
+
def auth_hint(self) -> str:
|
|
20
|
+
return f"Create a PAT at https://dev.azure.com/{self._org}/_usersSettings/tokens (Code Read & Write scope)"
|
|
21
|
+
|
|
22
|
+
def _prompt_token(self, vm_name: str) -> str:
|
|
23
|
+
return output.prompt_secret(
|
|
24
|
+
f" Azure DevOps PAT for '{self.display_name}'",
|
|
25
|
+
hint=self.auth_hint(),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
def credential_lines(self, token: str) -> list[str]:
|
|
29
|
+
return [f"https://{self._org}:{token}@dev.azure.com/{self._org}"]
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Base interface for git credential providers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def env_var_for_credential(name: str) -> str:
|
|
10
|
+
"""Derive the environment variable name for a credential config name.
|
|
11
|
+
|
|
12
|
+
e.g. "github" -> "GIT_CREDENTIALS_GITHUB"
|
|
13
|
+
"azdo-ifc" -> "GIT_CREDENTIALS_AZDO_IFC"
|
|
14
|
+
"""
|
|
15
|
+
return "GIT_CREDENTIALS_" + name.upper().replace("-", "_")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class GitCredentialProvider(ABC):
|
|
19
|
+
"""Interface for configuring git credentials on VMs.
|
|
20
|
+
|
|
21
|
+
Each provider knows how to obtain a token (via prompt or CLI) and
|
|
22
|
+
produce the credential line(s) for ~/.git-credentials.
|
|
23
|
+
|
|
24
|
+
Token resolution order:
|
|
25
|
+
1. GIT_CREDENTIALS_<NAME> environment variable
|
|
26
|
+
2. Interactive prompt (via _prompt_token)
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, config_name: str, description: str | None = None) -> None:
|
|
30
|
+
self._config_name = config_name
|
|
31
|
+
self._description = description
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def display_name(self) -> str:
|
|
35
|
+
"""Human-readable name: 'key (description)' or just 'key'."""
|
|
36
|
+
if self._description:
|
|
37
|
+
return f"{self._config_name} ({self._description})"
|
|
38
|
+
return self._config_name
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
def verify_auth(self) -> bool:
|
|
42
|
+
"""Check if authentication is possible (e.g. CLI tools present).
|
|
43
|
+
|
|
44
|
+
For prompt-based providers, this always returns True.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
@abstractmethod
|
|
48
|
+
def auth_hint(self) -> str:
|
|
49
|
+
"""Return a human-readable hint for how to authenticate."""
|
|
50
|
+
|
|
51
|
+
def obtain_token(self, vm_name: str) -> str:
|
|
52
|
+
"""Obtain a credential token: env var first, then prompt."""
|
|
53
|
+
from agentworks import output
|
|
54
|
+
|
|
55
|
+
env_name = env_var_for_credential(self._config_name)
|
|
56
|
+
token = os.environ.get(env_name)
|
|
57
|
+
if token:
|
|
58
|
+
output.detail(f"Git credential '{self.display_name}' found in environment")
|
|
59
|
+
return token
|
|
60
|
+
return self._prompt_token(vm_name)
|
|
61
|
+
|
|
62
|
+
@abstractmethod
|
|
63
|
+
def _prompt_token(self, vm_name: str) -> str:
|
|
64
|
+
"""Interactively prompt for a token."""
|
|
65
|
+
|
|
66
|
+
@abstractmethod
|
|
67
|
+
def credential_lines(self, token: str) -> list[str]:
|
|
68
|
+
"""Return lines for ~/.git-credentials.
|
|
69
|
+
|
|
70
|
+
Each line is a URL in the format: https://user:token@host
|
|
71
|
+
"""
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""GitHub git credential provider -- prompt for a personal access token."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from agentworks import output
|
|
6
|
+
from agentworks.git_credentials.base import GitCredentialProvider
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GitHubCredentialProvider(GitCredentialProvider):
|
|
10
|
+
"""Configures git credentials for GitHub via a personal access token."""
|
|
11
|
+
|
|
12
|
+
def verify_auth(self) -> bool:
|
|
13
|
+
return True
|
|
14
|
+
|
|
15
|
+
def auth_hint(self) -> str:
|
|
16
|
+
return "Create a PAT at https://github.com/settings/tokens (repo scope)"
|
|
17
|
+
|
|
18
|
+
def _prompt_token(self, vm_name: str) -> str:
|
|
19
|
+
return output.prompt_secret(f" GitHub PAT for '{self.display_name}'", hint=self.auth_hint())
|
|
20
|
+
|
|
21
|
+
def credential_lines(self, token: str) -> list[str]:
|
|
22
|
+
return [f"https://x-access-token:{token}@github.com"]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Agentworks default nerftools plugin config.
|
|
2
|
+
# Used when building the Claude Code plugin during VM init.
|
|
3
|
+
# Version uses the nerftools default (0.1.0) so the plugin path stays stable
|
|
4
|
+
# across rebuilds -- Claude Code grants permissions based on absolute tool paths.
|
|
5
|
+
|
|
6
|
+
package:
|
|
7
|
+
name: nerftools
|
|
8
|
+
description: "Nerf tools: scoped, safety-constrained CLI wrappers for AI agents"
|
|
9
|
+
|
|
10
|
+
targets:
|
|
11
|
+
claude-plugin:
|
|
12
|
+
marketplace:
|
|
13
|
+
name: agentworks-nerftools-local
|
|
14
|
+
description: "Agentworks local nerftools marketplace"
|
|
15
|
+
owner:
|
|
16
|
+
name: Agentworks
|