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
@@ -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}")
@@ -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"