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