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
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Agent template resolution and processing.
|
|
2
|
+
|
|
3
|
+
Handles inheritance (depth-first, left-to-right), merge rules, and the
|
|
4
|
+
built-in default template fallback. Follows the same pattern as VM and
|
|
5
|
+
workspace templates.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from agentworks.config import AgentTemplate, Config
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ResolvedAgentTemplate:
|
|
19
|
+
"""A fully resolved agent template with all inheritance applied."""
|
|
20
|
+
|
|
21
|
+
name: str
|
|
22
|
+
shell: str = "bash"
|
|
23
|
+
git_credentials: list[str] = field(default_factory=list)
|
|
24
|
+
user_install_commands: list[str] = field(default_factory=list)
|
|
25
|
+
dotfiles_source: str | None = None
|
|
26
|
+
dotfiles_destination: str = "~/.dotfiles"
|
|
27
|
+
dotfiles_install_cmd: str = "./install.sh"
|
|
28
|
+
mise_activate: bool = True
|
|
29
|
+
mise_packages: list[str] = field(default_factory=list)
|
|
30
|
+
mise_lockfile: str | None = None
|
|
31
|
+
mise_allow_unlocked: bool = False
|
|
32
|
+
mise_install_before: str = "7d"
|
|
33
|
+
mise_prune_on_reinit: bool = True
|
|
34
|
+
nerf_install_claude_plugin: bool = False
|
|
35
|
+
claude_marketplaces: list[str] = field(default_factory=list)
|
|
36
|
+
claude_plugins: list[str] = field(default_factory=list)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def resolve_from_dict(
|
|
40
|
+
templates: dict[str, AgentTemplate],
|
|
41
|
+
template_name: str | None = None,
|
|
42
|
+
) -> ResolvedAgentTemplate:
|
|
43
|
+
"""Resolve an agent template from a templates dict (no Config required)."""
|
|
44
|
+
if template_name is not None and template_name != "default":
|
|
45
|
+
if template_name not in templates:
|
|
46
|
+
msg = f"Unknown agent template: {template_name}"
|
|
47
|
+
raise ValueError(msg)
|
|
48
|
+
return _resolve(templates, template_name)
|
|
49
|
+
|
|
50
|
+
if "default" in templates:
|
|
51
|
+
return _resolve(templates, "default")
|
|
52
|
+
|
|
53
|
+
return ResolvedAgentTemplate(name="default")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def resolve_template(config: Config, template_name: str | None = None) -> ResolvedAgentTemplate:
|
|
57
|
+
"""Resolve an agent template by name, applying inheritance."""
|
|
58
|
+
return resolve_from_dict(config.agent_templates, template_name)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _resolve(templates: dict[str, AgentTemplate], name: str) -> ResolvedAgentTemplate:
|
|
62
|
+
"""Depth-first, left-to-right resolution."""
|
|
63
|
+
if name not in templates:
|
|
64
|
+
return ResolvedAgentTemplate(name=name)
|
|
65
|
+
|
|
66
|
+
tmpl = templates[name]
|
|
67
|
+
result = ResolvedAgentTemplate(name=name)
|
|
68
|
+
|
|
69
|
+
for parent_name in tmpl.inherits:
|
|
70
|
+
parent = _resolve(templates, parent_name)
|
|
71
|
+
_merge(result, parent)
|
|
72
|
+
|
|
73
|
+
_merge_template(result, tmpl)
|
|
74
|
+
result.name = name
|
|
75
|
+
return result
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _append_dedupe(target: list[str], source: list[str]) -> list[str]:
|
|
79
|
+
"""Append source items to target, skipping dupes. Preserves order."""
|
|
80
|
+
seen = set(target)
|
|
81
|
+
result = list(target)
|
|
82
|
+
for item in source:
|
|
83
|
+
if item not in seen:
|
|
84
|
+
seen.add(item)
|
|
85
|
+
result.append(item)
|
|
86
|
+
return result
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _merge_map(target: dict[str, str], source: dict[str, str]) -> dict[str, str]:
|
|
90
|
+
"""Merge source map into target. Source wins on key collision."""
|
|
91
|
+
return {**target, **source}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _merge(target: ResolvedAgentTemplate, source: ResolvedAgentTemplate) -> None:
|
|
95
|
+
"""Merge source into target. Scalars: source wins. Lists: append with dedupe."""
|
|
96
|
+
target.shell = source.shell
|
|
97
|
+
target.git_credentials = _append_dedupe(target.git_credentials, source.git_credentials)
|
|
98
|
+
target.user_install_commands = _append_dedupe(target.user_install_commands, source.user_install_commands)
|
|
99
|
+
target.dotfiles_source = source.dotfiles_source
|
|
100
|
+
target.dotfiles_destination = source.dotfiles_destination
|
|
101
|
+
target.dotfiles_install_cmd = source.dotfiles_install_cmd
|
|
102
|
+
target.mise_activate = source.mise_activate
|
|
103
|
+
target.mise_packages = _append_dedupe(target.mise_packages, source.mise_packages)
|
|
104
|
+
target.mise_lockfile = source.mise_lockfile
|
|
105
|
+
target.mise_allow_unlocked = source.mise_allow_unlocked
|
|
106
|
+
target.mise_install_before = source.mise_install_before
|
|
107
|
+
target.mise_prune_on_reinit = source.mise_prune_on_reinit
|
|
108
|
+
target.nerf_install_claude_plugin = source.nerf_install_claude_plugin
|
|
109
|
+
target.claude_marketplaces = _append_dedupe(target.claude_marketplaces, source.claude_marketplaces)
|
|
110
|
+
target.claude_plugins = _append_dedupe(target.claude_plugins, source.claude_plugins)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _merge_template(target: ResolvedAgentTemplate, tmpl: AgentTemplate) -> None:
|
|
114
|
+
"""Merge a raw AgentTemplate into a ResolvedAgentTemplate. None = not set, skip.
|
|
115
|
+
Scalars: child overrides. Lists: append with dedupe."""
|
|
116
|
+
if tmpl.shell is not None:
|
|
117
|
+
target.shell = tmpl.shell
|
|
118
|
+
if tmpl.git_credentials is not None:
|
|
119
|
+
target.git_credentials = _append_dedupe(target.git_credentials, tmpl.git_credentials)
|
|
120
|
+
if tmpl.user_install_commands is not None:
|
|
121
|
+
target.user_install_commands = _append_dedupe(target.user_install_commands, tmpl.user_install_commands)
|
|
122
|
+
if tmpl.dotfiles_source is not None:
|
|
123
|
+
target.dotfiles_source = tmpl.dotfiles_source
|
|
124
|
+
if tmpl.dotfiles_destination is not None:
|
|
125
|
+
target.dotfiles_destination = tmpl.dotfiles_destination
|
|
126
|
+
if tmpl.dotfiles_install_cmd is not None:
|
|
127
|
+
target.dotfiles_install_cmd = tmpl.dotfiles_install_cmd
|
|
128
|
+
if tmpl.mise_activate is not None:
|
|
129
|
+
target.mise_activate = tmpl.mise_activate
|
|
130
|
+
if tmpl.mise_packages is not None:
|
|
131
|
+
target.mise_packages = _append_dedupe(target.mise_packages, tmpl.mise_packages)
|
|
132
|
+
if tmpl.mise_lockfile is not None:
|
|
133
|
+
target.mise_lockfile = tmpl.mise_lockfile
|
|
134
|
+
if tmpl.mise_allow_unlocked is not None:
|
|
135
|
+
target.mise_allow_unlocked = tmpl.mise_allow_unlocked
|
|
136
|
+
if tmpl.mise_install_before is not None:
|
|
137
|
+
target.mise_install_before = tmpl.mise_install_before
|
|
138
|
+
if tmpl.mise_prune_on_reinit is not None:
|
|
139
|
+
target.mise_prune_on_reinit = tmpl.mise_prune_on_reinit
|
|
140
|
+
if tmpl.nerf_install_claude_plugin is not None:
|
|
141
|
+
target.nerf_install_claude_plugin = tmpl.nerf_install_claude_plugin
|
|
142
|
+
if tmpl.claude_marketplaces is not None:
|
|
143
|
+
target.claude_marketplaces = _append_dedupe(target.claude_marketplaces, tmpl.claude_marketplaces)
|
|
144
|
+
if tmpl.claude_plugins is not None:
|
|
145
|
+
target.claude_plugins = _append_dedupe(target.claude_plugins, tmpl.claude_plugins)
|
agentworks/catalog.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Built-in catalog loading, merging, and resolution.
|
|
2
|
+
|
|
3
|
+
The catalog provides named entries for apt sources, apt packages, system
|
|
4
|
+
install commands, and user install commands. A built-in catalog ships with
|
|
5
|
+
the package; custom config entries override built-in entries on name collision.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import tomllib
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from agentworks.config import Config
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CatalogError(Exception):
|
|
21
|
+
"""Raised when the catalog is invalid."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# -- Data classes --------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class AptSourceEntry:
|
|
29
|
+
name: str
|
|
30
|
+
description: str
|
|
31
|
+
key_url: str
|
|
32
|
+
key_path: str
|
|
33
|
+
source: str
|
|
34
|
+
source_file: str
|
|
35
|
+
key_dearmor: bool = False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class AptPackageEntry:
|
|
40
|
+
name: str
|
|
41
|
+
description: str
|
|
42
|
+
apt: list[str]
|
|
43
|
+
apt_sources: list[str] = field(default_factory=list)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class SystemInstallCommandEntry:
|
|
48
|
+
name: str
|
|
49
|
+
description: str
|
|
50
|
+
command: str
|
|
51
|
+
path: list[str] = field(default_factory=list)
|
|
52
|
+
test_exec: str | None = None
|
|
53
|
+
test_file: str | None = None
|
|
54
|
+
test_dir: str | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class UserInstallCommandEntry:
|
|
59
|
+
name: str
|
|
60
|
+
description: str
|
|
61
|
+
command: str
|
|
62
|
+
path: list[str] = field(default_factory=list)
|
|
63
|
+
test_exec: str | None = None
|
|
64
|
+
test_file: str | None = None
|
|
65
|
+
test_dir: str | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class ResolvedCatalog:
|
|
70
|
+
apt_sources: dict[str, AptSourceEntry]
|
|
71
|
+
apt_packages: dict[str, AptPackageEntry]
|
|
72
|
+
system_install_commands: dict[str, SystemInstallCommandEntry]
|
|
73
|
+
user_install_commands: dict[str, UserInstallCommandEntry]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# -- Loading -------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
_BUILTIN_CATALOG_PATH = Path(__file__).parent / "catalog.toml"
|
|
79
|
+
|
|
80
|
+
# source_file must be a simple filename (no slashes, no shell metacharacters)
|
|
81
|
+
_SAFE_FILENAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]*$")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _require_field(data: dict[str, object], key: str, context: str) -> object:
|
|
85
|
+
if key not in data:
|
|
86
|
+
raise CatalogError(f"{context}.{key} is required")
|
|
87
|
+
return data[key]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _require_list(data: dict[str, object], key: str, context: str) -> list[str]:
|
|
91
|
+
val = data.get(key, [])
|
|
92
|
+
if not isinstance(val, list):
|
|
93
|
+
raise CatalogError(f"{context}.{key} must be a list")
|
|
94
|
+
return [str(item) for item in val]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _load_apt_sources(raw: dict[str, object]) -> dict[str, AptSourceEntry]:
|
|
98
|
+
entries: dict[str, AptSourceEntry] = {}
|
|
99
|
+
for name, data in raw.items():
|
|
100
|
+
if not isinstance(data, dict):
|
|
101
|
+
raise CatalogError(f"apt_sources.{name} must be a table")
|
|
102
|
+
ctx = f"apt_sources.{name}"
|
|
103
|
+
source_file = str(_require_field(data, "source_file", ctx))
|
|
104
|
+
if not _SAFE_FILENAME_RE.match(source_file):
|
|
105
|
+
raise CatalogError(f"{ctx}.source_file must be a simple filename, got: {source_file}")
|
|
106
|
+
entries[name] = AptSourceEntry(
|
|
107
|
+
name=name,
|
|
108
|
+
description=str(data.get("description", "")),
|
|
109
|
+
key_url=str(_require_field(data, "key_url", ctx)),
|
|
110
|
+
key_path=str(_require_field(data, "key_path", ctx)),
|
|
111
|
+
source=str(_require_field(data, "source", ctx)),
|
|
112
|
+
source_file=source_file,
|
|
113
|
+
key_dearmor=bool(data.get("key_dearmor", False)),
|
|
114
|
+
)
|
|
115
|
+
return entries
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _load_apt_packages(raw: dict[str, object]) -> dict[str, AptPackageEntry]:
|
|
119
|
+
entries: dict[str, AptPackageEntry] = {}
|
|
120
|
+
for name, data in raw.items():
|
|
121
|
+
if not isinstance(data, dict):
|
|
122
|
+
raise CatalogError(f"apt_packages.{name} must be a table")
|
|
123
|
+
ctx = f"apt_packages.{name}"
|
|
124
|
+
entries[name] = AptPackageEntry(
|
|
125
|
+
name=name,
|
|
126
|
+
description=str(data.get("description", "")),
|
|
127
|
+
apt=_require_list(data, "apt", ctx),
|
|
128
|
+
apt_sources=_require_list(data, "apt_sources", ctx) if "apt_sources" in data else [],
|
|
129
|
+
)
|
|
130
|
+
return entries
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _load_test_fields(data: dict[str, object], ctx: str) -> dict[str, str | None]:
|
|
134
|
+
"""Load and validate test_exec/test_file/test_dir fields. At most one may be set."""
|
|
135
|
+
if "test" in data:
|
|
136
|
+
raise CatalogError(f"{ctx}: 'test' is not a valid field. Use 'test_exec', 'test_file', or 'test_dir'.")
|
|
137
|
+
fields: dict[str, str | None] = {}
|
|
138
|
+
for key in ("test_exec", "test_file", "test_dir"):
|
|
139
|
+
raw = str(data[key]).strip() if key in data else None
|
|
140
|
+
fields[key] = raw if raw else None
|
|
141
|
+
set_count = sum(1 for v in fields.values() if v is not None)
|
|
142
|
+
if set_count > 1:
|
|
143
|
+
raise CatalogError(f"{ctx}: at most one of test_exec, test_file, test_dir may be set")
|
|
144
|
+
return fields
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _load_system_commands(raw: dict[str, object]) -> dict[str, SystemInstallCommandEntry]:
|
|
148
|
+
entries: dict[str, SystemInstallCommandEntry] = {}
|
|
149
|
+
for name, data in raw.items():
|
|
150
|
+
if not isinstance(data, dict):
|
|
151
|
+
raise CatalogError(f"system_install_commands.{name} must be a table")
|
|
152
|
+
ctx = f"system_install_commands.{name}"
|
|
153
|
+
tests = _load_test_fields(data, ctx)
|
|
154
|
+
entries[name] = SystemInstallCommandEntry(
|
|
155
|
+
name=name,
|
|
156
|
+
description=str(data.get("description", "")),
|
|
157
|
+
command=str(_require_field(data, "command", ctx)),
|
|
158
|
+
path=_require_list(data, "path", ctx) if "path" in data else [],
|
|
159
|
+
**tests,
|
|
160
|
+
)
|
|
161
|
+
return entries
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _load_user_commands(raw: dict[str, object]) -> dict[str, UserInstallCommandEntry]:
|
|
165
|
+
entries: dict[str, UserInstallCommandEntry] = {}
|
|
166
|
+
for name, data in raw.items():
|
|
167
|
+
if not isinstance(data, dict):
|
|
168
|
+
raise CatalogError(f"user_install_commands.{name} must be a table")
|
|
169
|
+
ctx = f"user_install_commands.{name}"
|
|
170
|
+
tests = _load_test_fields(data, ctx)
|
|
171
|
+
entries[name] = UserInstallCommandEntry(
|
|
172
|
+
name=name,
|
|
173
|
+
description=str(data.get("description", "")),
|
|
174
|
+
command=str(_require_field(data, "command", ctx)),
|
|
175
|
+
path=_require_list(data, "path", ctx) if "path" in data else [],
|
|
176
|
+
**tests,
|
|
177
|
+
)
|
|
178
|
+
return entries
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _load_toml(path: Path) -> dict[str, object]:
|
|
182
|
+
with open(path, "rb") as f:
|
|
183
|
+
return tomllib.load(f)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def load_builtin_catalog() -> ResolvedCatalog:
|
|
187
|
+
"""Load the built-in catalog bundled with the package."""
|
|
188
|
+
if not _BUILTIN_CATALOG_PATH.exists():
|
|
189
|
+
raise CatalogError(f"Built-in catalog not found: {_BUILTIN_CATALOG_PATH}")
|
|
190
|
+
|
|
191
|
+
data = _load_toml(_BUILTIN_CATALOG_PATH)
|
|
192
|
+
return _parse_catalog(data)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _get_section(data: dict[str, object], key: str) -> dict[str, object]:
|
|
196
|
+
"""Extract a TOML table section, returning an empty dict if missing or wrong type."""
|
|
197
|
+
val = data.get(key, {})
|
|
198
|
+
if not isinstance(val, dict):
|
|
199
|
+
raise CatalogError(f"Catalog section '{key}' must be a table, got {type(val).__name__}")
|
|
200
|
+
return val
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _parse_catalog(data: dict[str, object]) -> ResolvedCatalog:
|
|
204
|
+
return ResolvedCatalog(
|
|
205
|
+
apt_sources=_load_apt_sources(_get_section(data, "apt_sources")),
|
|
206
|
+
apt_packages=_load_apt_packages(_get_section(data, "apt_packages")),
|
|
207
|
+
system_install_commands=_load_system_commands(_get_section(data, "system_install_commands")),
|
|
208
|
+
user_install_commands=_load_user_commands(_get_section(data, "user_install_commands")),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def load_catalog(config: Config) -> ResolvedCatalog:
|
|
213
|
+
"""Load and merge built-in + custom catalog entries.
|
|
214
|
+
|
|
215
|
+
Custom entries override built-in entries with the same name.
|
|
216
|
+
Cross-references (apt_sources in apt_packages) are validated.
|
|
217
|
+
"""
|
|
218
|
+
builtin = load_builtin_catalog()
|
|
219
|
+
|
|
220
|
+
# Parse custom entries (raw dicts from config) into typed entries
|
|
221
|
+
custom_apt_sources = _load_apt_sources(config.apt_sources)
|
|
222
|
+
custom_apt_packages = _load_apt_packages(config.apt_packages)
|
|
223
|
+
custom_system_cmds = _load_system_commands(config.system_install_commands)
|
|
224
|
+
custom_user_install_cmds = _load_user_commands(config.user_install_commands)
|
|
225
|
+
|
|
226
|
+
# Merge: custom wins on name collision
|
|
227
|
+
apt_sources = {**builtin.apt_sources, **custom_apt_sources}
|
|
228
|
+
apt_packages = {**builtin.apt_packages, **custom_apt_packages}
|
|
229
|
+
system_cmds = {**builtin.system_install_commands, **custom_system_cmds}
|
|
230
|
+
user_install_cmds = {**builtin.user_install_commands, **custom_user_install_cmds}
|
|
231
|
+
|
|
232
|
+
catalog = ResolvedCatalog(
|
|
233
|
+
apt_sources=apt_sources,
|
|
234
|
+
apt_packages=apt_packages,
|
|
235
|
+
system_install_commands=system_cmds,
|
|
236
|
+
user_install_commands=user_install_cmds,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
_validate_references(catalog)
|
|
240
|
+
return catalog
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _validate_references(catalog: ResolvedCatalog) -> None:
|
|
244
|
+
"""Validate cross-references within the catalog."""
|
|
245
|
+
for name, pkg in catalog.apt_packages.items():
|
|
246
|
+
for src_name in pkg.apt_sources:
|
|
247
|
+
if src_name not in catalog.apt_sources:
|
|
248
|
+
raise CatalogError(f"apt_packages.{name} references unknown apt source: {src_name}")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def validate_selections(config: Config, catalog: ResolvedCatalog) -> None:
|
|
252
|
+
"""Validate that vm.config and agent.config selections resolve in the catalog."""
|
|
253
|
+
for ref in config.vm.apt_packages:
|
|
254
|
+
if ref not in catalog.apt_packages:
|
|
255
|
+
raise CatalogError(f"vm.config.apt_packages references unknown entry: {ref}")
|
|
256
|
+
for ref in config.vm.system_install_commands:
|
|
257
|
+
if ref not in catalog.system_install_commands:
|
|
258
|
+
raise CatalogError(f"vm.config.system_install_commands references unknown entry: {ref}")
|
|
259
|
+
for ref in config.admin.user_install_commands:
|
|
260
|
+
if ref not in catalog.user_install_commands:
|
|
261
|
+
raise CatalogError(f"admin.config.user_install_commands references unknown entry: {ref}")
|
|
262
|
+
for ref in config.agent.user_install_commands:
|
|
263
|
+
if ref not in catalog.user_install_commands:
|
|
264
|
+
raise CatalogError(f"agent.config.user_install_commands references unknown entry: {ref}")
|
agentworks/catalog.toml
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Built-in catalog of apt sources, apt packages, system install commands, and
|
|
2
|
+
# user install commands. This file is read-only at runtime and ships with the
|
|
3
|
+
# agentworks package. Users can override entries by defining entries with the
|
|
4
|
+
# same name in their config.toml.
|
|
5
|
+
|
|
6
|
+
# -- Apt sources ---------------------------------------------------------------
|
|
7
|
+
# Third-party apt repositories. The {arch} placeholder is resolved at install
|
|
8
|
+
# time via `dpkg --print-architecture` (amd64 or arm64).
|
|
9
|
+
|
|
10
|
+
[apt_sources.github-cli]
|
|
11
|
+
description = "GitHub CLI official apt repository"
|
|
12
|
+
key_url = "https://cli.github.com/packages/githubcli-archive-keyring.gpg"
|
|
13
|
+
key_path = "/etc/apt/keyrings/githubcli-archive-keyring.gpg"
|
|
14
|
+
source = "deb [arch={arch} signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main"
|
|
15
|
+
source_file = "github-cli.list"
|
|
16
|
+
|
|
17
|
+
[apt_sources.hashicorp]
|
|
18
|
+
description = "HashiCorp official apt repository"
|
|
19
|
+
key_url = "https://apt.releases.hashicorp.com/gpg"
|
|
20
|
+
key_path = "/etc/apt/keyrings/hashicorp-archive-keyring.gpg"
|
|
21
|
+
key_dearmor = true
|
|
22
|
+
source = "deb [arch={arch} signed-by=/etc/apt/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com bookworm main"
|
|
23
|
+
source_file = "hashicorp.list"
|
|
24
|
+
|
|
25
|
+
[apt_sources.nodesource-v22]
|
|
26
|
+
description = "NodeSource Node.js 22.x apt repository"
|
|
27
|
+
key_url = "https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key"
|
|
28
|
+
key_path = "/etc/apt/keyrings/nodesource.gpg"
|
|
29
|
+
key_dearmor = true
|
|
30
|
+
source = "deb [arch={arch} signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main"
|
|
31
|
+
source_file = "nodesource.list"
|
|
32
|
+
|
|
33
|
+
[apt_sources.tofuutils-tenv]
|
|
34
|
+
description = "tofuutils tenv apt repository (Cloudsmith)"
|
|
35
|
+
key_url = "https://dl.cloudsmith.io/public/tofuutils/tenv/gpg.8ACD4386ADD982F6.key"
|
|
36
|
+
key_path = "/etc/apt/keyrings/tofuutils-tenv-archive-keyring.gpg"
|
|
37
|
+
key_dearmor = true
|
|
38
|
+
source = "deb [signed-by=/etc/apt/keyrings/tofuutils-tenv-archive-keyring.gpg] https://dl.cloudsmith.io/public/tofuutils/tenv/deb/debian bookworm main"
|
|
39
|
+
source_file = "tofuutils-tenv.list"
|
|
40
|
+
|
|
41
|
+
# -- Apt packages --------------------------------------------------------------
|
|
42
|
+
# Named sets of apt packages with optional apt source dependencies.
|
|
43
|
+
|
|
44
|
+
[apt_packages.gh]
|
|
45
|
+
description = "GitHub CLI"
|
|
46
|
+
apt_sources = ["github-cli"]
|
|
47
|
+
apt = ["gh"]
|
|
48
|
+
|
|
49
|
+
[apt_packages.terraform]
|
|
50
|
+
description = "HashiCorp Terraform"
|
|
51
|
+
apt_sources = ["hashicorp"]
|
|
52
|
+
apt = ["terraform"]
|
|
53
|
+
|
|
54
|
+
[apt_packages.nodejs]
|
|
55
|
+
description = "Node.js 22.x via NodeSource"
|
|
56
|
+
apt_sources = ["nodesource-v22"]
|
|
57
|
+
apt = ["nodejs"]
|
|
58
|
+
|
|
59
|
+
[apt_packages.tenv]
|
|
60
|
+
description = "tenv (Terraform/OpenTofu/Terragrunt version manager)"
|
|
61
|
+
apt_sources = ["tofuutils-tenv"]
|
|
62
|
+
apt = ["tenv"]
|
|
63
|
+
|
|
64
|
+
# -- System install commands ---------------------------------------------------
|
|
65
|
+
# Shell commands that install system-wide tooling (run once per VM).
|
|
66
|
+
#
|
|
67
|
+
# These may be re-run (e.g. during reinit) so they really should be idempotent.
|
|
68
|
+
#
|
|
69
|
+
# Optional checks can be defined to short-circuit installation if the tool is already present.
|
|
70
|
+
# Installed-check fields (at most one per entry):
|
|
71
|
+
# test_exec: skip if this command is on PATH (uses `command -v`)
|
|
72
|
+
# test_file: skip if this file exists
|
|
73
|
+
# test_dir: skip if this directory exists
|
|
74
|
+
|
|
75
|
+
[system_install_commands.az-cli]
|
|
76
|
+
description = "Azure CLI"
|
|
77
|
+
command = "curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash"
|
|
78
|
+
test_exec = "az"
|
|
79
|
+
|
|
80
|
+
# -- User install commands -----------------------------------------------------
|
|
81
|
+
# Shell commands that install per-user tooling (run for each user).
|
|
82
|
+
#
|
|
83
|
+
# These may be re-run (e.g. during reinit) so they really should be idempotent.
|
|
84
|
+
#
|
|
85
|
+
# Optional checks can be defined to short-circuit installation if the tool is already present.
|
|
86
|
+
# Installed-check fields (at most one per entry):
|
|
87
|
+
# test_exec: skip if this command is on PATH (uses `command -v`)
|
|
88
|
+
# test_file: skip if this file exists (~ resolves to the user's home)
|
|
89
|
+
# test_dir: skip if this directory exists (~ resolves to the user's home)
|
|
90
|
+
#
|
|
91
|
+
# path: optional list of directories to add to the user's PATH. This is only necessary if the install
|
|
92
|
+
# command doesn't modify shell files to update the PATH automatically.
|
|
93
|
+
|
|
94
|
+
# -- User install commands -----------------------------------------------------
|
|
95
|
+
|
|
96
|
+
[user_install_commands.oh-my-zsh]
|
|
97
|
+
description = "Oh My Zsh"
|
|
98
|
+
command = "sh -c \"$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)\" -- --unattended"
|
|
99
|
+
test_dir = "~/.oh-my-zsh"
|
|
100
|
+
|
|
101
|
+
[user_install_commands.bun]
|
|
102
|
+
description = "Bun JavaScript runtime"
|
|
103
|
+
command = "curl -fsSL https://bun.sh/install | bash"
|
|
104
|
+
test_exec = "bun"
|
|
105
|
+
|
|
106
|
+
[user_install_commands.fnm]
|
|
107
|
+
description = "Fast Node Manager"
|
|
108
|
+
command = "curl -fsSL https://fnm.vercel.app/install | bash"
|
|
109
|
+
test_exec = "fnm"
|
|
110
|
+
|
|
111
|
+
[user_install_commands.nvm]
|
|
112
|
+
description = "Node Version Manager"
|
|
113
|
+
command = "curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh | bash"
|
|
114
|
+
test_file = "~/.nvm/nvm.sh"
|
|
115
|
+
|
|
116
|
+
[user_install_commands.claude]
|
|
117
|
+
description = "Claude Code CLI"
|
|
118
|
+
command = "curl -fsSL https://claude.ai/install.sh | bash"
|
|
119
|
+
path = ["~/.local/bin"]
|
|
120
|
+
test_exec = "claude"
|
|
121
|
+
|
|
122
|
+
[user_install_commands.starship]
|
|
123
|
+
description = "Starship cross-shell prompt"
|
|
124
|
+
command = "curl -sS https://starship.rs/install.sh | sh -s -- -y -b ~/.local/bin"
|
|
125
|
+
path = ["~/.local/bin"]
|
|
126
|
+
test_exec = "starship"
|
|
127
|
+
|
|
128
|
+
[user_install_commands.uv]
|
|
129
|
+
description = "uv Python version manager"
|
|
130
|
+
command = "curl -LsSf https://astral.sh/uv/install.sh | sh"
|
|
131
|
+
test_exec = "uv"
|