dscli-agent 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.
dscli/cli.py ADDED
@@ -0,0 +1,842 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import logging
6
+ import os
7
+ import sys
8
+ import time
9
+ from dataclasses import fields
10
+ from pathlib import Path
11
+
12
+ from playwright.sync_api import Error as PlaywrightError
13
+
14
+ from . import __version__, ui
15
+ from .agent import run_agent
16
+ from .attachments import AttachmentError, collect_attachments
17
+ from .browser import (
18
+ BrowserError,
19
+ ChatError,
20
+ ChatSession,
21
+ HumanCheckRequired,
22
+ LoginRequired,
23
+ UploadError,
24
+ )
25
+ from .config import (
26
+ Config,
27
+ config_path,
28
+ ensure_config_file,
29
+ load_config,
30
+ parse_scalar,
31
+ )
32
+ from .prompts import build_initial_prompt
33
+ from .protocol import ProtocolError
34
+ from .tools import ApprovalRequired, Toolbox, workspace_preview
35
+
36
+ log = logging.getLogger("dscli")
37
+
38
+
39
+ def _utf8_stdio() -> None:
40
+ for stream in (sys.stdout, sys.stderr):
41
+ try:
42
+ stream.reconfigure(encoding="utf-8", errors="replace")
43
+ except (AttributeError, OSError):
44
+ pass
45
+
46
+
47
+ def _setup_logging(
48
+ verbose: bool,
49
+ quiet: bool,
50
+ handler: logging.Handler | None = None,
51
+ ) -> None:
52
+ level = logging.DEBUG if verbose else logging.WARNING if quiet else logging.INFO
53
+ if handler is not None:
54
+ handler.setLevel(level)
55
+ logging.basicConfig(
56
+ level=level,
57
+ handlers=[handler],
58
+ format="%(message)s",
59
+ force=True,
60
+ )
61
+ else:
62
+ logging.basicConfig(
63
+ level=level,
64
+ format="%(message)s",
65
+ stream=sys.stderr,
66
+ force=True,
67
+ )
68
+ logging.getLogger("asyncio").setLevel(logging.WARNING)
69
+
70
+
71
+ def _add_headless_flags(parser: argparse.ArgumentParser) -> None:
72
+ group = parser.add_mutually_exclusive_group()
73
+ group.add_argument("--headed", action="store_true", help="Show the browser window.")
74
+ group.add_argument("--headless", action="store_true", help="Run the browser headless.")
75
+
76
+
77
+ def _add_toggle_flags(parser: argparse.ArgumentParser) -> None:
78
+ group = parser.add_mutually_exclusive_group()
79
+ group.add_argument("--deepthink", dest="deepthink", action="store_true", default=None,
80
+ help="Enable DeepSeek's DeepThink mode.")
81
+ group.add_argument("--no-deepthink", dest="deepthink", action="store_false",
82
+ help="Disable DeepThink (default).")
83
+ group = parser.add_mutually_exclusive_group()
84
+ group.add_argument("--search", dest="search", action="store_true", default=None,
85
+ help="Enable DeepSeek web search.")
86
+ group.add_argument("--no-search", dest="search", action="store_false",
87
+ help="Disable web search (default).")
88
+
89
+
90
+ def _add_agent_flags(parser: argparse.ArgumentParser) -> None:
91
+ parser.add_argument("--workspace", default=None,
92
+ help="Workspace root (default: current directory).")
93
+ parser.add_argument("-y", "--yes", action="store_true",
94
+ help="Approve file writes and commands automatically.")
95
+ parser.add_argument("--allow-shell", action="store_true",
96
+ help="Allow run_command (still approved per command).")
97
+ parser.add_argument("--allow-outside", action="store_true",
98
+ help="Allow tools to access paths outside the workspace.")
99
+ parser.add_argument("--max-steps", type=int, default=None,
100
+ help="Maximum model turns per request.")
101
+ parser.add_argument("--timeout", type=int, default=None,
102
+ help="Response timeout per turn, seconds.")
103
+ parser.add_argument("--no-auto-login", action="store_true",
104
+ help="Do not open a visible browser when a headless login is needed.")
105
+ _add_headless_flags(parser)
106
+ _add_toggle_flags(parser)
107
+ parser.add_argument("-v", "--verbose", action="store_true",
108
+ help="Show model thoughts and debug logs.")
109
+ parser.add_argument("-q", "--quiet", action="store_true", help="Hide progress messages.")
110
+
111
+
112
+ def build_parser() -> argparse.ArgumentParser:
113
+ parser = argparse.ArgumentParser(
114
+ prog="dscli",
115
+ description="A local coding agent for your terminal.",
116
+ )
117
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
118
+ sub = parser.add_subparsers(dest="command", required=True)
119
+
120
+ login = sub.add_parser("login", help="Open a browser once and save a DeepSeek login.")
121
+ login.add_argument("--timeout", type=int, default=None, help="Seconds to wait for login.")
122
+
123
+ doctor = sub.add_parser("doctor", help="Inspect the DeepSeek page and selectors.")
124
+ doctor.add_argument("--send", action="store_true", help="Also send a one-word round-trip test.")
125
+ _add_headless_flags(doctor)
126
+ doctor.add_argument("--timeout", type=int, default=180, help="Timeout for the test message.")
127
+
128
+ chat = sub.add_parser("chat", help="Start an interactive conversation.")
129
+ _add_agent_flags(chat)
130
+ chat.add_argument("--plain", action="store_true",
131
+ help="Use the plain line-based interface instead of the TUI.")
132
+
133
+ run = sub.add_parser("run", help="Run a task with file-editing tools.")
134
+ run.add_argument("task", nargs="*", help="The task/prompt. Use '-' to read it from stdin.")
135
+ run.add_argument("-f", "--file", action="append", default=[], metavar="PATH",
136
+ help="Attach a file to the chat (repeatable; globs supported).")
137
+ run.add_argument("--prompt-file", help="Read the task from a UTF-8 text file.")
138
+ _add_agent_flags(run)
139
+ run.add_argument("--dry-run", action="store_true",
140
+ help="Print the initial prompt and exit without launching a browser.")
141
+
142
+ cfg = sub.add_parser("config", help="Show or edit the saved configuration.")
143
+ cfg.add_argument("--show", action="store_true", help="Show the current configuration.")
144
+ cfg.add_argument("--set", action="append", default=[], metavar="KEY=VALUE",
145
+ help="Set a config value (repeatable).")
146
+ cfg.add_argument("--path", action="store_true", help="Print the config file path.")
147
+ return parser
148
+
149
+
150
+ def _apply_run_config(cfg: Config, args: argparse.Namespace) -> Config:
151
+ if args.headed:
152
+ cfg.headless = False
153
+ elif args.headless:
154
+ cfg.headless = True
155
+ if args.timeout is not None:
156
+ if args.timeout <= 0:
157
+ raise ValueError("--timeout must be greater than 0.")
158
+ cfg.response_timeout_sec = args.timeout
159
+ cfg.first_response_timeout_sec = min(cfg.first_response_timeout_sec, args.timeout)
160
+ if args.max_steps is not None:
161
+ if args.max_steps < 1:
162
+ raise ValueError("--max-steps must be at least 1.")
163
+ cfg.max_steps = min(args.max_steps, 50)
164
+ if args.deepthink is not None:
165
+ cfg.deepthink = args.deepthink
166
+ if args.search is not None:
167
+ cfg.search = args.search
168
+ if args.allow_shell:
169
+ cfg.allow_shell = True
170
+ if args.allow_outside:
171
+ cfg.allow_outside = True
172
+ return cfg
173
+
174
+
175
+ def _read_task(args: argparse.Namespace, parser: argparse.ArgumentParser) -> str:
176
+ if args.prompt_file and args.task:
177
+ parser.error("Provide either a task or --prompt-file, not both.")
178
+ if args.prompt_file:
179
+ try:
180
+ task = Path(args.prompt_file).read_text(encoding="utf-8")
181
+ except OSError as exc:
182
+ parser.error(f"Could not read --prompt-file: {exc}")
183
+ elif args.task and args.task != ["-"]:
184
+ task = " ".join(args.task)
185
+ else:
186
+ if sys.stdin.isatty():
187
+ parser.error("Provide a task, use '-' to read stdin, or use --prompt-file.")
188
+ task = sys.stdin.read()
189
+ task = (task or "").strip()
190
+ if not task:
191
+ parser.error("The task is empty.")
192
+ return task
193
+
194
+
195
+ def _confirm(args: argparse.Namespace):
196
+ auto = {"value": bool(args.yes)}
197
+
198
+ def confirm(title: str, detail: str | None) -> bool:
199
+ if auto["value"]:
200
+ return True
201
+ if not sys.stdin.isatty():
202
+ raise ApprovalRequired(
203
+ f"{title} needs confirmation, but stdin is not interactive. Rerun with --yes."
204
+ )
205
+ print("", file=sys.stderr)
206
+ print(ui.style(title, "bold"), file=sys.stderr)
207
+ if detail:
208
+ looks_like_diff = detail.lstrip().startswith("---") or "\n+++" in detail[:200]
209
+ if looks_like_diff:
210
+ ui.print_diff(detail)
211
+ else:
212
+ for line in detail.splitlines():
213
+ print(ui.style(" " + line, "yellow"), file=sys.stderr)
214
+ print(
215
+ ui.style("Approve? [y]es / [n]o / [a]ll / [q]uit: ", "cyan"),
216
+ end="",
217
+ file=sys.stderr,
218
+ flush=True,
219
+ )
220
+ answer = (sys.stdin.readline() or "").strip().lower()
221
+ if answer in {"q", "quit"}:
222
+ raise KeyboardInterrupt
223
+ if answer in {"a", "all"}:
224
+ auto["value"] = True
225
+ return True
226
+ if answer in {"y", "yes"}:
227
+ return True
228
+ return False
229
+
230
+ return confirm
231
+
232
+
233
+ class _FinalStreamRenderer:
234
+ def __init__(self) -> None:
235
+ self._rendered = ""
236
+ self._started = False
237
+ self._finished = False
238
+ self._diverged = False
239
+
240
+ def update(self, text: str) -> None:
241
+ if self._finished or self._diverged or not text:
242
+ return
243
+ if text == self._rendered or self._rendered.startswith(text):
244
+ return
245
+ if not text.startswith(self._rendered):
246
+ self._diverged = True
247
+ return
248
+ if not self._started:
249
+ sys.stdout.write("agent> ")
250
+ self._started = True
251
+ sys.stdout.write(text[len(self._rendered):])
252
+ sys.stdout.flush()
253
+ self._rendered = text
254
+
255
+ def finish(self, final: str) -> None:
256
+ if self._finished:
257
+ return
258
+ self._finished = True
259
+ if self._started and not self._diverged and final.startswith(self._rendered):
260
+ sys.stdout.write(final[len(self._rendered):])
261
+ if not final.endswith("\n"):
262
+ sys.stdout.write("\n")
263
+ else:
264
+ if self._started:
265
+ sys.stdout.write("\n")
266
+ sys.stdout.write(f"agent> {final}")
267
+ if not final.endswith("\n"):
268
+ sys.stdout.write("\n")
269
+ sys.stdout.write("\n")
270
+ sys.stdout.flush()
271
+ self._rendered = final
272
+
273
+ def close_line(self) -> None:
274
+ if self._finished:
275
+ return
276
+ self._finished = True
277
+ if self._started:
278
+ sys.stdout.write("\n")
279
+ sys.stdout.flush()
280
+
281
+
282
+ def _new_session(cfg: Config, *, headless_override: bool | None = None) -> ChatSession:
283
+ return ChatSession(cfg, headless=headless_override)
284
+
285
+
286
+ def _ensure_ready(
287
+ session: ChatSession,
288
+ cfg: Config,
289
+ *,
290
+ allow_auto_login: bool,
291
+ ) -> ChatSession:
292
+ try:
293
+ if not cfg.headless:
294
+ session.wait_for_composer(cfg.login_timeout_sec, interactive=True)
295
+ else:
296
+ session.wait_for_composer(8, interactive=False)
297
+ return session
298
+ except (LoginRequired, HumanCheckRequired) as exc:
299
+ if session.import_chrome_login(30):
300
+ return session
301
+ if not allow_auto_login or not cfg.headless:
302
+ raise
303
+ log.warning(str(exc))
304
+ log.warning("Opening a browser window for a one-time login / human check...")
305
+ session.close()
306
+ headed = _new_session(cfg, headless_override=False)
307
+ headed.start()
308
+ headed.wait_for_composer(cfg.login_timeout_sec, interactive=True)
309
+ log.info("Login saved. Continuing in the visible browser (future runs can be headless).")
310
+ return headed
311
+
312
+
313
+ def cmd_login(args: argparse.Namespace) -> int:
314
+ ensure_config_file()
315
+ cfg = load_config()
316
+ cfg.headless = False
317
+ if args.timeout is not None:
318
+ cfg.login_timeout_sec = max(30, int(args.timeout))
319
+ session = _new_session(cfg, headless_override=False)
320
+ try:
321
+ session.login()
322
+ finally:
323
+ session.close()
324
+ print(f"Login profile: {cfg.profile_dir}")
325
+ return 0
326
+
327
+
328
+ def cmd_doctor(args: argparse.Namespace) -> int:
329
+ ensure_config_file()
330
+ cfg = load_config()
331
+ if args.headed:
332
+ cfg.headless = False
333
+ elif args.headless:
334
+ cfg.headless = True
335
+ session = _new_session(cfg)
336
+ try:
337
+ session.start()
338
+ if cfg.headless:
339
+ session.wait_for_composer(10, interactive=False)
340
+ else:
341
+ session.wait_for_composer(cfg.login_timeout_sec, interactive=True)
342
+ diag = session.diagnostics()
343
+ print(f"config: {config_path()}")
344
+ print(f"profile: {diag.get('profile')}")
345
+ print(f"chrome session: {diag.get('chrome_session') or '(not available)'}")
346
+ print(f"headless: {diag.get('headless')}")
347
+ print(f"url: {diag.get('url')}")
348
+ print(f"title: {diag.get('title')}")
349
+ print(f"composer: {diag.get('composerCount')} node(s), "
350
+ f"visible={diag.get('composerVisible')}")
351
+ print(f"send button: {diag.get('sendCount')} node(s), "
352
+ f"visible={diag.get('sendVisible')}, disabled={diag.get('sendDisabled')}")
353
+ print(f"assistant messages: {diag.get('assistantCount')}")
354
+ print(f"file input: {diag.get('fileInputCount')} node(s), "
355
+ f"{len((diag.get('accept') or '').split(','))} accepted token(s)")
356
+ print(f"cloudflare overlay: {diag.get('cloudflareVisible')}")
357
+ toggles = diag.get("toggles") or []
358
+ if toggles:
359
+ desc = ", ".join(f"{t['text']}={'on' if t['pressed'] else 'off'}" for t in toggles)
360
+ else:
361
+ desc = "(not found)"
362
+ print(f"toggles: {desc}")
363
+ labels = diag.get("newChatMatches") or []
364
+ print(f"new chat control: {', '.join(labels) if labels else '(not found)'}")
365
+ notices = diag.get("notificationText") or []
366
+ if notices:
367
+ print(f"notifications: {' | '.join(notices)}")
368
+ if args.send:
369
+ print("\nSending a connectivity test in a new chat...")
370
+ session.new_chat()
371
+ session.set_modes()
372
+ reply = session.send_and_wait(
373
+ "[CONNECTIVITY TEST] Reply with exactly one word: pong",
374
+ timeout_sec=max(60, int(args.timeout or 180)),
375
+ )
376
+ print(f"reply: {reply!r}")
377
+ return 0
378
+ finally:
379
+ session.close()
380
+
381
+
382
+ def cmd_config(args: argparse.Namespace) -> int:
383
+ path = ensure_config_file()
384
+ if args.path:
385
+ print(path)
386
+ return 0
387
+ if args.set:
388
+ cfg = load_config()
389
+ known = {f.name for f in fields(Config)}
390
+ for item in args.set:
391
+ if "=" not in item:
392
+ raise SystemExit(f"Expected KEY=VALUE, got: {item}")
393
+ key, raw = item.split("=", 1)
394
+ key = key.strip()
395
+ if key not in known:
396
+ raise SystemExit(f"Unknown config key: {key}")
397
+ if key == "selectors":
398
+ raise SystemExit("Edit the selectors object directly in the config JSON file.")
399
+ setattr(cfg, key, parse_scalar(raw))
400
+ cfg.save()
401
+ print(f"Saved {path}")
402
+ args.show = True
403
+ if args.show or not args.set:
404
+ cfg = load_config()
405
+ print(json.dumps(cfg.to_dict(), indent=2, ensure_ascii=False))
406
+ return 0
407
+
408
+
409
+ def _should_use_tui(args: argparse.Namespace) -> bool:
410
+ if getattr(args, "plain", False):
411
+ return False
412
+ if os.environ.get("DSCLI_PLAIN"):
413
+ return False
414
+ if os.environ.get("TERM", "").lower() == "dumb":
415
+ return False
416
+ try:
417
+ return sys.stdin.isatty() and sys.stdout.isatty()
418
+ except (AttributeError, ValueError):
419
+ return False
420
+
421
+
422
+ def _try_create_tui(args: argparse.Namespace, workspace: Path):
423
+ try:
424
+ from rich.logging import RichHandler
425
+
426
+ from .tui import Tui
427
+ except ImportError as exc:
428
+ log.warning(
429
+ "Interactive UI unavailable (%s); using plain mode. "
430
+ "Install the project dependencies to enable it.",
431
+ exc,
432
+ )
433
+ return None
434
+ try:
435
+ tui = Tui(
436
+ workspace=workspace,
437
+ auto_approve=bool(args.yes),
438
+ quiet=bool(args.quiet),
439
+ )
440
+ _setup_logging(
441
+ bool(args.verbose),
442
+ bool(args.quiet),
443
+ handler=RichHandler(
444
+ console=tui.console,
445
+ show_time=False,
446
+ show_path=False,
447
+ markup=False,
448
+ ),
449
+ )
450
+ return tui
451
+ except Exception as exc:
452
+ log.warning(
453
+ "Could not start the interactive UI (%s); using plain mode.",
454
+ exc,
455
+ )
456
+ return None
457
+
458
+
459
+ def _chat_loop_plain(
460
+ session: ChatSession,
461
+ toolbox: Toolbox,
462
+ cfg: Config,
463
+ args: argparse.Namespace,
464
+ workspace: Path,
465
+ ) -> int:
466
+ first_message = True
467
+ print("Interactive dscli. Type /help for commands, /exit to quit.")
468
+ while True:
469
+ try:
470
+ task = input("you> ").strip()
471
+ except EOFError:
472
+ print()
473
+ return 0
474
+ if not task:
475
+ continue
476
+ if task in {"/exit", "/quit"}:
477
+ return 0
478
+ if task == "/help":
479
+ print("/new Start a new conversation\n/exit Exit dscli (also /quit)")
480
+ continue
481
+ if task == "/new":
482
+ session.new_chat()
483
+ session.set_modes()
484
+ first_message = True
485
+ print("Started a new conversation.")
486
+ continue
487
+ if task.startswith("/"):
488
+ log.warning("Unknown command: %s. Type /help for available commands.", task)
489
+ continue
490
+
491
+ renderer = _FinalStreamRenderer()
492
+ try:
493
+ outcome = run_agent(
494
+ session=session,
495
+ config=cfg,
496
+ toolbox=toolbox,
497
+ task=task,
498
+ workspace=workspace,
499
+ attachments=[],
500
+ verbose=args.verbose,
501
+ quiet=args.quiet,
502
+ followup=not first_message,
503
+ on_final_snapshot=renderer.update,
504
+ )
505
+ except (ApprovalRequired, ProtocolError) as exc:
506
+ renderer.close_line()
507
+ first_message = False
508
+ log.error("Request error: %s", exc)
509
+ if toolbox.changed_files:
510
+ log.error("Files already changed: %s", ", ".join(toolbox.changed_files))
511
+ continue
512
+ except BaseException:
513
+ renderer.close_line()
514
+ raise
515
+ first_message = False
516
+ renderer.finish(outcome.final)
517
+ if outcome.limit_reached:
518
+ log.warning(
519
+ "Model-turn budget exhausted. Raise --max-steps or ask the agent to continue."
520
+ )
521
+
522
+
523
+ def _chat_loop_tui(
524
+ session: ChatSession,
525
+ toolbox: Toolbox,
526
+ cfg: Config,
527
+ args: argparse.Namespace,
528
+ workspace: Path,
529
+ tui,
530
+ ) -> int:
531
+ from .tui import TuiQuit
532
+
533
+ first_message = True
534
+ while True:
535
+ try:
536
+ task = tui.prompt().strip()
537
+ except EOFError:
538
+ return 0
539
+ except KeyboardInterrupt:
540
+ tui.system("Input cleared. Ctrl+D or /exit to quit.")
541
+ continue
542
+ if not task:
543
+ continue
544
+ if task in {"/exit", "/quit"}:
545
+ return 0
546
+ if task == "/help":
547
+ tui.print_help()
548
+ continue
549
+ if task == "/new":
550
+ session.new_chat()
551
+ session.set_modes()
552
+ first_message = True
553
+ tui.system("Started a new conversation.")
554
+ continue
555
+ if task.startswith("/"):
556
+ log.warning("Unknown command: %s. Type /help for available commands.", task)
557
+ continue
558
+
559
+ started = time.monotonic()
560
+ try:
561
+ outcome = run_agent(
562
+ session=session,
563
+ config=cfg,
564
+ toolbox=toolbox,
565
+ task=task,
566
+ workspace=workspace,
567
+ attachments=[],
568
+ verbose=args.verbose,
569
+ quiet=args.quiet,
570
+ followup=not first_message,
571
+ on_final_snapshot=tui.answer_snapshot,
572
+ display=tui,
573
+ )
574
+ except (ApprovalRequired, ProtocolError) as exc:
575
+ first_message = False
576
+ tui.turn_failed(str(exc), toolbox.changed_files)
577
+ continue
578
+ except KeyboardInterrupt as exc:
579
+ if isinstance(exc, TuiQuit):
580
+ tui.close()
581
+ raise
582
+ tui.interrupted()
583
+ stop = getattr(session, "stop_generation", None)
584
+ if callable(stop):
585
+ try:
586
+ stop()
587
+ except Exception:
588
+ pass
589
+ first_message = False
590
+ continue
591
+ except BaseException:
592
+ tui.close()
593
+ raise
594
+ first_message = False
595
+ tui.complete(outcome, time.monotonic() - started)
596
+ if outcome.limit_reached:
597
+ log.warning(
598
+ "Model-turn budget exhausted. Raise --max-steps or ask the agent to continue."
599
+ )
600
+
601
+
602
+ def cmd_chat(args: argparse.Namespace) -> int:
603
+ ensure_config_file()
604
+ cfg = _apply_run_config(load_config(), args)
605
+ workspace = Path(args.workspace).expanduser() if args.workspace else Path.cwd()
606
+ try:
607
+ workspace = workspace.resolve(strict=True)
608
+ except FileNotFoundError:
609
+ log.error("Workspace does not exist: %s", workspace)
610
+ return 1
611
+ if not workspace.is_dir():
612
+ log.error("Workspace is not a directory: %s", workspace)
613
+ return 1
614
+
615
+ tui = _try_create_tui(args, workspace) if _should_use_tui(args) else None
616
+ toolbox = Toolbox(
617
+ workspace,
618
+ allow_outside=cfg.allow_outside,
619
+ allow_shell=cfg.allow_shell,
620
+ confirm=tui.confirm if tui is not None else _confirm(args),
621
+ max_read_chars=cfg.max_read_chars,
622
+ max_write_chars=cfg.max_write_chars,
623
+ max_output_chars=cfg.max_tool_output_chars,
624
+ shell_timeout_sec=cfg.shell_timeout_sec,
625
+ )
626
+ session: ChatSession | None = None
627
+ try:
628
+ session = _new_session(cfg)
629
+ if tui is not None:
630
+ tui.start_status("Starting agent")
631
+ session.start()
632
+ if tui is not None:
633
+ tui.update_status("Getting ready")
634
+ session = _ensure_ready(
635
+ session,
636
+ cfg,
637
+ allow_auto_login=not args.no_auto_login,
638
+ )
639
+ if tui is not None:
640
+ tui.start_status("Getting ready")
641
+ session.new_chat()
642
+ session.set_modes()
643
+ if tui is not None:
644
+ tui.stop_status()
645
+ tui.banner(cfg)
646
+ return _chat_loop_tui(session, toolbox, cfg, args, workspace, tui)
647
+ return _chat_loop_plain(session, toolbox, cfg, args, workspace)
648
+ except ChatError as exc:
649
+ log.error("Chat error: %s", exc)
650
+ if exc.screenshot:
651
+ log.error("Screenshot: %s", exc.screenshot)
652
+ if toolbox.changed_files:
653
+ log.error("Files changed before the error: %s", ", ".join(toolbox.changed_files))
654
+ return 1
655
+ except (LoginRequired, HumanCheckRequired, BrowserError, UploadError) as exc:
656
+ log.error(str(exc))
657
+ return 1
658
+ except PlaywrightError as exc:
659
+ log.error("Browser automation failed: %s", exc)
660
+ log.error("Run `dscli doctor --headed` to inspect the page and selectors.")
661
+ return 1
662
+ except KeyboardInterrupt:
663
+ log.error("Interrupted.")
664
+ return 130
665
+ except Exception as exc:
666
+ if args.verbose:
667
+ log.exception("Unexpected error")
668
+ else:
669
+ log.error("Unexpected error: %s: %s", type(exc).__name__, exc)
670
+ return 1
671
+ finally:
672
+ if tui is not None:
673
+ tui.close()
674
+ if session is not None:
675
+ session.close()
676
+
677
+
678
+ def cmd_run(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
679
+ task = _read_task(args, parser)
680
+ ensure_config_file()
681
+ cfg = load_config()
682
+ cfg = _apply_run_config(cfg, args)
683
+
684
+ workspace = Path(args.workspace).expanduser() if args.workspace else Path.cwd()
685
+ try:
686
+ workspace = workspace.resolve(strict=True)
687
+ except FileNotFoundError:
688
+ log.error("Workspace does not exist: %s", workspace)
689
+ return 1
690
+ if not workspace.is_dir():
691
+ log.error("Workspace is not a directory: %s", workspace)
692
+ return 1
693
+
694
+ try:
695
+ attachments = collect_attachments(
696
+ args.file,
697
+ workspace,
698
+ max_count=cfg.max_attachments,
699
+ max_mb=cfg.max_attachment_mb,
700
+ )
701
+ except AttachmentError as exc:
702
+ log.error("Attachment error: %s", exc)
703
+ return 1
704
+
705
+ if args.dry_run:
706
+ prompt = build_initial_prompt(
707
+ task=task,
708
+ workspace=workspace,
709
+ attachments=attachments,
710
+ workspace_tree=workspace_preview(workspace),
711
+ allow_shell=cfg.allow_shell,
712
+ allow_outside=cfg.allow_outside,
713
+ max_steps=cfg.max_steps,
714
+ )
715
+ if not args.quiet:
716
+ log.info(
717
+ "Dry run: %d attachment(s), workspace %s, %d prompt characters.",
718
+ len(attachments),
719
+ workspace,
720
+ len(prompt),
721
+ )
722
+ print(prompt)
723
+ return 0
724
+
725
+ confirm = _confirm(args)
726
+ toolbox = Toolbox(
727
+ workspace,
728
+ allow_outside=cfg.allow_outside,
729
+ allow_shell=cfg.allow_shell,
730
+ confirm=confirm,
731
+ max_read_chars=cfg.max_read_chars,
732
+ max_write_chars=cfg.max_write_chars,
733
+ max_output_chars=cfg.max_tool_output_chars,
734
+ shell_timeout_sec=cfg.shell_timeout_sec,
735
+ )
736
+
737
+ session: ChatSession | None = None
738
+ outcome = None
739
+ try:
740
+ session = _new_session(cfg)
741
+ session.start()
742
+ session = _ensure_ready(
743
+ session, cfg, allow_auto_login=not args.no_auto_login
744
+ )
745
+ session.new_chat()
746
+ session.set_modes()
747
+ outcome = run_agent(
748
+ session=session,
749
+ config=cfg,
750
+ toolbox=toolbox,
751
+ task=task,
752
+ workspace=workspace,
753
+ attachments=attachments,
754
+ verbose=args.verbose,
755
+ quiet=args.quiet,
756
+ )
757
+ except ApprovalRequired as exc:
758
+ log.error("Approval required: %s", exc)
759
+ if toolbox.changed_files:
760
+ log.error("Files already changed: %s", ", ".join(toolbox.changed_files))
761
+ return 1
762
+ except ProtocolError as exc:
763
+ log.error("Model protocol error: %s", exc)
764
+ if toolbox.changed_files:
765
+ log.error("Files changed before the error: %s", ", ".join(toolbox.changed_files))
766
+ return 1
767
+ except ChatError as exc:
768
+ log.error("Chat error: %s", exc)
769
+ if exc.screenshot:
770
+ log.error("Screenshot: %s", exc.screenshot)
771
+ if toolbox.changed_files:
772
+ log.error("Files changed before the error: %s", ", ".join(toolbox.changed_files))
773
+ return 1
774
+ except (LoginRequired, HumanCheckRequired, BrowserError, UploadError) as exc:
775
+ log.error(str(exc))
776
+ return 1
777
+ except PlaywrightError as exc:
778
+ log.error("Browser automation failed: %s", exc)
779
+ log.error("Run `dscli doctor --headed` to inspect the page and selectors.")
780
+ return 1
781
+ except KeyboardInterrupt:
782
+ log.error("Interrupted.")
783
+ return 130
784
+ except Exception as exc:
785
+ if args.verbose:
786
+ log.exception("Unexpected error")
787
+ else:
788
+ log.error("Unexpected error: %s: %s", type(exc).__name__, exc)
789
+ return 1
790
+ finally:
791
+ if session is not None:
792
+ session.close()
793
+
794
+ if outcome is None:
795
+ return 1
796
+ final = outcome.final if outcome.final.endswith("\n") else outcome.final + "\n"
797
+ sys.stdout.write(final)
798
+ sys.stdout.flush()
799
+ if outcome.limit_reached:
800
+ log.warning("Stopped after the model-turn budget was exhausted. Raise --max-steps to continue.")
801
+ return 2
802
+ return 0
803
+
804
+
805
+ def main(argv: list[str] | None = None) -> int:
806
+ _utf8_stdio()
807
+ arguments = sys.argv[1:] if argv is None else argv
808
+ if not arguments:
809
+ arguments = ["chat"]
810
+ parser = build_parser()
811
+ args = parser.parse_args(arguments)
812
+ _setup_logging(bool(getattr(args, "verbose", False)), bool(getattr(args, "quiet", False)))
813
+ try:
814
+ if args.command == "login":
815
+ return cmd_login(args)
816
+ if args.command == "doctor":
817
+ return cmd_doctor(args)
818
+ if args.command == "config":
819
+ return cmd_config(args)
820
+ if args.command == "chat":
821
+ return cmd_chat(args)
822
+ if args.command == "run":
823
+ return cmd_run(args, parser)
824
+ parser.error(f"Unknown command: {args.command}")
825
+ return 2
826
+ except KeyboardInterrupt:
827
+ log.error("Interrupted.")
828
+ return 130
829
+ except (ApprovalRequired, BrowserError, ChatError, LoginRequired, HumanCheckRequired, UploadError) as exc:
830
+ log.error(str(exc))
831
+ return 1
832
+ except (AttachmentError, ValueError) as exc:
833
+ log.error(str(exc))
834
+ return 1
835
+ except PlaywrightError as exc:
836
+ log.error("Browser automation failed: %s", exc)
837
+ log.error("Run `dscli doctor --headed` to inspect the page and selectors.")
838
+ return 1
839
+
840
+
841
+ if __name__ == "__main__":
842
+ raise SystemExit(main())