dlab-cli 0.1.0__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.
- dlab/__init__.py +6 -0
- dlab/cli.py +1075 -0
- dlab/config.py +190 -0
- dlab/create_dpack.py +1096 -0
- dlab/create_dpack_wizard.py +1471 -0
- dlab/create_parallel_agent_wizard.py +582 -0
- dlab/data/__init__.py +0 -0
- dlab/data/models.json +1793 -0
- dlab/docker.py +591 -0
- dlab/local.py +269 -0
- dlab/model_fallback.py +360 -0
- dlab/parallel_tool.py +18 -0
- dlab/session.py +389 -0
- dlab/timeline.py +684 -0
- dlab/tui/__init__.py +9 -0
- dlab/tui/app.py +664 -0
- dlab/tui/log_watcher.py +208 -0
- dlab/tui/models.py +438 -0
- dlab/tui/widgets/__init__.py +18 -0
- dlab/tui/widgets/agent_list.py +170 -0
- dlab/tui/widgets/artifacts_pane.py +618 -0
- dlab/tui/widgets/log_view.py +505 -0
- dlab/tui/widgets/search_popup.py +151 -0
- dlab/tui/widgets/status_bar.py +106 -0
- dlab_cli-0.1.0.dist-info/METADATA +237 -0
- dlab_cli-0.1.0.dist-info/RECORD +30 -0
- dlab_cli-0.1.0.dist-info/WHEEL +5 -0
- dlab_cli-0.1.0.dist-info/entry_points.txt +2 -0
- dlab_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- dlab_cli-0.1.0.dist-info/top_level.txt +1 -0
dlab/create_dpack.py
ADDED
|
@@ -0,0 +1,1096 @@
|
|
|
1
|
+
"""
|
|
2
|
+
decision-pack scaffolding logic for dlab.
|
|
3
|
+
|
|
4
|
+
Generates a valid decision-pack directory structure from a configuration dict.
|
|
5
|
+
The TUI wizard (CreateDpackApp) is separate and calls generate_dpack().
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import io
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import tempfile
|
|
13
|
+
import zipfile
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from importlib.resources import files
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from dhub.cli.config import build_headers, get_api_url, get_optional_token, raise_for_status
|
|
20
|
+
import httpx
|
|
21
|
+
import yaml
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _load_bundled_models() -> dict[str, Any]:
|
|
25
|
+
"""Load the bundled models.dev fixture from package data."""
|
|
26
|
+
data_text: str = files("dlab.data").joinpath("models.json").read_text()
|
|
27
|
+
return json.loads(data_text)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_BUNDLED: dict[str, Any] = _load_bundled_models()
|
|
31
|
+
KNOWN_MODELS: list[str] = _BUNDLED["models"]
|
|
32
|
+
KNOWN_PROVIDER_ENVS: dict[str, list[str]] = _BUNDLED["provider_envs"]
|
|
33
|
+
|
|
34
|
+
CACHE_DIR: Path = Path.home() / ".cache" / "dlab"
|
|
35
|
+
MODEL_CACHE_FILE: Path = CACHE_DIR / "models.json"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def fetch_models_from_api() -> dict[str, Any]:
|
|
39
|
+
"""
|
|
40
|
+
Fetch model list and provider env vars from models.dev API.
|
|
41
|
+
|
|
42
|
+
Returns
|
|
43
|
+
-------
|
|
44
|
+
dict[str, Any]
|
|
45
|
+
Dict with "models" (list[str]) and "provider_envs" (dict[str, list[str]]).
|
|
46
|
+
"""
|
|
47
|
+
resp: httpx.Response = httpx.get("https://models.dev/api.json", timeout=10)
|
|
48
|
+
resp.raise_for_status()
|
|
49
|
+
data: dict[str, Any] = resp.json()
|
|
50
|
+
|
|
51
|
+
models: list[str] = []
|
|
52
|
+
provider_envs: dict[str, list[str]] = {}
|
|
53
|
+
|
|
54
|
+
for provider, pdata in data.items():
|
|
55
|
+
if not isinstance(pdata, dict):
|
|
56
|
+
continue
|
|
57
|
+
env_vars: list[str] = pdata.get("env", [])
|
|
58
|
+
if env_vars:
|
|
59
|
+
provider_envs[provider] = env_vars
|
|
60
|
+
provider_models: dict[str, Any] = pdata.get("models", {})
|
|
61
|
+
for model_id, mdata in provider_models.items():
|
|
62
|
+
if not isinstance(mdata, dict):
|
|
63
|
+
continue
|
|
64
|
+
# Skip cross-provider references (e.g. cloudflare listing
|
|
65
|
+
# "anthropic/claude-sonnet-4-5" — those are pass-through IDs)
|
|
66
|
+
if "/" in model_id:
|
|
67
|
+
continue
|
|
68
|
+
full_id: str = f"{provider}/{model_id}"
|
|
69
|
+
if mdata.get("tool_call"):
|
|
70
|
+
models.append(full_id)
|
|
71
|
+
|
|
72
|
+
return {"models": sorted(set(models)), "provider_envs": provider_envs}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def load_cached_models() -> dict[str, Any]:
|
|
76
|
+
"""Load cached model list and provider envs from disk."""
|
|
77
|
+
if MODEL_CACHE_FILE.exists():
|
|
78
|
+
try:
|
|
79
|
+
return json.loads(MODEL_CACHE_FILE.read_text())
|
|
80
|
+
except (json.JSONDecodeError, OSError):
|
|
81
|
+
pass
|
|
82
|
+
return {"models": [], "provider_envs": {}}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def save_model_cache(data: dict[str, Any]) -> None:
|
|
86
|
+
"""Save model list and provider envs to disk cache."""
|
|
87
|
+
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
MODEL_CACHE_FILE.write_text(json.dumps(data))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _model_sort_key(model_id: str) -> tuple[int, str]:
|
|
92
|
+
"""Sort key: (provider_rank, model_id) for popularity-based ordering."""
|
|
93
|
+
provider: str = model_id.split("/")[0] if "/" in model_id else model_id
|
|
94
|
+
rank: int = PROVIDER_RANK.get(provider, _DEFAULT_RANK)
|
|
95
|
+
return (rank, model_id)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def get_model_list() -> list[str]:
|
|
99
|
+
"""
|
|
100
|
+
Return merged model list from KNOWN_MODELS + cache, sorted by popularity.
|
|
101
|
+
|
|
102
|
+
Returns
|
|
103
|
+
-------
|
|
104
|
+
list[str]
|
|
105
|
+
Deduplicated model IDs, sorted by provider popularity then alphabetically.
|
|
106
|
+
"""
|
|
107
|
+
cached: dict[str, Any] = load_cached_models()
|
|
108
|
+
all_models: set[str] = set(KNOWN_MODELS) | set(cached.get("models", []))
|
|
109
|
+
return sorted(all_models, key=_model_sort_key)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def get_provider_env_vars(model_id: str) -> list[str]:
|
|
113
|
+
"""
|
|
114
|
+
Get required env vars for a model's provider.
|
|
115
|
+
|
|
116
|
+
Parameters
|
|
117
|
+
----------
|
|
118
|
+
model_id : str
|
|
119
|
+
Model ID in provider/name format.
|
|
120
|
+
|
|
121
|
+
Returns
|
|
122
|
+
-------
|
|
123
|
+
list[str]
|
|
124
|
+
Environment variable names needed.
|
|
125
|
+
"""
|
|
126
|
+
provider: str = model_id.split("/")[0] if "/" in model_id else model_id
|
|
127
|
+
# Check cache first
|
|
128
|
+
cached: dict[str, Any] = load_cached_models()
|
|
129
|
+
cached_envs: dict[str, list[str]] = cached.get("provider_envs", {})
|
|
130
|
+
if provider in cached_envs:
|
|
131
|
+
return cached_envs[provider]
|
|
132
|
+
return KNOWN_PROVIDER_ENVS.get(provider, [])
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# Provider popularity ranking (lower = more popular)
|
|
136
|
+
PROVIDER_RANK: dict[str, int] = {
|
|
137
|
+
"opencode": 0,
|
|
138
|
+
"anthropic": 1,
|
|
139
|
+
"openai": 2,
|
|
140
|
+
"google": 3,
|
|
141
|
+
"deepseek": 4,
|
|
142
|
+
"mistralai": 5,
|
|
143
|
+
"meta": 6,
|
|
144
|
+
"xai": 7,
|
|
145
|
+
"groq": 8,
|
|
146
|
+
"openrouter": 9,
|
|
147
|
+
}
|
|
148
|
+
_DEFAULT_RANK: int = 50
|
|
149
|
+
|
|
150
|
+
KNOWN_BASE_IMAGES: list[str] = [
|
|
151
|
+
"python:3.11-slim",
|
|
152
|
+
"python:3.12-slim",
|
|
153
|
+
"python:3.13-slim",
|
|
154
|
+
"ubuntu:22.04",
|
|
155
|
+
"ubuntu:24.04",
|
|
156
|
+
"debian:bookworm-slim",
|
|
157
|
+
"node:20-slim",
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
NAME_PATTERN: re.Pattern[str] = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
# OpenCode permission definitions
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
# Permissions the user can toggle in the wizard.
|
|
168
|
+
# Each entry: (key, label, description, default_value)
|
|
169
|
+
# default_value is "allow" or "deny".
|
|
170
|
+
# Ordered by importance — high-impact first, internal/basic last.
|
|
171
|
+
# The wizard renders a visual separator at HIGH_IMPACT_PERMISSION_COUNT.
|
|
172
|
+
HIGH_IMPACT_PERMISSION_COUNT: int = 6
|
|
173
|
+
|
|
174
|
+
CONFIGURABLE_PERMISSIONS: list[tuple[str, str, str, str]] = [
|
|
175
|
+
# --- High-impact (affect agent capabilities) ---
|
|
176
|
+
("webfetch", "Fetch URLs",
|
|
177
|
+
"Fetch content from URLs. Useful for downloading data, reading documentation, "
|
|
178
|
+
"or accessing APIs.",
|
|
179
|
+
"allow"),
|
|
180
|
+
("websearch", "Web search",
|
|
181
|
+
"Search the web. Useful for finding documentation, examples, or troubleshooting errors.",
|
|
182
|
+
"allow"),
|
|
183
|
+
("bash", "Shell commands",
|
|
184
|
+
"Run bash commands. Needed for pip install, running scripts, data processing. "
|
|
185
|
+
"Disable to restrict the agent to file operations only.",
|
|
186
|
+
"allow"),
|
|
187
|
+
("edit", "Edit files",
|
|
188
|
+
"Create and modify files in /workspace. Disable for read-only analysis agents.",
|
|
189
|
+
"allow"),
|
|
190
|
+
("external_directory", "External directory access",
|
|
191
|
+
"Read files outside /workspace (e.g. Python site-packages, system configs). "
|
|
192
|
+
"Useful for exploring installed libraries programmatically.",
|
|
193
|
+
"allow"),
|
|
194
|
+
("task", "Spawn subagent tasks",
|
|
195
|
+
"Let the agent spawn subagent tasks. Required for multi-agent workflows.",
|
|
196
|
+
"allow"),
|
|
197
|
+
# --- Internal/basic (rarely need to change) ---
|
|
198
|
+
("skill", "Use skills",
|
|
199
|
+
"Let the agent use opencode skills (knowledge files). "
|
|
200
|
+
"Disable if the agent should rely only on its system prompt.",
|
|
201
|
+
"allow"),
|
|
202
|
+
("codesearch", "Code search",
|
|
203
|
+
"Semantic code search across the codebase.",
|
|
204
|
+
"allow"),
|
|
205
|
+
("lsp", "Language server",
|
|
206
|
+
"Query language servers for code intelligence (definitions, references, hover). "
|
|
207
|
+
"Requires configured LSP servers in the container.",
|
|
208
|
+
"deny"),
|
|
209
|
+
("todoread", "Read todos",
|
|
210
|
+
"Read the internal task/todo list. Used by the agent to track its own progress.",
|
|
211
|
+
"allow"),
|
|
212
|
+
("todowrite", "Write todos",
|
|
213
|
+
"Write to the internal task/todo list. Used by the agent to plan multi-step work.",
|
|
214
|
+
"allow"),
|
|
215
|
+
]
|
|
216
|
+
|
|
217
|
+
# Permissions always set to the same value (not shown in wizard).
|
|
218
|
+
HARDCODED_PERMISSIONS: dict[str, str] = {
|
|
219
|
+
"read": "allow",
|
|
220
|
+
"glob": "allow",
|
|
221
|
+
"grep": "allow",
|
|
222
|
+
"list": "allow",
|
|
223
|
+
"question": "deny", # meaningless in automated mode
|
|
224
|
+
# TODO: decide on doom_loop default. OpenCode defaults to "ask" (auto-approved
|
|
225
|
+
# in run mode). Setting "deny" would stop agents stuck in loops but might block
|
|
226
|
+
# legitimate retries. Leaving it out for now to use OpenCode's default.
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# ---------------------------------------------------------------------------
|
|
231
|
+
# Validation
|
|
232
|
+
# ---------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
def validate_dpack_name(name: str) -> str | None:
|
|
235
|
+
"""
|
|
236
|
+
Validate a decision-pack name.
|
|
237
|
+
|
|
238
|
+
Parameters
|
|
239
|
+
----------
|
|
240
|
+
name : str
|
|
241
|
+
Proposed decision-pack name.
|
|
242
|
+
|
|
243
|
+
Returns
|
|
244
|
+
-------
|
|
245
|
+
str | None
|
|
246
|
+
Error message if invalid, None if valid.
|
|
247
|
+
"""
|
|
248
|
+
if not name:
|
|
249
|
+
return "Name is required"
|
|
250
|
+
if not NAME_PATTERN.match(name):
|
|
251
|
+
return "Name must be alphanumeric (hyphens and underscores allowed, cannot start with - or _)"
|
|
252
|
+
return None
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def filter_models(query: str, models: list[str] | None = None) -> list[str]:
|
|
256
|
+
"""
|
|
257
|
+
Filter models by a case-insensitive substring match.
|
|
258
|
+
|
|
259
|
+
Parameters
|
|
260
|
+
----------
|
|
261
|
+
query : str
|
|
262
|
+
Search query.
|
|
263
|
+
models : list[str] | None
|
|
264
|
+
Model list to filter. Defaults to KNOWN_MODELS.
|
|
265
|
+
|
|
266
|
+
Returns
|
|
267
|
+
-------
|
|
268
|
+
list[str]
|
|
269
|
+
Matching model names.
|
|
270
|
+
"""
|
|
271
|
+
source: list[str] = models if models is not None else KNOWN_MODELS
|
|
272
|
+
if not query:
|
|
273
|
+
return sorted(source, key=_model_sort_key)
|
|
274
|
+
q: str = query.lower()
|
|
275
|
+
|
|
276
|
+
provider_starts: list[str] = []
|
|
277
|
+
provider_contains: list[str] = []
|
|
278
|
+
name_contains: list[str] = []
|
|
279
|
+
|
|
280
|
+
for m in source:
|
|
281
|
+
provider: str = m.split("/")[0].lower() if "/" in m else m.lower()
|
|
282
|
+
if provider.startswith(q):
|
|
283
|
+
provider_starts.append(m)
|
|
284
|
+
elif q in provider:
|
|
285
|
+
provider_contains.append(m)
|
|
286
|
+
elif q in m.lower():
|
|
287
|
+
name_contains.append(m)
|
|
288
|
+
|
|
289
|
+
return (
|
|
290
|
+
sorted(provider_starts, key=_model_sort_key)
|
|
291
|
+
+ sorted(provider_contains, key=_model_sort_key)
|
|
292
|
+
+ sorted(name_contains, key=_model_sort_key)
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# ---------------------------------------------------------------------------
|
|
297
|
+
# Decision Hub API helpers
|
|
298
|
+
# ---------------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
def _dhub_headers() -> dict[str, str]:
|
|
301
|
+
"""Build Decision Hub API headers using dhub-cli config."""
|
|
302
|
+
return build_headers(get_optional_token())
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def search_skills(query: str, page_size: int = 20) -> list[dict[str, Any]]:
|
|
306
|
+
"""
|
|
307
|
+
Search the Decision Hub for skills.
|
|
308
|
+
|
|
309
|
+
Parameters
|
|
310
|
+
----------
|
|
311
|
+
query : str
|
|
312
|
+
Search query.
|
|
313
|
+
page_size : int
|
|
314
|
+
Maximum results to return.
|
|
315
|
+
|
|
316
|
+
Returns
|
|
317
|
+
-------
|
|
318
|
+
list[dict[str, Any]]
|
|
319
|
+
List of skill summaries with keys: org_slug, skill_name,
|
|
320
|
+
description, safety_rating, download_count, category.
|
|
321
|
+
"""
|
|
322
|
+
resp: httpx.Response = httpx.get(
|
|
323
|
+
f"{get_api_url()}/v1/skills",
|
|
324
|
+
params={"search": query, "page_size": page_size},
|
|
325
|
+
headers=_dhub_headers(),
|
|
326
|
+
timeout=15,
|
|
327
|
+
)
|
|
328
|
+
raise_for_status(resp)
|
|
329
|
+
data: Any = resp.json()
|
|
330
|
+
if isinstance(data, dict) and "items" in data:
|
|
331
|
+
return data["items"]
|
|
332
|
+
if isinstance(data, list):
|
|
333
|
+
return data
|
|
334
|
+
return []
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def ask_skills(query: str) -> list[dict[str, Any]]:
|
|
338
|
+
"""
|
|
339
|
+
Natural-language skill search via Decision Hub /v1/ask endpoint.
|
|
340
|
+
|
|
341
|
+
Parameters
|
|
342
|
+
----------
|
|
343
|
+
query : str
|
|
344
|
+
Natural language query.
|
|
345
|
+
|
|
346
|
+
Returns
|
|
347
|
+
-------
|
|
348
|
+
list[dict[str, Any]]
|
|
349
|
+
List of skill references with keys: org_slug, skill_name,
|
|
350
|
+
description, reason.
|
|
351
|
+
"""
|
|
352
|
+
resp: httpx.Response = httpx.get(
|
|
353
|
+
f"{get_api_url()}/v1/ask",
|
|
354
|
+
params={"q": query},
|
|
355
|
+
headers=_dhub_headers(),
|
|
356
|
+
timeout=30,
|
|
357
|
+
)
|
|
358
|
+
raise_for_status(resp)
|
|
359
|
+
data: Any = resp.json()
|
|
360
|
+
if isinstance(data, dict) and "skills" in data:
|
|
361
|
+
return data["skills"]
|
|
362
|
+
if isinstance(data, list):
|
|
363
|
+
return data
|
|
364
|
+
return []
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def download_skill(org: str, name: str, dest: Path) -> Path:
|
|
368
|
+
"""
|
|
369
|
+
Download a skill from Decision Hub and extract it.
|
|
370
|
+
|
|
371
|
+
Parameters
|
|
372
|
+
----------
|
|
373
|
+
org : str
|
|
374
|
+
Organization slug.
|
|
375
|
+
name : str
|
|
376
|
+
Skill name.
|
|
377
|
+
dest : Path
|
|
378
|
+
Parent directory to extract into (e.g. opencode/skills/).
|
|
379
|
+
|
|
380
|
+
Returns
|
|
381
|
+
-------
|
|
382
|
+
Path
|
|
383
|
+
Path to the extracted skill directory.
|
|
384
|
+
"""
|
|
385
|
+
resp: httpx.Response = httpx.get(
|
|
386
|
+
f"{get_api_url()}/v1/skills/{org}/{name}/download",
|
|
387
|
+
headers=_dhub_headers(),
|
|
388
|
+
timeout=30,
|
|
389
|
+
follow_redirects=True,
|
|
390
|
+
)
|
|
391
|
+
raise_for_status(resp)
|
|
392
|
+
|
|
393
|
+
skill_dir: Path = dest / name
|
|
394
|
+
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
395
|
+
|
|
396
|
+
content_type: str = resp.headers.get("content-type", "")
|
|
397
|
+
if "zip" in content_type or resp.content[:4] == b"PK\x03\x04":
|
|
398
|
+
with zipfile.ZipFile(io.BytesIO(resp.content)) as zf:
|
|
399
|
+
zf.extractall(skill_dir)
|
|
400
|
+
else:
|
|
401
|
+
# Assume it's a single markdown file
|
|
402
|
+
(skill_dir / "SKILL.md").write_bytes(resp.content)
|
|
403
|
+
|
|
404
|
+
return skill_dir
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
# ---------------------------------------------------------------------------
|
|
408
|
+
# File content generators
|
|
409
|
+
# ---------------------------------------------------------------------------
|
|
410
|
+
|
|
411
|
+
def _build_config_yaml(config: dict[str, Any]) -> str:
|
|
412
|
+
"""Build config.yaml content."""
|
|
413
|
+
data: dict[str, Any] = {
|
|
414
|
+
"name": config["name"],
|
|
415
|
+
"description": config["description"],
|
|
416
|
+
"docker_image_name": config["docker_image_name"],
|
|
417
|
+
"default_model": config["default_model"],
|
|
418
|
+
"requires_data": config.get("requires_data", True),
|
|
419
|
+
"requires_prompt": config.get("requires_prompt", True),
|
|
420
|
+
}
|
|
421
|
+
cli_name: str = config.get("cli_name", "")
|
|
422
|
+
if cli_name and cli_name != config["name"]:
|
|
423
|
+
data["cli_name"] = cli_name
|
|
424
|
+
content: str = yaml.dump(data, default_flow_style=False, sort_keys=False)
|
|
425
|
+
|
|
426
|
+
if config.get("modal_integration"):
|
|
427
|
+
content += (
|
|
428
|
+
"\n# Scripts listed here run inside the container before/after the agent's run.\n"
|
|
429
|
+
"hooks:\n"
|
|
430
|
+
" pre-run: deploy_modal.sh\n"
|
|
431
|
+
" # post-run: cleanup.sh\n"
|
|
432
|
+
)
|
|
433
|
+
else:
|
|
434
|
+
content += (
|
|
435
|
+
"\n# Scripts listed here run inside the container before/after the agent's run.\n"
|
|
436
|
+
"# hooks:\n"
|
|
437
|
+
"# pre-run: setup.sh\n"
|
|
438
|
+
"# post-run: cleanup.sh\n"
|
|
439
|
+
)
|
|
440
|
+
return content
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
PACKAGE_MANAGER_BASE_IMAGES: dict[str, str] = {
|
|
444
|
+
"conda": "continuumio/miniconda3:latest",
|
|
445
|
+
"pip": "python:3.11-slim",
|
|
446
|
+
"uv": "python:3.11-slim",
|
|
447
|
+
"pixi": "debian:bookworm-slim",
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _build_dockerfile(config: dict[str, Any]) -> str:
|
|
452
|
+
"""Build Dockerfile content based on package manager choice."""
|
|
453
|
+
pkg_mgr: str = config.get("package_manager", "pip")
|
|
454
|
+
python_lib: bool = config.get("python_lib", False)
|
|
455
|
+
python_lib_name: str = config.get("python_lib_name", "")
|
|
456
|
+
modal_integration: bool = config.get("modal_integration", False)
|
|
457
|
+
base_image: str = config.get("base_image", PACKAGE_MANAGER_BASE_IMAGES.get(pkg_mgr, "python:3.11-slim"))
|
|
458
|
+
|
|
459
|
+
lines: list[str] = [f"FROM {base_image}", "", "WORKDIR /workspace", ""]
|
|
460
|
+
|
|
461
|
+
if pkg_mgr == "conda":
|
|
462
|
+
lines.extend([
|
|
463
|
+
"COPY environment.yml /tmp/environment.yml",
|
|
464
|
+
"RUN conda env update -n base -f /tmp/environment.yml && \\",
|
|
465
|
+
" conda clean -afy",
|
|
466
|
+
"",
|
|
467
|
+
])
|
|
468
|
+
elif pkg_mgr == "uv":
|
|
469
|
+
lines.extend([
|
|
470
|
+
"COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv",
|
|
471
|
+
"COPY requirements.txt /tmp/requirements.txt",
|
|
472
|
+
"RUN uv pip install --system --no-cache -r /tmp/requirements.txt",
|
|
473
|
+
"",
|
|
474
|
+
])
|
|
475
|
+
elif pkg_mgr == "pixi":
|
|
476
|
+
lines.extend([
|
|
477
|
+
"RUN apt-get update && apt-get install -y curl && \\",
|
|
478
|
+
" curl -fsSL https://pixi.sh/install.sh | bash && \\",
|
|
479
|
+
" rm -rf /var/lib/apt/lists/*",
|
|
480
|
+
'ENV PATH="/root/.pixi/bin:$PATH"',
|
|
481
|
+
"",
|
|
482
|
+
"# Install pixi packages to /opt/pixi (not /workspace, which gets volume-mounted)",
|
|
483
|
+
"COPY pixi.toml /opt/pixi/pixi.toml",
|
|
484
|
+
"RUN cd /opt/pixi && pixi install",
|
|
485
|
+
'ENV PATH="/opt/pixi/.pixi/envs/default/bin:$PATH"',
|
|
486
|
+
"",
|
|
487
|
+
])
|
|
488
|
+
else: # pip
|
|
489
|
+
lines.extend([
|
|
490
|
+
"COPY requirements.txt /tmp/requirements.txt",
|
|
491
|
+
"RUN pip install --no-cache-dir -r /tmp/requirements.txt",
|
|
492
|
+
"",
|
|
493
|
+
])
|
|
494
|
+
|
|
495
|
+
# Optional: python lib and modal app
|
|
496
|
+
pythonpath_parts: list[str] = []
|
|
497
|
+
if python_lib and python_lib_name:
|
|
498
|
+
lines.append(f"COPY {python_lib_name}/ /opt/{python_lib_name}/")
|
|
499
|
+
pythonpath_parts.append("/opt")
|
|
500
|
+
if modal_integration:
|
|
501
|
+
lines.append("COPY modal_app/ /opt/modal_app/")
|
|
502
|
+
if "/opt" not in pythonpath_parts:
|
|
503
|
+
pythonpath_parts.append("/opt")
|
|
504
|
+
if pythonpath_parts:
|
|
505
|
+
lines.append(f'ENV PYTHONPATH="{":".join(pythonpath_parts)}"')
|
|
506
|
+
if python_lib or modal_integration:
|
|
507
|
+
lines.append("")
|
|
508
|
+
|
|
509
|
+
lines.append('CMD ["/bin/bash"]')
|
|
510
|
+
lines.append("")
|
|
511
|
+
|
|
512
|
+
return "\n".join(lines)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _build_env_file(config: dict[str, Any]) -> tuple[str, str]:
|
|
516
|
+
"""Build the environment/dependency file for the chosen package manager.
|
|
517
|
+
|
|
518
|
+
Returns
|
|
519
|
+
-------
|
|
520
|
+
tuple[str, str]
|
|
521
|
+
(filename, content) — e.g. ("environment.yml", "...") or
|
|
522
|
+
("requirements.txt", "...").
|
|
523
|
+
"""
|
|
524
|
+
pkg_mgr: str = config.get("package_manager", "pip")
|
|
525
|
+
docker_image_name: str = config.get("docker_image_name", f"dlab-{config.get('name', 'dpack')}")
|
|
526
|
+
modal: bool = config.get("modal_integration", False)
|
|
527
|
+
dhub: bool = config.get("dhub_integration", False)
|
|
528
|
+
|
|
529
|
+
# Collect pip-only packages
|
|
530
|
+
pip_pkgs: list[str] = []
|
|
531
|
+
if dhub:
|
|
532
|
+
pip_pkgs.append("dhub-cli")
|
|
533
|
+
if modal:
|
|
534
|
+
pip_pkgs.append("modal")
|
|
535
|
+
|
|
536
|
+
if pkg_mgr == "conda":
|
|
537
|
+
pip_section: str = ""
|
|
538
|
+
if pip_pkgs:
|
|
539
|
+
pip_lines: str = "\n".join(f" - {p}" for p in pip_pkgs)
|
|
540
|
+
pip_section = f" - pip:\n{pip_lines}\n"
|
|
541
|
+
content: str = (
|
|
542
|
+
"# Installed into base conda env (no named env needed)\n"
|
|
543
|
+
"channels:\n"
|
|
544
|
+
" - conda-forge\n"
|
|
545
|
+
"dependencies:\n"
|
|
546
|
+
" - python=3.11\n"
|
|
547
|
+
" # Add your conda packages here\n"
|
|
548
|
+
" # - numpy\n"
|
|
549
|
+
" # - pandas\n"
|
|
550
|
+
f"{pip_section}"
|
|
551
|
+
)
|
|
552
|
+
return ("environment.yml", content)
|
|
553
|
+
|
|
554
|
+
if pkg_mgr == "pixi":
|
|
555
|
+
pypi_section: str = ""
|
|
556
|
+
if pip_pkgs:
|
|
557
|
+
pypi_lines: str = "\n".join(f'{p} = "*"' for p in pip_pkgs)
|
|
558
|
+
pypi_section = f"\n[pypi-dependencies]\n{pypi_lines}\n"
|
|
559
|
+
import platform
|
|
560
|
+
machine: str = platform.machine()
|
|
561
|
+
if machine == "aarch64" or machine == "arm64":
|
|
562
|
+
pixi_platform: str = "linux-aarch64"
|
|
563
|
+
else:
|
|
564
|
+
pixi_platform = "linux-64"
|
|
565
|
+
|
|
566
|
+
content = (
|
|
567
|
+
"[project]\n"
|
|
568
|
+
f'name = "{docker_image_name}"\n'
|
|
569
|
+
'channels = ["conda-forge"]\n'
|
|
570
|
+
f'platforms = ["{pixi_platform}"]\n'
|
|
571
|
+
"\n"
|
|
572
|
+
"[dependencies]\n"
|
|
573
|
+
'python = "3.11.*"\n'
|
|
574
|
+
"# Add your packages here\n"
|
|
575
|
+
'# numpy = "*"\n'
|
|
576
|
+
'# pandas = "*"\n'
|
|
577
|
+
f"{pypi_section}"
|
|
578
|
+
)
|
|
579
|
+
return ("pixi.toml", content)
|
|
580
|
+
|
|
581
|
+
# pip or uv
|
|
582
|
+
pip_lines_str: str = "\n".join(pip_pkgs) + "\n" if pip_pkgs else ""
|
|
583
|
+
content = (
|
|
584
|
+
f"{pip_lines_str}"
|
|
585
|
+
"# Add your pip packages here\n"
|
|
586
|
+
"# numpy\n"
|
|
587
|
+
"# pandas\n"
|
|
588
|
+
)
|
|
589
|
+
return ("requirements.txt", content)
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _build_opencode_json(config: dict[str, Any]) -> str:
|
|
593
|
+
"""Build opencode.json content.
|
|
594
|
+
|
|
595
|
+
Writes ALL permissions explicitly as "allow" or "deny" to avoid
|
|
596
|
+
any "ask" defaults that would block automated (opencode run) mode.
|
|
597
|
+
"""
|
|
598
|
+
user_perms: dict[str, str] = config.get("permissions", {})
|
|
599
|
+
|
|
600
|
+
# Build the full permission block
|
|
601
|
+
perm: dict[str, Any] = dict(HARDCODED_PERMISSIONS)
|
|
602
|
+
|
|
603
|
+
# Add configurable permissions — use user value or default
|
|
604
|
+
for key, _label, _desc, default in CONFIGURABLE_PERMISSIONS:
|
|
605
|
+
perm[key] = user_perms.get(key, default)
|
|
606
|
+
|
|
607
|
+
data: dict[str, Any] = {
|
|
608
|
+
"default_agent": config["agent_name"],
|
|
609
|
+
"permission": perm,
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
return json.dumps(data, indent=2) + "\n"
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _build_agent_md(config: dict[str, Any]) -> str:
|
|
616
|
+
"""Build the main agent .md file."""
|
|
617
|
+
skeletons: dict[str, bool] = config.get("skeletons", {})
|
|
618
|
+
parallel: bool = skeletons.get("parallel_agents", False)
|
|
619
|
+
subagents: bool = skeletons.get("subagents", False)
|
|
620
|
+
|
|
621
|
+
tools_block: str = " # Tool settings here override the permission rules in opencode.json\n"
|
|
622
|
+
if parallel:
|
|
623
|
+
tools_block += " parallel-agents: true"
|
|
624
|
+
elif subagents:
|
|
625
|
+
tools_block += " read: true\n edit: true\n bash: true\n task: true"
|
|
626
|
+
else:
|
|
627
|
+
tools_block += " read: true"
|
|
628
|
+
|
|
629
|
+
prompt: str = "You are an AI assistant. Follow the user's prompt carefully."
|
|
630
|
+
|
|
631
|
+
if parallel:
|
|
632
|
+
prompt += """
|
|
633
|
+
|
|
634
|
+
## Spawning Parallel Agents
|
|
635
|
+
|
|
636
|
+
Use the `parallel-agents` tool to run multiple instances of a subagent in parallel.
|
|
637
|
+
Each instance gets its own isolated working directory with a copy of your data.
|
|
638
|
+
|
|
639
|
+
Example — spawn 3 instances of the "example-worker" agent:
|
|
640
|
+
|
|
641
|
+
```json
|
|
642
|
+
{
|
|
643
|
+
"agent": "example-worker",
|
|
644
|
+
"prompts": [
|
|
645
|
+
"Approach A: ...",
|
|
646
|
+
"Approach B: ...",
|
|
647
|
+
"Approach C: ..."
|
|
648
|
+
]
|
|
649
|
+
}
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
Each instance writes a `summary.md` with its findings. When all instances complete,
|
|
653
|
+
a consolidator agent automatically reads every `summary.md` and produces a
|
|
654
|
+
consolidated comparison in `parallel/consolidated_summary.md`.
|
|
655
|
+
|
|
656
|
+
You can also override models per instance:
|
|
657
|
+
|
|
658
|
+
```json
|
|
659
|
+
{
|
|
660
|
+
"agent": "example-worker",
|
|
661
|
+
"prompts": ["...", "..."],
|
|
662
|
+
"models": ["anthropic/claude-sonnet-4-5", "google/gemini-2.5-pro"]
|
|
663
|
+
}
|
|
664
|
+
```"""
|
|
665
|
+
elif subagents:
|
|
666
|
+
prompt += """
|
|
667
|
+
|
|
668
|
+
## Using Subagents
|
|
669
|
+
|
|
670
|
+
Use the `task` tool to delegate work to the "example-worker" subagent.
|
|
671
|
+
Rename and customize `opencode/agents/example-worker.md` for your use case."""
|
|
672
|
+
|
|
673
|
+
description: str = config.get("agent_description", f"Main orchestrator for {config['name']}")
|
|
674
|
+
|
|
675
|
+
return f"""---
|
|
676
|
+
description: {description}
|
|
677
|
+
mode: primary
|
|
678
|
+
tools:
|
|
679
|
+
{tools_block}
|
|
680
|
+
---
|
|
681
|
+
|
|
682
|
+
{prompt}
|
|
683
|
+
"""
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
EXAMPLE_WORKER_MD: str = """---
|
|
687
|
+
description: Example subagent
|
|
688
|
+
mode: subagent
|
|
689
|
+
tools:
|
|
690
|
+
# Tool settings here override the permission rules in opencode.json
|
|
691
|
+
read: true
|
|
692
|
+
edit: true
|
|
693
|
+
bash: true
|
|
694
|
+
parallel-agents: false
|
|
695
|
+
---
|
|
696
|
+
|
|
697
|
+
You are a worker agent. Complete the task described in the prompt.
|
|
698
|
+
"""
|
|
699
|
+
|
|
700
|
+
EXAMPLE_WORKER_YAML: str = """name: example-worker
|
|
701
|
+
description: "Run multiple worker instances in parallel"
|
|
702
|
+
timeout_minutes: 60
|
|
703
|
+
failure_behavior: continue
|
|
704
|
+
|
|
705
|
+
max_instances: 3
|
|
706
|
+
default_model: "anthropic/claude-sonnet-4-5"
|
|
707
|
+
|
|
708
|
+
subagent_suffix_prompt: |
|
|
709
|
+
When you complete your task, write summary.md with:
|
|
710
|
+
## Approach
|
|
711
|
+
## Results
|
|
712
|
+
## Recommendations
|
|
713
|
+
|
|
714
|
+
summarizer_prompt: |
|
|
715
|
+
Read all summary.md files from the parallel instances.
|
|
716
|
+
Create a consolidated comparison of the different approaches.
|
|
717
|
+
Present facts only — the orchestrator will make the final decision.
|
|
718
|
+
|
|
719
|
+
summarizer_model: "anthropic/claude-sonnet-4-5"
|
|
720
|
+
"""
|
|
721
|
+
|
|
722
|
+
def _build_deploy_modal_sh() -> str:
|
|
723
|
+
"""Build the deploy_modal.sh hook script.
|
|
724
|
+
|
|
725
|
+
Returns
|
|
726
|
+
-------
|
|
727
|
+
str
|
|
728
|
+
Shell script content.
|
|
729
|
+
"""
|
|
730
|
+
lines: list[str] = [
|
|
731
|
+
"#!/bin/bash",
|
|
732
|
+
"# Pre-run hook: deploy Modal app for cloud compute",
|
|
733
|
+
"set -e",
|
|
734
|
+
"",
|
|
735
|
+
"# Default to local execution — skip Modal deploy",
|
|
736
|
+
'if [ "${DLAB_RUN_MODAL_TOOL_LOCALLY:-1}" = "1" ]; then',
|
|
737
|
+
' echo "Local mode (DLAB_RUN_MODAL_TOOL_LOCALLY=1). Skipping Modal deployment."',
|
|
738
|
+
" exit 0",
|
|
739
|
+
"fi",
|
|
740
|
+
"",
|
|
741
|
+
"# Check Modal credentials",
|
|
742
|
+
'if [ -z "$MODAL_TOKEN_ID" ] || [ -z "$MODAL_TOKEN_SECRET" ]; then',
|
|
743
|
+
' echo "Warning: Modal tokens not set. Skipping Modal deployment."',
|
|
744
|
+
" exit 0",
|
|
745
|
+
"fi",
|
|
746
|
+
"",
|
|
747
|
+
]
|
|
748
|
+
lines.extend([
|
|
749
|
+
'MODAL_APP="/opt/modal_app/example.py"',
|
|
750
|
+
"",
|
|
751
|
+
'if [ -f "$MODAL_APP" ]; then',
|
|
752
|
+
' echo "Deploying Modal app..."',
|
|
753
|
+
' modal deploy "$MODAL_APP"',
|
|
754
|
+
' echo "Modal app deployed."',
|
|
755
|
+
"else",
|
|
756
|
+
' echo "Warning: Modal app not found at $MODAL_APP, skipping deploy"',
|
|
757
|
+
"fi",
|
|
758
|
+
"",
|
|
759
|
+
])
|
|
760
|
+
return "\n".join(lines)
|
|
761
|
+
|
|
762
|
+
def _build_modal_example(dpack_name: str, package_manager: str) -> str:
|
|
763
|
+
"""Build the Modal example.py content.
|
|
764
|
+
|
|
765
|
+
Parameters
|
|
766
|
+
----------
|
|
767
|
+
dpack_name : str
|
|
768
|
+
decision-pack name (used for the Modal app name).
|
|
769
|
+
package_manager : str
|
|
770
|
+
Package manager choice — "conda" uses micromamba image with
|
|
771
|
+
.micromamba_install(); everything else uses debian_slim with
|
|
772
|
+
.pip_install().
|
|
773
|
+
"""
|
|
774
|
+
if package_manager == "conda":
|
|
775
|
+
image_block = (
|
|
776
|
+
'# Conda packages — use micromamba for properly linked BLAS, MKL, etc.\n'
|
|
777
|
+
'CONDA_PACKAGES = [\n'
|
|
778
|
+
' "python=3.11",\n'
|
|
779
|
+
' # Add your conda packages here\n'
|
|
780
|
+
' # "numpy",\n'
|
|
781
|
+
' # "pandas",\n'
|
|
782
|
+
']\n'
|
|
783
|
+
'\n'
|
|
784
|
+
'# Packages that need pip (not on conda-forge or need specific versions)\n'
|
|
785
|
+
'PIP_PACKAGES = [\n'
|
|
786
|
+
' # "some-pip-only-package",\n'
|
|
787
|
+
']\n'
|
|
788
|
+
'\n'
|
|
789
|
+
'image = (\n'
|
|
790
|
+
' modal.Image.micromamba(python_version="3.11")\n'
|
|
791
|
+
' .run_commands(f"echo \'modal_app hash: {_modal_app_hash}\'")'
|
|
792
|
+
' # Cache buster\n'
|
|
793
|
+
' .micromamba_install(*CONDA_PACKAGES, channels=["conda-forge"])\n'
|
|
794
|
+
' .pip_install(*PIP_PACKAGES)\n'
|
|
795
|
+
')'
|
|
796
|
+
)
|
|
797
|
+
else:
|
|
798
|
+
image_block = (
|
|
799
|
+
'image = (\n'
|
|
800
|
+
' modal.Image.debian_slim(python_version="3.11")\n'
|
|
801
|
+
' .run_commands(f"echo \'modal_app hash: {_modal_app_hash}\'")'
|
|
802
|
+
' # Cache buster\n'
|
|
803
|
+
' .pip_install(\n'
|
|
804
|
+
' # Add your packages here\n'
|
|
805
|
+
' "numpy",\n'
|
|
806
|
+
' )\n'
|
|
807
|
+
')'
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
return f'''"""
|
|
811
|
+
Example Modal app for serverless cloud execution.
|
|
812
|
+
|
|
813
|
+
Deploy with: modal deploy example.py
|
|
814
|
+
|
|
815
|
+
Cache Busting
|
|
816
|
+
-------------
|
|
817
|
+
Modal caches images by their definition. If you change only the Python code
|
|
818
|
+
(not the package list), Modal won't rebuild the image. The self-hash trick
|
|
819
|
+
below forces a rebuild whenever this file changes.
|
|
820
|
+
|
|
821
|
+
Package Versions
|
|
822
|
+
----------------
|
|
823
|
+
If you use cloudpickle to send objects between the Docker container and Modal,
|
|
824
|
+
package versions here MUST match the Dockerfile. Otherwise unpickling will fail.
|
|
825
|
+
"""
|
|
826
|
+
|
|
827
|
+
import hashlib
|
|
828
|
+
from pathlib import Path
|
|
829
|
+
|
|
830
|
+
import modal
|
|
831
|
+
|
|
832
|
+
# Hash this file to force image rebuild when code changes.
|
|
833
|
+
# Without this, Modal uses a cached image even when the code is different.
|
|
834
|
+
_modal_app_hash = hashlib.sha256(Path(__file__).read_bytes()).hexdigest()[:12]
|
|
835
|
+
|
|
836
|
+
{image_block}
|
|
837
|
+
|
|
838
|
+
app = modal.App("{dpack_name}-compute")
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
@app.function(image=image, timeout=3600)
|
|
842
|
+
def run_compute(data: dict) -> dict:
|
|
843
|
+
"""Example compute function. Replace with your own logic."""
|
|
844
|
+
return {{"status": "done", "input_keys": list(data.keys())}}
|
|
845
|
+
'''
|
|
846
|
+
|
|
847
|
+
RUN_ON_MODAL_TS: str = """import { tool } from "@opencode-ai/plugin"
|
|
848
|
+
|
|
849
|
+
export default tool({
|
|
850
|
+
description: `Run a computation on Modal cloud.
|
|
851
|
+
|
|
852
|
+
Calls the run_compute() function deployed in docker/modal_app/example.py.
|
|
853
|
+
Pass a JSON string with the data to send to the Modal function.
|
|
854
|
+
|
|
855
|
+
The Modal app must be deployed first (happens automatically via the
|
|
856
|
+
pre-run hook deploy_modal.sh).`,
|
|
857
|
+
|
|
858
|
+
args: {
|
|
859
|
+
data: tool.schema.string().describe("JSON string with input data for the Modal function"),
|
|
860
|
+
},
|
|
861
|
+
|
|
862
|
+
async execute(args) {
|
|
863
|
+
const pyCode = `
|
|
864
|
+
import json, modal
|
|
865
|
+
f = modal.Function.from_name("<APP_NAME>", "run_compute")
|
|
866
|
+
data = json.loads('${args.data.replace(/'/g, "\\'")}')
|
|
867
|
+
result = f.remote(data)
|
|
868
|
+
print(json.dumps(result))
|
|
869
|
+
`.trim()
|
|
870
|
+
const result = await Bun.$`python -c "${pyCode}" 2>&1`.nothrow()
|
|
871
|
+
const output = result.text().trim()
|
|
872
|
+
if (result.exitCode !== 0) {
|
|
873
|
+
return `ERROR (exit code ${result.exitCode}):\\n${output}`
|
|
874
|
+
}
|
|
875
|
+
return output
|
|
876
|
+
},
|
|
877
|
+
})
|
|
878
|
+
"""
|
|
879
|
+
|
|
880
|
+
EXAMPLE_TOOL_TS: str = """import { tool } from "@opencode-ai/plugin"
|
|
881
|
+
|
|
882
|
+
export default tool({
|
|
883
|
+
description: "An example custom tool. Replace with your own logic.",
|
|
884
|
+
args: {
|
|
885
|
+
input: tool.schema.string().describe("Input to process"),
|
|
886
|
+
},
|
|
887
|
+
async run({ input }) {
|
|
888
|
+
return `Processed: ${input}`
|
|
889
|
+
},
|
|
890
|
+
})
|
|
891
|
+
"""
|
|
892
|
+
|
|
893
|
+
EXAMPLE_SKILL_MD: str = """---
|
|
894
|
+
name: Example Skill
|
|
895
|
+
description: An example skill. Replace with domain-specific knowledge.
|
|
896
|
+
---
|
|
897
|
+
|
|
898
|
+
# Example Skill
|
|
899
|
+
|
|
900
|
+
Add domain-specific knowledge, best practices, API references,
|
|
901
|
+
or other context that helps the agent do its job better.
|
|
902
|
+
"""
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
# ---------------------------------------------------------------------------
|
|
906
|
+
# Main generator
|
|
907
|
+
# ---------------------------------------------------------------------------
|
|
908
|
+
|
|
909
|
+
def generate_dpack(
|
|
910
|
+
output_dir: Path,
|
|
911
|
+
config: dict[str, Any],
|
|
912
|
+
on_progress: Callable[[str], None] | None = None,
|
|
913
|
+
) -> Path:
|
|
914
|
+
"""
|
|
915
|
+
Generate a complete decision-pack directory structure.
|
|
916
|
+
|
|
917
|
+
Creates in a temp directory first, then moves to final location on success.
|
|
918
|
+
|
|
919
|
+
Parameters
|
|
920
|
+
----------
|
|
921
|
+
output_dir : Path
|
|
922
|
+
Parent directory where the decision-pack directory will be created.
|
|
923
|
+
config : dict[str, Any]
|
|
924
|
+
Configuration dict. See source for supported keys.
|
|
925
|
+
on_progress : Callable[[str], None] | None
|
|
926
|
+
Optional callback for progress messages.
|
|
927
|
+
|
|
928
|
+
Returns
|
|
929
|
+
-------
|
|
930
|
+
Path
|
|
931
|
+
Path to the created decision-pack directory.
|
|
932
|
+
|
|
933
|
+
Raises
|
|
934
|
+
------
|
|
935
|
+
ValueError
|
|
936
|
+
If name is invalid or directory already exists.
|
|
937
|
+
"""
|
|
938
|
+
name: str = config["name"]
|
|
939
|
+
error: str | None = validate_dpack_name(name)
|
|
940
|
+
if error:
|
|
941
|
+
raise ValueError(error)
|
|
942
|
+
|
|
943
|
+
# Apply defaults
|
|
944
|
+
config.setdefault("description", f"dlab decision-pack: {name}")
|
|
945
|
+
config.setdefault("docker_image_name", f"dlab-{name}")
|
|
946
|
+
config.setdefault("package_manager", "pip")
|
|
947
|
+
pkg_mgr: str = config["package_manager"]
|
|
948
|
+
config.setdefault("base_image", PACKAGE_MANAGER_BASE_IMAGES.get(pkg_mgr, "python:3.11-slim"))
|
|
949
|
+
config.setdefault("default_model", "opencode/big-pickle")
|
|
950
|
+
config.setdefault("requires_data", True)
|
|
951
|
+
config.setdefault("requires_prompt", True)
|
|
952
|
+
config.setdefault("cli_name", "")
|
|
953
|
+
config.setdefault("agent_name", "orchestrator")
|
|
954
|
+
config.setdefault("agent_description", f"Main orchestrator for {name}")
|
|
955
|
+
config.setdefault("permissions", {})
|
|
956
|
+
config.setdefault("skeletons", {})
|
|
957
|
+
config.setdefault("selected_skills", [])
|
|
958
|
+
config.setdefault("python_lib", False)
|
|
959
|
+
config.setdefault("python_lib_name", "")
|
|
960
|
+
config.setdefault("modal_integration", False)
|
|
961
|
+
config.setdefault("dhub_integration", False)
|
|
962
|
+
|
|
963
|
+
skeletons: dict[str, bool] = config["skeletons"]
|
|
964
|
+
|
|
965
|
+
def _progress(msg: str) -> None:
|
|
966
|
+
if on_progress:
|
|
967
|
+
on_progress(msg)
|
|
968
|
+
|
|
969
|
+
final_dir: Path = output_dir / name
|
|
970
|
+
overwrite: bool = config.get("overwrite_existing", False)
|
|
971
|
+
if final_dir.exists() and not overwrite:
|
|
972
|
+
raise ValueError(f"Directory already exists: {final_dir}")
|
|
973
|
+
|
|
974
|
+
# Build in a temp directory, move to final location on success
|
|
975
|
+
with tempfile.TemporaryDirectory(prefix=f"dlab-{name}-") as tmp:
|
|
976
|
+
dpack_dir: Path = Path(tmp) / name
|
|
977
|
+
|
|
978
|
+
_progress("Creating directory structure...")
|
|
979
|
+
dpack_dir.mkdir(parents=True)
|
|
980
|
+
(dpack_dir / "docker").mkdir()
|
|
981
|
+
opencode_dir: Path = dpack_dir / "opencode"
|
|
982
|
+
opencode_dir.mkdir()
|
|
983
|
+
agents_dir: Path = opencode_dir / "agents"
|
|
984
|
+
agents_dir.mkdir()
|
|
985
|
+
|
|
986
|
+
_progress("Writing config.yaml...")
|
|
987
|
+
(dpack_dir / "config.yaml").write_text(_build_config_yaml(config))
|
|
988
|
+
|
|
989
|
+
_progress("Writing Dockerfile...")
|
|
990
|
+
(dpack_dir / "docker" / "Dockerfile").write_text(_build_dockerfile(config))
|
|
991
|
+
|
|
992
|
+
env_filename: str
|
|
993
|
+
env_content: str
|
|
994
|
+
env_filename, env_content = _build_env_file(config)
|
|
995
|
+
(dpack_dir / "docker" / env_filename).write_text(env_content)
|
|
996
|
+
|
|
997
|
+
if config.get("python_lib") and config.get("python_lib_name"):
|
|
998
|
+
lib_name: str = config["python_lib_name"]
|
|
999
|
+
lib_dir: Path = dpack_dir / "docker" / lib_name
|
|
1000
|
+
lib_dir.mkdir()
|
|
1001
|
+
(lib_dir / "__init__.py").write_text(
|
|
1002
|
+
f'"""{lib_name} — custom Python library for {name}."""\n'
|
|
1003
|
+
)
|
|
1004
|
+
|
|
1005
|
+
if config.get("modal_integration"):
|
|
1006
|
+
_progress("Setting up Modal integration...")
|
|
1007
|
+
modal_dir: Path = dpack_dir / "docker" / "modal_app"
|
|
1008
|
+
modal_dir.mkdir()
|
|
1009
|
+
(modal_dir / "__init__.py").write_text("")
|
|
1010
|
+
(modal_dir / "example.py").write_text(
|
|
1011
|
+
_build_modal_example(name, config["package_manager"])
|
|
1012
|
+
)
|
|
1013
|
+
(dpack_dir / "deploy_modal.sh").write_text(
|
|
1014
|
+
_build_deploy_modal_sh()
|
|
1015
|
+
)
|
|
1016
|
+
|
|
1017
|
+
_progress("Writing opencode config...")
|
|
1018
|
+
(opencode_dir / "opencode.json").write_text(_build_opencode_json(config))
|
|
1019
|
+
|
|
1020
|
+
agent_name: str = config["agent_name"]
|
|
1021
|
+
(agents_dir / f"{agent_name}.md").write_text(_build_agent_md(config))
|
|
1022
|
+
|
|
1023
|
+
has_subagents: bool = skeletons.get("subagents", False) or skeletons.get("parallel_agents", False)
|
|
1024
|
+
if has_subagents:
|
|
1025
|
+
(agents_dir / "example-worker.md").write_text(EXAMPLE_WORKER_MD)
|
|
1026
|
+
|
|
1027
|
+
if skeletons.get("parallel_agents", False):
|
|
1028
|
+
pa_dir: Path = opencode_dir / "parallel_agents"
|
|
1029
|
+
pa_dir.mkdir()
|
|
1030
|
+
(pa_dir / "example-worker.yaml").write_text(EXAMPLE_WORKER_YAML)
|
|
1031
|
+
|
|
1032
|
+
if skeletons.get("tools", False):
|
|
1033
|
+
_progress("Creating tool templates...")
|
|
1034
|
+
tools_dir: Path = opencode_dir / "tools"
|
|
1035
|
+
tools_dir.mkdir()
|
|
1036
|
+
(tools_dir / "example-tool.ts").write_text(EXAMPLE_TOOL_TS)
|
|
1037
|
+
|
|
1038
|
+
if config.get("modal_integration"):
|
|
1039
|
+
modal_app_name: str = f"{name}-compute"
|
|
1040
|
+
(tools_dir / "run-on-modal.ts").write_text(
|
|
1041
|
+
RUN_ON_MODAL_TS.replace("<APP_NAME>", modal_app_name)
|
|
1042
|
+
)
|
|
1043
|
+
|
|
1044
|
+
if skeletons.get("skills", False):
|
|
1045
|
+
skills_dir: Path = opencode_dir / "skills"
|
|
1046
|
+
skills_dir.mkdir(exist_ok=True)
|
|
1047
|
+
example_skill_dir: Path = skills_dir / "example-skill"
|
|
1048
|
+
example_skill_dir.mkdir()
|
|
1049
|
+
(example_skill_dir / "SKILL.md").write_text(EXAMPLE_SKILL_MD)
|
|
1050
|
+
|
|
1051
|
+
# Decision Hub integration: download dhub-cli skill
|
|
1052
|
+
if config.get("dhub_integration"):
|
|
1053
|
+
_progress("Downloading dhub-cli skill...")
|
|
1054
|
+
skills_dir = opencode_dir / "skills"
|
|
1055
|
+
skills_dir.mkdir(exist_ok=True)
|
|
1056
|
+
download_skill("pymc-labs", "dhub-cli", skills_dir)
|
|
1057
|
+
|
|
1058
|
+
# User-selected skills from Decision Hub
|
|
1059
|
+
selected_skills: list[dict[str, Any]] = config["selected_skills"]
|
|
1060
|
+
if selected_skills:
|
|
1061
|
+
_progress("Downloading skills from Decision Hub...")
|
|
1062
|
+
skills_dir = opencode_dir / "skills"
|
|
1063
|
+
skills_dir.mkdir(exist_ok=True)
|
|
1064
|
+
for skill in selected_skills:
|
|
1065
|
+
org: str = skill["org_slug"]
|
|
1066
|
+
sname: str = skill["skill_name"]
|
|
1067
|
+
download_skill(org, sname, skills_dir)
|
|
1068
|
+
|
|
1069
|
+
# .env.example
|
|
1070
|
+
env_vars: list[str] = list(get_provider_env_vars(config["default_model"]))
|
|
1071
|
+
if config.get("modal_integration"):
|
|
1072
|
+
env_vars.extend(["MODAL_TOKEN_ID", "MODAL_TOKEN_SECRET"])
|
|
1073
|
+
if env_vars:
|
|
1074
|
+
env_lines: list[str] = [
|
|
1075
|
+
f"# Environment variables for {name}",
|
|
1076
|
+
"# Copy this file to .env and fill in your keys:",
|
|
1077
|
+
"# cp .env.example .env",
|
|
1078
|
+
"",
|
|
1079
|
+
]
|
|
1080
|
+
for var in env_vars:
|
|
1081
|
+
env_lines.append(f"{var}=your-key-here")
|
|
1082
|
+
env_lines.append("")
|
|
1083
|
+
(dpack_dir / ".env.example").write_text("\n".join(env_lines))
|
|
1084
|
+
|
|
1085
|
+
(dpack_dir / ".gitignore").write_text(".env\n*.env\n!.env.example\n")
|
|
1086
|
+
|
|
1087
|
+
# Move to final location
|
|
1088
|
+
_progress("Finalizing...")
|
|
1089
|
+
if final_dir.exists() and overwrite:
|
|
1090
|
+
shutil.rmtree(final_dir)
|
|
1091
|
+
try:
|
|
1092
|
+
dpack_dir.rename(final_dir)
|
|
1093
|
+
except OSError:
|
|
1094
|
+
shutil.copytree(dpack_dir, final_dir)
|
|
1095
|
+
|
|
1096
|
+
return final_dir
|