dlab-cli 0.1.2__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,582 @@
1
+ """
2
+ Textual TUI wizard for creating a parallel agent configuration.
3
+
4
+ Single-screen wizard that collects configuration and writes a YAML file
5
+ to opencode/parallel_agents/{name}.yaml.
6
+ """
7
+
8
+ import json
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from textual.app import App, ComposeResult
13
+ from textual.binding import Binding
14
+ from textual.containers import Horizontal, Vertical, VerticalScroll
15
+ from textual.screen import Screen
16
+ from textual.widgets import (
17
+ Button,
18
+ Checkbox,
19
+ Input,
20
+ Label,
21
+ OptionList,
22
+ Static,
23
+ TextArea,
24
+ )
25
+ from textual.widgets.option_list import Option
26
+
27
+ from textual import work
28
+
29
+ from dlab.config import load_dpack_config
30
+ from dlab.create_dpack import filter_models, get_model_list
31
+ from dlab.create_dpack_wizard import DpackCheckbox, FormScroll
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Default prompt templates
36
+ # ---------------------------------------------------------------------------
37
+
38
+ DEFAULT_SUFFIX_PROMPT: str = """\
39
+ When you complete your task, write a summary.md file with:
40
+
41
+ ## Approach
42
+ Describe your approach and key decisions.
43
+
44
+ ## Results
45
+ Present your findings and outputs.
46
+
47
+ ## Recommendations
48
+ Provide actionable recommendations based on your analysis."""
49
+
50
+ DEFAULT_SUMMARIZER_PROMPT: str = """\
51
+ Read all summary.md files from the parallel instances.
52
+ Create a consolidated comparison highlighting:
53
+ - Key differences in approaches
54
+ - Agreement and disagreement across instances
55
+ - Overall recommendations based on all results"""
56
+
57
+ WORKER_AGENT_MD: str = """---
58
+ description: {description}
59
+ mode: subagent
60
+ tools:
61
+ read: true
62
+ edit: true
63
+ bash: true
64
+ parallel-agents: false
65
+ ---
66
+
67
+ You are a worker agent. Complete the task described in the prompt.
68
+ """
69
+
70
+ _NEW_AGENT_ID: str = "_new_agent"
71
+
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # Main screen
75
+ # ---------------------------------------------------------------------------
76
+
77
+ class ParallelAgentScreen(Screen):
78
+ """Single-screen wizard for parallel agent configuration."""
79
+
80
+ CSS = """
81
+ ParallelAgentScreen {
82
+ align: left bottom;
83
+ }
84
+ #pa-container {
85
+ width: 66%;
86
+ height: auto;
87
+ max-height: 90%;
88
+ padding: 1 2;
89
+ }
90
+ .field-label {
91
+ margin-top: 1;
92
+ }
93
+ .field-hint {
94
+ color: $text-muted;
95
+ text-style: italic;
96
+ text-wrap: wrap;
97
+ width: 1fr;
98
+ }
99
+ .section-divider {
100
+ margin-top: 1;
101
+ color: $accent;
102
+ }
103
+ .error-label {
104
+ color: $error;
105
+ }
106
+ .success-label {
107
+ color: $success;
108
+ }
109
+ .nav-bar {
110
+ margin-top: 1;
111
+ height: 1;
112
+ align: right middle;
113
+ }
114
+ #suffix-textarea, #summarizer-textarea {
115
+ height: 8;
116
+ border-left: tall $accent;
117
+ }
118
+ #retries-input {
119
+ margin-left: 4;
120
+ }
121
+ #new-agent-name-input {
122
+ margin-left: 4;
123
+ }
124
+ #model-selection-group {
125
+ display: none;
126
+ }
127
+ #summarizer-model-results {
128
+ height: auto;
129
+ max-height: 12;
130
+ }
131
+ """
132
+
133
+ def __init__(self) -> None:
134
+ super().__init__()
135
+ self._existing_agents: list[str] = []
136
+ self._default_model: str = ""
137
+ self._models: list[str] = get_model_list()
138
+ self._programmatic_fill: bool = False
139
+
140
+ def compose(self) -> ComposeResult:
141
+ with FormScroll(id="pa-container"):
142
+ yield Label("[b]Create Parallel Agent[/b]", classes="field-label")
143
+ yield Label("Tab / Shift+Tab to navigate | Ctrl+Q to quit", classes="field-hint")
144
+
145
+ # Agent selection or creation
146
+ yield Label("[b]Agent[/b]", classes="section-divider")
147
+ yield Label(
148
+ "Select an existing agent to run in parallel, or create a new one. "
149
+ "The parallel config YAML will share the agent's name.",
150
+ classes="field-hint",
151
+ )
152
+ with Vertical(classes="selection-group"):
153
+ yield OptionList(id="agent-select")
154
+ yield Label("Tab to continue", classes="option-hint")
155
+
156
+ # New agent name (only shown when "New agent..." selected)
157
+ yield Label("New agent name", classes="field-label", id="new-agent-label")
158
+ yield Label("Alphanumeric, hyphens, underscores", classes="field-hint")
159
+ yield Input(id="pa-name-input", placeholder="my-worker")
160
+ yield Label("", id="pa-name-error", classes="error-label")
161
+ yield Label(
162
+ "A worker agent .md will be created in opencode/agents/",
163
+ classes="field-hint",
164
+ id="new-agent-hint",
165
+ )
166
+
167
+ # Description
168
+ yield Label("[b]Description[/b]", classes="section-divider")
169
+ yield Label(
170
+ "Optional. Used in the YAML config for documentation purposes.",
171
+ classes="field-hint",
172
+ )
173
+ yield Input(id="pa-desc-input", placeholder="(default: Parallel agent: {name})")
174
+
175
+ # Timeout
176
+ yield Label("[b]Instance Timeout[/b]", classes="section-divider")
177
+ yield Label(
178
+ "Maximum time each parallel instance is allowed to run before being stopped.",
179
+ classes="field-hint",
180
+ )
181
+ yield Input(id="timeout-input", value="60", placeholder="60")
182
+
183
+ # Failure behavior
184
+ yield Label("[b]Instance Failure Behavior[/b]", classes="section-divider")
185
+ yield Label(
186
+ "What happens when one of the parallel instances fails or times out.",
187
+ classes="field-hint",
188
+ )
189
+ with Vertical(classes="cb-group"):
190
+ yield DpackCheckbox(
191
+ "Continue — other instances keep running",
192
+ value=True,
193
+ id="fail-continue",
194
+ )
195
+ yield DpackCheckbox(
196
+ "Fail fast — stop all instances on first failure",
197
+ value=False,
198
+ id="fail-fast",
199
+ )
200
+ yield DpackCheckbox(
201
+ "Retry — re-run failed instances",
202
+ value=False,
203
+ id="fail-retry",
204
+ )
205
+ yield Label("Max retries", classes="field-label", id="retries-label")
206
+ yield Input(id="retries-input", value="2", placeholder="2")
207
+
208
+ # Suffix prompt
209
+ yield Label("[b]Suffix Prompt[/b]", classes="section-divider")
210
+ yield Label(
211
+ "Appended to each worker's prompt. Typically instructs writing summary.md.",
212
+ classes="field-hint",
213
+ )
214
+ yield TextArea(DEFAULT_SUFFIX_PROMPT, id="suffix-textarea")
215
+
216
+ # Consolidator prompt
217
+ yield Label("[b]Consolidator Prompt[/b]", classes="section-divider")
218
+ yield Label(
219
+ "Given to the consolidator agent that reads all summary.md files and "
220
+ "produces a combined report. Only runs when 3 or more instances are spawned.",
221
+ classes="field-hint",
222
+ )
223
+ yield TextArea(DEFAULT_SUMMARIZER_PROMPT, id="summarizer-textarea")
224
+
225
+ # Consolidator model
226
+ yield Label("[b]Consolidator Model[/b]", classes="section-divider")
227
+ yield Label(
228
+ "Model used by the consolidator agent. Defaults to the decision-pack's default model.",
229
+ classes="field-hint",
230
+ )
231
+ yield Input(id="summarizer-model-input", placeholder="(uses decision-pack default)")
232
+ with Vertical(classes="selection-group", id="model-selection-group"):
233
+ yield OptionList(id="summarizer-model-results")
234
+ yield Label("Tab to continue", classes="option-hint")
235
+
236
+ # Errors + nav
237
+ yield Label("", id="pa-error", classes="error-label")
238
+ with Horizontal(classes="nav-bar"):
239
+ yield Button("Create", id="create-btn", variant="success")
240
+
241
+ def on_mount(self) -> None:
242
+ app: CreateParallelAgentApp = self.app # type: ignore[assignment]
243
+ self._default_model = app.dpack_config.get("default_model", "")
244
+ self._programmatic_fill = True
245
+ self.query_one("#summarizer-model-input", Input).value = self._default_model
246
+ self._refresh_models()
247
+
248
+ # Discover existing agents (exclude default agent)
249
+ config_dir: Path = Path(app.dpack_config["config_dir"])
250
+ agents_dir: Path = config_dir / "opencode" / "agents"
251
+ default_agent: str = ""
252
+
253
+ opencode_json: Path = config_dir / "opencode" / "opencode.json"
254
+ if opencode_json.exists():
255
+ try:
256
+ data: dict[str, Any] = json.loads(opencode_json.read_text())
257
+ default_agent = data.get("default_agent", "")
258
+ except (json.JSONDecodeError, OSError):
259
+ pass
260
+
261
+ # Find which agents already have a parallel config
262
+ pa_dir: Path = config_dir / "opencode" / "parallel_agents"
263
+ existing_yamls: set[str] = set()
264
+ if pa_dir.exists():
265
+ for yaml_file in pa_dir.glob("*.yaml"):
266
+ existing_yamls.add(yaml_file.stem)
267
+
268
+ if agents_dir.exists():
269
+ for md_file in sorted(agents_dir.glob("*.md")):
270
+ agent_name: str = md_file.stem
271
+ if agent_name != default_agent:
272
+ self._existing_agents.append(agent_name)
273
+
274
+ # Build agent selection list
275
+ ol: OptionList = self.query_one("#agent-select", OptionList)
276
+ first_available: int = -1
277
+ if self._existing_agents:
278
+ for i, agent in enumerate(self._existing_agents):
279
+ already_configured: bool = agent in existing_yamls
280
+ label: str = f"{agent} [dim](already configured)[/dim]" if already_configured else agent
281
+ ol.add_option(Option(label, id=agent, disabled=already_configured))
282
+ if not already_configured and first_available < 0:
283
+ first_available = i
284
+ ol.add_option(Option("New agent...", id=_NEW_AGENT_ID))
285
+
286
+ # Default: first available existing agent, or "New agent..."
287
+ if first_available >= 0:
288
+ ol.highlighted = first_available
289
+ self._show_new_agent_fields(False)
290
+ else:
291
+ ol.highlighted = ol.option_count - 1
292
+ self._show_new_agent_fields(True)
293
+
294
+ # Hide retries by default (continue is selected)
295
+ self._update_retries_visibility()
296
+
297
+ self.query_one("#pa-name-input", Input).focus()
298
+
299
+ def _show_new_agent_fields(self, show: bool) -> None:
300
+ self.query_one("#new-agent-label", Label).display = show
301
+ self.query_one("#pa-name-input", Input).display = show
302
+ self.query_one("#new-agent-hint", Label).display = show
303
+ # pa-error stays visible always (used for all validation errors)
304
+
305
+ def _update_retries_visibility(self) -> None:
306
+ retry: bool = self.query_one("#fail-retry", Checkbox).value
307
+ self.query_one("#retries-input", Input).display = retry
308
+ self.query_one("#retries-label", Label).display = retry
309
+
310
+ @work(thread=True)
311
+ def _refresh_models(self) -> None:
312
+ """Fetch models from API in background and refresh the list."""
313
+ from dlab.create_dpack import fetch_models_from_api, save_model_cache, _model_sort_key
314
+ try:
315
+ data: dict[str, Any] = fetch_models_from_api()
316
+ save_model_cache(data)
317
+ new_models: list[str] = sorted(
318
+ set(self._models) | set(data.get("models", [])),
319
+ key=_model_sort_key,
320
+ )
321
+ if new_models != self._models:
322
+ self._models = new_models
323
+ self.app.call_from_thread(self._rebuild_model_options, "")
324
+ except Exception:
325
+ pass
326
+
327
+ def _rebuild_model_options(self, query: str) -> None:
328
+ """Rebuild the model OptionList filtered by query."""
329
+ matches: list[str] = filter_models(query, self._models) if query else self._models
330
+ ol: OptionList = self.query_one("#summarizer-model-results", OptionList)
331
+ ol.clear_options()
332
+ for m in matches:
333
+ ol.add_option(Option(m, id=m))
334
+
335
+ def on_input_changed(self, event: Input.Changed) -> None:
336
+ if event.input.id != "summarizer-model-input":
337
+ return
338
+ if self._programmatic_fill:
339
+ self._programmatic_fill = False
340
+ return
341
+ group: Vertical = self.query_one("#model-selection-group", Vertical)
342
+ group.display = True
343
+ self._rebuild_model_options(event.value.strip())
344
+
345
+ def _get_selected_agent(self) -> str | None:
346
+ """Return the currently highlighted agent ID, or None."""
347
+ ol: OptionList = self.query_one("#agent-select", OptionList)
348
+ idx: int | None = ol.highlighted
349
+ if idx is None:
350
+ return None
351
+ return str(ol.get_option_at_index(idx).id)
352
+
353
+ def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted) -> None:
354
+ if event.option_list.id != "agent-select":
355
+ return
356
+ is_new: bool = str(event.option.id) == _NEW_AGENT_ID
357
+ self._show_new_agent_fields(is_new)
358
+
359
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
360
+ if event.option_list.id != "summarizer-model-results":
361
+ return
362
+ self._programmatic_fill = True
363
+ self.query_one("#summarizer-model-input", Input).value = str(event.option.prompt)
364
+ self.query_one("#model-selection-group", Vertical).display = False
365
+ self.query_one("#create-btn", Button).focus()
366
+
367
+ def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
368
+ # Radio group for failure behavior
369
+ radio_ids: list[str] = ["fail-continue", "fail-fast", "fail-retry"]
370
+ if event.checkbox.id in radio_ids and event.value:
371
+ for rid in radio_ids:
372
+ if rid != event.checkbox.id:
373
+ self.query_one(f"#{rid}", Checkbox).value = False
374
+ self._update_retries_visibility()
375
+
376
+ def on_button_pressed(self, event: Button.Pressed) -> None:
377
+ if event.button.id != "create-btn":
378
+ return
379
+
380
+ error_label: Label = self.query_one("#pa-error", Label)
381
+ name_error_label: Label = self.query_one("#pa-name-error", Label)
382
+ name_error_label.update("")
383
+
384
+ # Determine agent name
385
+ selected_agent: str | None = self._get_selected_agent()
386
+ create_new_agent: bool = selected_agent == _NEW_AGENT_ID
387
+ agent_name: str
388
+
389
+ if create_new_agent:
390
+ agent_name = self.query_one("#pa-name-input", Input).value.strip()
391
+ if not agent_name:
392
+ name_error_label.update("[red]Agent name is required[/red]")
393
+ return
394
+ if not agent_name.replace("_", "").replace("-", "").isalnum():
395
+ name_error_label.update("[red]Must be alphanumeric (with - or _)[/red]")
396
+ return
397
+ else:
398
+ agent_name = selected_agent or ""
399
+ if not agent_name:
400
+ error_label.update("[red]Select an agent[/red]")
401
+ return
402
+
403
+ error_label.update("")
404
+
405
+ # Validate timeout
406
+ timeout_str: str = self.query_one("#timeout-input", Input).value.strip()
407
+ try:
408
+ timeout: int = int(timeout_str) if timeout_str else 60
409
+ if timeout <= 0:
410
+ raise ValueError
411
+ except ValueError:
412
+ error_label.update("[red]Timeout must be a positive number[/red]")
413
+ return
414
+
415
+ # Gather values
416
+ description: str = self.query_one("#pa-desc-input", Input).value.strip()
417
+ if not description:
418
+ description = f"Parallel agent: {agent_name}"
419
+
420
+ failure_behavior: str = "continue"
421
+ if self.query_one("#fail-fast", Checkbox).value:
422
+ failure_behavior = "fail_fast"
423
+ elif self.query_one("#fail-retry", Checkbox).value:
424
+ failure_behavior = "retry"
425
+
426
+ max_retries: int = 2
427
+ if failure_behavior == "retry":
428
+ retries_str: str = self.query_one("#retries-input", Input).value.strip()
429
+ try:
430
+ max_retries = int(retries_str) if retries_str else 2
431
+ except ValueError:
432
+ max_retries = 2
433
+
434
+ suffix_prompt: str = self.query_one("#suffix-textarea", TextArea).text
435
+ summarizer_prompt: str = self.query_one("#summarizer-textarea", TextArea).text
436
+ summarizer_model: str = self.query_one("#summarizer-model-input", Input).value.strip()
437
+ if not summarizer_model:
438
+ summarizer_model = self._default_model
439
+
440
+ # Build YAML
441
+ yaml_lines: list[str] = [
442
+ f"name: {agent_name}",
443
+ f'description: "{description}"',
444
+ f"timeout_minutes: {timeout}",
445
+ f"failure_behavior: {failure_behavior}",
446
+ ]
447
+ if failure_behavior == "retry":
448
+ yaml_lines.append(f"max_retries: {max_retries}")
449
+
450
+ yaml_lines.append("")
451
+ yaml_lines.append("subagent_suffix_prompt: |")
452
+ for line in suffix_prompt.split("\n"):
453
+ yaml_lines.append(f" {line}")
454
+
455
+ yaml_lines.append("")
456
+ yaml_lines.append("summarizer_prompt: |")
457
+ for line in summarizer_prompt.split("\n"):
458
+ yaml_lines.append(f" {line}")
459
+
460
+ yaml_lines.append("")
461
+ yaml_lines.append(f'summarizer_model: "{summarizer_model}"')
462
+ yaml_lines.append("")
463
+
464
+ yaml_content: str = "\n".join(yaml_lines)
465
+
466
+ # Write files
467
+ app: CreateParallelAgentApp = self.app # type: ignore[assignment]
468
+ config_dir: Path = Path(app.dpack_config["config_dir"])
469
+ pa_dir: Path = config_dir / "opencode" / "parallel_agents"
470
+ pa_dir.mkdir(parents=True, exist_ok=True)
471
+
472
+ yaml_file: Path = pa_dir / f"{agent_name}.yaml"
473
+ if yaml_file.exists():
474
+ error_label.update(f"[red]{agent_name}.yaml already exists[/red]")
475
+ return
476
+
477
+ yaml_file.write_text(yaml_content)
478
+ created_files: list[str] = [str(yaml_file.relative_to(config_dir))]
479
+
480
+ # Create agent .md if "New agent..." was selected
481
+ if create_new_agent:
482
+ agents_dir: Path = config_dir / "opencode" / "agents"
483
+ agents_dir.mkdir(parents=True, exist_ok=True)
484
+ agent_file: Path = agents_dir / f"{agent_name}.md"
485
+ if not agent_file.exists():
486
+ agent_file.write_text(
487
+ WORKER_AGENT_MD.format(description=description)
488
+ )
489
+ created_files.append(str(agent_file.relative_to(config_dir)))
490
+
491
+ # Exit app with created files list
492
+ app.created_files = created_files
493
+ app.exit()
494
+
495
+
496
+ # ---------------------------------------------------------------------------
497
+ # Main App
498
+ # ---------------------------------------------------------------------------
499
+
500
+ class CreateParallelAgentApp(App):
501
+ """TUI wizard for creating a parallel agent configuration."""
502
+
503
+ TITLE = "dlab create-parallel-agent"
504
+ BINDINGS = [
505
+ Binding("ctrl+q", "quit", "Quit", priority=True),
506
+ Binding("down", "focus_next", show=False),
507
+ Binding("up", "focus_previous", show=False),
508
+ ]
509
+ theme = "monokai"
510
+
511
+ CSS = """
512
+ /* --- Flat widget overrides ------------------------------------------ */
513
+ Input {
514
+ border: none;
515
+ border-left: tall $accent;
516
+ height: 1;
517
+ padding: 0 1;
518
+ background: $surface;
519
+ &:focus {
520
+ border: none;
521
+ border-left: tall $accent;
522
+ }
523
+ }
524
+ Button {
525
+ min-width: 10;
526
+ border: none;
527
+ background: $surface;
528
+ &:hover {
529
+ background: $primary;
530
+ }
531
+ &.-success {
532
+ background: $success-muted;
533
+ border: none;
534
+ &:hover { background: $success; border: none; }
535
+ }
536
+ }
537
+ OptionList {
538
+ border: none;
539
+ border-left: tall $accent;
540
+ background: $surface;
541
+ scrollbar-size: 1 1;
542
+ }
543
+ Checkbox {
544
+ border: none;
545
+ background: transparent;
546
+ height: 1;
547
+ padding: 0;
548
+ }
549
+ Checkbox > .toggle--button {
550
+ color: $text-muted;
551
+ }
552
+ Checkbox.-on > .toggle--button {
553
+ color: $success;
554
+ }
555
+ .cb-group {
556
+ border-left: tall $accent;
557
+ background: $surface;
558
+ padding: 0 1;
559
+ height: auto;
560
+ }
561
+ .selection-group {
562
+ height: auto;
563
+ }
564
+ .option-hint {
565
+ display: none;
566
+ color: $text-muted;
567
+ text-style: italic;
568
+ height: 1;
569
+ }
570
+ .selection-group:focus-within .option-hint {
571
+ display: block;
572
+ }
573
+ """
574
+
575
+ def __init__(self, dpack: str = ".") -> None:
576
+ super().__init__()
577
+ self.dpack: str = dpack
578
+ self.dpack_config: dict[str, Any] = load_dpack_config(dpack)
579
+ self.created_files: list[str] = []
580
+
581
+ def on_mount(self) -> None:
582
+ self.push_screen(ParallelAgentScreen())
dlab/data/__init__.py ADDED
File without changes