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/cli.py ADDED
@@ -0,0 +1,1075 @@
1
+ """
2
+ Command-line interface for dlab.
3
+ """
4
+
5
+ import argparse
6
+ import difflib
7
+ import os
8
+ import shutil
9
+ import signal
10
+ import stat
11
+ import subprocess
12
+ import sys
13
+ import threading
14
+ import time as _time
15
+ from pathlib import Path
16
+ from typing import Any, Callable
17
+
18
+ from rich.console import Console
19
+ from rich.live import Live
20
+ from rich.padding import Padding
21
+ from rich.panel import Panel
22
+ from rich.spinner import Spinner
23
+ from rich.text import Text
24
+
25
+ from dlab.config import load_dpack_config
26
+ from dlab.model_fallback import preflight_check
27
+
28
+ # Note: Console must be created per-call (not module-level) so pytest capsys
29
+ # can capture output. Use _make_console() in command functions.
30
+ from dlab.docker import (
31
+ build_image,
32
+ count_dangling_images,
33
+ exec_command,
34
+ needs_rebuild,
35
+ run_opencode,
36
+ start_container,
37
+ stop_container,
38
+ )
39
+ from dlab.session import copy_hook_scripts, create_session, setup_opencode_config
40
+ from dlab.timeline import run_timeline
41
+
42
+
43
+ def _make_console() -> Console:
44
+ """Create a Console that writes to current sys.stdout (for testability)."""
45
+ return Console(highlight=False)
46
+
47
+
48
+ def _run_with_log_spinner(
49
+ console: Console,
50
+ indent: str,
51
+ logs_dir: Path,
52
+ run_fn: Callable[[], tuple[int, str, str]],
53
+ ) -> tuple[int, str, str]:
54
+ """
55
+ Run a blocking function while showing a spinner with log entry count.
56
+
57
+ Monitors all .log files in logs_dir (recursively) in a background
58
+ thread and updates a Rich spinner inline with the total line count.
59
+
60
+ Parameters
61
+ ----------
62
+ console : Console
63
+ Rich console for output.
64
+ indent : str
65
+ Indentation prefix for the spinner text.
66
+ logs_dir : Path
67
+ Directory containing log files (searched recursively).
68
+ run_fn : Callable
69
+ Blocking function that returns (exit_code, stdout, stderr).
70
+
71
+ Returns
72
+ -------
73
+ tuple[int, str, str]
74
+ (exit_code, stdout, stderr) from run_fn.
75
+ """
76
+ line_count: int = 0
77
+ running: bool = True
78
+ spinner: Spinner = Spinner("dots", style="dim")
79
+
80
+ def _count_lines() -> None:
81
+ nonlocal line_count
82
+ while running:
83
+ try:
84
+ total: int = 0
85
+ for log_file in logs_dir.rglob("*.log"):
86
+ try:
87
+ total += sum(1 for _ in open(log_file))
88
+ except (IOError, OSError):
89
+ pass
90
+ line_count = total
91
+ except (IOError, OSError):
92
+ pass
93
+ _time.sleep(0.5)
94
+
95
+ counter = threading.Thread(target=_count_lines, daemon=True)
96
+ counter.start()
97
+
98
+ def _make_renderable() -> Text:
99
+ text = Text(indent)
100
+ text.append_text(spinner.render(_time.time()))
101
+ text.append(f" » {line_count} ", style="dim")
102
+ text.append("msgs", style="#555555")
103
+ return text
104
+
105
+ with Live(_make_renderable(), console=console, refresh_per_second=10, transient=True) as live:
106
+ def _tick() -> None:
107
+ while running:
108
+ live.update(_make_renderable())
109
+ _time.sleep(0.1)
110
+
111
+ ticker = threading.Thread(target=_tick, daemon=True)
112
+ ticker.start()
113
+
114
+ result = run_fn()
115
+ running = False
116
+
117
+ return result
118
+
119
+
120
+ WRAPPER_TEMPLATE: str = '''#!/usr/bin/env python3
121
+ """
122
+ Auto-generated wrapper for {dpack_name}.
123
+ Created by: dlab install
124
+ """
125
+
126
+ import subprocess
127
+ import sys
128
+
129
+ CONFIG_DIR = "{config_dir}"
130
+
131
+
132
+ def main() -> None:
133
+ cmd = ["dlab", "--dpack", CONFIG_DIR] + sys.argv[1:]
134
+ result = subprocess.run(cmd)
135
+ sys.exit(result.returncode)
136
+
137
+
138
+ if __name__ == "__main__":
139
+ main()
140
+ '''
141
+
142
+
143
+ def create_parser() -> argparse.ArgumentParser:
144
+ """
145
+ Build argument parser with subcommands.
146
+
147
+ Returns
148
+ -------
149
+ argparse.ArgumentParser
150
+ Configured argument parser.
151
+ """
152
+ parser: argparse.ArgumentParser = argparse.ArgumentParser(
153
+ prog="dlab",
154
+ description="Run opencode in automated mode, sandboxed with Docker",
155
+ )
156
+
157
+ subparsers = parser.add_subparsers(dest="command", help="Commands")
158
+
159
+ # Run mode (default) - no subcommand needed, uses main parser args
160
+ parser.add_argument(
161
+ "--dpack",
162
+ metavar="PATH",
163
+ help="Path to decision-pack config directory",
164
+ )
165
+ parser.add_argument(
166
+ "--data",
167
+ nargs="+",
168
+ metavar="PATH",
169
+ help="Data files or directory to copy into the workspace",
170
+ )
171
+ parser.add_argument(
172
+ "--model",
173
+ metavar="MODEL",
174
+ help="Model to use (overrides default_model from config)",
175
+ )
176
+ parser.add_argument(
177
+ "--prompt",
178
+ metavar="TEXT",
179
+ help="Prompt text for the agent",
180
+ )
181
+ parser.add_argument(
182
+ "--prompt-file",
183
+ metavar="PATH",
184
+ help="Path to file containing prompt text",
185
+ )
186
+ parser.add_argument(
187
+ "--work-dir",
188
+ metavar="PATH",
189
+ help="Explicit work directory path",
190
+ )
191
+ parser.add_argument(
192
+ "--continue-dir",
193
+ metavar="PATH",
194
+ help="Continue an interrupted session from this work directory",
195
+ )
196
+ parser.add_argument(
197
+ "--rebuild",
198
+ action="store_true",
199
+ help="Force rebuild Docker image",
200
+ )
201
+ parser.add_argument(
202
+ "--env-file",
203
+ metavar="PATH",
204
+ help="Path to environment file (passed to Docker container)",
205
+ )
206
+ parser.add_argument(
207
+ "--no-sandboxing",
208
+ action="store_true",
209
+ help="Run opencode locally without Docker (no container isolation)",
210
+ )
211
+
212
+ # Install subcommand
213
+ install_parser = subparsers.add_parser(
214
+ "install",
215
+ help="Install a decision-pack as a wrapper script",
216
+ )
217
+ install_parser.add_argument(
218
+ "dpack_path",
219
+ metavar="PATH",
220
+ help="Path to decision-pack config directory",
221
+ )
222
+ install_parser.add_argument(
223
+ "--bin-dir",
224
+ metavar="PATH",
225
+ default=os.path.expanduser("~/.local/bin"),
226
+ help="Directory to install wrapper script (default: ~/.local/bin)",
227
+ )
228
+
229
+ # Connect subcommand
230
+ connect_parser = subparsers.add_parser(
231
+ "connect",
232
+ help="Connect to a running or completed session (TUI monitor)",
233
+ )
234
+ connect_parser.add_argument(
235
+ "work_dir",
236
+ metavar="WORK_DIR",
237
+ help="Path to session work directory",
238
+ )
239
+ connect_parser.add_argument(
240
+ "--log",
241
+ action="store_true",
242
+ help="Show rich formatted log output",
243
+ )
244
+ connect_parser.add_argument(
245
+ "--log-json",
246
+ action="store_true",
247
+ help="Show raw JSON log output",
248
+ )
249
+
250
+ # Create parallel agent subcommand
251
+ create_pa_parser = subparsers.add_parser(
252
+ "create-parallel-agent",
253
+ help="Interactive wizard to create a parallel agent configuration",
254
+ )
255
+ create_pa_parser.add_argument(
256
+ "dpack",
257
+ metavar="DPACK_DIR",
258
+ nargs="?",
259
+ default=".",
260
+ help="Path to decision-pack config directory (default: current directory)",
261
+ )
262
+
263
+ # Timeline subcommand
264
+ timeline_parser = subparsers.add_parser(
265
+ "timeline",
266
+ help="Display execution timeline and Gantt chart for a session",
267
+ )
268
+ timeline_parser.add_argument(
269
+ "work_dir",
270
+ metavar="WORK_DIR",
271
+ nargs="?",
272
+ default=None,
273
+ help="Path to session work directory (default: cwd if it has _opencode_logs)",
274
+ )
275
+
276
+ # Create decision-pack subcommand
277
+ create_dpack_parser = subparsers.add_parser(
278
+ "create-dpack",
279
+ help="Interactive wizard to create a new decision-pack directory",
280
+ )
281
+ create_dpack_parser.add_argument(
282
+ "output_dir",
283
+ metavar="OUTPUT_DIR",
284
+ nargs="?",
285
+ default=".",
286
+ help="Directory where the decision-pack will be created (default: current directory)",
287
+ )
288
+
289
+ return parser
290
+
291
+
292
+ def cmd_run(args: argparse.Namespace) -> int:
293
+ """
294
+ Handle run mode - create session and start agent.
295
+
296
+ Parameters
297
+ ----------
298
+ args : argparse.Namespace
299
+ Parsed command-line arguments.
300
+
301
+ Returns
302
+ -------
303
+ int
304
+ Exit code (0 for success, non-zero for failure).
305
+ """
306
+ if not args.dpack:
307
+ print("Error: --dpack is required for run mode", file=sys.stderr)
308
+ return 1
309
+
310
+ # Load config early so we can check requires_data
311
+ try:
312
+ config: dict[str, Any] = load_dpack_config(args.dpack)
313
+ except ValueError as e:
314
+ print(f"Error: {e}", file=sys.stderr)
315
+ return 1
316
+
317
+ # Resolve execution mode: Docker vs local
318
+ no_sandboxing: bool = getattr(args, "no_sandboxing", False)
319
+ if not no_sandboxing:
320
+ from dlab.local import is_docker_available
321
+ if not is_docker_available():
322
+ err: Console = Console(stderr=True, highlight=False)
323
+ err.print(
324
+ "Oops, couldn't find a running Docker daemon. "
325
+ "decision-lab attempts to use Docker for sandboxing "
326
+ "and locked environments by default.\n"
327
+ )
328
+ err.print(
329
+ "To run locally without sandboxing, add the "
330
+ "[cyan]--no-sandboxing[/cyan] flag to the command.\n"
331
+ )
332
+ err.print(
333
+ "[yellow]Warning: without sandboxing, decision-lab "
334
+ "will potentially have access to your whole system. "
335
+ "Please be aware of the risk.[/yellow]"
336
+ )
337
+ return 1
338
+
339
+ # Auto-default --env-file to decision-pack .env if present
340
+ if not args.env_file:
341
+ dpack_env: Path = Path(args.dpack).resolve() / ".env"
342
+ if dpack_env.exists():
343
+ args.env_file = str(dpack_env)
344
+
345
+ env_file_missing: bool = not args.env_file
346
+
347
+ # Check for continue mode vs new session mode
348
+ continue_mode: bool = bool(args.continue_dir)
349
+ requires_data: bool = config.get("requires_data", True)
350
+ requires_prompt: bool = config.get("requires_prompt", True)
351
+
352
+ if continue_mode:
353
+ if args.data:
354
+ print("Error: Cannot use --data with --continue-dir", file=sys.stderr)
355
+ return 1
356
+ else:
357
+ if requires_data and not args.data:
358
+ print("Error: --data is required (or use --continue-dir to resume)", file=sys.stderr)
359
+ return 1
360
+ if args.data:
361
+ for data_path in args.data:
362
+ if not Path(data_path).exists():
363
+ print(f"Error: Data path does not exist: {data_path}", file=sys.stderr)
364
+ return 1
365
+
366
+ if requires_prompt and not args.prompt and not args.prompt_file:
367
+ print("Error: --prompt or --prompt-file is required", file=sys.stderr)
368
+ return 1
369
+
370
+ if args.prompt and args.prompt_file:
371
+ print("Error: Cannot specify both --prompt and --prompt-file", file=sys.stderr)
372
+ return 1
373
+
374
+ prompt: str = ""
375
+ if args.prompt_file:
376
+ prompt_path: Path = Path(args.prompt_file)
377
+ if not prompt_path.exists():
378
+ print(f"Error: Prompt file not found: {args.prompt_file}", file=sys.stderr)
379
+ return 1
380
+ prompt = prompt_path.read_text()
381
+ elif args.prompt:
382
+ prompt = args.prompt
383
+
384
+ console: Console = _make_console()
385
+
386
+ model: str = args.model if args.model else config["default_model"]
387
+ fallback_msgs: list[str] = []
388
+
389
+ # Pre-flight model validation (before any session/Docker work)
390
+ pf_errors, pf_warnings = preflight_check(
391
+ model, config["config_dir"], args.env_file, no_sandboxing,
392
+ )
393
+ if pf_errors:
394
+ for err in pf_errors:
395
+ console.print(f"[red]Error:[/red] {err}")
396
+ return 1
397
+ if pf_warnings:
398
+ for warn in pf_warnings:
399
+ console.print(f"[yellow]Model fallback:[/yellow] {warn}")
400
+
401
+ if continue_mode:
402
+ continue_dir = Path(args.continue_dir).resolve()
403
+ if not continue_dir.exists():
404
+ raise ValueError(f"Continue directory not found: {args.continue_dir}")
405
+
406
+ if args.work_dir:
407
+ # Copy continue-dir to work-dir, then continue from there
408
+ work_path = Path(args.work_dir).resolve()
409
+ if work_path.exists():
410
+ raise ValueError(f"Work directory already exists: {args.work_dir}")
411
+ shutil.copytree(continue_dir, work_path)
412
+ work_dir = str(work_path)
413
+ print(f"Copied {continue_dir} to {work_dir}")
414
+ else:
415
+ # Continue in place - ask for confirmation
416
+ work_dir = str(continue_dir)
417
+ print(f"Will continue session in: {work_dir}")
418
+ confirm = input("Continue? [y/N]: ").strip().lower()
419
+ if confirm != "y":
420
+ print("Aborted.")
421
+ return 0
422
+
423
+ # Overwrite .opencode with latest from decision-pack (agent prompts may have changed)
424
+ opencode_dir = Path(work_dir) / ".opencode"
425
+ if opencode_dir.exists():
426
+ if no_sandboxing:
427
+ # Local mode: files are user-owned
428
+ shutil.rmtree(opencode_dir)
429
+ else:
430
+ # Docker mode: files may be root-owned (e.g. node_modules/)
431
+ subprocess.run(
432
+ ["sudo", "rm", "-rf", str(opencode_dir)],
433
+ check=True,
434
+ )
435
+ fallback_msgs: list[str] = setup_opencode_config(
436
+ config["config_dir"], work_dir, model, args.env_file,
437
+ no_sandboxing,
438
+ )
439
+
440
+ # Refresh hook scripts from decision-pack
441
+ hooks_dest: Path = Path(work_dir) / "_hooks"
442
+ if hooks_dest.exists():
443
+ shutil.rmtree(hooks_dest)
444
+ copy_hook_scripts(config, work_dir)
445
+ else:
446
+ try:
447
+ state: dict[str, Any] = create_session(
448
+ config,
449
+ args.data,
450
+ work_dir=args.work_dir,
451
+ orchestrator_model=model,
452
+ env_file=args.env_file,
453
+ no_sandboxing=no_sandboxing,
454
+ )
455
+ except ValueError as e:
456
+ err_msg: str = str(e)
457
+ if "already exists" in err_msg:
458
+ # Extract the path from the error message
459
+ work_path_str: str = err_msg.split(": ", 1)[-1] if ": " in err_msg else str(args.work_dir or "")
460
+ console.print(
461
+ f"Oops, work directory [bold]{work_path_str}[/bold] already exists.\n"
462
+ f"You can remove it with: [cyan]rm -rf {work_path_str}[/cyan]"
463
+ )
464
+ else:
465
+ print(f"Error: {e}", file=sys.stderr)
466
+ return 1
467
+ work_dir = state["work_dir"]
468
+ fallback_msgs = state.get("model_fallback_messages", [])
469
+ image_name: str = config["docker_image_name"]
470
+ container_name: str = Path(work_dir).name # Use session dir basename
471
+
472
+ # --- Header ---
473
+ I: str = " " # 6-space indent for content under phase labels
474
+ if no_sandboxing:
475
+ console.print(f"[bold]dlab[/bold] [dim]·[/dim] {config['name']} [dim]·[/dim] {model} [dim]·[/dim] [yellow]no sandboxing[/yellow]")
476
+ else:
477
+ console.print(f"[bold]dlab[/bold] [dim]·[/dim] {config['name']} [dim]·[/dim] {model}")
478
+ if continue_mode:
479
+ console.print(f"[dim]Continuing:[/dim] {work_dir}")
480
+ else:
481
+ console.print(f"[dim]Session:[/dim] {work_dir}")
482
+ if env_file_missing:
483
+ console.print(f"{I}[yellow]Warning:[/yellow] No --env-file provided and no .env found in decision-pack.")
484
+ console.print(f"{I}[yellow] The agent may fail if it needs API keys.[/yellow]")
485
+ console.print()
486
+
487
+ # Compute step numbering
488
+ hooks: dict[str, Any] = config.get("hooks", {})
489
+ pre_run_hooks: list[str] = hooks.get("pre-run", [])
490
+ post_run_hooks: list[str] = hooks.get("post-run", [])
491
+
492
+ step: int = 0
493
+ total_steps: int = 2 # setup + cleanup (always present)
494
+ if not no_sandboxing and pre_run_hooks:
495
+ total_steps += 1
496
+ total_steps += 1 # running agent (always present)
497
+ if not no_sandboxing and post_run_hooks:
498
+ total_steps += 1
499
+
500
+ def next_step(label: str) -> str:
501
+ nonlocal step
502
+ step += 1
503
+ return f"[bold]\\[{step}/{total_steps}] {label}[/bold]"
504
+
505
+ # =====================================================================
506
+ # LOCAL MODE (--no-sandboxing)
507
+ # =====================================================================
508
+ if no_sandboxing:
509
+ from dlab.local import (
510
+ build_local_env,
511
+ build_local_prompt,
512
+ copy_docker_dir,
513
+ run_opencode_local,
514
+ )
515
+
516
+ console.print(next_step("Setting up local environment"))
517
+
518
+ # Check opencode is installed
519
+ if shutil.which("opencode") is None:
520
+ console.print(f"{I}[bold red]Error:[/bold red] opencode is not installed.")
521
+ console.print(f"{I}Install with: [bold]curl -fsSL https://opencode.ai/install | bash[/bold]")
522
+ console.print(f"{I}See: [dim]https://opencode.ai[/dim]")
523
+ return 1
524
+
525
+ # Copy docker/ as _docker/ so the agent can read it
526
+ copy_docker_dir(config["config_dir"], work_dir)
527
+ console.print(f"{I}[dim]Copied docker/ to _docker/[/dim]")
528
+
529
+ env_file_path: str | None = getattr(args, "env_file", None)
530
+ local_env: dict[str, str] = build_local_env(env_file=env_file_path)
531
+ console.print(f"{I}[green]Ready[/green]")
532
+
533
+ # Prepend system instructions to prompt
534
+ local_prompt: str = build_local_prompt(prompt, config)
535
+
536
+ console.print(next_step("Running agent ..."))
537
+ hint_text: Text = Text()
538
+ hint_text.append("dlab connect ", style="bold")
539
+ hint_text.append(work_dir, style="dim")
540
+ hint_text.append("\n Live-monitor the run\n\n")
541
+ hint_text.append("dlab timeline ", style="bold")
542
+ hint_text.append(work_dir, style="dim")
543
+ hint_text.append("\n View execution timeline after the run")
544
+ panel: Panel = Panel(hint_text, title="[dim]Monitoring[/dim]", border_style="dim", expand=False, padding=(0, 1))
545
+ console.print(Padding(panel, (0, 0, 0, 6)))
546
+
547
+ try:
548
+ logs_dir_local: Path = Path(work_dir) / "_opencode_logs"
549
+ exit_code, stdout, stderr = _run_with_log_spinner(
550
+ console, I, logs_dir_local,
551
+ lambda: run_opencode_local(work_dir, local_prompt, model, local_env),
552
+ )
553
+ if stderr:
554
+ console.print(f"{I}[red]{stderr}[/red]", highlight=False)
555
+ except KeyboardInterrupt:
556
+ console.print(f"\n{I}[yellow]Interrupted.[/yellow]")
557
+ exit_code = 130
558
+
559
+ console.print(next_step("Cleanup"))
560
+ if exit_code == 0:
561
+ console.print(f"{I}[bold green]Done.[/bold green]")
562
+ else:
563
+ console.print(f"{I}[bold red]Done (exit code {exit_code}).[/bold red]")
564
+
565
+ return exit_code
566
+
567
+ # =====================================================================
568
+ # DOCKER MODE (default)
569
+ # =====================================================================
570
+ force_rebuild: bool = getattr(args, "rebuild", False)
571
+ opencode_version: str = config["opencode_version"]
572
+
573
+ should_rebuild: bool
574
+ rebuild_reason: str
575
+ if force_rebuild:
576
+ should_rebuild = True
577
+ rebuild_reason = "--rebuild flag passed"
578
+ else:
579
+ should_rebuild, rebuild_reason = needs_rebuild(
580
+ config["config_dir"], image_name, opencode_version,
581
+ )
582
+
583
+ console.print(next_step("Setting up environment"))
584
+ if should_rebuild:
585
+ console.print(f"{I}[yellow]Building image:[/yellow] {image_name}")
586
+ console.print(f"{I}[dim]Reason: {rebuild_reason}[/dim]")
587
+ console.print(f"{I}[dim]opencode version: {opencode_version}[/dim]")
588
+ try:
589
+ build_line_count: int = 0
590
+ build_spinner: Spinner = Spinner("dots", style="dim")
591
+
592
+ def _build_renderable() -> Text:
593
+ text = Text(I)
594
+ text.append_text(build_spinner.render(_time.time()))
595
+ text.append(f" » {build_line_count} ", style="dim")
596
+ text.append("msgs", style="#555555")
597
+ return text
598
+
599
+ build_running: bool = True
600
+
601
+ with Live(_build_renderable(), console=console, refresh_per_second=10, transient=True) as build_live:
602
+ def _build_tick() -> None:
603
+ while build_running:
604
+ build_live.update(_build_renderable())
605
+ _time.sleep(0.1)
606
+
607
+ build_ticker = threading.Thread(target=_build_tick, daemon=True)
608
+ build_ticker.start()
609
+
610
+ def _on_build_output(line: str) -> None:
611
+ nonlocal build_line_count
612
+ build_line_count += 1
613
+
614
+ build_image(config["config_dir"], image_name, opencode_version, on_output=_on_build_output)
615
+ build_running = False
616
+
617
+ console.print(f"{I}[green]Image built.[/green]")
618
+ except ValueError as e:
619
+ console.print(f"{I}[bold red]Error:[/bold red] {e}", highlight=False)
620
+ return 1
621
+ else:
622
+ console.print(f"{I}[dim]Image:[/dim] {image_name} [dim](cached)[/dim]")
623
+
624
+ dangling: int = count_dangling_images()
625
+ if dangling > 0:
626
+ console.print(f"{I}[yellow]Warning:[/yellow] {dangling} dangling Docker image(s) using disk space")
627
+ console.print(f"{I}[dim]Clean up with: docker image prune -f[/dim]")
628
+
629
+ env_file: str | None = getattr(args, "env_file", None)
630
+
631
+ # Forward all DLAB_* env vars from host to container
632
+ extra_env: dict[str, str] = {
633
+ key: value
634
+ for key, value in os.environ.items()
635
+ if key.startswith("DLAB_")
636
+ }
637
+ for key, value in extra_env.items():
638
+ console.print(f"{I}[dim]{key}={value}[/dim]")
639
+
640
+ try:
641
+ start_container(image_name, work_dir, container_name, env_file=env_file, extra_env=extra_env)
642
+ console.print(f"{I}[green]Container started:[/green] {container_name}")
643
+ except ValueError as e:
644
+ console.print(f"{I}[bold red]Error:[/bold red] {e}", highlight=False)
645
+ return 1
646
+
647
+ # Set up signal handlers to ensure container cleanup on interrupt
648
+ container_stopped: bool = False
649
+
650
+ interrupted: bool = False
651
+
652
+ def cleanup_handler(signum: int, frame: Any) -> None:
653
+ nonlocal interrupted
654
+ interrupted = True
655
+ console.print(f"\n{I}[yellow]Interrupted — will stop after cleanup.[/yellow]")
656
+ # Raise KeyboardInterrupt to break out of the blocking run_opencode call
657
+ raise KeyboardInterrupt
658
+
659
+ original_sigint = signal.signal(signal.SIGINT, cleanup_handler)
660
+ original_sigterm = signal.signal(signal.SIGTERM, cleanup_handler)
661
+
662
+ exit_code: int = 1
663
+ try:
664
+ # --- Pre-run hooks (optional step) ---
665
+ if pre_run_hooks:
666
+ console.print(next_step("Pre-run hooks"))
667
+ for script in pre_run_hooks:
668
+ console.print(f"{I}[cyan]{script}[/cyan]")
669
+ hook_exit, hook_out, hook_err = exec_command(
670
+ container_name,
671
+ ["bash", "-c", f"chmod +x /workspace/_hooks/{script} && /workspace/_hooks/{script}"],
672
+ )
673
+ if hook_out:
674
+ for line in hook_out.rstrip("\n").split("\n"):
675
+ console.print(f"{I} [dim]{line}[/dim]")
676
+ if hook_err:
677
+ console.print(f"{I} [red]{hook_err.rstrip()}[/red]")
678
+ if hook_exit != 0:
679
+ console.print(f"{I}[bold red]ERROR:[/bold red] {script} failed (exit {hook_exit})")
680
+ exit_code = hook_exit
681
+ raise RuntimeError(f"Pre-run hook failed: {script}")
682
+
683
+ # --- Running agent ---
684
+ console.print(next_step("Running agent ..."))
685
+ hint_text: Text = Text()
686
+ hint_text.append("dlab connect ", style="bold")
687
+ hint_text.append(work_dir, style="dim")
688
+ hint_text.append("\n Live-monitor the run\n\n")
689
+ hint_text.append("dlab timeline ", style="bold")
690
+ hint_text.append(work_dir, style="dim")
691
+ hint_text.append("\n View execution timeline after the run")
692
+ panel: Panel = Panel(hint_text, title="[dim]Monitoring[/dim]", border_style="dim", expand=False, padding=(0, 1))
693
+ console.print(Padding(panel, (0, 0, 0, 6)))
694
+
695
+ logs_dir_path: Path = Path(work_dir) / "_opencode_logs"
696
+ exit_code, stdout, stderr = _run_with_log_spinner(
697
+ console, I, logs_dir_path,
698
+ lambda: run_opencode(container_name, prompt, model),
699
+ )
700
+ if stderr:
701
+ console.print(f"{I}[red]{stderr}[/red]", highlight=False)
702
+
703
+ # --- Post-run hooks (optional step) ---
704
+ if post_run_hooks:
705
+ console.print(next_step("Post-run hooks"))
706
+ for script in post_run_hooks:
707
+ console.print(f"{I}[cyan]{script}[/cyan]")
708
+ hook_exit, hook_out, hook_err = exec_command(
709
+ container_name,
710
+ ["bash", "-c", f"chmod +x /workspace/_hooks/{script} && /workspace/_hooks/{script}"],
711
+ )
712
+ if hook_out:
713
+ for line in hook_out.rstrip("\n").split("\n"):
714
+ console.print(f"{I} [dim]{line}[/dim]")
715
+ if hook_err:
716
+ console.print(f"{I} [red]{hook_err.rstrip()}[/red]")
717
+ if hook_exit != 0:
718
+ console.print(f"{I}[bold yellow]WARNING:[/bold yellow] {script} failed (exit {hook_exit})")
719
+ except RuntimeError:
720
+ pass # Hook failure — exit_code already set
721
+ except KeyboardInterrupt:
722
+ exit_code = 130
723
+ except Exception as e:
724
+ console.print(f"{I}[bold red]Error:[/bold red] {e}", highlight=False)
725
+ finally:
726
+ # Restore original signal handlers so a second Ctrl+C during cleanup
727
+ # does the default thing (hard exit) instead of looping
728
+ signal.signal(signal.SIGINT, original_sigint)
729
+ signal.signal(signal.SIGTERM, original_sigterm)
730
+
731
+ # --- Cleanup ---
732
+ console.print(next_step("Cleanup"))
733
+ # Fix file ownership before stopping (container runs as root)
734
+ uid_gid: str = f"{os.getuid()}:{os.getgid()}"
735
+ exec_command(container_name, ["chown", "-R", uid_gid, "/workspace", "/_opencode_logs"])
736
+ console.print(f"{I}[dim]Stopping container...[/dim]")
737
+ stop_container(container_name)
738
+ container_stopped = True
739
+ if interrupted:
740
+ console.print(f"{I}[yellow]Interrupted.[/yellow]")
741
+ elif exit_code == 0:
742
+ console.print(f"{I}[bold green]Done.[/bold green]")
743
+ else:
744
+ console.print(f"{I}[bold red]Done (exit code {exit_code}).[/bold red]")
745
+
746
+ return exit_code
747
+
748
+
749
+ def cmd_install(args: argparse.Namespace) -> int:
750
+ """
751
+ Handle install mode - create wrapper script for a decision-pack.
752
+
753
+ Parameters
754
+ ----------
755
+ args : argparse.Namespace
756
+ Parsed command-line arguments.
757
+
758
+ Returns
759
+ -------
760
+ int
761
+ Exit code (0 for success, non-zero for failure).
762
+ """
763
+ try:
764
+ config: dict[str, Any] = load_dpack_config(args.dpack_path)
765
+ except ValueError as e:
766
+ print(f"Error: {e}", file=sys.stderr)
767
+ return 1
768
+
769
+ dpack_name: str = config["name"]
770
+ cli_name: str = config.get("cli_name", "") or dpack_name
771
+ config_dir: str = config["config_dir"]
772
+
773
+ bin_dir: Path = Path(args.bin_dir)
774
+ if not bin_dir.exists():
775
+ bin_dir.mkdir(parents=True)
776
+
777
+ wrapper_path: Path = bin_dir / cli_name
778
+ wrapper_content: str = WRAPPER_TEMPLATE.format(
779
+ dpack_name=dpack_name,
780
+ config_dir=config_dir,
781
+ )
782
+
783
+ wrapper_path.write_text(wrapper_content)
784
+
785
+ current_mode: int = wrapper_path.stat().st_mode
786
+ wrapper_path.chmod(current_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
787
+
788
+ print(f"Installed wrapper: {wrapper_path}")
789
+ print(f"decision-pack: {dpack_name}")
790
+ if cli_name != dpack_name:
791
+ print(f"CLI name: {cli_name}")
792
+ print(f"Config: {config_dir}")
793
+
794
+ if str(bin_dir) not in os.environ.get("PATH", ""):
795
+ print()
796
+ print(f"Note: {bin_dir} may not be in your PATH")
797
+ print(f"Add to your shell config: export PATH=\"{bin_dir}:$PATH\"")
798
+
799
+ return 0
800
+
801
+
802
+ def cmd_connect(args: argparse.Namespace) -> int:
803
+ """
804
+ Handle connect mode - TUI monitor for running or completed sessions.
805
+
806
+ Parameters
807
+ ----------
808
+ args : argparse.Namespace
809
+ Parsed command-line arguments.
810
+
811
+ Returns
812
+ -------
813
+ int
814
+ Exit code (0 for success, non-zero for failure).
815
+ """
816
+ work_dir: Path = Path(args.work_dir).resolve()
817
+
818
+ if not work_dir.exists():
819
+ print(f"Error: Work directory not found: {work_dir}", file=sys.stderr)
820
+ return 1
821
+
822
+ logs_dir: Path = work_dir / "_opencode_logs"
823
+ if not logs_dir.exists():
824
+ print(f"Error: No logs directory found: {logs_dir}", file=sys.stderr)
825
+ print("Make sure this is a valid dlab session directory.", file=sys.stderr)
826
+ return 1
827
+
828
+ # Non-interactive modes (for scripting/piping)
829
+ if args.log_json:
830
+ print("Error: --log-json mode is not yet implemented", file=sys.stderr)
831
+ return 1
832
+
833
+ if args.log:
834
+ print("Error: --log mode is not yet implemented", file=sys.stderr)
835
+ return 1
836
+
837
+ # Interactive TUI mode (default)
838
+ from dlab.tui import ConnectApp
839
+
840
+ app = ConnectApp(work_dir)
841
+ app.run()
842
+ return 0
843
+
844
+
845
+ def cmd_create_parallel_agent(args: argparse.Namespace) -> int:
846
+ """
847
+ Launch TUI wizard for creating a parallel agent configuration.
848
+
849
+ Parameters
850
+ ----------
851
+ args : argparse.Namespace
852
+ Parsed command-line arguments.
853
+
854
+ Returns
855
+ -------
856
+ int
857
+ Exit code (0 for success).
858
+ """
859
+ from rich.console import Console
860
+
861
+ from dlab.config import list_config_issues
862
+ from dlab.create_parallel_agent_wizard import CreateParallelAgentApp
863
+
864
+ dpack: str = getattr(args, "dpack", ".")
865
+ is_default: bool = dpack == "."
866
+ resolved: str = str(Path(dpack).resolve())
867
+
868
+ issues: list[str] = list_config_issues(dpack)
869
+ if issues:
870
+ console: Console = Console(highlight=False)
871
+ if is_default:
872
+ console.print("[yellow]No decision-pack directory provided, checking current directory...[/yellow]")
873
+ console.print(f"[red]{resolved} is not a valid decision-pack directory:[/red]")
874
+ for issue in issues:
875
+ console.print(f" [dim]- {issue}[/dim]")
876
+ if is_default:
877
+ console.print()
878
+ console.print("Usage: [bold]dlab create-parallel-agent <dpack-dir>[/bold]")
879
+ return 1
880
+
881
+ try:
882
+ app: CreateParallelAgentApp = CreateParallelAgentApp(dpack)
883
+ except ValueError as e:
884
+ print(f"Error: {e}", file=sys.stderr)
885
+ return 1
886
+ app.run()
887
+
888
+ if app.created_files:
889
+ console: Console = Console(highlight=False)
890
+ console.print("[bold green]Created:[/bold green]")
891
+ for f in app.created_files:
892
+ console.print(f" {f}")
893
+ return 0
894
+
895
+
896
+ def cmd_timeline(args: argparse.Namespace) -> int:
897
+ """
898
+ Handle timeline mode - display execution timeline for a session.
899
+
900
+ Parameters
901
+ ----------
902
+ args : argparse.Namespace
903
+ Parsed command-line arguments.
904
+
905
+ Returns
906
+ -------
907
+ int
908
+ Exit code (0 for success, non-zero for failure).
909
+ """
910
+ work_dir: Path | None = Path(args.work_dir) if args.work_dir else None
911
+ return run_timeline(work_dir)
912
+
913
+
914
+ def cmd_create_dpack(args: argparse.Namespace) -> int:
915
+ """
916
+ Handle create-dpack mode - launch TUI wizard.
917
+
918
+ Parameters
919
+ ----------
920
+ args : argparse.Namespace
921
+ Parsed command-line arguments.
922
+
923
+ Returns
924
+ -------
925
+ int
926
+ Exit code (0 for success).
927
+ """
928
+ from dlab.create_dpack_wizard import CreateDpackApp
929
+
930
+ output_dir: str = getattr(args, "output_dir", ".")
931
+ app: CreateDpackApp = CreateDpackApp(output_dir)
932
+ app.run()
933
+ return 0
934
+
935
+
936
+ def _suggest_corrections(unknown_args: list[str], parser: argparse.ArgumentParser) -> None:
937
+ """
938
+ Print fuzzy-match suggestions for unknown CLI arguments and exit.
939
+
940
+ Parameters
941
+ ----------
942
+ unknown_args : list[str]
943
+ Unrecognized arguments from parse_known_args.
944
+ parser : argparse.ArgumentParser
945
+ The argument parser (used to extract valid flags and subcommands).
946
+ """
947
+ valid_flags: list[str] = [
948
+ opt
949
+ for action in parser._actions
950
+ for opt in action.option_strings
951
+ if opt.startswith("--")
952
+ ]
953
+ valid_subcommands: list[str] = []
954
+ for action in parser._subparsers._actions:
955
+ if hasattr(action, "choices") and action.choices:
956
+ valid_subcommands = list(action.choices.keys())
957
+ break
958
+
959
+ # Skip values that follow unknown flags (e.g., "out" after "--workdir out").
960
+ # These aren't separate errors — they're the value of the preceding unknown flag.
961
+ skip_next: bool = False
962
+ for i, arg in enumerate(unknown_args):
963
+ if skip_next:
964
+ skip_next = False
965
+ continue
966
+
967
+ if arg.startswith("--"):
968
+ matches: list[str] = difflib.get_close_matches(
969
+ arg, valid_flags, n=1, cutoff=0.6,
970
+ )
971
+ if matches:
972
+ print(f"Unknown argument: {arg}. Did you mean {matches[0]}?", file=sys.stderr)
973
+ else:
974
+ print(f"Unknown argument: {arg}", file=sys.stderr)
975
+ # If next arg doesn't start with '-', it's likely this flag's value
976
+ if i + 1 < len(unknown_args) and not unknown_args[i + 1].startswith("-"):
977
+ skip_next = True
978
+ elif not arg.startswith("-") and valid_subcommands:
979
+ matches = difflib.get_close_matches(
980
+ arg, valid_subcommands, n=1, cutoff=0.6,
981
+ )
982
+ if matches:
983
+ print(f"Unknown command: {arg}. Did you mean {matches[0]}?", file=sys.stderr)
984
+ else:
985
+ print(f"Unknown argument: {arg}", file=sys.stderr)
986
+ else:
987
+ print(f"Unknown argument: {arg}", file=sys.stderr)
988
+ sys.exit(2)
989
+
990
+
991
+ def main() -> None:
992
+ """
993
+ Entry point for the CLI.
994
+ """
995
+ parser: argparse.ArgumentParser = create_parser()
996
+
997
+ # Override argparse's error handler so misspelled subcommands don't produce
998
+ # a generic "invalid choice" message before we can suggest corrections.
999
+ # We collect bad subcommand values and merge them with unknown flags.
1000
+ import re
1001
+
1002
+ class _BadSubcommand(Exception):
1003
+ """Raised when argparse detects an invalid subcommand choice."""
1004
+ def __init__(self, value: str) -> None:
1005
+ self.value = value
1006
+
1007
+ original_error = parser.error
1008
+
1009
+ def custom_error(message: str) -> None:
1010
+ match = re.search(r"invalid choice: '([^']+)'", message)
1011
+ if match:
1012
+ raise _BadSubcommand(match.group(1))
1013
+ original_error(message)
1014
+
1015
+ parser.error = custom_error # type: ignore[assignment]
1016
+
1017
+ args: argparse.Namespace
1018
+ unknown: list[str]
1019
+ try:
1020
+ args, unknown = parser.parse_known_args()
1021
+ except _BadSubcommand as e:
1022
+ # Argparse caught a bad subcommand. This often happens when an unknown
1023
+ # flag's value (e.g., "out" from "--workdir out") gets interpreted as a
1024
+ # positional subcommand argument. Re-parse with subcommands disabled so
1025
+ # valid flags are recognized and only truly unknown flags remain.
1026
+ parser_no_sub: argparse.ArgumentParser = create_parser()
1027
+ # Remove subparsers action so positionals don't trigger subcommand matching
1028
+ parser_no_sub._subparsers._actions[:] = [
1029
+ a for a in parser_no_sub._subparsers._actions
1030
+ if not hasattr(a, "choices")
1031
+ ]
1032
+ _, unknown = parser_no_sub.parse_known_args()
1033
+ # The bad subcommand value was likely a flag's argument — don't report
1034
+ # it separately unless it looks like it was meant as a subcommand
1035
+ if e.value not in unknown:
1036
+ matches = difflib.get_close_matches(
1037
+ e.value,
1038
+ [c for a in parser._subparsers._actions
1039
+ if hasattr(a, "choices") and a.choices
1040
+ for c in a.choices.keys()],
1041
+ n=1, cutoff=0.6,
1042
+ )
1043
+ if matches:
1044
+ unknown.append(e.value)
1045
+ if unknown:
1046
+ _suggest_corrections(unknown, parser)
1047
+ else:
1048
+ # Nothing unknown after re-parse — the "bad subcommand" was just
1049
+ # a value trailing an unknown flag. Report the original error.
1050
+ original_error(f"argument command: invalid choice: '{e.value}'")
1051
+
1052
+ if unknown:
1053
+ _suggest_corrections(unknown, parser)
1054
+
1055
+ if args.command == "install":
1056
+ exit_code: int = cmd_install(args)
1057
+ elif args.command == "connect":
1058
+ exit_code = cmd_connect(args)
1059
+ elif args.command == "create-parallel-agent":
1060
+ exit_code = cmd_create_parallel_agent(args)
1061
+ elif args.command == "create-dpack":
1062
+ exit_code = cmd_create_dpack(args)
1063
+ elif args.command == "timeline":
1064
+ exit_code = cmd_timeline(args)
1065
+ elif args.dpack or args.data or args.prompt or args.prompt_file or args.continue_dir:
1066
+ exit_code = cmd_run(args)
1067
+ else:
1068
+ parser.print_help()
1069
+ exit_code = 0
1070
+
1071
+ sys.exit(exit_code)
1072
+
1073
+
1074
+ if __name__ == "__main__":
1075
+ main()