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