dlab-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dlab/__init__.py +6 -0
- dlab/cli.py +1075 -0
- dlab/config.py +190 -0
- dlab/create_dpack.py +1096 -0
- dlab/create_dpack_wizard.py +1471 -0
- dlab/create_parallel_agent_wizard.py +582 -0
- dlab/data/__init__.py +0 -0
- dlab/data/models.json +1793 -0
- dlab/docker.py +591 -0
- dlab/local.py +269 -0
- dlab/model_fallback.py +360 -0
- dlab/parallel_tool.py +18 -0
- dlab/session.py +389 -0
- dlab/timeline.py +684 -0
- dlab/tui/__init__.py +9 -0
- dlab/tui/app.py +664 -0
- dlab/tui/log_watcher.py +208 -0
- dlab/tui/models.py +438 -0
- dlab/tui/widgets/__init__.py +18 -0
- dlab/tui/widgets/agent_list.py +170 -0
- dlab/tui/widgets/artifacts_pane.py +618 -0
- dlab/tui/widgets/log_view.py +505 -0
- dlab/tui/widgets/search_popup.py +151 -0
- dlab/tui/widgets/status_bar.py +106 -0
- dlab_cli-0.1.0.dist-info/METADATA +237 -0
- dlab_cli-0.1.0.dist-info/RECORD +30 -0
- dlab_cli-0.1.0.dist-info/WHEEL +5 -0
- dlab_cli-0.1.0.dist-info/entry_points.txt +2 -0
- dlab_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- dlab_cli-0.1.0.dist-info/top_level.txt +1 -0
dlab/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()
|