scootcli 0.9.0__tar.gz → 0.11.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.
- {scootcli-0.9.0/src/scootcli.egg-info → scootcli-0.11.0}/PKG-INFO +8 -5
- {scootcli-0.9.0 → scootcli-0.11.0}/README.md +7 -4
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/__init__.py +1 -1
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/agent.py +30 -21
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/approvals.py +19 -5
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/cli.py +28 -19
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/hooks.py +21 -5
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/config.py +76 -10
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/credentials.py +3 -15
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/headless.py +9 -5
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/hooks.py +106 -5
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/images.py +4 -3
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/keys.py +13 -11
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/preferences.py +36 -12
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/prompts.py +9 -1
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/providers/router.py +14 -8
- scootcli-0.11.0/src/scootcli/rendering.py +159 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/repl.py +56 -8
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/sessions.py +50 -28
- scootcli-0.11.0/src/scootcli/store.py +47 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/tools/base.py +91 -19
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/tools/read_file.py +7 -1
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/tools/search.py +37 -10
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/workspace.py +4 -2
- {scootcli-0.9.0 → scootcli-0.11.0/src/scootcli.egg-info}/PKG-INFO +8 -5
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli.egg-info/SOURCES.txt +3 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_agent.py +35 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_approvals.py +31 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_config.py +89 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_headless.py +28 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_hooks.py +212 -0
- scootcli-0.11.0/tests/test_rendering.py +75 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_router.py +16 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_scope.py +9 -2
- {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_sessions.py +64 -0
- scootcli-0.11.0/tests/test_store.py +36 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_tools.py +129 -0
- scootcli-0.9.0/src/scootcli/rendering.py +0 -86
- {scootcli-0.9.0 → scootcli-0.11.0}/LICENSE +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/pyproject.toml +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/setup.cfg +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/__main__.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/activity.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/auth.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/clipboard.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/__init__.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/approve.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/auth.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/base.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/compact.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/copy.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/exit.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/forget.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/help.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/init.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/logo.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/model.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/panel.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/reset.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/resume.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/route.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/save.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/scope.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/sessions.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/status.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/verbosity.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/worktree.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/commands/yolo.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/context.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/errors.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/lineeditor.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/logo.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/models.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/panel.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/presets.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/pricing.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/project.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/providers/__init__.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/providers/anthropic.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/providers/base.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/providers/openai_chat.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/providers/openai_responses.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/providers/registry.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/resize.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/status.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/tools/__init__.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/tools/edit_file.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/tools/list_dir.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/tools/open_editor.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/tools/run_shell.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/tools/update_plan.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/tools/write_file.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/transport.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/updates.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/vision.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli/worktree.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli.egg-info/dependency_links.txt +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli.egg-info/entry_points.txt +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/src/scootcli.egg-info/top_level.txt +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_anthropic.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_auth.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_clipboard.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_images.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_lineeditor.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_logo.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_models.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_panel.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_preferences.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_providers.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_readiness.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_repl.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_resilience.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_resize.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_small_060.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_smoke.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_streaming.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_transport_native.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_vision.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_workspace.py +0 -0
- {scootcli-0.9.0 → scootcli-0.11.0}/tests/test_worktree.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scootcli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.0
|
|
4
4
|
Summary: A tiny coding agent that goes where you point it
|
|
5
5
|
Author: Sergey Nes
|
|
6
6
|
License-Expression: MIT
|
|
@@ -321,10 +321,13 @@ New capability is a drop-in file: a tool in `tools/`, a slash command in `comman
|
|
|
321
321
|
|
|
322
322
|
## Security
|
|
323
323
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
-
|
|
327
|
-
-
|
|
324
|
+
What scoot protects, and what it does not.
|
|
325
|
+
|
|
326
|
+
- **Your API keys.** Read from the environment or from `~/.config/scoot/credentials.json` (directory `0700`, file `0600`), validated before saving, never echoed, and removed from the environment of every hook scoot runs. Key-shaped strings are masked in error messages and in saved sessions (message text, tool arguments, and the text copies inside provider replay data; signed or encrypted replay fields are stored as received). Streamed model output is printed as it arrives and is not filtered.
|
|
327
|
+
- **A cloned repository cannot reconfigure you.** A project `.env` may choose the model, effort, and how the terminal looks. It may not set provider base URLs or proxies (which would send your key elsewhere), the approval mode, the scope, the root, hooks, or scoot's config and state directories; those come from flags, your environment, or `~/.config/scoot/.env`, and a project value for them is ignored and reported at start. A project's `.scoot/hooks.json` runs only after you review it and run `/hooks trust`; the trust is bound to the file's content, so an edited file asks again.
|
|
328
|
+
- **File tools are scoped, shell commands are not sandboxed.** `read_file`, `list_dir`, `search`, `write_file`, and `edit_file` stay inside the workspace plus the paths you grant, and never follow a symlink out of it. `run_shell` runs the command as you, with your normal access to files, network, and other programs; approving a command means exactly that. The denylist (`rm -rf`, `git push`, `sudo`, piping a download into a shell, and so on) is an accident guard that forces a confirmation in every mode, including when a project's hook says `allow` (a hook in your own global config can waive it, so an editor or phone bridge you installed still works); it is not a boundary.
|
|
329
|
+
- **The default is `yolo`.** Tool calls run without asking, except the denylist and paths outside the workspace. Use `--approval always` or `auto-read` on code you do not trust yet, and `/worktree start` to keep autonomous edits out of your checkout.
|
|
330
|
+
- **Saved sessions** are owner-only, keyed by workspace, and pruned to the last 20; `/forget all` removes them. Session ids are file names, never paths.
|
|
328
331
|
- No third-party dependencies means no third-party code to audit.
|
|
329
332
|
|
|
330
333
|
## Contributing
|
|
@@ -296,10 +296,13 @@ New capability is a drop-in file: a tool in `tools/`, a slash command in `comman
|
|
|
296
296
|
|
|
297
297
|
## Security
|
|
298
298
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
-
|
|
302
|
-
-
|
|
299
|
+
What scoot protects, and what it does not.
|
|
300
|
+
|
|
301
|
+
- **Your API keys.** Read from the environment or from `~/.config/scoot/credentials.json` (directory `0700`, file `0600`), validated before saving, never echoed, and removed from the environment of every hook scoot runs. Key-shaped strings are masked in error messages and in saved sessions (message text, tool arguments, and the text copies inside provider replay data; signed or encrypted replay fields are stored as received). Streamed model output is printed as it arrives and is not filtered.
|
|
302
|
+
- **A cloned repository cannot reconfigure you.** A project `.env` may choose the model, effort, and how the terminal looks. It may not set provider base URLs or proxies (which would send your key elsewhere), the approval mode, the scope, the root, hooks, or scoot's config and state directories; those come from flags, your environment, or `~/.config/scoot/.env`, and a project value for them is ignored and reported at start. A project's `.scoot/hooks.json` runs only after you review it and run `/hooks trust`; the trust is bound to the file's content, so an edited file asks again.
|
|
303
|
+
- **File tools are scoped, shell commands are not sandboxed.** `read_file`, `list_dir`, `search`, `write_file`, and `edit_file` stay inside the workspace plus the paths you grant, and never follow a symlink out of it. `run_shell` runs the command as you, with your normal access to files, network, and other programs; approving a command means exactly that. The denylist (`rm -rf`, `git push`, `sudo`, piping a download into a shell, and so on) is an accident guard that forces a confirmation in every mode, including when a project's hook says `allow` (a hook in your own global config can waive it, so an editor or phone bridge you installed still works); it is not a boundary.
|
|
304
|
+
- **The default is `yolo`.** Tool calls run without asking, except the denylist and paths outside the workspace. Use `--approval always` or `auto-read` on code you do not trust yet, and `/worktree start` to keep autonomous edits out of your checkout.
|
|
305
|
+
- **Saved sessions** are owner-only, keyed by workspace, and pruned to the last 20; `/forget all` removes them. Session ids are file names, never paths.
|
|
303
306
|
- No third-party dependencies means no third-party code to audit.
|
|
304
307
|
|
|
305
308
|
## Contributing
|
|
@@ -18,7 +18,7 @@ from dataclasses import dataclass
|
|
|
18
18
|
from typing import List, Optional
|
|
19
19
|
|
|
20
20
|
from . import tools
|
|
21
|
-
from .approvals import Approval, Decision, needs_prompt
|
|
21
|
+
from .approvals import Approval, Decision, denylisted_reason, needs_prompt
|
|
22
22
|
from .config import Config
|
|
23
23
|
from .errors import ScootError, ContextLengthError, Interrupted, ModelUnavailableError
|
|
24
24
|
from .prompts import build_agent_system_prompt
|
|
@@ -40,6 +40,8 @@ _CUT_OFF = {
|
|
|
40
40
|
"incomplete": "the stream ended before the reply was complete",
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
_MAX_FALLBACKS = 6 # a turn's model-unavailable switches, so a bad router cannot spin forever
|
|
44
|
+
|
|
43
45
|
|
|
44
46
|
def _strip_done(text: str) -> str:
|
|
45
47
|
"""Remove a trailing ``DONE`` sentinel line from the model's final message."""
|
|
@@ -112,6 +114,7 @@ class Agent:
|
|
|
112
114
|
compacted = False
|
|
113
115
|
stop_blocks = 0
|
|
114
116
|
cut_off = 0 # tool batches the model could not finish (output limit); bounded retries
|
|
117
|
+
fallbacks = 0 # model-unavailable switches this turn; bounded independently of steps
|
|
115
118
|
while True:
|
|
116
119
|
if cancel_event.is_set():
|
|
117
120
|
return AgentOutcome("interrupted", steps=steps)
|
|
@@ -124,8 +127,9 @@ class Agent:
|
|
|
124
127
|
except Interrupted:
|
|
125
128
|
return AgentOutcome("interrupted", steps=steps)
|
|
126
129
|
except ModelUnavailableError as exc:
|
|
127
|
-
|
|
128
|
-
|
|
130
|
+
fallbacks += 1
|
|
131
|
+
if fallbacks <= _MAX_FALLBACKS and self._fallback_model(session):
|
|
132
|
+
ui.assistant(f"model unavailable, switching to {session.active_model}.")
|
|
129
133
|
steps -= 1 # don't count the failed attempt
|
|
130
134
|
continue
|
|
131
135
|
return AgentOutcome("error", error=_fmt_error(exc), steps=steps)
|
|
@@ -235,10 +239,10 @@ class Agent:
|
|
|
235
239
|
decision = hooks.run("Stop", payload)
|
|
236
240
|
return (decision.reason or "the Stop hook asked to continue") if decision.blocks else ""
|
|
237
241
|
|
|
238
|
-
def _pre_tool_hook(self, session, tool, args, cancel_event) -> "tuple[str, str]":
|
|
242
|
+
def _pre_tool_hook(self, session, tool, args, cancel_event) -> "tuple[str, str, bool]":
|
|
239
243
|
hooks = self._hooks(session)
|
|
240
244
|
if hooks is None or not hooks.has("PreToolUse"):
|
|
241
|
-
return "", ""
|
|
245
|
+
return "", "", False
|
|
242
246
|
from .hooks import tool_kind
|
|
243
247
|
|
|
244
248
|
from .hooks import tool_alias
|
|
@@ -246,7 +250,7 @@ class Agent:
|
|
|
246
250
|
payload = hooks.payload(session, "PreToolUse", tool_name=tool.name, tool_input=args,
|
|
247
251
|
tool_kind=tool_kind(tool), tool_alias=tool_alias(tool.name))
|
|
248
252
|
decision = hooks.run("PreToolUse", payload, cancel_event)
|
|
249
|
-
return decision.action, decision.reason
|
|
253
|
+
return decision.action, decision.reason, decision.from_global
|
|
250
254
|
|
|
251
255
|
def _post_tool_hook(self, session, tool, args, result: ToolResult) -> None:
|
|
252
256
|
hooks = self._hooks(session)
|
|
@@ -288,10 +292,10 @@ class Agent:
|
|
|
288
292
|
except Exception:
|
|
289
293
|
available = []
|
|
290
294
|
bad = getattr(session, "bad_models", None) or set()
|
|
291
|
-
available = [m for m in available if m not in bad]
|
|
295
|
+
available = [m for m in available if m not in bad] # never re-pick a model that already failed
|
|
292
296
|
hints = compute_hints(session)
|
|
293
297
|
return router.choose(session, hints, available, session.resolved_model(),
|
|
294
|
-
classify=self._classify_with_provider).model
|
|
298
|
+
classify=self._classify_with_provider, excluded=bad).model
|
|
295
299
|
|
|
296
300
|
def _classify_with_provider(self, model: str, prompt: str) -> str:
|
|
297
301
|
"""One small, non-streaming call used by a configured router classifier."""
|
|
@@ -300,18 +304,15 @@ class Agent:
|
|
|
300
304
|
return result.content or ""
|
|
301
305
|
|
|
302
306
|
def _fallback_model(self, session) -> bool:
|
|
303
|
-
"""After a model-unavailable error,
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
if bad == fallback and session.model.lower() != "auto":
|
|
307
|
-
return False
|
|
307
|
+
"""After a model-unavailable error, mark the failed model bad and switch. Returns False when
|
|
308
|
+
there is nothing new to try, so the loop cannot alternate forever between two dead models."""
|
|
309
|
+
failed = session.active_model
|
|
308
310
|
if hasattr(session, "bad_models"):
|
|
309
|
-
session.bad_models.add(
|
|
311
|
+
session.bad_models.add(failed)
|
|
312
|
+
bad = getattr(session, "bad_models", None) or set()
|
|
310
313
|
new = self._pick_model(session)
|
|
311
|
-
if new
|
|
312
|
-
|
|
313
|
-
return False
|
|
314
|
-
new = fallback
|
|
314
|
+
if not new or new in bad:
|
|
315
|
+
return False # every candidate is already known unavailable this turn
|
|
315
316
|
session.active_model = new
|
|
316
317
|
return True
|
|
317
318
|
|
|
@@ -434,6 +435,8 @@ class Agent:
|
|
|
434
435
|
for tc in tool_calls:
|
|
435
436
|
if cancel_event.is_set():
|
|
436
437
|
return self._interrupted(session, tool_calls)
|
|
438
|
+
if scope is not None:
|
|
439
|
+
scope.clear_once() # a prior call's one-shot path grant does not carry into this one
|
|
437
440
|
tc_id = tc.get("id", "")
|
|
438
441
|
fn = tc.get("function", {}) or {}
|
|
439
442
|
name = fn.get("name", "")
|
|
@@ -461,7 +464,7 @@ class Agent:
|
|
|
461
464
|
ui.tool_result(name, ToolResult(ok=False, summary="outside the workspace: declined"))
|
|
462
465
|
continue
|
|
463
466
|
# A PreToolUse hook may deny (skip the tool), allow (skip the prompt), or ask (force it).
|
|
464
|
-
hook_action, hook_reason = self._pre_tool_hook(session, tool, args, cancel_event)
|
|
467
|
+
hook_action, hook_reason, hook_from_global = self._pre_tool_hook(session, tool, args, cancel_event)
|
|
465
468
|
if cancel_event.is_set(): # ESC while the hook ran
|
|
466
469
|
self._append_tool(session, tc_id, "not executed: the user interrupted the turn")
|
|
467
470
|
return self._interrupted(session, tool_calls)
|
|
@@ -471,7 +474,13 @@ class Agent:
|
|
|
471
474
|
continue
|
|
472
475
|
# Approval policy: auto-approve when the mode/trust allows it, else prompt.
|
|
473
476
|
must_prompt = needs_prompt(mode, tool, args, trusted)
|
|
474
|
-
|
|
477
|
+
# A hook may waive scoot's own approval prompt. It may also waive the denylist confirmation,
|
|
478
|
+
# but only when it lives in the user's own global config: that is the user's machine-level
|
|
479
|
+
# delegation (an editor or a phone bridge approving on their behalf). A project's hook, even
|
|
480
|
+
# one trusted to run, is repo-authored and must not silently auto-run a catastrophic command.
|
|
481
|
+
waivable = hook_action == "allow" and (hook_from_global or not (
|
|
482
|
+
tool.name == "run_shell" and denylisted_reason(args.get("command", ""))))
|
|
483
|
+
if waivable:
|
|
475
484
|
must_prompt = None
|
|
476
485
|
elif hook_action == "ask":
|
|
477
486
|
must_prompt = hook_reason or "hook asked for confirmation"
|
|
@@ -553,7 +562,7 @@ class Agent:
|
|
|
553
562
|
verdict = asker(tool, paths[0], ctx)
|
|
554
563
|
if verdict == "once":
|
|
555
564
|
for p in paths:
|
|
556
|
-
scope.
|
|
565
|
+
scope.grant_once(p) # this call only; re-asked next time
|
|
557
566
|
elif verdict == "dir":
|
|
558
567
|
for p in paths:
|
|
559
568
|
scope.grant_dir(p)
|
|
@@ -46,9 +46,13 @@ MODES = ("always", "auto-read", "auto-edits", "yolo")
|
|
|
46
46
|
|
|
47
47
|
# Catastrophic shell commands that are ALWAYS confirmed, even in yolo mode.
|
|
48
48
|
_DENYLIST = [
|
|
49
|
-
|
|
49
|
+
# rm with a recursive flag and a force flag anywhere in the same command (``-rf``, ``-r -f``,
|
|
50
|
+
# ``-fR``, ``--recursive --force``); ``[^|;&]*`` keeps both lookaheads inside one command.
|
|
51
|
+
(re.compile(r"\brm\b(?=[^|;&]*\s-(?:[a-z]*r[a-z]*|-recursive)\b)(?=[^|;&]*\s-(?:[a-z]*f[a-z]*|-force)\b)", re.I),
|
|
52
|
+
"recursive force delete"),
|
|
50
53
|
(re.compile(r":\(\)\s*\{.*\|.*&\s*\}\s*;", re.S), "fork bomb"),
|
|
51
|
-
|
|
54
|
+
# git push with any options between ``git`` and ``push`` (``-C repo``, ``--git-dir=x``, ``-c k=v``).
|
|
55
|
+
(re.compile(r"\bgit\b(?:\s+-{1,2}[\w-]+(?:[= ]\S+)?)*\s+push\b", re.I), "git push"),
|
|
52
56
|
(re.compile(r"\bsudo\b", re.I), "sudo"),
|
|
53
57
|
(re.compile(r"\b(shutdown|reboot|halt|poweroff)\b", re.I), "power/shutdown"),
|
|
54
58
|
(re.compile(r"\bmkfs\b|\bdd\s+.*of=/dev/", re.I), "disk/format command"),
|
|
@@ -101,8 +105,10 @@ def _render(tool: Tool, args: dict, ctx: ToolContext) -> None:
|
|
|
101
105
|
except Exception as exc: # a preview must never crash the loop
|
|
102
106
|
preview = color(f"(preview unavailable: {exc})", "gray")
|
|
103
107
|
if preview:
|
|
108
|
+
from .rendering import strip_controls
|
|
109
|
+
|
|
104
110
|
for line in preview.splitlines():
|
|
105
|
-
print(" " + line)
|
|
111
|
+
print(" " + strip_controls(line))
|
|
106
112
|
|
|
107
113
|
|
|
108
114
|
def _edit_args(args: dict) -> dict:
|
|
@@ -129,6 +135,14 @@ def _edit_args(args: dict) -> dict:
|
|
|
129
135
|
SCOPE_CHOICES = ("once", "dir", "all", "deny", "abort")
|
|
130
136
|
|
|
131
137
|
|
|
138
|
+
def _menu_key() -> str:
|
|
139
|
+
"""One keypress for the approval menus: ``A`` (the session-wide choice) keeps its case, every other
|
|
140
|
+
key is lowercased so ``T`` still means trust. ``read_key`` used to lowercase everything, which made
|
|
141
|
+
the advertised ``[A]`` unreachable."""
|
|
142
|
+
key = read_key(keep_case=True)
|
|
143
|
+
return key if key == "A" else key.lower()
|
|
144
|
+
|
|
145
|
+
|
|
132
146
|
def request_scope(tool: Tool, path, ctx: ToolContext) -> str:
|
|
133
147
|
"""Ask once about a path outside the workspace. Returns one of ``SCOPE_CHOICES``."""
|
|
134
148
|
from pathlib import Path as _P
|
|
@@ -146,7 +160,7 @@ def request_scope(tool: Tool, path, ctx: ToolContext) -> str:
|
|
|
146
160
|
+ color("quit", "red") + " [q] › ",
|
|
147
161
|
end="", flush=True,
|
|
148
162
|
)
|
|
149
|
-
key =
|
|
163
|
+
key = _menu_key()
|
|
150
164
|
print(key)
|
|
151
165
|
if key in ("a", "y", "\r", "\n", ""):
|
|
152
166
|
return "once"
|
|
@@ -181,7 +195,7 @@ def request_approval(tool: Tool, args: dict, ctx: ToolContext) -> Approval:
|
|
|
181
195
|
end="",
|
|
182
196
|
flush=True,
|
|
183
197
|
)
|
|
184
|
-
key =
|
|
198
|
+
key = _menu_key()
|
|
185
199
|
print(key) # echo the choice for a clean transcript
|
|
186
200
|
|
|
187
201
|
if key in ("a", "y", "\r", "\n", ""):
|
|
@@ -15,6 +15,7 @@ Global flags override config: ``--model``, ``--proxy``, ``--root``,
|
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
17
|
import argparse
|
|
18
|
+
from pathlib import Path
|
|
18
19
|
import json as _json
|
|
19
20
|
import sys
|
|
20
21
|
from typing import List, Optional
|
|
@@ -23,7 +24,7 @@ from .providers import ProviderPool
|
|
|
23
24
|
from . import __version__
|
|
24
25
|
from .config import Config
|
|
25
26
|
from .errors import ScootError
|
|
26
|
-
from .rendering import color, eprint, redact
|
|
27
|
+
from .rendering import color, eprint, redact, strip_controls
|
|
27
28
|
|
|
28
29
|
|
|
29
30
|
def _build_parser() -> argparse.ArgumentParser:
|
|
@@ -89,7 +90,10 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
89
90
|
|
|
90
91
|
|
|
91
92
|
def _config_from_args(args: argparse.Namespace) -> Config:
|
|
92
|
-
|
|
93
|
+
# The workspace decides which project .env applies, so resolve --root before loading; otherwise
|
|
94
|
+
# `scoot --root ~/other` would take the launch directory's settings into the other project.
|
|
95
|
+
start = Path(args.root).expanduser().resolve() if args.root else None
|
|
96
|
+
return Config.load(cwd=start).override(
|
|
93
97
|
model=args.model,
|
|
94
98
|
provider=args.provider,
|
|
95
99
|
effort=args.effort,
|
|
@@ -146,30 +150,40 @@ def _run_once(config: Config, pool: ProviderPool, prompt: str, as_json: bool, re
|
|
|
146
150
|
import threading
|
|
147
151
|
|
|
148
152
|
from .agent import Agent
|
|
149
|
-
from .repl import ReplSession, ReplUI
|
|
153
|
+
from .repl import OneShotJsonUI, ReplSession, ReplUI
|
|
154
|
+
|
|
155
|
+
def _emit_json(status: str, *, model: str = "", steps: int = 0, content: str = "",
|
|
156
|
+
error: str = "", usage=None, cost=None) -> None:
|
|
157
|
+
"""The one and only object written to stdout in --json mode, on every exit path."""
|
|
158
|
+
print(_json.dumps({"status": status, "model": model, "steps": steps, "content": content,
|
|
159
|
+
"error": error, "usage": usage or {}, "cost": cost}, indent=2))
|
|
150
160
|
|
|
151
161
|
from .providers.registry import readiness
|
|
152
162
|
|
|
153
163
|
ready, message = readiness(config)
|
|
154
164
|
if not ready: # fail fast with guidance instead of a connection error after retries
|
|
155
165
|
if as_json:
|
|
156
|
-
|
|
157
|
-
"error": message, "usage": {}}, indent=2))
|
|
166
|
+
_emit_json("error", error=message)
|
|
158
167
|
else:
|
|
159
168
|
eprint(color(message, "yellow"))
|
|
160
169
|
return 1
|
|
161
170
|
session = ReplSession(config, pool)
|
|
162
171
|
if resume is not None:
|
|
163
172
|
session.apply_record(resume)
|
|
164
|
-
from .hooks import Hooks, session_event, submit_prompt
|
|
173
|
+
from .hooks import Hooks, session_event, startup_notices, submit_prompt
|
|
165
174
|
|
|
166
175
|
session.hooks = Hooks(config.root)
|
|
176
|
+
for note in startup_notices(session):
|
|
177
|
+
eprint(color(f"⚠ {note}", "yellow"))
|
|
167
178
|
session_event(session, "SessionStart", source="resume" if resume is not None else "startup")
|
|
168
179
|
submitted = submit_prompt(session, prompt)
|
|
169
180
|
if submitted is None:
|
|
170
181
|
reason = getattr(session, "hook_block_reason", "") or "a UserPromptSubmit hook blocked it"
|
|
171
|
-
eprint(color(f"⏹ prompt not sent: {reason}", "yellow"))
|
|
172
182
|
session_event(session, "SessionEnd", reason="blocked")
|
|
183
|
+
if as_json:
|
|
184
|
+
_emit_json("blocked", model=session.active_model, error=reason)
|
|
185
|
+
else:
|
|
186
|
+
eprint(color(f"⏹ prompt not sent: {reason}", "yellow"))
|
|
173
187
|
return 1
|
|
174
188
|
prompt = submitted
|
|
175
189
|
# Fold any dropped image paths into the prompt (best-effort; no-op when none/disabled).
|
|
@@ -182,25 +196,20 @@ def _run_once(config: Config, pool: ProviderPool, prompt: str, as_json: bool, re
|
|
|
182
196
|
session.messages.append({"role": "user", "content": prompt})
|
|
183
197
|
# JSON output must be clean, so never stream tokens to stdout in that mode.
|
|
184
198
|
agent_config = config.override(stream=False) if as_json else config
|
|
185
|
-
|
|
199
|
+
ui = OneShotJsonUI() if as_json else ReplUI()
|
|
200
|
+
outcome = Agent(agent_config, pool).run_turn(session, ui, threading.Event())
|
|
186
201
|
session.autosave() # persist so `scoot -c` can continue this conversation
|
|
187
202
|
session_event(session, "SessionEnd", reason=outcome.status)
|
|
188
203
|
|
|
189
204
|
if as_json:
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
"steps": outcome.steps,
|
|
194
|
-
"content": outcome.content,
|
|
195
|
-
"error": outcome.error,
|
|
196
|
-
"usage": session.last_usage,
|
|
197
|
-
"cost": session.session_cost(),
|
|
198
|
-
}, indent=2))
|
|
205
|
+
_emit_json(outcome.status, model=session.active_model, steps=outcome.steps,
|
|
206
|
+
content=outcome.content, error=outcome.error, usage=session.last_usage,
|
|
207
|
+
cost=session.session_cost())
|
|
199
208
|
return 0 if outcome.status == "done" else 1
|
|
200
209
|
|
|
201
210
|
if outcome.status == "done":
|
|
202
211
|
if outcome.content.strip() and not outcome.streamed:
|
|
203
|
-
print(outcome.content.strip())
|
|
212
|
+
print(strip_controls(outcome.content.strip()))
|
|
204
213
|
if config.verbose:
|
|
205
214
|
from .pricing import fmt
|
|
206
215
|
|
|
@@ -211,7 +220,7 @@ def _run_once(config: Config, pool: ProviderPool, prompt: str, as_json: bool, re
|
|
|
211
220
|
f"cost={fmt(session.session_cost())}", "gray"))
|
|
212
221
|
return 0
|
|
213
222
|
if outcome.status == "incomplete" and outcome.content.strip() and not outcome.streamed:
|
|
214
|
-
print(outcome.content.strip())
|
|
223
|
+
print(strip_controls(outcome.content.strip()))
|
|
215
224
|
eprint(color(f"⚠ {redact(outcome.error or outcome.status)}", "red"))
|
|
216
225
|
return 1
|
|
217
226
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from ..hooks import EVENTS, enabled, global_path, project_path
|
|
5
|
+
from ..hooks import EVENTS, enabled, global_path, project_path, project_trust, trust_project, untrust_project
|
|
6
6
|
from ..rendering import color
|
|
7
7
|
from . import register
|
|
8
8
|
from .base import SlashCommand
|
|
@@ -10,14 +10,29 @@ from .base import SlashCommand
|
|
|
10
10
|
|
|
11
11
|
def _run(session, args: str):
|
|
12
12
|
hooks = getattr(session, "hooks", None)
|
|
13
|
-
|
|
13
|
+
sub = (args or "").strip().lower()
|
|
14
|
+
root = getattr(getattr(session, "config", None), "root", ".")
|
|
15
|
+
if sub == "trust":
|
|
16
|
+
if trust_project(root):
|
|
17
|
+
print(color(f"trusted {project_path(root)}; its hooks run from now on (until the file changes).", "gray"))
|
|
18
|
+
else:
|
|
19
|
+
print(color(f"no project hooks file at {project_path(root)}.", "yellow"))
|
|
20
|
+
sub = "reload"
|
|
21
|
+
elif sub == "untrust":
|
|
22
|
+
print(color("project hooks are off again." if untrust_project(root) else "project hooks were not trusted.", "gray"))
|
|
23
|
+
sub = "reload"
|
|
24
|
+
if sub == "reload" and hooks is not None:
|
|
14
25
|
hooks.reload()
|
|
15
26
|
print(color("hooks reloaded.", "gray"))
|
|
16
27
|
print(color("hooks:", "bold"))
|
|
17
28
|
if not enabled():
|
|
18
29
|
print(color(" disabled by SCOOT_HOOKS=0", "yellow"))
|
|
19
|
-
root = getattr(getattr(session, "config", None), "root", ".")
|
|
20
30
|
print(color(f" files: {project_path(root)} (project), {global_path()} (global)", "gray"))
|
|
31
|
+
trust = project_trust(root)
|
|
32
|
+
if trust == "untrusted":
|
|
33
|
+
print(color(" project hooks: NOT trusted, not running. review the file, then /hooks trust", "yellow"))
|
|
34
|
+
elif trust == "trusted":
|
|
35
|
+
print(color(" project hooks: trusted (/hooks untrust to turn them off)", "gray"))
|
|
21
36
|
if hooks is None or not hooks.config:
|
|
22
37
|
print(color(" none configured", "gray"))
|
|
23
38
|
else:
|
|
@@ -31,7 +46,8 @@ def _run(session, args: str):
|
|
|
31
46
|
for r in hooks.history[-8:]:
|
|
32
47
|
detail = f" {r.detail[:60]}" if r.detail else ""
|
|
33
48
|
print(f" {r.event:<16} {r.outcome:<8} {r.seconds:>5.2f}s {r.command[:40]}{detail}")
|
|
34
|
-
print(color(" usage: /hooks [reload]", "gray"))
|
|
49
|
+
print(color(" usage: /hooks [reload|trust|untrust]", "gray"))
|
|
35
50
|
|
|
36
51
|
|
|
37
|
-
register(SlashCommand("hooks", "list configured hooks and recent results", _run,
|
|
52
|
+
register(SlashCommand("hooks", "list configured hooks and recent results; trust a project's hooks", _run,
|
|
53
|
+
usage="[reload|trust|untrust]"))
|
|
@@ -12,6 +12,13 @@ Only scoot's own keys are imported from them: ``SCOOT_*``, the providers' API-ke
|
|
|
12
12
|
(``OPENAI_API_KEY``, ...), and the proxy variables. A project's other secrets never enter scoot's
|
|
13
13
|
process through a ``.env`` file.
|
|
14
14
|
|
|
15
|
+
The project file gets a narrower allowlist than the global one. A repository is untrusted input:
|
|
16
|
+
its ``.env`` may choose what the model is asked and how the terminal looks, but not where requests
|
|
17
|
+
go (base URLs, proxies), what runs on this machine (hooks), what the tools may touch (approval,
|
|
18
|
+
scope, root), or where scoot's own files live (config and state directories). Those keys come from
|
|
19
|
+
flags, the real environment, or the user's global file; a project value for them is ignored and
|
|
20
|
+
reported, never applied.
|
|
21
|
+
|
|
15
22
|
The CLI-flag layer is applied by the caller (``cli.py``) via :meth:`Config.override`; this module
|
|
16
23
|
handles the lower layers.
|
|
17
24
|
"""
|
|
@@ -21,6 +28,8 @@ from __future__ import annotations
|
|
|
21
28
|
import os
|
|
22
29
|
from dataclasses import dataclass, replace
|
|
23
30
|
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
from .errors import ConfigError
|
|
24
33
|
from typing import List, Optional, Tuple
|
|
25
34
|
|
|
26
35
|
# No proxy by default (set HTTPS_PROXY / --proxy when one is required).
|
|
@@ -91,16 +100,37 @@ def allowed_env_key(key: str) -> bool:
|
|
|
91
100
|
return False
|
|
92
101
|
|
|
93
102
|
|
|
94
|
-
|
|
95
|
-
|
|
103
|
+
# Keys a *project* .env may not set: they move requests, run code, widen what tools may touch, or
|
|
104
|
+
# relocate scoot's own files. Anything a cloned repository must not be able to decide for you.
|
|
105
|
+
_PROJECT_BLOCKED_KEYS = frozenset({
|
|
106
|
+
"SCOOT_APPROVAL", "SCOOT_SCOPE", "SCOOT_ROOT", "SCOOT_HOOKS", "SCOOT_CONFIG_DIR", "SCOOT_STATE_DIR",
|
|
107
|
+
*_PROXY_KEYS,
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def project_env_key_allowed(key: str) -> bool:
|
|
112
|
+
"""The project layer's allowlist: scoot's keys minus the ones that move data or grant power.
|
|
113
|
+
|
|
114
|
+
A base URL override (``SCOOT_<PROVIDER>_BASE_URL``) is the sharpest of them: with it a repository
|
|
115
|
+
could send the user's own API key to a host of its choosing on the first request.
|
|
116
|
+
"""
|
|
117
|
+
if not allowed_env_key(key):
|
|
118
|
+
return False
|
|
119
|
+
if key in _PROJECT_BLOCKED_KEYS or (key.startswith("SCOOT_") and key.endswith("_BASE_URL")):
|
|
120
|
+
return False
|
|
121
|
+
return True
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def parse_dotenv(env_path: Path) -> List[Tuple[str, str]]:
|
|
125
|
+
"""``(key, value)`` pairs from a ``.env`` file, in order; unreadable files yield nothing.
|
|
96
126
|
|
|
97
127
|
Accepts an optional ``export`` prefix and single or double quotes around the value.
|
|
98
128
|
"""
|
|
99
|
-
|
|
129
|
+
pairs: List[Tuple[str, str]] = []
|
|
100
130
|
try:
|
|
101
131
|
lines = env_path.read_text().splitlines()
|
|
102
132
|
except OSError:
|
|
103
|
-
return
|
|
133
|
+
return pairs
|
|
104
134
|
for line in lines:
|
|
105
135
|
line = line.strip()
|
|
106
136
|
if not line or line.startswith("#") or "=" not in line:
|
|
@@ -109,17 +139,32 @@ def load_dotenv(env_path: Path, allow=allowed_env_key) -> List[str]:
|
|
|
109
139
|
line = line[len("export "):].lstrip()
|
|
110
140
|
key, _, value = line.partition("=")
|
|
111
141
|
key = key.strip()
|
|
112
|
-
if not key
|
|
142
|
+
if not key:
|
|
113
143
|
continue
|
|
114
144
|
value = value.strip()
|
|
115
145
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
|
116
146
|
value = value[1:-1]
|
|
147
|
+
pairs.append((key, value))
|
|
148
|
+
return pairs
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def load_dotenv(env_path: Path, allow=allowed_env_key) -> List[str]:
|
|
152
|
+
"""Import allowed ``KEY=VALUE`` lines into ``os.environ`` (never overriding); return imported keys."""
|
|
153
|
+
imported: List[str] = []
|
|
154
|
+
for key, value in parse_dotenv(env_path):
|
|
155
|
+
if not allow(key):
|
|
156
|
+
continue
|
|
117
157
|
if key not in os.environ:
|
|
118
158
|
os.environ[key] = value
|
|
119
159
|
imported.append(key)
|
|
120
160
|
return imported
|
|
121
161
|
|
|
122
162
|
|
|
163
|
+
def rejected_project_keys(env_path: Path) -> List[str]:
|
|
164
|
+
"""Keys in a project ``.env`` that scoot knows but will not take from a repository."""
|
|
165
|
+
return [k for k, _ in parse_dotenv(env_path) if allowed_env_key(k) and not project_env_key_allowed(k)]
|
|
166
|
+
|
|
167
|
+
|
|
123
168
|
def _as_bool(value: str) -> bool:
|
|
124
169
|
return value.strip().lower() not in ("false", "0", "no", "off", "")
|
|
125
170
|
|
|
@@ -192,6 +237,7 @@ class Config:
|
|
|
192
237
|
editor: str = "idea" # external editor for the open_editor tool: idea | vscode
|
|
193
238
|
scope: str = "workspace" # where file tools may go: workspace (ask once outside it) | anywhere
|
|
194
239
|
env_files: Tuple[str, ...] = () # the .env files that were read, in load order (shown by /status)
|
|
240
|
+
ignored_project_keys: Tuple[str, ...] = () # keys the project .env tried to set that only the user may set
|
|
195
241
|
logo: bool = True # mascot in the launch banner + status-bar face (--no-logo / SCOOT_LOGO / /logo)
|
|
196
242
|
emoji: bool = True # 🛴 transcript label; off → ⏺ for terminals without an emoji font (--no-emoji)
|
|
197
243
|
|
|
@@ -201,22 +247,42 @@ class Config:
|
|
|
201
247
|
"""Load config from the ``.env`` files + environment variables, falling back to defaults."""
|
|
202
248
|
cwd = (cwd or Path.cwd()).resolve()
|
|
203
249
|
files = env_files(cwd)
|
|
250
|
+
project = find_project_env(cwd)
|
|
251
|
+
ignored: List[str] = []
|
|
204
252
|
for env_file in files:
|
|
205
|
-
|
|
253
|
+
if env_file == project:
|
|
254
|
+
load_dotenv(env_file, allow=project_env_key_allowed)
|
|
255
|
+
ignored = rejected_project_keys(env_file)
|
|
256
|
+
else:
|
|
257
|
+
load_dotenv(env_file)
|
|
206
258
|
|
|
207
259
|
get = os.environ.get
|
|
260
|
+
|
|
261
|
+
def _int(key: str, default: int, low: int, high: int) -> int:
|
|
262
|
+
raw = get(key)
|
|
263
|
+
if raw is None or raw.strip() == "":
|
|
264
|
+
return default
|
|
265
|
+
try:
|
|
266
|
+
value = int(raw.strip())
|
|
267
|
+
except ValueError:
|
|
268
|
+
raise ConfigError(f"{key} must be an integer, got {raw!r}")
|
|
269
|
+
if not (low <= value <= high):
|
|
270
|
+
raise ConfigError(f"{key} must be between {low} and {high}, got {value}")
|
|
271
|
+
return value
|
|
272
|
+
|
|
208
273
|
root = Path(get("SCOOT_ROOT", str(cwd))).resolve()
|
|
209
274
|
model, model_source = _resolve_model_setting(get("SCOOT_MODEL"), root)
|
|
210
275
|
return cls(
|
|
211
276
|
proxy=get("HTTPS_PROXY") or get("https_proxy") or DEFAULT_PROXY,
|
|
212
277
|
env_files=tuple(str(f) for f in files),
|
|
278
|
+
ignored_project_keys=tuple(ignored),
|
|
213
279
|
provider=get("SCOOT_PROVIDER", "").strip().lower(),
|
|
214
280
|
model=model,
|
|
215
281
|
model_source=model_source,
|
|
216
282
|
effort=get("SCOOT_EFFORT", "medium").strip().lower(),
|
|
217
|
-
timeout=
|
|
218
|
-
max_steps=
|
|
219
|
-
compact_at=
|
|
283
|
+
timeout=_int("SCOOT_TIMEOUT", 120, 1, 86_400),
|
|
284
|
+
max_steps=_int("SCOOT_MAX_STEPS", 50, 1, 10_000),
|
|
285
|
+
compact_at=_int("SCOOT_COMPACT_AT", 100_000, 1_000, 100_000_000),
|
|
220
286
|
approval=get("SCOOT_APPROVAL", "yolo"),
|
|
221
287
|
root=root,
|
|
222
288
|
verbose=_as_bool(get("SCOOT_VERBOSE", "false")),
|
|
@@ -229,7 +295,7 @@ class Config:
|
|
|
229
295
|
verbosity=get("SCOOT_VERBOSITY", "full").strip().lower(),
|
|
230
296
|
images=_as_bool(get("SCOOT_IMAGES", "true")),
|
|
231
297
|
vision_model=get("SCOOT_VISION_MODEL", "auto"),
|
|
232
|
-
image_max_bytes=
|
|
298
|
+
image_max_bytes=_int("SCOOT_IMAGE_MAX_BYTES", 4 * 1024 * 1024, 1024, 512 * 1024 * 1024),
|
|
233
299
|
editor=get("SCOOT_EDITOR", "idea").strip().lower(),
|
|
234
300
|
scope=get("SCOOT_SCOPE", "workspace").strip().lower(),
|
|
235
301
|
logo=_resolve_logo_setting(get("SCOOT_LOGO")),
|
|
@@ -32,21 +32,9 @@ def _load() -> Dict[str, str]:
|
|
|
32
32
|
|
|
33
33
|
|
|
34
34
|
def _write(keys: Dict[str, str]) -> Path:
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
os.chmod(directory, 0o700)
|
|
39
|
-
except OSError:
|
|
40
|
-
pass # best-effort on exotic filesystems
|
|
41
|
-
# Open with a restrictive mode from the start so a key is never briefly world-readable.
|
|
42
|
-
fd = os.open(_file(), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
43
|
-
with os.fdopen(fd, "w") as fh:
|
|
44
|
-
json.dump({"keys": keys}, fh)
|
|
45
|
-
try:
|
|
46
|
-
os.chmod(_file(), 0o600) # tighten in case the file pre-existed
|
|
47
|
-
except OSError:
|
|
48
|
-
pass
|
|
49
|
-
return _file()
|
|
35
|
+
from .store import write_json_atomic
|
|
36
|
+
|
|
37
|
+
return write_json_atomic(_file(), {"keys": keys})
|
|
50
38
|
|
|
51
39
|
|
|
52
40
|
def load_key(provider: str) -> Optional[str]:
|