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.
@@ -0,0 +1,1471 @@
1
+ """
2
+ Textual TUI wizard for creating a new decision-pack directory.
3
+
4
+ Multi-screen wizard that collects configuration and calls generate_dpack().
5
+ """
6
+
7
+ import shutil
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from textual import work
12
+ from textual.actions import SkipAction
13
+ from textual.app import App, ComposeResult
14
+ from textual.binding import Binding
15
+ from textual.containers import Horizontal, Vertical, VerticalScroll
16
+ from textual.content import Content
17
+ from textual.screen import Screen
18
+
19
+
20
+ class FormScroll(VerticalScroll, can_focus=False):
21
+ """VerticalScroll that doesn't consume arrow keys.
22
+
23
+ Overrides scroll actions to raise SkipAction, so arrow keys pass through
24
+ to app-level focus navigation. Still scrolls into view automatically
25
+ when children receive focus.
26
+ """
27
+
28
+ def action_scroll_up(self) -> None:
29
+ raise SkipAction()
30
+
31
+ def action_scroll_down(self) -> None:
32
+ raise SkipAction()
33
+
34
+ def action_scroll_left(self) -> None:
35
+ raise SkipAction()
36
+
37
+ def action_scroll_right(self) -> None:
38
+ raise SkipAction()
39
+ from textual.style import Style
40
+ from textual.widgets import (
41
+ Button,
42
+ Checkbox,
43
+ Input,
44
+ Label,
45
+ ListItem,
46
+ ListView,
47
+ OptionList,
48
+ Static,
49
+ )
50
+ from textual.widgets.option_list import Option
51
+
52
+ class BackButton(Button, can_focus=False):
53
+ """Back button that is clickable but skipped in focus/Tab navigation."""
54
+
55
+
56
+ from dlab.create_dpack import (
57
+ CONFIGURABLE_PERMISSIONS,
58
+ HIGH_IMPACT_PERMISSION_COUNT,
59
+ KNOWN_BASE_IMAGES,
60
+ PACKAGE_MANAGER_BASE_IMAGES,
61
+ ask_skills,
62
+ filter_models,
63
+ generate_dpack,
64
+ get_model_list,
65
+ validate_dpack_name,
66
+ )
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Custom checkbox with ▢/▣ glyphs
71
+ # ---------------------------------------------------------------------------
72
+
73
+ class DpackCheckbox(Checkbox):
74
+ """Checkbox with ▢ (unchecked) and ▣ (checked) glyphs."""
75
+
76
+ BUTTON_LEFT: str = ""
77
+ BUTTON_RIGHT: str = ""
78
+
79
+ BINDINGS = [
80
+ Binding("tab", "tab_out", show=False),
81
+ ]
82
+
83
+ @property
84
+ def _button(self) -> Content:
85
+ button_style = self.get_visual_style("toggle--button")
86
+ glyph: str = "▣" if self.value else "▢"
87
+ return Content.assemble((glyph, button_style))
88
+
89
+ def action_tab_out(self) -> None:
90
+ """Tab jumps out of cb-group container to next element outside."""
91
+ # Walk up to find cb-group parent
92
+ cb_group = self.parent
93
+ while cb_group and not cb_group.has_class("cb-group"):
94
+ cb_group = cb_group.parent
95
+ if cb_group:
96
+ # Find first focusable widget after self that is NOT inside this cb-group
97
+ found_self: bool = False
98
+ for widget in self.screen.focus_chain:
99
+ if widget is self:
100
+ found_self = True
101
+ continue
102
+ if found_self and cb_group not in widget.ancestors:
103
+ widget.focus()
104
+ return
105
+ # Fallback: default focus_next
106
+ self.screen.focus_next()
107
+
108
+
109
+ # ---------------------------------------------------------------------------
110
+ # Screen 1: Basics
111
+ # ---------------------------------------------------------------------------
112
+
113
+ class BasicsScreen(Screen):
114
+ """Collect decision-pack name and description."""
115
+
116
+ CSS = """
117
+ BasicsScreen {
118
+ align: left bottom;
119
+ }
120
+ #basics-container {
121
+ width: 66%;
122
+ height: auto;
123
+ padding: 1 2;
124
+ }
125
+ .field-label {
126
+ margin-top: 1;
127
+ color: $text;
128
+ }
129
+ .field-hint {
130
+ color: $text-muted;
131
+ text-style: italic;
132
+ margin-bottom: 0;
133
+ text-wrap: wrap;
134
+ width: 1fr;
135
+ }
136
+ .error-label {
137
+ color: $error;
138
+ }
139
+ .nav-bar {
140
+ margin-top: 1;
141
+ height: 1;
142
+ align: right middle;
143
+ }
144
+ """
145
+
146
+ def compose(self) -> ComposeResult:
147
+ with Vertical(id="basics-container"):
148
+ yield Label("[b]Step 1 of 8[/b] — Basics", classes="field-label")
149
+ yield Label("Tab / Shift+Tab to navigate | Ctrl+Q to quit", classes="field-hint")
150
+ yield Label("decision-pack name", classes="field-label")
151
+ yield Label("Alphanumeric, hyphens, underscores", classes="field-hint")
152
+ yield Input(id="name-input", placeholder="my-dpack")
153
+ with Horizontal(id="collision-bar"):
154
+ yield Label("", id="name-error", classes="error-label")
155
+ yield Button("Delete & Overwrite", id="overwrite-btn", variant="error")
156
+
157
+ yield Label("Description", classes="field-label")
158
+ yield Input(id="desc-input", placeholder="(default: dlab decision-pack: {name})")
159
+
160
+ yield Label("CLI name", classes="field-label")
161
+ yield Label("Override command name when installed (default: decision-pack name)", classes="field-hint")
162
+ yield Input(id="cli-name-input", placeholder="(same as decision-pack name)")
163
+
164
+ with Horizontal(classes="nav-bar"):
165
+ yield Button("Next →", id="next-btn")
166
+
167
+ def on_mount(self) -> None:
168
+ state: dict[str, Any] = self.app.wizard_state
169
+ if state.get("name"):
170
+ self.query_one("#name-input", Input).value = state["name"]
171
+ if state.get("description"):
172
+ self.query_one("#desc-input", Input).value = state["description"]
173
+ if state.get("cli_name"):
174
+ self.query_one("#cli-name-input", Input).value = state["cli_name"]
175
+ self.query_one("#overwrite-btn", Button).display = False
176
+ self.query_one("#name-input", Input).focus()
177
+
178
+ def on_input_changed(self, event: Input.Changed) -> None:
179
+ if event.input.id == "name-input":
180
+ self.query_one("#overwrite-btn", Button).display = False
181
+ self.app.wizard_state.pop("overwrite_existing", None)
182
+ self.query_one("#name-error", Label).update("")
183
+
184
+ def on_button_pressed(self, event: Button.Pressed) -> None:
185
+ if event.button.id == "overwrite-btn":
186
+ self.app.wizard_state["overwrite_existing"] = True
187
+ self.query_one("#name-error", Label).update("[green]decision-pack will be overwritten[/green]")
188
+ self.query_one("#overwrite-btn", Button).display = False
189
+ return
190
+
191
+ if event.button.id != "next-btn":
192
+ return
193
+
194
+ name: str = self.query_one("#name-input", Input).value.strip()
195
+ error: str | None = validate_dpack_name(name)
196
+ error_label: Label = self.query_one("#name-error", Label)
197
+ if error:
198
+ error_label.update(f"[red]{error}[/red]")
199
+ return
200
+
201
+ # Check for existing decision-pack
202
+ dpack_dir: Path = Path(self.app.output_dir) / name
203
+ if dpack_dir.exists() and not self.app.wizard_state.get("overwrite_existing"):
204
+ error_label.update("[red]decision-pack already exists.[/red]")
205
+ self.query_one("#overwrite-btn", Button).display = True
206
+ return
207
+
208
+ error_label.update("")
209
+
210
+ desc: str = self.query_one("#desc-input", Input).value.strip()
211
+
212
+ state: dict[str, Any] = self.app.wizard_state
213
+ state["name"] = name
214
+ state["description"] = desc or f"dlab decision-pack: {name}"
215
+ state["cli_name"] = self.query_one("#cli-name-input", Input).value.strip()
216
+
217
+ self.app.push_screen(ContainerScreen())
218
+
219
+
220
+ # ---------------------------------------------------------------------------
221
+ # Screen 2: Container setup
222
+ # ---------------------------------------------------------------------------
223
+
224
+ class ContainerScreen(Screen):
225
+ """Choose package manager and base image."""
226
+
227
+ BINDINGS = [Binding("escape", "go_back", "Back")]
228
+
229
+ CSS = """
230
+ ContainerScreen {
231
+ align: left bottom;
232
+ }
233
+ #container-container {
234
+ width: 66%;
235
+ height: auto;
236
+ max-height: 80%;
237
+ padding: 1 2;
238
+ }
239
+ .field-label {
240
+ margin-top: 1;
241
+ }
242
+ .field-hint {
243
+ color: $text-muted;
244
+ text-style: italic;
245
+ text-wrap: wrap;
246
+ width: 1fr;
247
+ }
248
+ .cb-desc {
249
+ color: $text-muted;
250
+ text-style: italic;
251
+ padding-left: 4;
252
+ margin-bottom: 0;
253
+ text-wrap: wrap;
254
+ width: 1fr;
255
+ }
256
+ .nav-bar {
257
+ margin-top: 1;
258
+ height: 1;
259
+ align: right middle;
260
+ }
261
+ #base-image-list {
262
+ height: auto;
263
+ max-height: 8;
264
+ margin-top: 1;
265
+ scrollbar-size: 1 1;
266
+ }
267
+ """
268
+
269
+ PKG_MGR_OPTIONS: list[tuple[str, str, str]] = [
270
+ ("conda", "conda (Recommended)",
271
+ "Miniconda base image. Creates environment.yml with conda-forge channel. "
272
+ "Best for scientific Python (BLAS, PyMC, JAX, etc.)"),
273
+ ("pip", "pip",
274
+ "Python slim base image. Creates requirements.txt. "
275
+ "Simplest option for most projects."),
276
+ ("uv", "uv",
277
+ "Python slim base image with uv. Creates requirements.txt. "
278
+ "Fast dependency resolution, drop-in pip replacement."),
279
+ ("pixi", "pixi",
280
+ "Debian base with pixi. Creates pixi.toml. "
281
+ "Modern conda-forge workflow with lockfile support."),
282
+ ]
283
+
284
+ def __init__(self) -> None:
285
+ super().__init__()
286
+ self._programmatic_fill: bool = False
287
+
288
+ def compose(self) -> ComposeResult:
289
+ with FormScroll(id="container-container"):
290
+ yield Label("[b]Step 2 of 8[/b] — Container Setup", classes="field-label")
291
+ yield Label("Tab / Shift+Tab to navigate | Ctrl+Q to quit", classes="field-hint")
292
+
293
+ yield Label("[b]Package Manager[/b]", classes="field-label")
294
+ with Vertical(classes="cb-group"):
295
+ for key, label, desc in self.PKG_MGR_OPTIONS:
296
+ yield DpackCheckbox(
297
+ label,
298
+ value=(key == "conda"),
299
+ id=f"pkg-{key}",
300
+ )
301
+ yield Label(desc, classes="cb-desc")
302
+
303
+ yield Label("[b]Base Image[/b]", classes="field-label")
304
+ yield Label("Select or enter a custom Docker base image", classes="field-hint")
305
+ yield Input(id="base-image-input", placeholder="python:3.11-slim")
306
+ with Vertical(classes="selection-group"):
307
+ yield OptionList(
308
+ *[Option(img, id=img) for img in KNOWN_BASE_IMAGES],
309
+ id="base-image-list",
310
+ )
311
+ yield Label("Tab to continue", classes="option-hint")
312
+
313
+ with Horizontal(classes="nav-bar nav-swap"):
314
+ yield Button("Next →", id="next-btn")
315
+ yield Button("← Back", id="back-btn")
316
+
317
+ def on_mount(self) -> None:
318
+ state: dict[str, Any] = self.app.wizard_state
319
+ # Restore package manager selection
320
+ pkg_mgr: str = state.get("package_manager", "conda")
321
+ for key, _, _ in self.PKG_MGR_OPTIONS:
322
+ self.query_one(f"#pkg-{key}", Checkbox).value = (key == pkg_mgr)
323
+
324
+ # Set base image from state or default for package manager
325
+ base_image: str = state.get("base_image", PACKAGE_MANAGER_BASE_IMAGES.get(pkg_mgr, "python:3.11-slim"))
326
+ self._programmatic_fill = True
327
+ self.query_one("#base-image-input", Input).value = base_image
328
+
329
+ self.query_one(f"#pkg-{pkg_mgr}", Checkbox).focus()
330
+
331
+ def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
332
+ # Radio group for package manager
333
+ pkg_ids: list[str] = [f"pkg-{k}" for k, _, _ in self.PKG_MGR_OPTIONS]
334
+ if event.checkbox.id in pkg_ids and event.value:
335
+ for pid in pkg_ids:
336
+ if pid != event.checkbox.id:
337
+ self.query_one(f"#{pid}", Checkbox).value = False
338
+ # Update base image to default for selected package manager
339
+ pkg_mgr: str = event.checkbox.id.removeprefix("pkg-")
340
+ default_image: str = PACKAGE_MANAGER_BASE_IMAGES.get(pkg_mgr, "python:3.11-slim")
341
+ self._programmatic_fill = True
342
+ self.query_one("#base-image-input", Input).value = default_image
343
+
344
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
345
+ if event.option_list.id == "base-image-list":
346
+ self._programmatic_fill = True
347
+ self.query_one("#base-image-input", Input).value = str(event.option.id)
348
+ self.screen.focus_next()
349
+
350
+ def on_input_changed(self, event: Input.Changed) -> None:
351
+ if event.input.id != "base-image-input":
352
+ return
353
+ if self._programmatic_fill:
354
+ self._programmatic_fill = False
355
+ return
356
+ # Filter base image list
357
+ query: str = event.value.strip().lower()
358
+ ol: OptionList = self.query_one("#base-image-list", OptionList)
359
+ ol.clear_options()
360
+ for img in KNOWN_BASE_IMAGES:
361
+ if not query or query in img.lower():
362
+ ol.add_option(Option(img, id=img))
363
+
364
+ def on_button_pressed(self, event: Button.Pressed) -> None:
365
+ if event.button.id == "back-btn":
366
+ self.app.pop_screen()
367
+ return
368
+ if event.button.id != "next-btn":
369
+ return
370
+
371
+ state: dict[str, Any] = self.app.wizard_state
372
+
373
+ # Get selected package manager from radio checkboxes
374
+ pkg_mgr: str = "conda"
375
+ for key, _, _ in self.PKG_MGR_OPTIONS:
376
+ if self.query_one(f"#pkg-{key}", Checkbox).value:
377
+ pkg_mgr = key
378
+ break
379
+
380
+ state["package_manager"] = pkg_mgr
381
+
382
+ # Get base image (from input, not from package manager default)
383
+ base_image: str = self.query_one("#base-image-input", Input).value.strip()
384
+ if not base_image:
385
+ base_image = PACKAGE_MANAGER_BASE_IMAGES.get(pkg_mgr, "python:3.11-slim")
386
+ state["base_image"] = base_image
387
+
388
+ self.app.push_screen(FeaturesScreen())
389
+
390
+ def action_go_back(self) -> None:
391
+ self.app.pop_screen()
392
+
393
+
394
+ # ---------------------------------------------------------------------------
395
+ # Screen 3: Additional features
396
+ # ---------------------------------------------------------------------------
397
+
398
+ class FeaturesScreen(Screen):
399
+ """Configure additional features: dhub, python lib, modal, requires_data."""
400
+
401
+ BINDINGS = [Binding("escape", "go_back", "Back")]
402
+
403
+ CSS = """
404
+ FeaturesScreen {
405
+ align: left bottom;
406
+ }
407
+ #features-container {
408
+ width: 66%;
409
+ height: auto;
410
+ padding: 1 2;
411
+ }
412
+ .field-label {
413
+ margin-top: 1;
414
+ }
415
+ .field-hint {
416
+ color: $text-muted;
417
+ text-style: italic;
418
+ text-wrap: wrap;
419
+ width: 1fr;
420
+ }
421
+ .cb-desc {
422
+ color: $text-muted;
423
+ text-style: italic;
424
+ padding-left: 4;
425
+ margin-bottom: 0;
426
+ text-wrap: wrap;
427
+ width: 1fr;
428
+ }
429
+ .nav-bar {
430
+ margin-top: 1;
431
+ height: 1;
432
+ align: right middle;
433
+ }
434
+ """
435
+
436
+ def compose(self) -> ComposeResult:
437
+ with Vertical(id="features-container"):
438
+ yield Label("[b]Step 3 of 8[/b] — Additional Features", classes="field-label")
439
+ yield Label("Tab / Shift+Tab to navigate | Ctrl+Q to quit", classes="field-hint")
440
+
441
+ with Vertical(classes="cb-group"):
442
+ yield DpackCheckbox(
443
+ "Decision Hub integration",
444
+ value=True,
445
+ id="dhub-cb",
446
+ )
447
+ yield Label(
448
+ "The agent can dynamically search hub.decision.ai for skills "
449
+ "and install them at runtime. Adds dhub-cli to container dependencies.",
450
+ classes="cb-desc",
451
+ )
452
+ yield DpackCheckbox(
453
+ "Python library — create a {name}_lib/ package in docker/",
454
+ value=False,
455
+ id="python-lib-cb",
456
+ )
457
+ yield Label(
458
+ "Adds {name}_lib/__init__.py, COPY + PYTHONPATH in Dockerfile.",
459
+ classes="cb-desc",
460
+ )
461
+ yield DpackCheckbox(
462
+ "Modal integration — create docker/modal_app/ with example script",
463
+ value=False,
464
+ id="modal-cb",
465
+ )
466
+ yield Label(
467
+ "For serverless cloud execution (e.g. heavy compute).",
468
+ classes="cb-desc",
469
+ )
470
+ yield DpackCheckbox(
471
+ "Requires data directory — agent needs --data to run",
472
+ value=True,
473
+ id="requires-data-cb",
474
+ )
475
+ yield Label(
476
+ "If unchecked, --data becomes optional when running this decision-pack.",
477
+ classes="cb-desc",
478
+ )
479
+ yield DpackCheckbox(
480
+ "Requires prompt — agent needs --prompt or --prompt-file to run",
481
+ value=True,
482
+ id="requires-prompt-cb",
483
+ )
484
+ yield Label(
485
+ "If unchecked, --prompt becomes optional (useful for fully automated decision-packs).",
486
+ classes="cb-desc",
487
+ )
488
+
489
+ with Horizontal(classes="nav-bar nav-swap"):
490
+ yield Button("Next →", id="next-btn")
491
+ yield Button("← Back", id="back-btn")
492
+
493
+ def on_mount(self) -> None:
494
+ state: dict[str, Any] = self.app.wizard_state
495
+ if state.get("dhub_integration") is not None:
496
+ self.query_one("#dhub-cb", Checkbox).value = state["dhub_integration"]
497
+ if state.get("python_lib") is not None:
498
+ self.query_one("#python-lib-cb", Checkbox).value = state["python_lib"]
499
+ if state.get("modal_integration") is not None:
500
+ self.query_one("#modal-cb", Checkbox).value = state["modal_integration"]
501
+ if state.get("requires_data") is not None:
502
+ self.query_one("#requires-data-cb", Checkbox).value = state["requires_data"]
503
+ if state.get("requires_prompt") is not None:
504
+ self.query_one("#requires-prompt-cb", Checkbox).value = state["requires_prompt"]
505
+ self.query_one("#dhub-cb", Checkbox).focus()
506
+
507
+ def on_button_pressed(self, event: Button.Pressed) -> None:
508
+ if event.button.id == "back-btn":
509
+ self.app.pop_screen()
510
+ return
511
+ if event.button.id != "next-btn":
512
+ return
513
+
514
+ state: dict[str, Any] = self.app.wizard_state
515
+ name: str = state.get("name", "dpack")
516
+
517
+ state["dhub_integration"] = self.query_one("#dhub-cb", Checkbox).value
518
+
519
+ python_lib: bool = self.query_one("#python-lib-cb", Checkbox).value
520
+ state["python_lib"] = python_lib
521
+ state["python_lib_name"] = name.replace("-", "_") + "_lib" if python_lib else ""
522
+
523
+ state["modal_integration"] = self.query_one("#modal-cb", Checkbox).value
524
+ state["requires_data"] = self.query_one("#requires-data-cb", Checkbox).value
525
+ state["requires_prompt"] = self.query_one("#requires-prompt-cb", Checkbox).value
526
+
527
+ self.app.push_screen(ModelScreen())
528
+
529
+ def action_go_back(self) -> None:
530
+ self.app.pop_screen()
531
+
532
+
533
+ # ---------------------------------------------------------------------------
534
+ # Screen 3: Model selection
535
+ # ---------------------------------------------------------------------------
536
+
537
+ class ModelScreen(Screen):
538
+ """Select default model with live filtering."""
539
+
540
+ BINDINGS = [Binding("escape", "go_back", "Back")]
541
+
542
+ CSS = """
543
+ ModelScreen {
544
+ align: left bottom;
545
+ }
546
+ #model-container {
547
+ width: 66%;
548
+ height: auto;
549
+ padding: 1 2;
550
+ }
551
+ #model-results {
552
+ height: auto;
553
+ max-height: 18;
554
+ }
555
+ .nav-bar {
556
+ margin-top: 1;
557
+ height: 1;
558
+ align: right middle;
559
+ }
560
+ .field-label {
561
+ margin-top: 1;
562
+ }
563
+ .field-hint {
564
+ color: $text-muted;
565
+ text-style: italic;
566
+ text-wrap: wrap;
567
+ width: 1fr;
568
+ }
569
+ """
570
+
571
+ def __init__(self) -> None:
572
+ super().__init__()
573
+ self._models: list[str] = get_model_list()
574
+ self._programmatic_fill: bool = False
575
+
576
+ def compose(self) -> ComposeResult:
577
+ with Vertical(id="model-container"):
578
+ yield Label("[b]Step 4 of 8[/b] — Default Model", classes="field-label")
579
+ yield Label("Tab / Shift+Tab to navigate | Ctrl+Q to quit", classes="field-hint")
580
+ yield Label("Type to filter, or enter a custom model name", classes="field-hint")
581
+ yield Input(id="model-input", placeholder="opencode/big-pickle")
582
+ with Vertical(classes="selection-group"):
583
+ yield OptionList(
584
+ *[Option(m, id=m) for m in self._models],
585
+ id="model-results",
586
+ )
587
+ yield Label("Tab to continue", classes="option-hint")
588
+ with Horizontal(classes="nav-bar nav-swap"):
589
+ yield Button("Next →", id="next-btn")
590
+ yield Button("← Back", id="back-btn")
591
+
592
+ def on_mount(self) -> None:
593
+ state: dict[str, Any] = self.app.wizard_state
594
+ if state.get("default_model"):
595
+ self._programmatic_fill = True
596
+ self.query_one("#model-input", Input).value = state["default_model"]
597
+ self.query_one("#model-input", Input).focus()
598
+ self._refresh_models()
599
+
600
+ @work(thread=True)
601
+ def _refresh_models(self) -> None:
602
+ """Fetch models from API in background and refresh the list."""
603
+ from dlab.create_dpack import fetch_models_from_api, save_model_cache
604
+ try:
605
+ data: dict[str, Any] = fetch_models_from_api()
606
+ save_model_cache(data)
607
+ from dlab.create_dpack import _model_sort_key
608
+ new_models: list[str] = sorted(set(self._models) | set(data.get("models", [])), key=_model_sort_key)
609
+ if new_models != self._models:
610
+ self._models = new_models
611
+ self.app.call_from_thread(self._rebuild_options, "")
612
+ except Exception:
613
+ pass # Network failure is fine — we have cached/hardcoded models
614
+
615
+ def _rebuild_options(self, query: str) -> None:
616
+ """Rebuild the OptionList with current model list, filtered by query."""
617
+ matches: list[str] = filter_models(query, self._models) if query else self._models
618
+ ol: OptionList = self.query_one("#model-results", OptionList)
619
+ ol.clear_options()
620
+ for m in matches:
621
+ ol.add_option(Option(m, id=m))
622
+
623
+ def on_input_changed(self, event: Input.Changed) -> None:
624
+ if event.input.id != "model-input":
625
+ return
626
+ if self._programmatic_fill:
627
+ self._programmatic_fill = False
628
+ return
629
+ self._rebuild_options(event.value.strip())
630
+
631
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
632
+ if event.option_list.id != "model-results":
633
+ return
634
+ self._programmatic_fill = True
635
+ self.query_one("#model-input", Input).value = str(event.option.prompt)
636
+ self.query_one("#next-btn", Button).focus()
637
+
638
+ def on_button_pressed(self, event: Button.Pressed) -> None:
639
+ if event.button.id == "back-btn":
640
+ self.app.pop_screen()
641
+ return
642
+ if event.button.id != "next-btn":
643
+ return
644
+
645
+ model: str = self.query_one("#model-input", Input).value.strip()
646
+ if not model:
647
+ model = "opencode/big-pickle"
648
+
649
+ self.app.wizard_state["default_model"] = model
650
+ self.app.push_screen(PermissionsScreen())
651
+
652
+ def action_go_back(self) -> None:
653
+ self.app.pop_screen()
654
+
655
+
656
+ # ---------------------------------------------------------------------------
657
+ # Screen 4: Permissions
658
+ # ---------------------------------------------------------------------------
659
+
660
+ class PermissionsScreen(Screen):
661
+ """Configure opencode.json permissions."""
662
+
663
+ BINDINGS = [Binding("escape", "go_back", "Back")]
664
+
665
+ CSS = """
666
+ PermissionsScreen {
667
+ align: left bottom;
668
+ }
669
+ #perms-container {
670
+ width: 66%;
671
+ height: auto;
672
+ max-height: 80%;
673
+ padding: 1 2;
674
+ }
675
+ .field-label {
676
+ margin-top: 1;
677
+ }
678
+ .field-hint {
679
+ color: $text-muted;
680
+ text-style: italic;
681
+ text-wrap: wrap;
682
+ width: 1fr;
683
+ }
684
+ .cb-desc {
685
+ color: $text-muted;
686
+ text-style: italic;
687
+ padding-left: 4;
688
+ margin-bottom: 0;
689
+ text-wrap: wrap;
690
+ width: 1fr;
691
+ }
692
+ .section-divider {
693
+ margin-top: 1;
694
+ color: $accent;
695
+ }
696
+ .cb-group {
697
+ height: auto;
698
+ }
699
+ .nav-bar {
700
+ margin-top: 1;
701
+ height: 1;
702
+ align: right middle;
703
+ }
704
+ """
705
+
706
+ def compose(self) -> ComposeResult:
707
+ with FormScroll(id="perms-container"):
708
+ yield Label("[b]Step 5 of 8[/b] — Permissions", classes="field-label")
709
+ yield Label("Tab / Shift+Tab to navigate | Ctrl+Q to quit", classes="field-hint")
710
+ yield Label(
711
+ "These permissions are written to opencode.json and apply project-wide. "
712
+ "In automated mode (opencode run), every permission must be explicitly "
713
+ "allow or deny — there is no interactive approval prompt.",
714
+ classes="field-hint",
715
+ )
716
+ yield Label(
717
+ "Each agent's .md frontmatter has a tools: section that can further "
718
+ "restrict permissions per-agent (overrides opencode.json). "
719
+ "Subagent permissions are controlled in their own .md frontmatter.",
720
+ classes="field-hint",
721
+ )
722
+
723
+ yield Label("[b]High-impact[/b]", classes="section-divider")
724
+ with Vertical(classes="cb-group"):
725
+ for key, label, desc, default in CONFIGURABLE_PERMISSIONS[:HIGH_IMPACT_PERMISSION_COUNT]:
726
+ yield DpackCheckbox(label, value=(default == "allow"), id=f"perm-{key}")
727
+ yield Label(desc, classes="cb-desc")
728
+
729
+ yield Label("[b]Internal/basic[/b]", classes="section-divider")
730
+ with Vertical(classes="cb-group"):
731
+ for key, label, desc, default in CONFIGURABLE_PERMISSIONS[HIGH_IMPACT_PERMISSION_COUNT:]:
732
+ yield DpackCheckbox(label, value=(default == "allow"), id=f"perm-{key}")
733
+ yield Label(desc, classes="cb-desc")
734
+
735
+ with Horizontal(classes="nav-bar nav-swap"):
736
+ yield Button("Next →", id="next-btn")
737
+ yield Button("← Back", id="back-btn")
738
+
739
+ def on_mount(self) -> None:
740
+ state: dict[str, Any] = self.app.wizard_state
741
+ perms: dict[str, str] = state.get("permissions", {})
742
+
743
+ for key, _label, _desc, _default in CONFIGURABLE_PERMISSIONS:
744
+ if key in perms:
745
+ self.query_one(f"#perm-{key}", Checkbox).value = (perms[key] == "allow")
746
+
747
+ first_key: str = CONFIGURABLE_PERMISSIONS[0][0]
748
+ self.query_one(f"#perm-{first_key}", Checkbox).focus()
749
+
750
+ def on_button_pressed(self, event: Button.Pressed) -> None:
751
+ if event.button.id == "back-btn":
752
+ self.app.pop_screen()
753
+ return
754
+ if event.button.id != "next-btn":
755
+ return
756
+
757
+ perms: dict[str, str] = {}
758
+ for key, _label, _desc, _default in CONFIGURABLE_PERMISSIONS:
759
+ checked: bool = self.query_one(f"#perm-{key}", Checkbox).value
760
+ perms[key] = "allow" if checked else "deny"
761
+ self.app.wizard_state["permissions"] = perms
762
+
763
+ self.app.push_screen(SkeletonsScreen())
764
+
765
+ def action_go_back(self) -> None:
766
+ self.app.pop_screen()
767
+
768
+
769
+ # ---------------------------------------------------------------------------
770
+ # Screen 5: Directory skeletons
771
+ # ---------------------------------------------------------------------------
772
+
773
+ class SkeletonsScreen(Screen):
774
+ """Select which opencode directory skeletons to scaffold."""
775
+
776
+ BINDINGS = [Binding("escape", "go_back", "Back")]
777
+
778
+ CSS = """
779
+ SkeletonsScreen {
780
+ align: left bottom;
781
+ }
782
+ #skeletons-container {
783
+ width: 66%;
784
+ height: auto;
785
+ max-height: 80%;
786
+ padding: 1 2;
787
+ }
788
+ .field-label {
789
+ margin-top: 1;
790
+ }
791
+ .field-hint {
792
+ color: $text-muted;
793
+ text-style: italic;
794
+ text-wrap: wrap;
795
+ width: 1fr;
796
+ }
797
+ .cb-desc {
798
+ color: $text-muted;
799
+ text-style: italic;
800
+ padding-left: 4;
801
+ margin-bottom: 0;
802
+ text-wrap: wrap;
803
+ width: 1fr;
804
+ }
805
+ .nav-bar {
806
+ margin-top: 1;
807
+ height: 1;
808
+ align: right middle;
809
+ }
810
+ """
811
+
812
+ def compose(self) -> ComposeResult:
813
+ with FormScroll(id="skeletons-container"):
814
+ yield Label("[b]Step 6 of 8[/b] — Directory Skeletons", classes="field-label")
815
+ yield Label("Tab / Shift+Tab to navigate | Ctrl+Q to quit", classes="field-hint")
816
+ yield Label(
817
+ "Scaffold opencode directories with example files. "
818
+ "All are enabled by default — disable the ones you don't need.",
819
+ classes="field-hint",
820
+ )
821
+
822
+ with Vertical(classes="cb-group"):
823
+ yield DpackCheckbox(
824
+ "Skills — knowledge files the agent can reference (opencode/skills/)",
825
+ value=True,
826
+ id="skel-skills",
827
+ )
828
+ yield Label(
829
+ "Add domain-specific knowledge, API references, or best practices.",
830
+ classes="cb-desc",
831
+ )
832
+ yield DpackCheckbox(
833
+ "Tools — custom TypeScript tools (opencode/tools/)",
834
+ value=True,
835
+ id="skel-tools",
836
+ )
837
+ yield Label(
838
+ "Extend the agent with custom tools written in TypeScript.",
839
+ classes="cb-desc",
840
+ id="skel-tools-desc",
841
+ )
842
+ yield DpackCheckbox(
843
+ "Subagents — additional agents the main agent can delegate to",
844
+ value=True,
845
+ id="skel-subagents",
846
+ )
847
+ yield Label(
848
+ "Create an example subagent .md file in opencode/agents/.",
849
+ classes="cb-desc",
850
+ )
851
+ yield DpackCheckbox(
852
+ "Parallel subagents — run multiple subagent instances simultaneously",
853
+ value=True,
854
+ id="skel-parallel",
855
+ )
856
+ yield Label(
857
+ "Adds parallel agent YAML config + parallel-agents tool to the main agent. "
858
+ "Requires subagents (auto-enabled).",
859
+ classes="cb-desc",
860
+ )
861
+
862
+ with Horizontal(classes="nav-bar nav-swap"):
863
+ yield Button("Next →", id="next-btn")
864
+ yield Button("← Back", id="back-btn")
865
+
866
+ def on_mount(self) -> None:
867
+ state: dict[str, Any] = self.app.wizard_state
868
+ skels: dict[str, bool] = state.get("skeletons", {})
869
+
870
+ # Restore from state if going back (skels may have False values)
871
+ if skels:
872
+ self.query_one("#skel-skills", Checkbox).value = skels.get("skills", True)
873
+ self.query_one("#skel-tools", Checkbox).value = skels.get("tools", True)
874
+ self.query_one("#skel-subagents", Checkbox).value = skels.get("subagents", True)
875
+ self.query_one("#skel-parallel", Checkbox).value = skels.get("parallel_agents", True)
876
+
877
+ # Modal integration requires tools
878
+ if state.get("modal_integration"):
879
+ tools_cb: Checkbox = self.query_one("#skel-tools", Checkbox)
880
+ tools_cb.value = True
881
+ tools_cb.disabled = True
882
+ self.query_one("#skel-tools-desc", Label).update(
883
+ "Required by Modal integration (includes run-on-modal tool)."
884
+ )
885
+
886
+ # Decision Hub integration requires skills
887
+ if state.get("dhub_integration"):
888
+ skills_cb: Checkbox = self.query_one("#skel-skills", Checkbox)
889
+ skills_cb.value = True
890
+ skills_cb.disabled = True
891
+
892
+ self.query_one("#skel-skills", Checkbox).focus()
893
+
894
+ def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
895
+ # parallel implies subagents
896
+ if event.checkbox.id == "skel-parallel" and event.value:
897
+ self.query_one("#skel-subagents", Checkbox).value = True
898
+
899
+ def on_button_pressed(self, event: Button.Pressed) -> None:
900
+ if event.button.id == "back-btn":
901
+ self.app.pop_screen()
902
+ return
903
+ if event.button.id != "next-btn":
904
+ return
905
+
906
+ self.app.wizard_state["skeletons"] = {
907
+ "skills": self.query_one("#skel-skills", Checkbox).value,
908
+ "tools": self.query_one("#skel-tools", Checkbox).value,
909
+ "subagents": self.query_one("#skel-subagents", Checkbox).value,
910
+ "parallel_agents": self.query_one("#skel-parallel", Checkbox).value,
911
+ }
912
+
913
+ # Skip skill search if skills not selected
914
+ if self.app.wizard_state["skeletons"]["skills"]:
915
+ self.app.push_screen(SkillSearchScreen())
916
+ else:
917
+ self.app.push_screen(SummaryScreen())
918
+
919
+ def action_go_back(self) -> None:
920
+ self.app.pop_screen()
921
+
922
+
923
+ # ---------------------------------------------------------------------------
924
+ # Screen 6: Skill search (Decision Hub)
925
+ # ---------------------------------------------------------------------------
926
+
927
+ class SkillSearchScreen(Screen):
928
+ """Search and select skills from Decision Hub.
929
+
930
+ Two search mechanisms feed into the same results list:
931
+ - Keyword search (Enter in the input) — via /v1/skills
932
+ - Natural-language "ask" (automatic on mount from description) — via /v1/ask
933
+ """
934
+
935
+ BINDINGS = [Binding("escape", "go_back", "Back")]
936
+
937
+ CSS = """
938
+ SkillSearchScreen {
939
+ align: left bottom;
940
+ }
941
+ #skill-container {
942
+ width: 66%;
943
+ height: auto;
944
+ max-height: 80%;
945
+ padding: 1 2;
946
+ }
947
+ .field-label {
948
+ margin-top: 1;
949
+ }
950
+ .field-hint {
951
+ color: $text-muted;
952
+ text-style: italic;
953
+ text-wrap: wrap;
954
+ width: 1fr;
955
+ }
956
+ #skill-results {
957
+ height: auto;
958
+ max-height: 14;
959
+ display: none;
960
+ }
961
+ #selected-skills {
962
+ height: auto;
963
+ max-height: 6;
964
+ display: none;
965
+ }
966
+ .nav-bar {
967
+ margin-top: 1;
968
+ height: 1;
969
+ align: right middle;
970
+ }
971
+ .status-label {
972
+ color: $text-muted;
973
+ margin-top: 0;
974
+ }
975
+ """
976
+
977
+ def __init__(self) -> None:
978
+ super().__init__()
979
+ self._results: list[dict[str, Any]] = []
980
+ self._selected: list[dict[str, Any]] = []
981
+
982
+ def compose(self) -> ComposeResult:
983
+ with FormScroll(id="skill-container"):
984
+ yield Label("[b]Step 7 of 8[/b] — Decision Hub Skills", classes="field-label")
985
+ yield Label("Tab / Shift+Tab to navigate | Ctrl+Q to quit", classes="field-hint")
986
+ yield Label(
987
+ "Search for skills to include in your decision-pack. "
988
+ "Describe what you need and press Enter. "
989
+ "You can skip this step.",
990
+ classes="field-hint",
991
+ )
992
+ yield Label("")
993
+ yield Input(id="skill-search-input", placeholder="Describe what skills you need...")
994
+ yield Label("", id="search-status", classes="status-label")
995
+ with Vertical(classes="selection-group"):
996
+ yield OptionList(id="skill-results")
997
+ yield Label("Tab to continue", classes="option-hint")
998
+
999
+ yield Label("Selected skills:", classes="field-label")
1000
+ with Vertical(classes="selection-group"):
1001
+ yield OptionList(id="selected-skills")
1002
+ yield Label("Tab to continue", classes="option-hint")
1003
+
1004
+ with Horizontal(classes="nav-bar nav-swap"):
1005
+ yield Button("Skip →", id="next-btn")
1006
+ yield Button("← Back", id="back-btn")
1007
+
1008
+ def on_mount(self) -> None:
1009
+ state: dict[str, Any] = self.app.wizard_state
1010
+ self._selected = list(state.get("selected_skills", []))
1011
+ self._refresh_selected_display()
1012
+
1013
+ search_input: Input = self.query_one("#skill-search-input", Input)
1014
+
1015
+ # If the user wrote a custom description, use it for
1016
+ # automatic skill recommendations on mount.
1017
+ desc: str = state.get("description", "")
1018
+ name: str = state.get("name", "")
1019
+ auto_desc: str = f"dlab decision-pack: {name}"
1020
+ if desc and desc != auto_desc:
1021
+ search_input.value = desc
1022
+ self._do_search(desc)
1023
+
1024
+ search_input.focus()
1025
+
1026
+ def on_input_submitted(self, event: Input.Submitted) -> None:
1027
+ if event.input.id != "skill-search-input":
1028
+ return
1029
+ query: str = event.value.strip()
1030
+ if not query:
1031
+ return
1032
+ self._do_search(query)
1033
+
1034
+ @work(thread=True)
1035
+ def _do_search(self, query: str) -> None:
1036
+ """Run natural-language skill search via Decision Hub."""
1037
+ status: Label = self.query_one("#search-status", Label)
1038
+ self.app.call_from_thread(status.update, "Searching...")
1039
+
1040
+ try:
1041
+ results: list[dict[str, Any]] = ask_skills(query)
1042
+ self._results = results
1043
+ self.app.call_from_thread(self._display_results, results)
1044
+ except Exception as e:
1045
+ self.app.call_from_thread(status.update, f"[red]Error: {e}[/red]")
1046
+
1047
+ def _display_results(self, results: list[dict[str, Any]]) -> None:
1048
+ status: Label = self.query_one("#search-status", Label)
1049
+ ol: OptionList = self.query_one("#skill-results", OptionList)
1050
+ ol.clear_options()
1051
+
1052
+ if not results:
1053
+ status.update("No results found.")
1054
+ ol.display = False
1055
+ return
1056
+
1057
+ ol.display = True
1058
+ status.update(f"{len(results)} result(s) — select to add")
1059
+ for skill in results:
1060
+ name: str = f"{skill.get('org_slug', '?')}/{skill.get('skill_name', '?')}"
1061
+ reason: str = skill.get("reason", skill.get("description", ""))
1062
+ line: str = f" {name} [dim]{reason}[/dim]"
1063
+ ol.add_option(Option(line, id=name))
1064
+
1065
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
1066
+ if event.option_list.id == "skill-results":
1067
+ self._add_skill(event.option.id)
1068
+ elif event.option_list.id == "selected-skills":
1069
+ self._remove_skill(event.option.id)
1070
+
1071
+ def _add_skill(self, skill_id: str | None) -> None:
1072
+ if skill_id is None:
1073
+ return
1074
+ for skill in self._results:
1075
+ key: str = f"{skill.get('org_slug', '')}/{skill.get('skill_name', '')}"
1076
+ if key == skill_id:
1077
+ if not any(s.get("skill_name") == skill.get("skill_name") for s in self._selected):
1078
+ self._selected.append(skill)
1079
+ self._refresh_selected_display()
1080
+ return
1081
+
1082
+ def _remove_skill(self, skill_id: str | None) -> None:
1083
+ if skill_id is None:
1084
+ return
1085
+ self._selected = [
1086
+ s for s in self._selected
1087
+ if f"{s.get('org_slug', '')}/{s.get('skill_name', '')}" != skill_id
1088
+ ]
1089
+ self._refresh_selected_display()
1090
+
1091
+ def _refresh_selected_display(self) -> None:
1092
+ ol: OptionList = self.query_one("#selected-skills", OptionList)
1093
+ ol.clear_options()
1094
+ if self._selected:
1095
+ ol.display = True
1096
+ for skill in self._selected:
1097
+ name: str = f"{skill.get('org_slug', '')}/{skill.get('skill_name', '')}"
1098
+ ol.add_option(Option(f"✓ {name} [dim](click to remove)[/dim]", id=name))
1099
+ self.query_one("#next-btn", Button).label = "Next →"
1100
+ else:
1101
+ ol.display = False
1102
+ self.query_one("#next-btn", Button).label = "Skip →"
1103
+
1104
+ def on_button_pressed(self, event: Button.Pressed) -> None:
1105
+ if event.button.id == "back-btn":
1106
+ self.app.pop_screen()
1107
+ return
1108
+ if event.button.id == "next-btn":
1109
+ self.app.wizard_state["selected_skills"] = self._selected
1110
+ self.app.push_screen(SummaryScreen())
1111
+
1112
+ def action_go_back(self) -> None:
1113
+ self.app.pop_screen()
1114
+
1115
+
1116
+ # ---------------------------------------------------------------------------
1117
+ # Screen 7: Summary & confirm
1118
+ # ---------------------------------------------------------------------------
1119
+
1120
+ class SummaryScreen(Screen):
1121
+ """Show summary and confirm decision-pack creation."""
1122
+
1123
+ BINDINGS = [Binding("escape", "go_back", "Back")]
1124
+
1125
+ CSS = """
1126
+ SummaryScreen {
1127
+ align: left bottom;
1128
+ }
1129
+ #summary-container {
1130
+ width: 66%;
1131
+ height: auto;
1132
+ max-height: 80%;
1133
+ padding: 1 2;
1134
+ }
1135
+ .field-label {
1136
+ margin-top: 1;
1137
+ }
1138
+ .nav-bar {
1139
+ margin-top: 1;
1140
+ height: 1;
1141
+ align: right middle;
1142
+ }
1143
+ #create-btn {
1144
+ min-width: 16;
1145
+ }
1146
+ """
1147
+
1148
+ def compose(self) -> ComposeResult:
1149
+ step: str = self._step_label()
1150
+ with FormScroll(id="summary-container"):
1151
+ yield Label(f"[b]{step}[/b] — Review & Create", classes="field-label")
1152
+ yield Label("Tab / Shift+Tab to navigate | Ctrl+Q to quit", classes="field-hint")
1153
+ yield Static(id="summary-content")
1154
+ yield Label("", id="result-label")
1155
+ yield Button("Delete & Overwrite", id="overwrite-btn", variant="error")
1156
+ with Horizontal(classes="nav-bar nav-swap-wide"):
1157
+ yield Button("Create decision-pack", id="create-btn", variant="success")
1158
+ yield Button("Done", id="done-btn", variant="primary")
1159
+ yield Button("Keep Partial", id="keep-btn")
1160
+ yield Button("← Back", id="back-btn")
1161
+
1162
+ def _step_label(self) -> str:
1163
+ skills_enabled: bool = self.app.wizard_state.get("skeletons", {}).get("skills", True)
1164
+ total: int = 8 if skills_enabled else 7
1165
+ return f"Step {total} of {total}"
1166
+
1167
+ def on_mount(self) -> None:
1168
+ self.query_one("#done-btn", Button).display = False
1169
+ self.query_one("#keep-btn", Button).display = False
1170
+ self.query_one("#overwrite-btn", Button).display = False
1171
+ self._show_review()
1172
+ self.query_one("#create-btn", Button).focus()
1173
+
1174
+ def _show_review(self) -> None:
1175
+ state: dict[str, Any] = self.app.wizard_state
1176
+ skeletons: dict[str, bool] = state.get("skeletons", {})
1177
+ permissions: dict[str, str] = state.get("permissions", {})
1178
+ selected_skills: list[dict[str, Any]] = state.get("selected_skills", [])
1179
+
1180
+ enabled_skels: list[str] = [k for k, v in skeletons.items() if v]
1181
+ skel_str: str = ", ".join(enabled_skels) if enabled_skels else "none"
1182
+
1183
+ allowed: list[str] = [k for k, v in permissions.items() if v == "allow"]
1184
+ denied: list[str] = [k for k, v in permissions.items() if v == "deny"]
1185
+ allowed_str: str = ", ".join(allowed) if allowed else "none"
1186
+ denied_str: str = ", ".join(denied) if denied else "none"
1187
+
1188
+ skill_names: list[str] = [
1189
+ f"{s.get('org_slug', '')}/{s.get('skill_name', '')}"
1190
+ for s in selected_skills
1191
+ ]
1192
+ skill_str: str = ", ".join(skill_names) if skill_names else "none"
1193
+
1194
+ pkg_mgr: str = state.get("package_manager", "pip")
1195
+ python_lib_name: str = state.get("python_lib_name", "")
1196
+ modal: bool = state.get("modal_integration", False)
1197
+ extras: list[str] = []
1198
+ if python_lib_name:
1199
+ extras.append(f"python lib ({python_lib_name})")
1200
+ if modal:
1201
+ extras.append("modal integration")
1202
+ extras_str: str = ", ".join(extras) if extras else "none"
1203
+
1204
+ summary: str = f"""
1205
+ [b]decision-pack[/b]
1206
+ Name: {state['name']}
1207
+ Description: {state.get('description', '')}
1208
+
1209
+ [b]Container[/b]
1210
+ Package mgr: {pkg_mgr}
1211
+ Base image: {state.get('base_image', '')}
1212
+ Extras: {extras_str}
1213
+
1214
+ [b]Model[/b]
1215
+ Default: {state.get('default_model', '')}
1216
+
1217
+ [b]Permissions[/b]
1218
+ Allowed: {allowed_str}
1219
+ Denied: {denied_str}
1220
+
1221
+ [b]Skeletons[/b]
1222
+ Enabled: {skel_str}
1223
+
1224
+ [b]Hub Skills[/b]
1225
+ Selected: {skill_str}
1226
+ """
1227
+ self.query_one("#summary-content", Static).update(summary)
1228
+
1229
+ def _show_walkthrough(self, dpack_path: Path) -> None:
1230
+ """Replace summary with a walkthrough of what was created."""
1231
+ state: dict[str, Any] = self.app.wizard_state
1232
+ skeletons: dict[str, bool] = state.get("skeletons", {})
1233
+ agent_name: str = state.get("agent_name", "orchestrator")
1234
+ pkg_mgr: str = state.get("package_manager", "pip")
1235
+ selected_skills: list[dict[str, Any]] = state.get("selected_skills", [])
1236
+
1237
+ # Determine env file name
1238
+ env_files: dict[str, str] = {
1239
+ "conda": "environment.yml",
1240
+ "pixi": "pixi.toml",
1241
+ }
1242
+ env_file: str = env_files.get(pkg_mgr, "requirements.txt")
1243
+
1244
+ lines: list[str] = [
1245
+ f"[green bold]decision-pack created: {dpack_path}[/green bold]",
1246
+ "",
1247
+ "[b]What was created:[/b]",
1248
+ f" config.yaml decision-pack configuration (name, model, docker image)",
1249
+ f" docker/Dockerfile Container setup ({pkg_mgr})",
1250
+ f" docker/{env_file:<20s} Package dependencies",
1251
+ f" opencode/opencode.json Points to your main agent: {agent_name}",
1252
+ f" opencode/agents/{agent_name}.md",
1253
+ f" Your main agent's system prompt",
1254
+ ]
1255
+
1256
+ python_lib_name: str = state.get("python_lib_name", "")
1257
+ if python_lib_name:
1258
+ lines.append(f" docker/{python_lib_name}/")
1259
+ lines.append(f" Python library package")
1260
+ if state.get("modal_integration"):
1261
+ lines.append(f" docker/modal_app/ Modal serverless compute")
1262
+
1263
+ has_subagents: bool = skeletons.get("subagents", False) or skeletons.get("parallel_agents", False)
1264
+ if has_subagents:
1265
+ lines.append(f" opencode/agents/example-worker.md")
1266
+ lines.append(f" Example subagent (rename & customize)")
1267
+ if skeletons.get("parallel_agents"):
1268
+ lines.append(f" opencode/parallel_agents/example-worker.yaml")
1269
+ lines.append(f" Parallel agent config")
1270
+ if skeletons.get("tools"):
1271
+ lines.append(f" opencode/tools/example-tool.ts")
1272
+ lines.append(f" Example custom tool")
1273
+ if skeletons.get("skills"):
1274
+ lines.append(f" opencode/skills/example-skill/SKILL.md")
1275
+ lines.append(f" Example skill")
1276
+ for skill in selected_skills:
1277
+ sname: str = skill.get("skill_name", "?")
1278
+ lines.append(f" opencode/skills/{sname}/")
1279
+ lines.append(f" Downloaded from Decision Hub")
1280
+
1281
+ lines.extend([
1282
+ "",
1283
+ "[b]Next steps:[/b]",
1284
+ f" 1. Edit opencode/agents/{agent_name}.md to write your agent's system prompt",
1285
+ f" 2. Add your data files to a directory and run:",
1286
+ f" dlab --dpack {dpack_path} --data ./your-data --prompt \"Your task\"",
1287
+ f" 3. Install as a shortcut:",
1288
+ f" dlab install {dpack_path}",
1289
+ ])
1290
+
1291
+ self.query_one("#summary-content", Static).update("\n".join(lines))
1292
+
1293
+ def on_button_pressed(self, event: Button.Pressed) -> None:
1294
+ if event.button.id == "back-btn":
1295
+ self.app.pop_screen()
1296
+ return
1297
+ if event.button.id in ("done-btn", "keep-btn"):
1298
+ self.app.exit()
1299
+ return
1300
+ if event.button.id == "overwrite-btn":
1301
+ self.app.wizard_state["overwrite_existing"] = True
1302
+ self.query_one("#result-label", Label).update(
1303
+ "[green]decision-pack will be overwritten[/green]"
1304
+ )
1305
+ self.query_one("#overwrite-btn", Button).display = False
1306
+ return
1307
+ if event.button.id != "create-btn":
1308
+ return
1309
+
1310
+ result_label: Label = self.query_one("#result-label", Label)
1311
+ state: dict[str, Any] = self.app.wizard_state
1312
+ output_dir: Path = Path(self.app.output_dir)
1313
+ dpack_dir: Path = output_dir / state["name"]
1314
+
1315
+ # Pre-create collision check
1316
+ if dpack_dir.exists() and not state.get("overwrite_existing"):
1317
+ result_label.update("[red]decision-pack directory exists![/red]")
1318
+ self.query_one("#overwrite-btn", Button).display = True
1319
+ return
1320
+
1321
+ self._run_create(output_dir, state)
1322
+
1323
+ @work(thread=True)
1324
+ def _run_create(self, output_dir: Path, state: dict[str, Any]) -> None:
1325
+ """Run decision-pack creation in background with progress feedback."""
1326
+ def _on_progress(msg: str) -> None:
1327
+ self.app.call_from_thread(
1328
+ self.query_one("#result-label", Label).update, msg
1329
+ )
1330
+
1331
+ try:
1332
+ dpack_path: Path = generate_dpack(output_dir, dict(state), on_progress=_on_progress)
1333
+ self.app.call_from_thread(self._on_create_success, dpack_path)
1334
+ except Exception as e:
1335
+ self.app.call_from_thread(self._on_create_error, str(e))
1336
+
1337
+ def _on_create_success(self, dpack_path: Path) -> None:
1338
+ self.query_one("#result-label", Label).update("")
1339
+ self._show_walkthrough(dpack_path)
1340
+ self.query_one("#create-btn", Button).display = False
1341
+ self.query_one("#back-btn", Button).display = False
1342
+ self.query_one("#done-btn", Button).display = True
1343
+
1344
+ def _on_create_error(self, error_msg: str) -> None:
1345
+ self.query_one("#result-label", Label).update(f"[red]Error: {error_msg}[/red]")
1346
+ self.query_one("#create-btn", Button).display = False
1347
+ self.query_one("#keep-btn", Button).display = True
1348
+ self.query_one("#back-btn", Button).label = "← Go Back (fix & retry)"
1349
+
1350
+ def action_go_back(self) -> None:
1351
+ self.app.pop_screen()
1352
+
1353
+
1354
+ # ---------------------------------------------------------------------------
1355
+ # Main App
1356
+ # ---------------------------------------------------------------------------
1357
+
1358
+ class CreateDpackApp(App):
1359
+ """Multi-screen wizard for creating a new decision-pack directory."""
1360
+
1361
+ TITLE = "dlab create-dpack"
1362
+ BINDINGS = [
1363
+ Binding("ctrl+q", "quit", "Quit", priority=True),
1364
+ Binding("down", "focus_next", show=False),
1365
+ Binding("up", "focus_previous", show=False),
1366
+ Binding("left", "focus_previous", show=False),
1367
+ Binding("right", "focus_next", show=False),
1368
+ ]
1369
+ theme = "monokai"
1370
+
1371
+ CSS = """
1372
+ /* --- Flat widget overrides ------------------------------------------ */
1373
+ Input {
1374
+ border: none;
1375
+ border-left: tall $accent;
1376
+ height: 1;
1377
+ padding: 0 1;
1378
+ background: $surface;
1379
+ &:focus {
1380
+ border: none;
1381
+ border-left: tall $accent;
1382
+ }
1383
+ }
1384
+ Button {
1385
+ min-width: 10;
1386
+ border: none;
1387
+ background: $surface;
1388
+ &:hover {
1389
+ background: $primary;
1390
+ }
1391
+ &.-primary {
1392
+ background: $primary-muted;
1393
+ border: none;
1394
+ &:hover { background: $primary; border: none; }
1395
+ }
1396
+ &.-success {
1397
+ background: $success-muted;
1398
+ border: none;
1399
+ &:hover { background: $success; border: none; }
1400
+ }
1401
+ }
1402
+ OptionList {
1403
+ border: none;
1404
+ border-left: tall $accent;
1405
+ background: $surface;
1406
+ scrollbar-size: 1 1;
1407
+ }
1408
+ Checkbox {
1409
+ border: none;
1410
+ background: transparent;
1411
+ height: 1;
1412
+ padding: 0;
1413
+ }
1414
+ Checkbox > .toggle--button {
1415
+ color: $text-muted;
1416
+ }
1417
+ Checkbox.-on > .toggle--button {
1418
+ color: $success;
1419
+ }
1420
+ #model-results, #skill-results, #selected-skills {
1421
+ margin-top: 1;
1422
+ }
1423
+ .cb-group {
1424
+ border-left: tall $accent;
1425
+ background: $surface;
1426
+ padding: 0 1;
1427
+ height: auto;
1428
+ }
1429
+ #overwrite-btn {
1430
+ color: $text;
1431
+ }
1432
+ #collision-bar {
1433
+ height: auto;
1434
+ }
1435
+ /* TODO: The nav-swap offset hack visually swaps button positions so that
1436
+ Next appears right and Back appears left, while keeping Next first in
1437
+ focus order. This is brittle (hardcoded pixel offsets). Replace with a
1438
+ proper solution when Textual adds CSS `order` or when we rename decision-packs. */
1439
+ .nav-swap > #next-btn {
1440
+ offset: 10 0;
1441
+ }
1442
+ .nav-swap > #back-btn {
1443
+ offset: -10 0;
1444
+ }
1445
+ .nav-swap-wide > #create-btn {
1446
+ offset: 12 0;
1447
+ }
1448
+ .nav-swap-wide > #back-btn {
1449
+ offset: -18 0;
1450
+ }
1451
+ .selection-group {
1452
+ height: auto;
1453
+ }
1454
+ .option-hint {
1455
+ display: none;
1456
+ color: $text-muted;
1457
+ text-style: italic;
1458
+ height: 1;
1459
+ }
1460
+ .selection-group:focus-within .option-hint {
1461
+ display: block;
1462
+ }
1463
+ """
1464
+
1465
+ def __init__(self, output_dir: str = ".") -> None:
1466
+ super().__init__()
1467
+ self.output_dir: str = output_dir
1468
+ self.wizard_state: dict[str, Any] = {}
1469
+
1470
+ def on_mount(self) -> None:
1471
+ self.push_screen(BasicsScreen())