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