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/config.py
ADDED
|
@@ -0,0 +1,894 @@
|
|
|
1
|
+
"""Agentworks configuration loading and validation.
|
|
2
|
+
|
|
3
|
+
Config lives at ~/.config/agentworks/config.toml. It is read-only at runtime.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
import tomllib
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING, Protocol
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import Mapping
|
|
17
|
+
|
|
18
|
+
from agentworks.agents.templates import ResolvedAgentTemplate
|
|
19
|
+
from agentworks.vms.templates import ResolvedVMTemplate
|
|
20
|
+
|
|
21
|
+
CONFIG_DIR = Path.home() / ".config" / "agentworks"
|
|
22
|
+
CONFIG_PATH = CONFIG_DIR / "config.toml"
|
|
23
|
+
|
|
24
|
+
NAME_RE = re.compile(r"^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$")
|
|
25
|
+
# Linux username: alphanumeric, hyphens, underscores; 1-32 chars
|
|
26
|
+
VM_USER_RE = re.compile(r"^[a-z_][a-z0-9_-]{0,31}$")
|
|
27
|
+
# SSH host prefix: alphanumeric, hyphens, underscores, dots
|
|
28
|
+
SSH_HOST_PREFIX_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]*$")
|
|
29
|
+
|
|
30
|
+
MAX_NAME_LENGTH = 30
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def validate_name(name: str) -> None:
|
|
34
|
+
"""Validate a resource name, raising ValidationError on failure.
|
|
35
|
+
|
|
36
|
+
Rules: lowercase alphanumeric, hyphens, underscores. Must start and end with
|
|
37
|
+
alphanumeric. No consecutive hyphens (reserved for agent username separator).
|
|
38
|
+
Max 30 characters (leaves room for agent username derivation within the
|
|
39
|
+
32-character Linux username limit).
|
|
40
|
+
"""
|
|
41
|
+
from agentworks.output import ValidationError
|
|
42
|
+
|
|
43
|
+
if len(name) > MAX_NAME_LENGTH:
|
|
44
|
+
raise ValidationError(
|
|
45
|
+
f"name '{name}' is too long ({len(name)} chars, max {MAX_NAME_LENGTH})"
|
|
46
|
+
)
|
|
47
|
+
if not NAME_RE.match(name) or "--" in name:
|
|
48
|
+
raise ValidationError(
|
|
49
|
+
f"invalid name '{name}'. Names must be lowercase alphanumeric "
|
|
50
|
+
"with hyphens or underscores, must start and end with a letter or digit, "
|
|
51
|
+
"and cannot contain consecutive hyphens (--)"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def validate_admin_username(admin_username: str) -> None:
|
|
56
|
+
"""Validate an admin username for shell and OS safety."""
|
|
57
|
+
from agentworks.output import ValidationError
|
|
58
|
+
|
|
59
|
+
if not VM_USER_RE.match(admin_username):
|
|
60
|
+
raise ValidationError(
|
|
61
|
+
f"invalid admin_username '{admin_username}'. Must be a valid Linux username "
|
|
62
|
+
"(lowercase, alphanumeric/hyphens/underscores, max 32 chars)"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# Valid values for enum-like fields
|
|
67
|
+
VALID_PLATFORMS = ("lima", "azure", "wsl2", "proxmox")
|
|
68
|
+
VALID_GIT_CREDENTIAL_TYPES = ("azdo", "github")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ConfigError(Exception):
|
|
72
|
+
"""Raised when configuration is invalid."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# -- Data classes ----------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class OperatorConfig:
|
|
80
|
+
ssh_public_key: Path
|
|
81
|
+
ssh_private_key: Path
|
|
82
|
+
ssh_config: Path = field(default_factory=lambda: Path.home() / ".ssh" / "config")
|
|
83
|
+
ssh_config_dir: bool = True
|
|
84
|
+
ssh_host_prefix: str = "awvm--"
|
|
85
|
+
extra_ssh_public_keys: list[Path] = field(default_factory=list)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
#: Backward-compatible alias; prefer ``OperatorConfig``.
|
|
89
|
+
UserConfig = OperatorConfig
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True)
|
|
93
|
+
class PathsConfig:
|
|
94
|
+
local_workspaces: Path = field(default_factory=lambda: Path.home() / "workspaces")
|
|
95
|
+
vm_workspaces: str = "/opt/agentworks/workspaces"
|
|
96
|
+
vscode_workspaces: Path = field(default_factory=lambda: Path.home() / "aw-vscode-workspaces")
|
|
97
|
+
backups: Path = field(default_factory=lambda: CONFIG_DIR / "backups")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class DefaultsConfig:
|
|
102
|
+
platform: str | None = None
|
|
103
|
+
vm_host: str | None = None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True)
|
|
107
|
+
class VMTemplate:
|
|
108
|
+
"""VM template definition. All fields are optional (None = inherit/default)."""
|
|
109
|
+
|
|
110
|
+
name: str
|
|
111
|
+
inherits: list[str] = field(default_factory=list)
|
|
112
|
+
# Provisioning
|
|
113
|
+
cpus: int | None = None
|
|
114
|
+
memory: int | None = None
|
|
115
|
+
disk: int | None = None
|
|
116
|
+
azure_vm_size: str | None = None
|
|
117
|
+
swap: int | None = None
|
|
118
|
+
# System-wide initialization
|
|
119
|
+
apt: list[str] | None = None
|
|
120
|
+
apt_packages: list[str] | None = None
|
|
121
|
+
snap: list[str] | None = None
|
|
122
|
+
system_install_commands: list[str] | None = None
|
|
123
|
+
# Nerf tools
|
|
124
|
+
nerf_build_claude_plugin: bool | None = None
|
|
125
|
+
skip_nerf_defaults: bool | None = None
|
|
126
|
+
nerf_addl_manifests: list[Path] | None = None
|
|
127
|
+
nerf_home_dir: str | None = None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(frozen=True)
|
|
131
|
+
class AdminConfig:
|
|
132
|
+
"""Per-user config for the admin user on VMs."""
|
|
133
|
+
|
|
134
|
+
username: str = "agentworks"
|
|
135
|
+
shell: str = "zsh"
|
|
136
|
+
git_credentials: list[str] = field(default_factory=list)
|
|
137
|
+
user_install_commands: list[str] = field(default_factory=list)
|
|
138
|
+
dotfiles_source: str | None = None
|
|
139
|
+
dotfiles_destination: str = "~/.dotfiles"
|
|
140
|
+
dotfiles_install_cmd: str = "./install.sh"
|
|
141
|
+
mise_activate: bool = True
|
|
142
|
+
mise_packages: list[str] = field(default_factory=list)
|
|
143
|
+
mise_lockfile: str | None = None
|
|
144
|
+
mise_allow_unlocked: bool = False
|
|
145
|
+
mise_install_before: str = "7d"
|
|
146
|
+
mise_prune_on_reinit: bool = True
|
|
147
|
+
nerf_install_claude_plugin: bool = False
|
|
148
|
+
git_force_safe_directory: bool = True
|
|
149
|
+
# Claude Code
|
|
150
|
+
claude_marketplaces: list[str] = field(default_factory=list)
|
|
151
|
+
claude_plugins: list[str] = field(default_factory=list)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass(frozen=True)
|
|
155
|
+
class AgentTemplate:
|
|
156
|
+
"""Agent template definition. All fields are optional (None = inherit/default)."""
|
|
157
|
+
|
|
158
|
+
name: str
|
|
159
|
+
inherits: list[str] = field(default_factory=list)
|
|
160
|
+
shell: str | None = None
|
|
161
|
+
git_credentials: list[str] | None = None
|
|
162
|
+
user_install_commands: list[str] | None = None
|
|
163
|
+
dotfiles_source: str | None = None
|
|
164
|
+
dotfiles_destination: str | None = None
|
|
165
|
+
dotfiles_install_cmd: str | None = None
|
|
166
|
+
mise_activate: bool | None = None
|
|
167
|
+
mise_packages: list[str] | None = None
|
|
168
|
+
mise_lockfile: str | None = None
|
|
169
|
+
mise_allow_unlocked: bool | None = None
|
|
170
|
+
mise_install_before: str | None = None
|
|
171
|
+
mise_prune_on_reinit: bool | None = None
|
|
172
|
+
nerf_install_claude_plugin: bool | None = None
|
|
173
|
+
claude_marketplaces: list[str] | None = None
|
|
174
|
+
claude_plugins: list[str] | None = None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@dataclass(frozen=True)
|
|
178
|
+
class WorkspaceTemplate:
|
|
179
|
+
name: str
|
|
180
|
+
inherits: list[str] = field(default_factory=list)
|
|
181
|
+
repo: str | None = None
|
|
182
|
+
tmuxinator: bool | None = None # None = not explicitly set (inherit/default to True)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@dataclass(frozen=True)
|
|
186
|
+
class GitCredentialConfig:
|
|
187
|
+
name: str
|
|
188
|
+
type: str
|
|
189
|
+
org: str | None = None
|
|
190
|
+
description: str | None = None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@dataclass(frozen=True)
|
|
194
|
+
class SessionTemplate:
|
|
195
|
+
"""Session template definition. All fields optional (None = inherit/default)."""
|
|
196
|
+
|
|
197
|
+
name: str
|
|
198
|
+
inherits: list[str] = field(default_factory=list)
|
|
199
|
+
command: str | None = None
|
|
200
|
+
description: str | None = None
|
|
201
|
+
restart_command: str | None = None
|
|
202
|
+
env: dict[str, str] | None = None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@dataclass(frozen=True)
|
|
206
|
+
class SessionConfig:
|
|
207
|
+
history_limit: int = 50_000
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@dataclass(frozen=True)
|
|
211
|
+
class AzureConfig:
|
|
212
|
+
subscription_id: str
|
|
213
|
+
resource_group: str
|
|
214
|
+
region: str
|
|
215
|
+
idle_timeout_hours: int = 2
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@dataclass(frozen=True)
|
|
219
|
+
class ProxmoxConfig:
|
|
220
|
+
api_url: str
|
|
221
|
+
node: str
|
|
222
|
+
token_id: str
|
|
223
|
+
template_vmid: int
|
|
224
|
+
storage: str = "local-lvm"
|
|
225
|
+
bridge: str = "vmbr0"
|
|
226
|
+
pool: str = "agentworks"
|
|
227
|
+
verify_ssl: bool = True
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@dataclass(frozen=True)
|
|
231
|
+
class Config:
|
|
232
|
+
operator: OperatorConfig
|
|
233
|
+
paths: PathsConfig
|
|
234
|
+
defaults: DefaultsConfig
|
|
235
|
+
vm_templates: dict[str, VMTemplate]
|
|
236
|
+
vm: ResolvedVMTemplate
|
|
237
|
+
admin: AdminConfig
|
|
238
|
+
agent_templates: dict[str, AgentTemplate]
|
|
239
|
+
agent: ResolvedAgentTemplate
|
|
240
|
+
session: SessionConfig
|
|
241
|
+
session_templates: dict[str, SessionTemplate]
|
|
242
|
+
workspace_templates: dict[str, WorkspaceTemplate]
|
|
243
|
+
git_credentials: dict[str, GitCredentialConfig]
|
|
244
|
+
apt_sources: dict[str, object] = field(default_factory=dict)
|
|
245
|
+
apt_packages: dict[str, object] = field(default_factory=dict)
|
|
246
|
+
system_install_commands: dict[str, object] = field(default_factory=dict)
|
|
247
|
+
user_install_commands: dict[str, object] = field(default_factory=dict)
|
|
248
|
+
azure: AzureConfig | None = None
|
|
249
|
+
proxmox: ProxmoxConfig | None = None
|
|
250
|
+
config_issues: tuple[str, ...] = ()
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# -- Loading ---------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _expand(path_str: str) -> Path:
|
|
257
|
+
return Path(path_str).expanduser()
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _require(data: dict[str, object], key: str, context: str) -> object:
|
|
261
|
+
if key not in data:
|
|
262
|
+
raise ConfigError(f"{context}.{key} is required")
|
|
263
|
+
return data[key]
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _require_string_list(data: dict[str, object], key: str, context: str) -> list[str]:
|
|
267
|
+
"""Load a key as a list of strings, raising ConfigError on type mismatch."""
|
|
268
|
+
val = data.get(key, [])
|
|
269
|
+
if not isinstance(val, list) or not all(isinstance(v, str) for v in val):
|
|
270
|
+
raise ConfigError(f"{context}.{key} must be a list of strings")
|
|
271
|
+
return val
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _warn_unexpected_keys(
|
|
275
|
+
raw: dict[str, object],
|
|
276
|
+
known: set[str],
|
|
277
|
+
section: str,
|
|
278
|
+
issues: list[str],
|
|
279
|
+
) -> None:
|
|
280
|
+
"""Record unexpected keys in a config section.
|
|
281
|
+
|
|
282
|
+
This catches the common TOML pitfall where a [section] header is
|
|
283
|
+
commented out and its keys land in the previous section, as well as
|
|
284
|
+
typos and version mismatches. Issues are collected on the Config object
|
|
285
|
+
so that doctor can report all of them without short-circuiting.
|
|
286
|
+
"""
|
|
287
|
+
unexpected = set(raw.keys()) - known
|
|
288
|
+
if unexpected:
|
|
289
|
+
keys = ", ".join(sorted(unexpected))
|
|
290
|
+
issues.append(f"unexpected keys in [{section}]: {keys}")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
_OPERATOR_KEYS = {
|
|
294
|
+
"ssh_public_key",
|
|
295
|
+
"ssh_private_key",
|
|
296
|
+
"ssh_config",
|
|
297
|
+
"ssh_config_dir",
|
|
298
|
+
"ssh_host_prefix",
|
|
299
|
+
"extra_ssh_public_keys",
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _load_operator(data: dict[str, object], issues: list[str]) -> OperatorConfig:
|
|
304
|
+
raw = data.get("operator")
|
|
305
|
+
section_name = "operator"
|
|
306
|
+
if not isinstance(raw, dict):
|
|
307
|
+
# Accept [user] as a deprecated alias for [operator]
|
|
308
|
+
raw = data.get("user")
|
|
309
|
+
if isinstance(raw, dict):
|
|
310
|
+
print(
|
|
311
|
+
"WARNING: config [user] section is deprecated; rename it to [operator].",
|
|
312
|
+
file=sys.stderr,
|
|
313
|
+
)
|
|
314
|
+
section_name = "user"
|
|
315
|
+
else:
|
|
316
|
+
raise ConfigError("[operator] section is required")
|
|
317
|
+
|
|
318
|
+
_warn_unexpected_keys(raw, _OPERATOR_KEYS, section_name, issues)
|
|
319
|
+
|
|
320
|
+
pub = _expand(str(_require(raw, "ssh_public_key", section_name)))
|
|
321
|
+
priv = _expand(str(_require(raw, "ssh_private_key", section_name)))
|
|
322
|
+
|
|
323
|
+
if not pub.exists():
|
|
324
|
+
raise ConfigError(f"{section_name}.ssh_public_key does not exist: {pub}")
|
|
325
|
+
if not priv.exists():
|
|
326
|
+
raise ConfigError(f"{section_name}.ssh_private_key does not exist: {priv}")
|
|
327
|
+
|
|
328
|
+
ssh_config = Path.home() / ".ssh" / "config"
|
|
329
|
+
if "ssh_config" in raw:
|
|
330
|
+
ssh_config = _expand(str(raw["ssh_config"]))
|
|
331
|
+
|
|
332
|
+
extra_keys: list[Path] = []
|
|
333
|
+
for entry in raw.get("extra_ssh_public_keys", []):
|
|
334
|
+
p = _expand(str(entry))
|
|
335
|
+
if not p.exists():
|
|
336
|
+
raise ConfigError(f"{section_name}.extra_ssh_public_keys: file does not exist: {p}")
|
|
337
|
+
extra_keys.append(p)
|
|
338
|
+
|
|
339
|
+
host_prefix = str(raw.get("ssh_host_prefix", "awvm--"))
|
|
340
|
+
if not SSH_HOST_PREFIX_RE.match(host_prefix):
|
|
341
|
+
raise ConfigError(
|
|
342
|
+
f"{section_name}.ssh_host_prefix must be alphanumeric with hyphens, underscores, "
|
|
343
|
+
f"or dots (no whitespace or special characters), got: {host_prefix!r}"
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
return OperatorConfig(
|
|
347
|
+
ssh_public_key=pub,
|
|
348
|
+
ssh_private_key=priv,
|
|
349
|
+
ssh_config=ssh_config,
|
|
350
|
+
ssh_config_dir=bool(raw.get("ssh_config_dir", True)),
|
|
351
|
+
ssh_host_prefix=host_prefix,
|
|
352
|
+
extra_ssh_public_keys=extra_keys,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _load_paths(data: dict[str, object]) -> PathsConfig:
|
|
357
|
+
raw = data.get("paths", {})
|
|
358
|
+
if not isinstance(raw, dict):
|
|
359
|
+
raise ConfigError("[paths] must be a table")
|
|
360
|
+
defaults = PathsConfig()
|
|
361
|
+
local_ws = _expand(str(raw["local_workspaces"])) if "local_workspaces" in raw else defaults.local_workspaces
|
|
362
|
+
vm_ws = str(raw["vm_workspaces"]) if "vm_workspaces" in raw else defaults.vm_workspaces
|
|
363
|
+
if "vscode_workspaces" in raw:
|
|
364
|
+
vscode_ws = _expand(str(raw["vscode_workspaces"]))
|
|
365
|
+
elif "code_workspaces" in raw:
|
|
366
|
+
vscode_ws = _expand(str(raw["code_workspaces"]))
|
|
367
|
+
else:
|
|
368
|
+
vscode_ws = defaults.vscode_workspaces
|
|
369
|
+
backups = _expand(str(raw["backups"])) if "backups" in raw else defaults.backups
|
|
370
|
+
return PathsConfig(local_workspaces=local_ws, vm_workspaces=vm_ws, vscode_workspaces=vscode_ws, backups=backups)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
_DEFAULTS_KEYS = {"platform", "vm_host"}
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _load_defaults(data: dict[str, object], issues: list[str]) -> DefaultsConfig:
|
|
377
|
+
raw = data.get("defaults", {})
|
|
378
|
+
if not isinstance(raw, dict):
|
|
379
|
+
raise ConfigError("[defaults] must be a table")
|
|
380
|
+
|
|
381
|
+
if "git_credentials" in raw:
|
|
382
|
+
raise ConfigError(
|
|
383
|
+
"defaults.git_credentials has been removed. Move git_credentials into "
|
|
384
|
+
"[admin.config] and/or [agent.config]. See docs/guides/config-migration.md."
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
_warn_unexpected_keys(raw, _DEFAULTS_KEYS, "defaults", issues)
|
|
388
|
+
|
|
389
|
+
platform = raw.get("platform")
|
|
390
|
+
if platform is not None and platform not in VALID_PLATFORMS:
|
|
391
|
+
raise ConfigError(f"defaults.platform must be one of {VALID_PLATFORMS}, got: {platform}")
|
|
392
|
+
|
|
393
|
+
return DefaultsConfig(
|
|
394
|
+
platform=str(platform) if platform is not None else None,
|
|
395
|
+
vm_host=str(raw["vm_host"]) if "vm_host" in raw else None,
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
_VM_TEMPLATE_KEYS = {
|
|
400
|
+
"inherits",
|
|
401
|
+
"cpus",
|
|
402
|
+
"memory",
|
|
403
|
+
"disk",
|
|
404
|
+
"azure_vm_size",
|
|
405
|
+
"swap",
|
|
406
|
+
"apt",
|
|
407
|
+
"apt_packages",
|
|
408
|
+
"snap",
|
|
409
|
+
"system_install_commands",
|
|
410
|
+
"nerf_build_claude_plugin",
|
|
411
|
+
"skip_nerf_defaults",
|
|
412
|
+
"nerf_addl_manifests",
|
|
413
|
+
"nerf_home_dir",
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _load_vm_templates(data: dict[str, object], issues: list[str]) -> dict[str, VMTemplate]:
|
|
418
|
+
raw = data.get("vm_templates", {})
|
|
419
|
+
if not isinstance(raw, dict):
|
|
420
|
+
raise ConfigError("[vm_templates] must be a table")
|
|
421
|
+
|
|
422
|
+
if "vm" in data and isinstance(data["vm"], dict) and "config" in data["vm"]:
|
|
423
|
+
raise ConfigError(
|
|
424
|
+
"[vm.config] has been replaced by [vm_templates.default]. See docs/guides/config-migration.md for details."
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
templates: dict[str, VMTemplate] = {}
|
|
428
|
+
for name, tdata in raw.items():
|
|
429
|
+
if not isinstance(tdata, dict):
|
|
430
|
+
raise ConfigError(f"vm_templates.{name} must be a table")
|
|
431
|
+
_warn_unexpected_keys(tdata, _VM_TEMPLATE_KEYS, f"vm_templates.{name}", issues)
|
|
432
|
+
|
|
433
|
+
nerf_addl = [_expand(str(m)) for m in tdata["nerf_addl_manifests"]] if "nerf_addl_manifests" in tdata else None
|
|
434
|
+
|
|
435
|
+
templates[name] = VMTemplate(
|
|
436
|
+
name=name,
|
|
437
|
+
inherits=list(tdata.get("inherits", [])),
|
|
438
|
+
cpus=int(tdata["cpus"]) if "cpus" in tdata else None,
|
|
439
|
+
memory=int(tdata["memory"]) if "memory" in tdata else None,
|
|
440
|
+
disk=int(tdata["disk"]) if "disk" in tdata else None,
|
|
441
|
+
azure_vm_size=str(tdata["azure_vm_size"]) if "azure_vm_size" in tdata else None,
|
|
442
|
+
swap=int(tdata["swap"]) if "swap" in tdata else None,
|
|
443
|
+
apt=list(tdata["apt"]) if "apt" in tdata else None,
|
|
444
|
+
apt_packages=list(tdata["apt_packages"]) if "apt_packages" in tdata else None,
|
|
445
|
+
snap=list(tdata["snap"]) if "snap" in tdata else None,
|
|
446
|
+
system_install_commands=(
|
|
447
|
+
list(tdata["system_install_commands"]) if "system_install_commands" in tdata else None
|
|
448
|
+
),
|
|
449
|
+
nerf_build_claude_plugin=(
|
|
450
|
+
bool(tdata["nerf_build_claude_plugin"]) if "nerf_build_claude_plugin" in tdata else None
|
|
451
|
+
),
|
|
452
|
+
skip_nerf_defaults=bool(tdata["skip_nerf_defaults"]) if "skip_nerf_defaults" in tdata else None,
|
|
453
|
+
nerf_addl_manifests=nerf_addl,
|
|
454
|
+
nerf_home_dir=str(tdata["nerf_home_dir"]) if "nerf_home_dir" in tdata else None,
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
# Validate inherits references and cycles
|
|
458
|
+
for name, tmpl in templates.items():
|
|
459
|
+
for parent in tmpl.inherits:
|
|
460
|
+
if parent not in templates and parent != "default":
|
|
461
|
+
raise ConfigError(f"vm_templates.{name}.inherits references unknown template: {parent}")
|
|
462
|
+
_detect_template_cycles(templates, "vm_templates")
|
|
463
|
+
|
|
464
|
+
return templates
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
_USER_CONFIG_KEYS = {
|
|
468
|
+
"username",
|
|
469
|
+
"shell",
|
|
470
|
+
"git_credentials",
|
|
471
|
+
"user_install_commands",
|
|
472
|
+
"dotfiles_source",
|
|
473
|
+
"dotfiles_destination",
|
|
474
|
+
"dotfiles_install_cmd",
|
|
475
|
+
"mise_activate",
|
|
476
|
+
"mise_packages",
|
|
477
|
+
"mise_lockfile",
|
|
478
|
+
"mise_allow_unlocked",
|
|
479
|
+
"mise_install_before",
|
|
480
|
+
"mise_prune_on_reinit",
|
|
481
|
+
"nerf_install_claude_plugin",
|
|
482
|
+
"git_force_safe_directory",
|
|
483
|
+
"claude_marketplaces",
|
|
484
|
+
"claude_plugins",
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _load_admin_config(data: dict[str, object], issues: list[str]) -> AdminConfig:
|
|
489
|
+
"""Load admin per-user config from [admin.config]."""
|
|
490
|
+
top = data.get("admin", {})
|
|
491
|
+
if not isinstance(top, dict):
|
|
492
|
+
raise ConfigError("[admin] must be a table")
|
|
493
|
+
raw = top.get("config", {})
|
|
494
|
+
if not isinstance(raw, dict):
|
|
495
|
+
raise ConfigError("[admin.config] must be a table")
|
|
496
|
+
|
|
497
|
+
_warn_unexpected_keys(raw, _USER_CONFIG_KEYS, "admin.config", issues)
|
|
498
|
+
|
|
499
|
+
return AdminConfig(
|
|
500
|
+
username=str(raw.get("username", "agentworks")),
|
|
501
|
+
shell=str(raw.get("shell", "zsh")),
|
|
502
|
+
git_credentials=list(raw.get("git_credentials", [])),
|
|
503
|
+
user_install_commands=list(raw.get("user_install_commands", [])),
|
|
504
|
+
dotfiles_source=str(raw["dotfiles_source"]) if "dotfiles_source" in raw else None,
|
|
505
|
+
dotfiles_destination=str(raw.get("dotfiles_destination", "~/.dotfiles")),
|
|
506
|
+
dotfiles_install_cmd=str(raw.get("dotfiles_install_cmd", "./install.sh")),
|
|
507
|
+
mise_activate=bool(raw.get("mise_activate", True)),
|
|
508
|
+
mise_packages=list(raw.get("mise_packages", [])),
|
|
509
|
+
mise_lockfile=str(raw["mise_lockfile"]) if "mise_lockfile" in raw else None,
|
|
510
|
+
mise_allow_unlocked=bool(raw.get("mise_allow_unlocked", False)),
|
|
511
|
+
mise_install_before=str(raw.get("mise_install_before", "7d")),
|
|
512
|
+
mise_prune_on_reinit=bool(raw.get("mise_prune_on_reinit", True)),
|
|
513
|
+
nerf_install_claude_plugin=bool(raw.get("nerf_install_claude_plugin", False)),
|
|
514
|
+
git_force_safe_directory=bool(raw.get("git_force_safe_directory", True)),
|
|
515
|
+
claude_marketplaces=_require_string_list(raw, "claude_marketplaces", "admin.config"),
|
|
516
|
+
claude_plugins=_require_string_list(raw, "claude_plugins", "admin.config"),
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
_AGENT_TEMPLATE_KEYS = _USER_CONFIG_KEYS | {"inherits"}
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _load_agent_templates(data: dict[str, object], issues: list[str]) -> dict[str, AgentTemplate]:
|
|
524
|
+
raw = data.get("agent_templates", {})
|
|
525
|
+
if not isinstance(raw, dict):
|
|
526
|
+
raise ConfigError("[agent_templates] must be a table")
|
|
527
|
+
|
|
528
|
+
if "agent" in data and isinstance(data["agent"], dict) and "config" in data["agent"]:
|
|
529
|
+
raise ConfigError(
|
|
530
|
+
"[agent.config] has been replaced by [agent_templates.default]. "
|
|
531
|
+
"See docs/guides/config-migration.md for details."
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
templates: dict[str, AgentTemplate] = {}
|
|
535
|
+
for name, tdata in raw.items():
|
|
536
|
+
if not isinstance(tdata, dict):
|
|
537
|
+
raise ConfigError(f"agent_templates.{name} must be a table")
|
|
538
|
+
_warn_unexpected_keys(tdata, _AGENT_TEMPLATE_KEYS, f"agent_templates.{name}", issues)
|
|
539
|
+
|
|
540
|
+
templates[name] = AgentTemplate(
|
|
541
|
+
name=name,
|
|
542
|
+
inherits=list(tdata.get("inherits", [])),
|
|
543
|
+
shell=str(tdata["shell"]) if "shell" in tdata else None,
|
|
544
|
+
git_credentials=list(tdata["git_credentials"]) if "git_credentials" in tdata else None,
|
|
545
|
+
user_install_commands=(list(tdata["user_install_commands"]) if "user_install_commands" in tdata else None),
|
|
546
|
+
dotfiles_source=str(tdata["dotfiles_source"]) if "dotfiles_source" in tdata else None,
|
|
547
|
+
dotfiles_destination=(str(tdata["dotfiles_destination"]) if "dotfiles_destination" in tdata else None),
|
|
548
|
+
dotfiles_install_cmd=(str(tdata["dotfiles_install_cmd"]) if "dotfiles_install_cmd" in tdata else None),
|
|
549
|
+
mise_activate=bool(tdata["mise_activate"]) if "mise_activate" in tdata else None,
|
|
550
|
+
mise_packages=list(tdata["mise_packages"]) if "mise_packages" in tdata else None,
|
|
551
|
+
mise_lockfile=str(tdata["mise_lockfile"]) if "mise_lockfile" in tdata else None,
|
|
552
|
+
mise_allow_unlocked=(bool(tdata["mise_allow_unlocked"]) if "mise_allow_unlocked" in tdata else None),
|
|
553
|
+
mise_install_before=(str(tdata["mise_install_before"]) if "mise_install_before" in tdata else None),
|
|
554
|
+
mise_prune_on_reinit=(bool(tdata["mise_prune_on_reinit"]) if "mise_prune_on_reinit" in tdata else None),
|
|
555
|
+
nerf_install_claude_plugin=(
|
|
556
|
+
bool(tdata["nerf_install_claude_plugin"]) if "nerf_install_claude_plugin" in tdata else None
|
|
557
|
+
),
|
|
558
|
+
claude_marketplaces=(
|
|
559
|
+
_require_string_list(tdata, "claude_marketplaces", f"agent_templates.{name}")
|
|
560
|
+
if "claude_marketplaces" in tdata else None
|
|
561
|
+
),
|
|
562
|
+
claude_plugins=(
|
|
563
|
+
_require_string_list(tdata, "claude_plugins", f"agent_templates.{name}")
|
|
564
|
+
if "claude_plugins" in tdata else None
|
|
565
|
+
),
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
for name, tmpl in templates.items():
|
|
569
|
+
for parent in tmpl.inherits:
|
|
570
|
+
if parent not in templates and parent != "default":
|
|
571
|
+
raise ConfigError(f"agent_templates.{name}.inherits references unknown template: {parent}")
|
|
572
|
+
_detect_template_cycles(templates, "agent_templates")
|
|
573
|
+
|
|
574
|
+
return templates
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _load_catalog_sections(
|
|
578
|
+
data: dict[str, object],
|
|
579
|
+
) -> tuple[
|
|
580
|
+
dict[str, object],
|
|
581
|
+
dict[str, object],
|
|
582
|
+
dict[str, object],
|
|
583
|
+
dict[str, object],
|
|
584
|
+
]:
|
|
585
|
+
"""Load the four user-defined catalog sections as raw dicts.
|
|
586
|
+
|
|
587
|
+
Actual parsing into typed entries happens in catalog.py during merge.
|
|
588
|
+
Here we just validate that each section is a table of tables.
|
|
589
|
+
"""
|
|
590
|
+
sections = {}
|
|
591
|
+
for section_name in ("apt_sources", "apt_packages", "system_install_commands", "user_install_commands"):
|
|
592
|
+
raw = data.get(section_name, {})
|
|
593
|
+
if not isinstance(raw, dict):
|
|
594
|
+
raise ConfigError(f"[{section_name}] must be a table")
|
|
595
|
+
for name, entry in raw.items():
|
|
596
|
+
if not isinstance(entry, dict):
|
|
597
|
+
raise ConfigError(f"{section_name}.{name} must be a table")
|
|
598
|
+
sections[section_name] = raw
|
|
599
|
+
return (
|
|
600
|
+
sections["apt_sources"],
|
|
601
|
+
sections["apt_packages"],
|
|
602
|
+
sections["system_install_commands"],
|
|
603
|
+
sections["user_install_commands"],
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _load_workspace_templates(data: dict[str, object]) -> dict[str, WorkspaceTemplate]:
|
|
608
|
+
raw = data.get("workspace_templates", {})
|
|
609
|
+
if not isinstance(raw, dict):
|
|
610
|
+
raise ConfigError("[workspace_templates] must be a table")
|
|
611
|
+
|
|
612
|
+
templates: dict[str, WorkspaceTemplate] = {}
|
|
613
|
+
for name, tdata in raw.items():
|
|
614
|
+
if not isinstance(tdata, dict):
|
|
615
|
+
raise ConfigError(f"workspace_templates.{name} must be a table")
|
|
616
|
+
templates[name] = WorkspaceTemplate(
|
|
617
|
+
name=name,
|
|
618
|
+
inherits=list(tdata.get("inherits", [])),
|
|
619
|
+
repo=str(tdata["repo"]) if "repo" in tdata else None,
|
|
620
|
+
tmuxinator=bool(tdata["tmuxinator"]) if "tmuxinator" in tdata else None,
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
# validate inherits references and cycles
|
|
624
|
+
for name, tmpl in templates.items():
|
|
625
|
+
for parent in tmpl.inherits:
|
|
626
|
+
if parent not in templates and parent != "default":
|
|
627
|
+
raise ConfigError(f"workspace_templates.{name}.inherits references unknown template: {parent}")
|
|
628
|
+
_detect_template_cycles(templates, "workspace_templates")
|
|
629
|
+
|
|
630
|
+
return templates
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
class _HasInherits(Protocol):
|
|
634
|
+
@property
|
|
635
|
+
def inherits(self) -> list[str]: ...
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _detect_template_cycles(templates: Mapping[str, _HasInherits], label: str) -> None:
|
|
639
|
+
visited: set[str] = set()
|
|
640
|
+
in_stack: set[str] = set()
|
|
641
|
+
|
|
642
|
+
def visit(name: str) -> None:
|
|
643
|
+
if name not in templates:
|
|
644
|
+
return # implicit default or already validated
|
|
645
|
+
if name in in_stack:
|
|
646
|
+
raise ConfigError(f"{label} inheritance cycle detected involving: {name}")
|
|
647
|
+
if name in visited:
|
|
648
|
+
return
|
|
649
|
+
in_stack.add(name)
|
|
650
|
+
for parent in templates[name].inherits:
|
|
651
|
+
visit(parent)
|
|
652
|
+
in_stack.remove(name)
|
|
653
|
+
visited.add(name)
|
|
654
|
+
|
|
655
|
+
for name in templates:
|
|
656
|
+
visit(name)
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _load_git_credentials(data: dict[str, object]) -> dict[str, GitCredentialConfig]:
|
|
660
|
+
raw = data.get("git_credentials", {})
|
|
661
|
+
if not isinstance(raw, dict):
|
|
662
|
+
raise ConfigError("[git_credentials] must be a table")
|
|
663
|
+
|
|
664
|
+
creds: dict[str, GitCredentialConfig] = {}
|
|
665
|
+
for name, cdata in raw.items():
|
|
666
|
+
if not isinstance(cdata, dict):
|
|
667
|
+
raise ConfigError(f"git_credentials.{name} must be a table")
|
|
668
|
+
cred_type = str(_require(cdata, "type", f"git_credentials.{name}"))
|
|
669
|
+
if cred_type not in VALID_GIT_CREDENTIAL_TYPES:
|
|
670
|
+
raise ConfigError(
|
|
671
|
+
f"git_credentials.{name}.type must be one of {VALID_GIT_CREDENTIAL_TYPES}, got: {cred_type}"
|
|
672
|
+
)
|
|
673
|
+
if cred_type == "azdo" and "org" not in cdata:
|
|
674
|
+
raise ConfigError(f"git_credentials.{name}.org is required for azdo type")
|
|
675
|
+
creds[name] = GitCredentialConfig(
|
|
676
|
+
name=name,
|
|
677
|
+
type=cred_type,
|
|
678
|
+
org=str(cdata["org"]) if "org" in cdata else None,
|
|
679
|
+
description=str(cdata["description"]) if "description" in cdata else None,
|
|
680
|
+
)
|
|
681
|
+
return creds
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
_SESSION_CONFIG_KEYS = {"history_limit"}
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _load_session_config(data: dict[str, object], issues: list[str]) -> SessionConfig:
|
|
688
|
+
session_section = data.get("session", {})
|
|
689
|
+
if not isinstance(session_section, dict):
|
|
690
|
+
raise ConfigError("[session] must be a table")
|
|
691
|
+
raw = session_section.get("config", {})
|
|
692
|
+
if not isinstance(raw, dict):
|
|
693
|
+
raise ConfigError("[session.config] must be a table")
|
|
694
|
+
|
|
695
|
+
_warn_unexpected_keys(raw, _SESSION_CONFIG_KEYS, "session.config", issues)
|
|
696
|
+
|
|
697
|
+
history_limit = int(raw.get("history_limit", 50_000))
|
|
698
|
+
if history_limit < 1:
|
|
699
|
+
raise ConfigError("session.config.history_limit must be a positive integer")
|
|
700
|
+
|
|
701
|
+
return SessionConfig(
|
|
702
|
+
history_limit=history_limit,
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
_SESSION_TEMPLATE_KEYS = {"inherits", "command", "description", "restart_command", "env"}
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def _load_session_templates(data: dict[str, object], issues: list[str]) -> dict[str, SessionTemplate]:
|
|
710
|
+
raw = data.get("session_templates", {})
|
|
711
|
+
if not isinstance(raw, dict):
|
|
712
|
+
raise ConfigError("[session_templates] must be a table")
|
|
713
|
+
|
|
714
|
+
templates: dict[str, SessionTemplate] = {}
|
|
715
|
+
for name, tdata in raw.items():
|
|
716
|
+
if not isinstance(tdata, dict):
|
|
717
|
+
raise ConfigError(f"session_templates.{name} must be a table")
|
|
718
|
+
_warn_unexpected_keys(tdata, _SESSION_TEMPLATE_KEYS, f"session_templates.{name}", issues)
|
|
719
|
+
env_raw = tdata.get("env")
|
|
720
|
+
env: dict[str, str] | None = None
|
|
721
|
+
if env_raw is not None:
|
|
722
|
+
if not isinstance(env_raw, dict):
|
|
723
|
+
raise ConfigError(f"session_templates.{name}.env must be a table")
|
|
724
|
+
env = {str(k): str(v) for k, v in env_raw.items()}
|
|
725
|
+
templates[name] = SessionTemplate(
|
|
726
|
+
name=name,
|
|
727
|
+
inherits=list(tdata.get("inherits", [])),
|
|
728
|
+
command=str(tdata["command"]) if "command" in tdata else None,
|
|
729
|
+
description=str(tdata["description"]) if "description" in tdata else None,
|
|
730
|
+
restart_command=str(tdata["restart_command"]) if "restart_command" in tdata else None,
|
|
731
|
+
env=env,
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
for name, tmpl in templates.items():
|
|
735
|
+
for parent in tmpl.inherits:
|
|
736
|
+
if parent not in templates and parent != "default":
|
|
737
|
+
raise ConfigError(f"session_templates.{name}.inherits references unknown template: {parent}")
|
|
738
|
+
_detect_template_cycles(templates, "session_templates")
|
|
739
|
+
|
|
740
|
+
return templates
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _load_azure(data: dict[str, object]) -> AzureConfig | None:
|
|
744
|
+
raw = data.get("azure")
|
|
745
|
+
if raw is None:
|
|
746
|
+
return None
|
|
747
|
+
if not isinstance(raw, dict):
|
|
748
|
+
raise ConfigError("[azure] must be a table")
|
|
749
|
+
return AzureConfig(
|
|
750
|
+
subscription_id=str(_require(raw, "subscription_id", "azure")),
|
|
751
|
+
resource_group=str(_require(raw, "resource_group", "azure")),
|
|
752
|
+
region=str(_require(raw, "region", "azure")),
|
|
753
|
+
idle_timeout_hours=int(raw.get("idle_timeout_hours", 2)),
|
|
754
|
+
)
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def _load_proxmox(data: dict[str, object]) -> ProxmoxConfig | None:
|
|
758
|
+
raw = data.get("proxmox")
|
|
759
|
+
if raw is None:
|
|
760
|
+
return None
|
|
761
|
+
if not isinstance(raw, dict):
|
|
762
|
+
raise ConfigError("[proxmox] must be a table")
|
|
763
|
+
return ProxmoxConfig(
|
|
764
|
+
api_url=str(_require(raw, "api_url", "proxmox")),
|
|
765
|
+
node=str(_require(raw, "node", "proxmox")),
|
|
766
|
+
token_id=str(_require(raw, "token_id", "proxmox")),
|
|
767
|
+
template_vmid=int(str(_require(raw, "template_vmid", "proxmox"))),
|
|
768
|
+
storage=str(raw.get("storage", "local-lvm")),
|
|
769
|
+
bridge=str(raw.get("bridge", "vmbr0")),
|
|
770
|
+
pool=str(raw.get("pool", "agentworks")),
|
|
771
|
+
verify_ssl=bool(raw.get("verify_ssl", True)),
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
EXPECTED_TOP_LEVEL_KEYS = {
|
|
776
|
+
"operator",
|
|
777
|
+
"paths",
|
|
778
|
+
"defaults",
|
|
779
|
+
"vm_templates",
|
|
780
|
+
"admin",
|
|
781
|
+
"agent_templates",
|
|
782
|
+
"session",
|
|
783
|
+
"session_templates",
|
|
784
|
+
"apt_sources",
|
|
785
|
+
"apt_packages",
|
|
786
|
+
"system_install_commands",
|
|
787
|
+
"user_install_commands",
|
|
788
|
+
"workspace_templates",
|
|
789
|
+
"git_credentials",
|
|
790
|
+
"azure",
|
|
791
|
+
"proxmox",
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def _warn_unexpected_top_level_keys(data: dict[str, object], issues: list[str]) -> None:
|
|
796
|
+
"""Record unexpected top-level keys.
|
|
797
|
+
|
|
798
|
+
This catches a common TOML pitfall: uncommenting a key without its section
|
|
799
|
+
header causes the key to land in the wrong (or top-level) section.
|
|
800
|
+
"""
|
|
801
|
+
unexpected = set(data.keys()) - EXPECTED_TOP_LEVEL_KEYS
|
|
802
|
+
if unexpected:
|
|
803
|
+
keys = ", ".join(sorted(unexpected))
|
|
804
|
+
issues.append(f"unexpected top-level keys in config: {keys}")
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def load_config(path: Path | None = None, *, warn_issues: bool = True) -> Config:
|
|
808
|
+
"""Load and validate the agentworks configuration.
|
|
809
|
+
|
|
810
|
+
Args:
|
|
811
|
+
path: Override config file path (default: ~/.config/agentworks/config.toml).
|
|
812
|
+
warn_issues: Emit config issues as warnings to stderr (default: True).
|
|
813
|
+
Set to False when the caller handles issues itself (e.g. doctor).
|
|
814
|
+
|
|
815
|
+
Returns:
|
|
816
|
+
Validated Config object.
|
|
817
|
+
|
|
818
|
+
Raises:
|
|
819
|
+
ConfigError: If the config is missing or invalid.
|
|
820
|
+
SystemExit: If the config file does not exist.
|
|
821
|
+
"""
|
|
822
|
+
config_path = path or CONFIG_PATH
|
|
823
|
+
if not config_path.exists():
|
|
824
|
+
print(f"Configuration file not found: {config_path}", file=sys.stderr)
|
|
825
|
+
print("Create it to get started. See the documentation for the schema.", file=sys.stderr)
|
|
826
|
+
raise SystemExit(1)
|
|
827
|
+
|
|
828
|
+
with open(config_path, "rb") as f:
|
|
829
|
+
try:
|
|
830
|
+
data = tomllib.load(f)
|
|
831
|
+
except tomllib.TOMLDecodeError as e:
|
|
832
|
+
print(f"Error: invalid config file {config_path}: {e}", file=sys.stderr)
|
|
833
|
+
raise SystemExit(1) from None
|
|
834
|
+
|
|
835
|
+
issues: list[str] = []
|
|
836
|
+
|
|
837
|
+
_warn_unexpected_top_level_keys(data, issues)
|
|
838
|
+
|
|
839
|
+
if "dotfiles" in data:
|
|
840
|
+
raise ConfigError(
|
|
841
|
+
"[dotfiles] section has been removed. Move dotfiles settings into "
|
|
842
|
+
"[admin.config] (dotfiles_source, dotfiles_destination, dotfiles_install_cmd). "
|
|
843
|
+
"See docs/guides/config-migration.md for details."
|
|
844
|
+
)
|
|
845
|
+
|
|
846
|
+
git_credentials = _load_git_credentials(data)
|
|
847
|
+
apt_sources, apt_packages, system_cmds, user_cmds = _load_catalog_sections(data)
|
|
848
|
+
|
|
849
|
+
session_config = _load_session_config(data, issues)
|
|
850
|
+
session_templates = _load_session_templates(data, issues)
|
|
851
|
+
|
|
852
|
+
loaded_vm_templates = _load_vm_templates(data, issues)
|
|
853
|
+
loaded_agent_templates = _load_agent_templates(data, issues)
|
|
854
|
+
|
|
855
|
+
# Resolve default templates eagerly so config.vm / config.agent work everywhere
|
|
856
|
+
from agentworks.vms.templates import resolve_from_dict as _resolve_vm
|
|
857
|
+
|
|
858
|
+
resolved_vm = _resolve_vm(loaded_vm_templates)
|
|
859
|
+
|
|
860
|
+
from agentworks.agents.templates import resolve_from_dict as _resolve_agent
|
|
861
|
+
|
|
862
|
+
resolved_agent = _resolve_agent(loaded_agent_templates)
|
|
863
|
+
|
|
864
|
+
admin = _load_admin_config(data, issues)
|
|
865
|
+
|
|
866
|
+
config = Config(
|
|
867
|
+
operator=_load_operator(data, issues),
|
|
868
|
+
paths=_load_paths(data),
|
|
869
|
+
defaults=_load_defaults(data, issues),
|
|
870
|
+
vm_templates=loaded_vm_templates,
|
|
871
|
+
vm=resolved_vm,
|
|
872
|
+
admin=admin,
|
|
873
|
+
agent_templates=loaded_agent_templates,
|
|
874
|
+
agent=resolved_agent,
|
|
875
|
+
session=session_config,
|
|
876
|
+
session_templates=session_templates,
|
|
877
|
+
workspace_templates=_load_workspace_templates(data),
|
|
878
|
+
git_credentials=git_credentials,
|
|
879
|
+
apt_sources=apt_sources,
|
|
880
|
+
apt_packages=apt_packages,
|
|
881
|
+
system_install_commands=system_cmds,
|
|
882
|
+
user_install_commands=user_cmds,
|
|
883
|
+
azure=_load_azure(data),
|
|
884
|
+
proxmox=_load_proxmox(data),
|
|
885
|
+
config_issues=tuple(issues),
|
|
886
|
+
)
|
|
887
|
+
|
|
888
|
+
if warn_issues and config.config_issues:
|
|
889
|
+
from agentworks.output import warn
|
|
890
|
+
|
|
891
|
+
for issue in config.config_issues:
|
|
892
|
+
warn(f"Config: {issue}")
|
|
893
|
+
|
|
894
|
+
return config
|