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.
- {dscli_agent-0.3.0 → dscli_agent-0.4.0}/PKG-INFO +28 -13
- {dscli_agent-0.3.0 → dscli_agent-0.4.0}/README.md +27 -12
- {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli/__init__.py +1 -1
- {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli/agent.py +3 -3
- {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli/cli.py +11 -7
- {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli/compaction.py +4 -1
- {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli/config.py +2 -1
- {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli/display.py +3 -1
- {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli/prompts.py +7 -7
- {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli/tools.py +135 -24
- {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli/tui.py +4 -3
- {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli_agent.egg-info/PKG-INFO +28 -13
- {dscli_agent-0.3.0 → dscli_agent-0.4.0}/tests/test_agent.py +1 -1
- {dscli_agent-0.3.0 → dscli_agent-0.4.0}/tests/test_cli.py +26 -0
- {dscli_agent-0.3.0 → dscli_agent-0.4.0}/tests/test_config.py +20 -0
- {dscli_agent-0.3.0 → dscli_agent-0.4.0}/tests/test_tools.py +101 -2
- {dscli_agent-0.3.0 → dscli_agent-0.4.0}/tests/test_tui.py +8 -1
- {dscli_agent-0.3.0 → dscli_agent-0.4.0}/LICENSE +0 -0
- {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli/__main__.py +0 -0
- {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli/attachments.py +0 -0
- {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli/branding.py +0 -0
- {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli/browser.py +0 -0
- {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli/protocol.py +0 -0
- {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli/providers.py +0 -0
- {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli/scraper.py +0 -0
- {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli/tokens.py +0 -0
- {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli/ui.py +0 -0
- {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli_agent.egg-info/SOURCES.txt +0 -0
- {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli_agent.egg-info/dependency_links.txt +0 -0
- {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli_agent.egg-info/entry_points.txt +0 -0
- {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli_agent.egg-info/requires.txt +0 -0
- {dscli_agent-0.3.0 → dscli_agent-0.4.0}/dscli_agent.egg-info/top_level.txt +0 -0
- {dscli_agent-0.3.0 → dscli_agent-0.4.0}/pyproject.toml +0 -0
- {dscli_agent-0.3.0 → dscli_agent-0.4.0}/setup.cfg +0 -0
- {dscli_agent-0.3.0 → dscli_agent-0.4.0}/tests/test_branding.py +0 -0
- {dscli_agent-0.3.0 → dscli_agent-0.4.0}/tests/test_browser.py +0 -0
- {dscli_agent-0.3.0 → dscli_agent-0.4.0}/tests/test_compaction.py +0 -0
- {dscli_agent-0.3.0 → dscli_agent-0.4.0}/tests/test_display.py +0 -0
- {dscli_agent-0.3.0 → dscli_agent-0.4.0}/tests/test_protocol.py +0 -0
- {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
|
+
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
|
|
266
|
-
dscli run --prompt-file task.md -y
|
|
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
|
-
| `--
|
|
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
|
|
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
|
|
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
|
-
-
|
|
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
|
|
402
|
-
`--
|
|
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
|
-
-
|
|
406
|
-
|
|
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`, `
|
|
431
|
-
|
|
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
|
|
236
|
-
dscli run --prompt-file task.md -y
|
|
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
|
-
| `--
|
|
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
|
|
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
|
|
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
|
-
-
|
|
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
|
|
372
|
-
`--
|
|
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
|
-
-
|
|
376
|
-
|
|
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`, `
|
|
401
|
-
|
|
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
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
101
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
153
|
-
|
|
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 =
|
|
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
|
-
"
|
|
784
|
-
"
|
|
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.
|
|
797
|
-
|
|
798
|
-
command
|
|
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
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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
|
+
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
|
|
266
|
-
dscli run --prompt-file task.md -y
|
|
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
|
-
| `--
|
|
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
|
|
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
|
|
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
|
-
-
|
|
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
|
|
402
|
-
`--
|
|
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
|
-
-
|
|
406
|
-
|
|
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`, `
|
|
431
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|