dscli-agent 0.3.0__tar.gz → 0.4.0__tar.gz

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.
Files changed (40) hide show
  1. {dscli_agent-0.3.0 → dscli_agent-0.4.0}/PKG-INFO +28 -13
  2. {dscli_agent-0.3.0 → dscli_agent-0.4.0}/README.md +27 -12
  3. {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli/__init__.py +1 -1
  4. {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli/agent.py +3 -3
  5. {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli/cli.py +11 -7
  6. {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli/compaction.py +4 -1
  7. {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli/config.py +2 -1
  8. {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli/display.py +3 -1
  9. {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli/prompts.py +7 -7
  10. {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli/tools.py +135 -24
  11. {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli/tui.py +4 -3
  12. {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli_agent.egg-info/PKG-INFO +28 -13
  13. {dscli_agent-0.3.0 → dscli_agent-0.4.0}/tests/test_agent.py +1 -1
  14. {dscli_agent-0.3.0 → dscli_agent-0.4.0}/tests/test_cli.py +26 -0
  15. {dscli_agent-0.3.0 → dscli_agent-0.4.0}/tests/test_config.py +20 -0
  16. {dscli_agent-0.3.0 → dscli_agent-0.4.0}/tests/test_tools.py +101 -2
  17. {dscli_agent-0.3.0 → dscli_agent-0.4.0}/tests/test_tui.py +8 -1
  18. {dscli_agent-0.3.0 → dscli_agent-0.4.0}/LICENSE +0 -0
  19. {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli/__main__.py +0 -0
  20. {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli/attachments.py +0 -0
  21. {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli/branding.py +0 -0
  22. {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli/browser.py +0 -0
  23. {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli/protocol.py +0 -0
  24. {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli/providers.py +0 -0
  25. {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli/scraper.py +0 -0
  26. {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli/tokens.py +0 -0
  27. {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli/ui.py +0 -0
  28. {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli_agent.egg-info/SOURCES.txt +0 -0
  29. {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli_agent.egg-info/dependency_links.txt +0 -0
  30. {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli_agent.egg-info/entry_points.txt +0 -0
  31. {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli_agent.egg-info/requires.txt +0 -0
  32. {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli_agent.egg-info/top_level.txt +0 -0
  33. {dscli_agent-0.3.0 → dscli_agent-0.4.0}/pyproject.toml +0 -0
  34. {dscli_agent-0.3.0 → dscli_agent-0.4.0}/setup.cfg +0 -0
  35. {dscli_agent-0.3.0 → dscli_agent-0.4.0}/tests/test_branding.py +0 -0
  36. {dscli_agent-0.3.0 → dscli_agent-0.4.0}/tests/test_browser.py +0 -0
  37. {dscli_agent-0.3.0 → dscli_agent-0.4.0}/tests/test_compaction.py +0 -0
  38. {dscli_agent-0.3.0 → dscli_agent-0.4.0}/tests/test_display.py +0 -0
  39. {dscli_agent-0.3.0 → dscli_agent-0.4.0}/tests/test_protocol.py +0 -0
  40. {dscli_agent-0.3.0 → dscli_agent-0.4.0}/tests/test_tokens.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dscli-agent
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: A local coding agent that uses DeepSeek web chat as its model
5
5
  Author: Anish
6
6
  License-Expression: MIT
@@ -262,8 +262,11 @@ dscli run "Refactor the parser" -f src/parser.py -f tests/test_parser.py
262
262
  # Globs work too; quote them so the shell does not expand them
263
263
  dscli run "Fix the bug" -f "src/**/*.py" --workspace C:\projects\app
264
264
 
265
- # Read a long task from a file; auto-approve edits; allow shell commands
266
- dscli run --prompt-file task.md -y --allow-shell
265
+ # Read a long task from a file; auto-approve edits and approved shell commands
266
+ dscli run --prompt-file task.md -y
267
+
268
+ # Disable command execution entirely for this run
269
+ dscli run "Review the docs" --no-shell
267
270
  ```
268
271
 
269
272
  The final answer is printed to stdout. Progress, diffs, and approval prompts go to
@@ -281,7 +284,7 @@ failures and unconfirmed completions are always logged to stderr, including unde
281
284
  | `-f, --file PATH` | Attach a file (repeatable, globs supported). |
282
285
  | `--workspace PATH` | Root the tools may touch. Defaults to the current directory. |
283
286
  | `-y, --yes` | Approve every write/patch/command without asking. |
284
- | `--allow-shell` | Enable `run_command` (still approved per command). |
287
+ | `--no-shell` | Disable `run_command` for this run (commands are otherwise enabled and approved per command). |
285
288
  | `--allow-outside` | Let tools read/write outside the workspace. |
286
289
  | `--headed` / `--headless` | Show or hide the browser. Headless is the default. |
287
290
  | `--search` | Turn DeepSeek's Search mode on for the run (DeepThink is always on). |
@@ -355,12 +358,19 @@ creep and unnecessary abstractions, confirm hard-to-reverse actions, reference c
355
358
  | `apply_patch` | The only way to edit an existing file: exact `search` → `replace`. |
356
359
  | `list_files` | Bounded recursive listing. |
357
360
  | `delete_file` | Delete one file when explicitly requested (approval required). |
358
- | `run_command` | Run a shell command (only with `--allow-shell`, approval required). |
361
+ | `run_command` | Run a command with an approval prompt via PowerShell, cmd.exe, sh, or bash. |
359
362
  | `update_plan` | Create or revise the task plan; the task cannot finish while steps are unfinished. |
360
363
 
361
364
  `apply_patch` normalises line endings for matching, preserves the file's encoding and
362
365
  line endings when writing, and refuses ambiguous matches unless `replace_all` is set.
363
366
 
367
+ `run_command` uses `shell: auto` by default: PowerShell when available on Windows
368
+ (cmd.exe otherwise), and `/bin/sh` elsewhere. Pass `"shell": "cmd"` for cmd.exe syntax
369
+ such as `%VAR%` or batch builtins, `"powershell"`, `"sh"`, or `"bash"` to choose another
370
+ interpreter. On POSIX systems, `auto` falls back to `/bin/sh`; `bash` is selected only
371
+ when explicitly requested. The approval prompt names the resolved interpreter, so a
372
+ command is never run under a shell you did not see.
373
+
364
374
  ### Plan and verification loop
365
375
 
366
376
  The agent can maintain a short task plan with `update_plan` (at most 20 steps). The run
@@ -386,7 +396,7 @@ message changed files. A verified result is still only as trustworthy as the com
386
396
  configured.
387
397
 
388
398
  The verification command is supplied by you and is never a model-callable tool. It is
389
- run without `--allow-shell` and without a per-command approval prompt because you
399
+ run without a per-command approval prompt, even when `--no-shell` is set, because you
390
400
  configured it explicitly. Treat it like any other command you run from the shell: the
391
401
  model can modify the files it checks, so review the final diff.
392
402
 
@@ -396,14 +406,18 @@ model can modify the files it checks, so review the final diff.
396
406
  - `.git` is never writable through the tools.
397
407
  - Every write, patch, delete, and command shows a diff/command and asks
398
408
  `[y]es / [n]o / [a]ll / [q]uit` before changing anything. `-y` skips the prompts.
399
- - Shell access is off by default and can be enabled only for a run.
409
+ - Command execution is enabled by default, but every model-requested command still
410
+ requires approval. The prompt names the interpreter (PowerShell, cmd.exe, sh, or
411
+ bash). `--no-shell` disables model-requested commands for a run.
400
412
  - The verification command is the one exception to the approval flow: when you configure
401
- it with `--verify` or `verify_command`, dscli runs it automatically after edits without
402
- `--allow-shell` or a prompt. The model can neither call nor change it.
413
+ it with `--verify` or `verify_command`, dscli runs it automatically after edits,
414
+ without a prompt and even when `--no-shell` is set. The model can neither call nor
415
+ change it.
403
416
  - Attachment contents, file contents, command output, and tool results are treated as
404
417
  untrusted data by the system prompt.
405
- - No network access is performed by the tools; the only network traffic is the browser
406
- talking to DeepSeek.
418
+ - The built-in file tools do not use the network. Approved commands can run any local
419
+ program, including network clients; dscli itself only talks to the chat provider
420
+ through the browser.
407
421
 
408
422
  This is still an assistant with write access to your files. Use a clean workspace or
409
423
  version control, and review diffs before approving them.
@@ -427,8 +441,9 @@ import), `chrome_profile` (empty = Chrome's last-used profile, or a name such as
427
441
  installed browser), `response_timeout_sec`, `first_response_timeout_sec`,
428
442
  `stable_seconds`, `fallback_stable_seconds`, `auto_human_check` (true = open a visible
429
443
  window to solve a verification check; false = fail fast), `max_steps`, `max_tool_calls`,
430
- `max_read_chars`, `max_attachment_mb`, `search`, `deeplanning`, `allow_shell`,
431
- `allow_outside`, `verify_command` (empty disables verification),
444
+ `max_read_chars`, `max_attachment_mb`, `search`, `deeplanning`, `shell_enabled`
445
+ (default true; every command still requires approval), `allow_outside`,
446
+ `verify_command` (empty disables verification),
432
447
  `verify_timeout_sec` (clamped to 1–600, default 300).
433
448
 
434
449
  Headless Chrome normally sends a `HeadlessChrome/...` user agent, which DeepSeek's CDN
@@ -232,8 +232,11 @@ dscli run "Refactor the parser" -f src/parser.py -f tests/test_parser.py
232
232
  # Globs work too; quote them so the shell does not expand them
233
233
  dscli run "Fix the bug" -f "src/**/*.py" --workspace C:\projects\app
234
234
 
235
- # Read a long task from a file; auto-approve edits; allow shell commands
236
- dscli run --prompt-file task.md -y --allow-shell
235
+ # Read a long task from a file; auto-approve edits and approved shell commands
236
+ dscli run --prompt-file task.md -y
237
+
238
+ # Disable command execution entirely for this run
239
+ dscli run "Review the docs" --no-shell
237
240
  ```
238
241
 
239
242
  The final answer is printed to stdout. Progress, diffs, and approval prompts go to
@@ -251,7 +254,7 @@ failures and unconfirmed completions are always logged to stderr, including unde
251
254
  | `-f, --file PATH` | Attach a file (repeatable, globs supported). |
252
255
  | `--workspace PATH` | Root the tools may touch. Defaults to the current directory. |
253
256
  | `-y, --yes` | Approve every write/patch/command without asking. |
254
- | `--allow-shell` | Enable `run_command` (still approved per command). |
257
+ | `--no-shell` | Disable `run_command` for this run (commands are otherwise enabled and approved per command). |
255
258
  | `--allow-outside` | Let tools read/write outside the workspace. |
256
259
  | `--headed` / `--headless` | Show or hide the browser. Headless is the default. |
257
260
  | `--search` | Turn DeepSeek's Search mode on for the run (DeepThink is always on). |
@@ -325,12 +328,19 @@ creep and unnecessary abstractions, confirm hard-to-reverse actions, reference c
325
328
  | `apply_patch` | The only way to edit an existing file: exact `search` → `replace`. |
326
329
  | `list_files` | Bounded recursive listing. |
327
330
  | `delete_file` | Delete one file when explicitly requested (approval required). |
328
- | `run_command` | Run a shell command (only with `--allow-shell`, approval required). |
331
+ | `run_command` | Run a command with an approval prompt via PowerShell, cmd.exe, sh, or bash. |
329
332
  | `update_plan` | Create or revise the task plan; the task cannot finish while steps are unfinished. |
330
333
 
331
334
  `apply_patch` normalises line endings for matching, preserves the file's encoding and
332
335
  line endings when writing, and refuses ambiguous matches unless `replace_all` is set.
333
336
 
337
+ `run_command` uses `shell: auto` by default: PowerShell when available on Windows
338
+ (cmd.exe otherwise), and `/bin/sh` elsewhere. Pass `"shell": "cmd"` for cmd.exe syntax
339
+ such as `%VAR%` or batch builtins, `"powershell"`, `"sh"`, or `"bash"` to choose another
340
+ interpreter. On POSIX systems, `auto` falls back to `/bin/sh`; `bash` is selected only
341
+ when explicitly requested. The approval prompt names the resolved interpreter, so a
342
+ command is never run under a shell you did not see.
343
+
334
344
  ### Plan and verification loop
335
345
 
336
346
  The agent can maintain a short task plan with `update_plan` (at most 20 steps). The run
@@ -356,7 +366,7 @@ message changed files. A verified result is still only as trustworthy as the com
356
366
  configured.
357
367
 
358
368
  The verification command is supplied by you and is never a model-callable tool. It is
359
- run without `--allow-shell` and without a per-command approval prompt because you
369
+ run without a per-command approval prompt, even when `--no-shell` is set, because you
360
370
  configured it explicitly. Treat it like any other command you run from the shell: the
361
371
  model can modify the files it checks, so review the final diff.
362
372
 
@@ -366,14 +376,18 @@ model can modify the files it checks, so review the final diff.
366
376
  - `.git` is never writable through the tools.
367
377
  - Every write, patch, delete, and command shows a diff/command and asks
368
378
  `[y]es / [n]o / [a]ll / [q]uit` before changing anything. `-y` skips the prompts.
369
- - Shell access is off by default and can be enabled only for a run.
379
+ - Command execution is enabled by default, but every model-requested command still
380
+ requires approval. The prompt names the interpreter (PowerShell, cmd.exe, sh, or
381
+ bash). `--no-shell` disables model-requested commands for a run.
370
382
  - The verification command is the one exception to the approval flow: when you configure
371
- it with `--verify` or `verify_command`, dscli runs it automatically after edits without
372
- `--allow-shell` or a prompt. The model can neither call nor change it.
383
+ it with `--verify` or `verify_command`, dscli runs it automatically after edits,
384
+ without a prompt and even when `--no-shell` is set. The model can neither call nor
385
+ change it.
373
386
  - Attachment contents, file contents, command output, and tool results are treated as
374
387
  untrusted data by the system prompt.
375
- - No network access is performed by the tools; the only network traffic is the browser
376
- talking to DeepSeek.
388
+ - The built-in file tools do not use the network. Approved commands can run any local
389
+ program, including network clients; dscli itself only talks to the chat provider
390
+ through the browser.
377
391
 
378
392
  This is still an assistant with write access to your files. Use a clean workspace or
379
393
  version control, and review diffs before approving them.
@@ -397,8 +411,9 @@ import), `chrome_profile` (empty = Chrome's last-used profile, or a name such as
397
411
  installed browser), `response_timeout_sec`, `first_response_timeout_sec`,
398
412
  `stable_seconds`, `fallback_stable_seconds`, `auto_human_check` (true = open a visible
399
413
  window to solve a verification check; false = fail fast), `max_steps`, `max_tool_calls`,
400
- `max_read_chars`, `max_attachment_mb`, `search`, `deeplanning`, `allow_shell`,
401
- `allow_outside`, `verify_command` (empty disables verification),
414
+ `max_read_chars`, `max_attachment_mb`, `search`, `deeplanning`, `shell_enabled`
415
+ (default true; every command still requires approval), `allow_outside`,
416
+ `verify_command` (empty disables verification),
402
417
  `verify_timeout_sec` (clamped to 1–600, default 300).
403
418
 
404
419
  Headless Chrome normally sends a `HeadlessChrome/...` user agent, which DeepSeek's CDN
@@ -1,3 +1,3 @@
1
1
  """dscli - a local coding agent for your terminal."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.4.0"
@@ -182,7 +182,7 @@ class Agent:
182
182
  live_plan=self.toolbox.plan,
183
183
  changed=self.toolbox.changed_files,
184
184
  attachments=self.attachments,
185
- allow_shell=self.cfg.allow_shell,
185
+ allow_shell=self.cfg.shell_enabled,
186
186
  allow_outside=self.cfg.allow_outside,
187
187
  max_steps=self.cfg.max_steps,
188
188
  verify_command=self._verify_command,
@@ -277,7 +277,7 @@ class Agent:
277
277
  workspace=self.workspace,
278
278
  attachments=self.attachments,
279
279
  workspace_tree=workspace_preview(self.workspace),
280
- allow_shell=self.cfg.allow_shell,
280
+ allow_shell=self.cfg.shell_enabled,
281
281
  allow_outside=self.cfg.allow_outside,
282
282
  max_steps=self.cfg.max_steps,
283
283
  verify_command=self._verify_command,
@@ -468,7 +468,7 @@ class Agent:
468
468
  step=turn,
469
469
  max_steps=self.cfg.max_steps,
470
470
  changed=self.toolbox.changed_files,
471
- allow_shell=self.cfg.allow_shell,
471
+ allow_shell=self.cfg.shell_enabled,
472
472
  per_result_chars=self.cfg.max_tool_output_chars,
473
473
  total_chars=self.cfg.max_turn_output_chars,
474
474
  notice=notice,
@@ -97,8 +97,12 @@ def _add_agent_flags(parser: argparse.ArgumentParser) -> None:
97
97
  help="Workspace root (default: current directory).")
98
98
  parser.add_argument("-y", "--yes", action="store_true",
99
99
  help="Approve file writes and commands automatically.")
100
- parser.add_argument("--allow-shell", action="store_true",
101
- help="Allow run_command (still approved per command).")
100
+ shell = parser.add_mutually_exclusive_group()
101
+ shell.add_argument("--allow-shell", dest="allow_shell", action="store_true",
102
+ default=None,
103
+ help="Enable run_command with per-command approval (default).")
104
+ shell.add_argument("--no-shell", dest="allow_shell", action="store_false",
105
+ help="Disable command execution for this run.")
102
106
  parser.add_argument("--allow-outside", action="store_true",
103
107
  help="Allow tools to access paths outside the workspace.")
104
108
  parser.add_argument("--max-steps", type=int, default=None,
@@ -198,8 +202,8 @@ def _apply_run_config(cfg: Config, args: argparse.Namespace) -> Config:
198
202
  cfg.search = args.search
199
203
  if args.deeplanning is not None:
200
204
  cfg.deeplanning = args.deeplanning
201
- if args.allow_shell:
202
- cfg.allow_shell = True
205
+ if args.allow_shell is not None:
206
+ cfg.shell_enabled = args.allow_shell
203
207
  if args.allow_outside:
204
208
  cfg.allow_outside = True
205
209
  if getattr(args, "verify", None) is not None:
@@ -697,7 +701,7 @@ def cmd_chat(args: argparse.Namespace) -> int:
697
701
  toolbox = Toolbox(
698
702
  workspace,
699
703
  allow_outside=cfg.allow_outside,
700
- allow_shell=cfg.allow_shell,
704
+ allow_shell=cfg.shell_enabled,
701
705
  confirm=tui.confirm if tui is not None else _confirm(args),
702
706
  max_read_chars=cfg.max_read_chars,
703
707
  max_write_chars=cfg.max_write_chars,
@@ -809,7 +813,7 @@ def cmd_run(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
809
813
  workspace=workspace,
810
814
  attachments=attachments,
811
815
  workspace_tree=workspace_preview(workspace),
812
- allow_shell=cfg.allow_shell,
816
+ allow_shell=cfg.shell_enabled,
813
817
  allow_outside=cfg.allow_outside,
814
818
  max_steps=cfg.max_steps,
815
819
  verify_command=cfg.verification_command(),
@@ -830,7 +834,7 @@ def cmd_run(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
830
834
  toolbox = Toolbox(
831
835
  workspace,
832
836
  allow_outside=cfg.allow_outside,
833
- allow_shell=cfg.allow_shell,
837
+ allow_shell=cfg.shell_enabled,
834
838
  confirm=confirm,
835
839
  max_read_chars=cfg.max_read_chars,
836
840
  max_write_chars=cfg.max_write_chars,
@@ -66,7 +66,10 @@ class ConversationLog:
66
66
  if args.get("path") is not None:
67
67
  detail = str(args["path"])
68
68
  elif result.name == "run_command":
69
- detail = _clip(str(args.get("command", "")), 120).replace("\n", " ")
69
+ shell = str(args.get("shell") or "auto")
70
+ detail = f"{shell}: " + _clip(
71
+ str(args.get("command", "")), 120
72
+ ).replace("\n", " ")
70
73
  elif result.name == "update_plan":
71
74
  steps = args.get("steps")
72
75
  count = len(steps) if isinstance(steps, list) else 0
@@ -84,7 +84,8 @@ class Config:
84
84
  search: bool = False
85
85
  # Require a non-empty update_plan before any mutating tool may run.
86
86
  deeplanning: bool = False
87
- allow_shell: bool = False
87
+ # Commands are available by default, but every one requires approval.
88
+ shell_enabled: bool = True
88
89
  allow_outside: bool = False
89
90
  shell_timeout_sec: int = 120
90
91
 
@@ -13,7 +13,9 @@ def call_label(call: ToolCall) -> str:
13
13
  args = call.arguments or {}
14
14
  path = args.get("path")
15
15
  if call.name == "run_command":
16
- command = str(args.get("command", ""))
16
+ shell = str(args.get("shell") or "").strip()
17
+ prefix = f"[{shell}] " if shell and shell.lower() != "auto" else ""
18
+ command = prefix + str(args.get("command", ""))
17
19
  if len(command) > 90:
18
20
  command = command[:87] + "..."
19
21
  return f"run_command {command}"
@@ -43,8 +43,8 @@ TOOLS
43
43
  List files and directories. Depth defaults to 2 and is capped at 6. Large/ignored directories are skipped and very large listings are truncated.
44
44
  - delete_file {"path": "relative/path"}
45
45
  Delete one existing file. Only use this when the user explicitly asks for that file to be deleted. Never delete a file to replace, rewrite, or recreate it, and never delete it because apply_patch failed.
46
- - run_command {"command": "a shell command", "timeout_sec": 120}
47
- Run a command in the workspace root. Only available if the environment block says shell is enabled. It is still subject to user approval. timeout_sec is capped at 600 seconds, and very long output is truncated.
46
+ - run_command {"command": "a shell command", "shell": "auto", "timeout_sec": 120}
47
+ Run a command in the workspace root after the user approves it. shell is one of auto, powershell, cmd, sh, or bash. auto uses PowerShell on Windows and /bin/sh elsewhere; pass shell="cmd" for cmd.exe syntax on Windows, or shell="bash" for bash. Every command shows its interpreter and text to the user for approval; if the user denies it, do not retry or work around the denial. Command execution is available unless the environment block says shell is disabled. timeout_sec is capped at 600 seconds, and very long output is truncated.
48
48
  - update_plan {"steps": [{"text": "step description", "status": "pending"}]}
49
49
  Replace the task plan. Valid statuses are pending, in_progress, and completed. The plan belongs to the current user message and starts empty for each new request. Create a short plan before multi-step work and update the statuses as you make progress. Plans are capped at __MAX_PLAN_STEPS__ steps of at most __MAX_PLAN_STEP_CHARS__ characters each. The task cannot finish while any step is pending or in progress. Pass an empty list to clear the plan only when the work is abandoned or the plan no longer matches the task.
50
50
 
@@ -148,11 +148,11 @@ def _recap(task: str, limit: int = 1800) -> str:
148
148
 
149
149
 
150
150
  def _shell_description(allow_shell: bool) -> str:
151
+ if not allow_shell:
152
+ return "disabled for this run"
151
153
  if platform.system() == "Windows":
152
- shell = "PowerShell" if allow_shell else "PowerShell (disabled for this run)"
153
- else:
154
- shell = "/bin/sh" if allow_shell else "/bin/sh (disabled for this run)"
155
- return shell
154
+ return "auto (PowerShell if installed, otherwise cmd.exe; shell='cmd' forces cmd.exe)"
155
+ return "auto (/bin/sh by default; shell='bash' selects bash)"
156
156
 
157
157
 
158
158
  def _environment_lines(
@@ -423,7 +423,7 @@ def _args_summary(result: ToolResult) -> str:
423
423
  count = len(steps) if isinstance(steps, list) else 0
424
424
  return json.dumps({"steps": count}, ensure_ascii=False)
425
425
  summary: dict[str, object] = {}
426
- for key in ("path", "start_line", "end_line", "depth", "command", "timeout_sec", "replace_all"):
426
+ for key in ("path", "start_line", "end_line", "depth", "command", "shell", "timeout_sec", "replace_all"):
427
427
  if key in args:
428
428
  summary[key] = args[key]
429
429
  if "content" in args and isinstance(args["content"], str):
@@ -35,6 +35,20 @@ MAX_PLAN_STEPS = 20
35
35
  MAX_PLAN_STEP_CHARS = 300
36
36
  PLAN_STATUSES = {"pending", "in_progress", "completed"}
37
37
 
38
+ SHELL_CHOICES = ("auto", "powershell", "cmd", "sh", "bash")
39
+ _SHELL_ALIASES = {
40
+ "auto": "auto",
41
+ "default": "auto",
42
+ "shell": "auto",
43
+ "powershell": "powershell",
44
+ "pwsh": "powershell",
45
+ "ps": "powershell",
46
+ "cmd": "cmd",
47
+ "command_prompt": "cmd",
48
+ "sh": "sh",
49
+ "bash": "bash",
50
+ }
51
+
38
52
  SKIP_DIRS = {
39
53
  ".git",
40
54
  ".hg",
@@ -151,6 +165,27 @@ def _decode(raw: bytes) -> str:
151
165
  return raw.decode("utf-8", errors="replace")
152
166
 
153
167
 
168
+ def _normalize_shell(value: Any) -> str:
169
+ if value is None:
170
+ return "auto"
171
+ if not isinstance(value, str):
172
+ raise ToolError(
173
+ f"shell must be a string, got {type(value).__name__}."
174
+ )
175
+ cleaned = value.strip().lower()
176
+ if not cleaned:
177
+ return "auto"
178
+ cleaned = cleaned.rsplit("\\", 1)[-1].rsplit("/", 1)[-1]
179
+ if cleaned.endswith(".exe"):
180
+ cleaned = cleaned[:-4]
181
+ normalized = _SHELL_ALIASES.get(cleaned)
182
+ if normalized is None:
183
+ raise ToolError(
184
+ f"Unknown shell '{value}'. Use one of: {', '.join(SHELL_CHOICES)}."
185
+ )
186
+ return normalized
187
+
188
+
154
189
  def _rel(path: Path, root: Path) -> str:
155
190
  try:
156
191
  return path.relative_to(root).as_posix()
@@ -235,7 +270,7 @@ class Toolbox:
235
270
  root: Path,
236
271
  *,
237
272
  allow_outside: bool = False,
238
- allow_shell: bool = False,
273
+ allow_shell: bool = True,
239
274
  confirm: ConfirmFn | None = None,
240
275
  max_read_chars: int = 40000,
241
276
  max_write_chars: int = 1_000_000,
@@ -482,6 +517,9 @@ class Toolbox:
482
517
  command = pick("command", "cmd", "script", "input")
483
518
  if command is not None:
484
519
  args["command"] = command
520
+ shell = pick("shell", "interpreter", "shell_type")
521
+ if shell is not None:
522
+ args["shell"] = shell
485
523
  timeout = pick("timeout_sec", "timeout", "timeout_seconds")
486
524
  if timeout is not None:
487
525
  args["timeout_sec"] = timeout
@@ -780,8 +818,8 @@ class Toolbox:
780
818
  def _run_command(self, args: dict[str, Any]) -> ToolResult:
781
819
  if not self.allow_shell:
782
820
  raise ToolError(
783
- "Shell access is disabled for this run. Ask the user to rerun with --allow-shell "
784
- "and approve the command."
821
+ "Command execution is disabled for this run (--no-shell or "
822
+ "shell_enabled=false)."
785
823
  )
786
824
  command = args.get("command")
787
825
  if not isinstance(command, str) or not command.strip():
@@ -793,9 +831,17 @@ class Toolbox:
793
831
  except (TypeError, ValueError):
794
832
  timeout = self.shell_timeout_sec
795
833
  timeout = max(1, min(timeout, 600))
796
- self._approve(f"Run command in {self.root}?", command)
797
- return self._execute_shell_command(
798
- command, timeout, name="run_command", arguments=args
834
+ argv, shell_label = self._build_shell_command(command, args.get("shell"))
835
+ self._approve(
836
+ f"Run {shell_label} command in {self.root}?",
837
+ command,
838
+ )
839
+ return self._run_shell_process(
840
+ argv,
841
+ timeout,
842
+ name="run_command",
843
+ arguments=args,
844
+ shell_label=shell_label,
799
845
  )
800
846
 
801
847
  def run_verification(self, command: str, *, timeout_sec: int = 300) -> ToolResult:
@@ -824,6 +870,66 @@ class Toolbox:
824
870
  output=f"Could not start the verification command: {exc}",
825
871
  )
826
872
 
873
+ def _build_shell_command(
874
+ self, command: str, shell: Any
875
+ ) -> tuple[list[str] | str, str]:
876
+ requested = _normalize_shell(shell)
877
+ if requested == "auto":
878
+ if os.name != "nt":
879
+ return ["/bin/sh", "-c", command], "/bin/sh"
880
+ if shutil.which("pwsh") or shutil.which("powershell"):
881
+ requested = "powershell"
882
+ else:
883
+ requested = "cmd"
884
+
885
+ if requested == "powershell":
886
+ exe = shutil.which("pwsh") or shutil.which("powershell")
887
+ if exe is None:
888
+ raise ToolError(
889
+ "PowerShell was not found on PATH. On Windows, retry with "
890
+ "shell='cmd' or shell='auto'."
891
+ )
892
+ return [
893
+ exe,
894
+ "-NoLogo",
895
+ "-NoProfile",
896
+ "-NonInteractive",
897
+ "-Command",
898
+ "$ProgressPreference='SilentlyContinue'; "
899
+ "[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; " + command,
900
+ ], "PowerShell"
901
+
902
+ if requested == "cmd":
903
+ if os.name != "nt":
904
+ raise ToolError(
905
+ "shell='cmd' is only available on Windows. Use shell='sh' "
906
+ "or shell='bash'."
907
+ )
908
+ exe = os.environ.get("COMSPEC") or shutil.which("cmd.exe") or "cmd.exe"
909
+ # cmd.exe has its own quoting rules. Python's list-to-command-line
910
+ # conversion backslash-escapes inner quotes, which cmd prints
911
+ # literally, so build the command line ourselves.
912
+ return f'"{exe}" /d /s /c "{command}"', "cmd.exe"
913
+
914
+ if requested == "sh":
915
+ if os.name == "nt":
916
+ exe = shutil.which("sh")
917
+ if exe is None:
918
+ raise ToolError(
919
+ "No sh interpreter was found on PATH. On Windows, use "
920
+ "shell='cmd' or shell='powershell'."
921
+ )
922
+ return [exe, "-c", command], "sh"
923
+ return ["/bin/sh", "-c", command], "/bin/sh"
924
+
925
+ if requested == "bash":
926
+ exe = shutil.which("bash")
927
+ if exe is None:
928
+ raise ToolError("bash was not found on PATH. Use shell='sh' instead.")
929
+ return [exe, "-c", command], "bash"
930
+
931
+ raise ToolError(f"Unsupported shell: {requested}.")
932
+
827
933
  def _execute_shell_command(
828
934
  self,
829
935
  command: str,
@@ -832,25 +938,27 @@ class Toolbox:
832
938
  name: str,
833
939
  arguments: dict[str, Any],
834
940
  ) -> ToolResult:
835
- if os.name == "nt":
836
- pwsh = shutil.which("pwsh") or shutil.which("powershell")
837
- if pwsh:
838
- cmd = [
839
- pwsh,
840
- "-NoLogo",
841
- "-NoProfile",
842
- "-NonInteractive",
843
- "-Command",
844
- "$ProgressPreference='SilentlyContinue'; "
845
- "[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; " + command,
846
- ]
847
- else:
848
- cmd = [os.environ.get("COMSPEC", "cmd.exe"), "/d", "/s", "/c", command]
849
- else:
850
- cmd = ["/bin/sh", "-c", command]
941
+ argv, shell_label = self._build_shell_command(command, "auto")
942
+ return self._run_shell_process(
943
+ argv,
944
+ timeout,
945
+ name=name,
946
+ arguments=arguments,
947
+ shell_label=shell_label,
948
+ )
949
+
950
+ def _run_shell_process(
951
+ self,
952
+ argv: list[str] | str,
953
+ timeout: int,
954
+ *,
955
+ name: str,
956
+ arguments: dict[str, Any],
957
+ shell_label: str,
958
+ ) -> ToolResult:
851
959
  try:
852
960
  proc = subprocess.run(
853
- cmd,
961
+ argv,
854
962
  cwd=str(self.root),
855
963
  stdout=subprocess.PIPE,
856
964
  stderr=subprocess.PIPE,
@@ -877,7 +985,10 @@ class Toolbox:
877
985
  if not combined:
878
986
  combined = "(no output)"
879
987
  ok = proc.returncode == 0
880
- header = f"exit code {proc.returncode} (cwd {self._display(self.root) or '.'})"
988
+ header = (
989
+ f"exit code {proc.returncode} ({shell_label}, "
990
+ f"cwd {self._display(self.root) or '.'})"
991
+ )
881
992
  return ToolResult(
882
993
  name=name,
883
994
  arguments=arguments,
@@ -99,7 +99,9 @@ def _first_sentence(text: str) -> str:
99
99
  def _tool_target(name: str, args: dict | None, limit: int = 110) -> str:
100
100
  args = args or {}
101
101
  if name == "run_command":
102
- return _shorten(str(args.get("command", "")), limit)
102
+ shell = str(args.get("shell") or "").strip()
103
+ prefix = f"{shell}: " if shell and shell.lower() != "auto" else ""
104
+ return _shorten(prefix + str(args.get("command", "")), limit)
103
105
  if name == "update_plan":
104
106
  steps = args.get("steps")
105
107
  count = len(steps) if isinstance(steps, list) else 0
@@ -297,8 +299,7 @@ class Tui:
297
299
  modes.append("search")
298
300
  if cfg.deeplanning:
299
301
  modes.append("deeplanning")
300
- if cfg.allow_shell:
301
- modes.append("shell allowed")
302
+ modes.append("shell allowed" if cfg.shell_enabled else "shell off")
302
303
  verify = cfg.verification_command()
303
304
  if verify:
304
305
  modes.append(f"verify: {_shorten(verify, 60)}")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dscli-agent
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: A local coding agent that uses DeepSeek web chat as its model
5
5
  Author: Anish
6
6
  License-Expression: MIT
@@ -262,8 +262,11 @@ dscli run "Refactor the parser" -f src/parser.py -f tests/test_parser.py
262
262
  # Globs work too; quote them so the shell does not expand them
263
263
  dscli run "Fix the bug" -f "src/**/*.py" --workspace C:\projects\app
264
264
 
265
- # Read a long task from a file; auto-approve edits; allow shell commands
266
- dscli run --prompt-file task.md -y --allow-shell
265
+ # Read a long task from a file; auto-approve edits and approved shell commands
266
+ dscli run --prompt-file task.md -y
267
+
268
+ # Disable command execution entirely for this run
269
+ dscli run "Review the docs" --no-shell
267
270
  ```
268
271
 
269
272
  The final answer is printed to stdout. Progress, diffs, and approval prompts go to
@@ -281,7 +284,7 @@ failures and unconfirmed completions are always logged to stderr, including unde
281
284
  | `-f, --file PATH` | Attach a file (repeatable, globs supported). |
282
285
  | `--workspace PATH` | Root the tools may touch. Defaults to the current directory. |
283
286
  | `-y, --yes` | Approve every write/patch/command without asking. |
284
- | `--allow-shell` | Enable `run_command` (still approved per command). |
287
+ | `--no-shell` | Disable `run_command` for this run (commands are otherwise enabled and approved per command). |
285
288
  | `--allow-outside` | Let tools read/write outside the workspace. |
286
289
  | `--headed` / `--headless` | Show or hide the browser. Headless is the default. |
287
290
  | `--search` | Turn DeepSeek's Search mode on for the run (DeepThink is always on). |
@@ -355,12 +358,19 @@ creep and unnecessary abstractions, confirm hard-to-reverse actions, reference c
355
358
  | `apply_patch` | The only way to edit an existing file: exact `search` → `replace`. |
356
359
  | `list_files` | Bounded recursive listing. |
357
360
  | `delete_file` | Delete one file when explicitly requested (approval required). |
358
- | `run_command` | Run a shell command (only with `--allow-shell`, approval required). |
361
+ | `run_command` | Run a command with an approval prompt via PowerShell, cmd.exe, sh, or bash. |
359
362
  | `update_plan` | Create or revise the task plan; the task cannot finish while steps are unfinished. |
360
363
 
361
364
  `apply_patch` normalises line endings for matching, preserves the file's encoding and
362
365
  line endings when writing, and refuses ambiguous matches unless `replace_all` is set.
363
366
 
367
+ `run_command` uses `shell: auto` by default: PowerShell when available on Windows
368
+ (cmd.exe otherwise), and `/bin/sh` elsewhere. Pass `"shell": "cmd"` for cmd.exe syntax
369
+ such as `%VAR%` or batch builtins, `"powershell"`, `"sh"`, or `"bash"` to choose another
370
+ interpreter. On POSIX systems, `auto` falls back to `/bin/sh`; `bash` is selected only
371
+ when explicitly requested. The approval prompt names the resolved interpreter, so a
372
+ command is never run under a shell you did not see.
373
+
364
374
  ### Plan and verification loop
365
375
 
366
376
  The agent can maintain a short task plan with `update_plan` (at most 20 steps). The run
@@ -386,7 +396,7 @@ message changed files. A verified result is still only as trustworthy as the com
386
396
  configured.
387
397
 
388
398
  The verification command is supplied by you and is never a model-callable tool. It is
389
- run without `--allow-shell` and without a per-command approval prompt because you
399
+ run without a per-command approval prompt, even when `--no-shell` is set, because you
390
400
  configured it explicitly. Treat it like any other command you run from the shell: the
391
401
  model can modify the files it checks, so review the final diff.
392
402
 
@@ -396,14 +406,18 @@ model can modify the files it checks, so review the final diff.
396
406
  - `.git` is never writable through the tools.
397
407
  - Every write, patch, delete, and command shows a diff/command and asks
398
408
  `[y]es / [n]o / [a]ll / [q]uit` before changing anything. `-y` skips the prompts.
399
- - Shell access is off by default and can be enabled only for a run.
409
+ - Command execution is enabled by default, but every model-requested command still
410
+ requires approval. The prompt names the interpreter (PowerShell, cmd.exe, sh, or
411
+ bash). `--no-shell` disables model-requested commands for a run.
400
412
  - The verification command is the one exception to the approval flow: when you configure
401
- it with `--verify` or `verify_command`, dscli runs it automatically after edits without
402
- `--allow-shell` or a prompt. The model can neither call nor change it.
413
+ it with `--verify` or `verify_command`, dscli runs it automatically after edits,
414
+ without a prompt and even when `--no-shell` is set. The model can neither call nor
415
+ change it.
403
416
  - Attachment contents, file contents, command output, and tool results are treated as
404
417
  untrusted data by the system prompt.
405
- - No network access is performed by the tools; the only network traffic is the browser
406
- talking to DeepSeek.
418
+ - The built-in file tools do not use the network. Approved commands can run any local
419
+ program, including network clients; dscli itself only talks to the chat provider
420
+ through the browser.
407
421
 
408
422
  This is still an assistant with write access to your files. Use a clean workspace or
409
423
  version control, and review diffs before approving them.
@@ -427,8 +441,9 @@ import), `chrome_profile` (empty = Chrome's last-used profile, or a name such as
427
441
  installed browser), `response_timeout_sec`, `first_response_timeout_sec`,
428
442
  `stable_seconds`, `fallback_stable_seconds`, `auto_human_check` (true = open a visible
429
443
  window to solve a verification check; false = fail fast), `max_steps`, `max_tool_calls`,
430
- `max_read_chars`, `max_attachment_mb`, `search`, `deeplanning`, `allow_shell`,
431
- `allow_outside`, `verify_command` (empty disables verification),
444
+ `max_read_chars`, `max_attachment_mb`, `search`, `deeplanning`, `shell_enabled`
445
+ (default true; every command still requires approval), `allow_outside`,
446
+ `verify_command` (empty disables verification),
432
447
  `verify_timeout_sec` (clamped to 1–600, default 300).
433
448
 
434
449
  Headless Chrome normally sends a `HeadlessChrome/...` user agent, which DeepSeek's CDN
@@ -501,7 +501,7 @@ class PlanAndVerificationTests(unittest.TestCase):
501
501
  ),
502
502
  envelope(final="done", complete=True),
503
503
  ],
504
- config=Config(deeplanning=True, allow_shell=True),
504
+ config=Config(deeplanning=True, shell_enabled=True),
505
505
  )
506
506
  self.assertTrue(outcome.completed)
507
507
  self.assertTrue((self.workspace / "keep.txt").exists())
@@ -212,9 +212,35 @@ class InteractiveChatTests(unittest.TestCase):
212
212
  self.assertEqual(run.task, ["do work"])
213
213
  self.assertTrue(run.allow_shell)
214
214
  self.assertTrue(run.dry_run)
215
+ self.assertIsNone(
216
+ build_parser().parse_args(["run", "do work"]).allow_shell
217
+ )
218
+ no_shell = build_parser().parse_args(["run", "do work", "--no-shell"])
219
+ self.assertFalse(no_shell.allow_shell)
220
+ with redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
221
+ build_parser().parse_args(
222
+ ["run", "do work", "--allow-shell", "--no-shell"]
223
+ )
215
224
  with redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
216
225
  build_parser().parse_args(["run", "do work", "--no-clear"])
217
226
 
227
+ def test_apply_shell_config_precedence(self) -> None:
228
+ parser = build_parser()
229
+ cfg = _apply_run_config(Config(), parser.parse_args(["run", "task"]))
230
+ self.assertTrue(cfg.shell_enabled)
231
+
232
+ cfg = _apply_run_config(
233
+ Config(shell_enabled=True),
234
+ parser.parse_args(["run", "task", "--no-shell"]),
235
+ )
236
+ self.assertFalse(cfg.shell_enabled)
237
+
238
+ cfg = _apply_run_config(
239
+ Config(shell_enabled=False),
240
+ parser.parse_args(["run", "task", "--allow-shell"]),
241
+ )
242
+ self.assertTrue(cfg.shell_enabled)
243
+
218
244
  def test_should_use_tui_respects_flags_and_environment(self) -> None:
219
245
  plain = build_parser().parse_args(["chat", "--plain"])
220
246
  self.assertFalse(_should_use_tui(plain))
@@ -33,6 +33,26 @@ class DeeplanningConfigTests(unittest.TestCase):
33
33
  self.assertTrue(cfg.deeplanning)
34
34
 
35
35
 
36
+ class ShellConfigTests(unittest.TestCase):
37
+ def test_shell_is_enabled_by_default(self) -> None:
38
+ self.assertTrue(Config().shell_enabled)
39
+
40
+ def test_shell_enabled_loads_from_json(self) -> None:
41
+ with tempfile.TemporaryDirectory() as tmp:
42
+ path = Path(tmp) / "config.json"
43
+ path.write_text(json.dumps({"shell_enabled": False}), encoding="utf-8")
44
+ cfg = load_config(path)
45
+ self.assertFalse(cfg.shell_enabled)
46
+
47
+ def test_legacy_allow_shell_key_is_ignored(self) -> None:
48
+ with tempfile.TemporaryDirectory() as tmp:
49
+ path = Path(tmp) / "config.json"
50
+ path.write_text(json.dumps({"allow_shell": False}), encoding="utf-8")
51
+ cfg = load_config(path)
52
+ self.assertTrue(cfg.shell_enabled)
53
+ self.assertNotIn("allow_shell", cfg.to_dict())
54
+
55
+
36
56
  class SelectorTests(unittest.TestCase):
37
57
  def test_defaults_are_used(self) -> None:
38
58
  self.assertEqual(Config().merged_selectors(), dict(DEEPSEEK_SELECTORS))
@@ -1,5 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import os
4
+ import shutil
3
5
  import tempfile
4
6
  import unittest
5
7
  from pathlib import Path
@@ -85,7 +87,12 @@ class ToolboxTests(unittest.TestCase):
85
87
  self.assertIn("outside the workspace", result.output)
86
88
 
87
89
  def test_shell_disabled(self) -> None:
88
- result = self.execute("run_command", command="echo hi")
90
+ box = Toolbox(
91
+ self.root,
92
+ allow_shell=False,
93
+ confirm=lambda title, detail: True,
94
+ )
95
+ result = box.execute(call("run_command", command="echo hi"))
89
96
  self.assertFalse(result.ok)
90
97
  self.assertIn("disabled", result.output)
91
98
 
@@ -144,7 +151,7 @@ class PlanToolTests(unittest.TestCase):
144
151
  self.assertIn("cleared", result.output.lower())
145
152
 
146
153
  def test_update_plan_needs_no_confirmer_or_shell(self) -> None:
147
- box = Toolbox(self.root)
154
+ box = Toolbox(self.root, allow_shell=False)
148
155
  result = box.execute(call("update_plan", steps=[{"text": "x"}]))
149
156
  self.assertTrue(result.ok, result.output)
150
157
  self.assertFalse(box.allow_shell)
@@ -193,6 +200,98 @@ class PlanToolTests(unittest.TestCase):
193
200
  self.assertEqual(self.box.changed_files, ["a.txt", "b.txt"])
194
201
 
195
202
 
203
+ class CommandExecutionTests(unittest.TestCase):
204
+ def setUp(self) -> None:
205
+ self.tmp = tempfile.TemporaryDirectory()
206
+ self.root = Path(self.tmp.name)
207
+
208
+ def tearDown(self) -> None:
209
+ self.tmp.cleanup()
210
+
211
+ def test_shell_is_enabled_with_approval_by_default(self) -> None:
212
+ approvals: list[tuple[str, str | None]] = []
213
+
214
+ def confirm(title: str, detail: str | None) -> bool:
215
+ approvals.append((title, detail))
216
+ return True
217
+
218
+ box = Toolbox(self.root, confirm=confirm)
219
+ result = box.execute(call("run_command", command="echo shell-ok"))
220
+ self.assertTrue(result.ok, result.output)
221
+ self.assertIn("shell-ok", result.output)
222
+ self.assertEqual(len(approvals), 1)
223
+ title, detail = approvals[0]
224
+ self.assertEqual(detail, "echo shell-ok")
225
+ if os.name == "nt":
226
+ has_powershell = bool(
227
+ shutil.which("pwsh") or shutil.which("powershell")
228
+ )
229
+ expected_shell = "PowerShell" if has_powershell else "cmd.exe"
230
+ else:
231
+ expected_shell = "/bin/sh"
232
+ self.assertIn(expected_shell, title)
233
+
234
+ @unittest.skipUnless(os.name == "nt", "cmd.exe is Windows-only")
235
+ def test_cmd_shell_runs_through_cmd_exe(self) -> None:
236
+ approvals: list[tuple[str, str | None]] = []
237
+
238
+ def confirm(title: str, detail: str | None) -> bool:
239
+ approvals.append((title, detail))
240
+ return True
241
+
242
+ box = Toolbox(self.root, confirm=confirm)
243
+ result = box.execute(
244
+ call("run_command", command='echo "cmd-ok"', shell="cmd")
245
+ )
246
+ self.assertTrue(result.ok, result.output)
247
+ self.assertIn('"cmd-ok"', result.output)
248
+ self.assertNotIn('\\"cmd-ok\\"', result.output)
249
+ self.assertIn("cmd.exe", approvals[0][0])
250
+ self.assertIn("cmd.exe", result.output)
251
+
252
+ @unittest.skipUnless(os.name == "nt", "PowerShell is Windows-only here")
253
+ def test_powershell_can_be_requested_explicitly(self) -> None:
254
+ if not (shutil.which("pwsh") or shutil.which("powershell")):
255
+ self.skipTest("PowerShell is not installed")
256
+ approvals: list[str] = []
257
+ box = Toolbox(
258
+ self.root,
259
+ confirm=lambda title, detail: approvals.append(title) or True,
260
+ )
261
+ result = box.execute(
262
+ call(
263
+ "run_command",
264
+ command="Write-Output powershell-ok",
265
+ shell="powershell",
266
+ )
267
+ )
268
+ self.assertTrue(result.ok, result.output)
269
+ self.assertIn("powershell-ok", result.output)
270
+ self.assertIn("PowerShell", approvals[0])
271
+
272
+ def test_unknown_shell_is_rejected_without_approval(self) -> None:
273
+ approvals: list[str] = []
274
+ box = Toolbox(
275
+ self.root,
276
+ confirm=lambda title, detail: approvals.append(title) or True,
277
+ )
278
+ result = box.execute(
279
+ call("run_command", command="echo hi", shell="fish")
280
+ )
281
+ self.assertFalse(result.ok)
282
+ self.assertIn("Unknown shell", result.output)
283
+ self.assertEqual(approvals, [])
284
+
285
+ @unittest.skipIf(os.name == "nt", "cmd.exe is available on Windows")
286
+ def test_cmd_shell_is_rejected_off_windows(self) -> None:
287
+ box = Toolbox(self.root, confirm=lambda title, detail: True)
288
+ result = box.execute(
289
+ call("run_command", command="echo hi", shell="cmd")
290
+ )
291
+ self.assertFalse(result.ok)
292
+ self.assertIn("only available on Windows", result.output)
293
+
294
+
196
295
  class VerificationTests(unittest.TestCase):
197
296
  def setUp(self) -> None:
198
297
  self.tmp = tempfile.TemporaryDirectory()
@@ -420,7 +420,7 @@ class TuiTests(unittest.TestCase):
420
420
  tui.banner(
421
421
  Config(
422
422
  search=True,
423
- allow_shell=True,
423
+ shell_enabled=True,
424
424
  verify_command="pytest -q",
425
425
  )
426
426
  )
@@ -449,6 +449,13 @@ class TuiTests(unittest.TestCase):
449
449
  self.assertIn("search", text)
450
450
  self.assertIn("deeplanning", text)
451
451
 
452
+ def test_banner_reports_disabled_shell(self) -> None:
453
+ from dscli.config import Config
454
+
455
+ tui = self.make_tui()
456
+ tui.banner(Config(shell_enabled=False))
457
+ self.assertIn("shell off", self.output())
458
+
452
459
  def test_banner_clears_the_screen_by_default(self) -> None:
453
460
  tui = self.make_tui()
454
461
  from dscli.config import Config
File without changes
File without changes
File without changes
File without changes
File without changes