scootcli 0.4.0__tar.gz → 0.6.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.4.0/src/scootcli.egg-info → scootcli-0.6.0}/PKG-INFO +14 -4
- {scootcli-0.4.0 → scootcli-0.6.0}/README.md +13 -3
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/__init__.py +1 -1
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/agent.py +70 -1
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/approvals.py +34 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/cli.py +21 -1
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/commands/__init__.py +1 -0
- scootcli-0.6.0/src/scootcli/commands/scope.py +33 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/commands/status.py +12 -3
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/config.py +2 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/headless.py +26 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/keys.py +6 -1
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/panel.py +3 -0
- scootcli-0.6.0/src/scootcli/pricing.py +80 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/prompts.py +1 -1
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/providers/router.py +3 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/repl.py +68 -3
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/status.py +1 -1
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/tools/base.py +76 -11
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/tools/edit_file.py +1 -1
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/tools/list_dir.py +1 -1
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/tools/open_editor.py +1 -1
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/tools/read_file.py +1 -1
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/tools/run_shell.py +2 -2
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/tools/write_file.py +1 -1
- scootcli-0.6.0/src/scootcli/updates.py +46 -0
- {scootcli-0.4.0 → scootcli-0.6.0/src/scootcli.egg-info}/PKG-INFO +14 -4
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli.egg-info/SOURCES.txt +5 -0
- scootcli-0.6.0/tests/test_scope.py +225 -0
- scootcli-0.6.0/tests/test_small_060.py +182 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/LICENSE +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/pyproject.toml +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/setup.cfg +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/__main__.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/activity.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/auth.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/clipboard.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/commands/approve.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/commands/auth.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/commands/base.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/commands/compact.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/commands/copy.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/commands/exit.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/commands/forget.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/commands/help.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/commands/hooks.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/commands/init.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/commands/logo.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/commands/model.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/commands/panel.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/commands/reset.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/commands/resume.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/commands/route.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/commands/save.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/commands/sessions.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/commands/verbosity.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/commands/worktree.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/commands/yolo.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/context.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/credentials.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/errors.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/hooks.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/images.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/lineeditor.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/logo.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/models.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/preferences.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/presets.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/project.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/providers/__init__.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/providers/anthropic.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/providers/base.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/providers/openai_chat.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/providers/openai_responses.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/providers/registry.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/rendering.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/sessions.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/tools/__init__.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/tools/search.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/tools/update_plan.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/transport.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/vision.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/workspace.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli/worktree.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli.egg-info/dependency_links.txt +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli.egg-info/entry_points.txt +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/src/scootcli.egg-info/top_level.txt +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_agent.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_anthropic.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_approvals.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_auth.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_clipboard.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_config.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_headless.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_hooks.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_images.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_lineeditor.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_logo.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_models.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_panel.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_preferences.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_providers.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_readiness.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_repl.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_resilience.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_router.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_sessions.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_smoke.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_streaming.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_tools.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_transport_native.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_vision.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.0}/tests/test_workspace.py +0 -0
- {scootcli-0.4.0 → scootcli-0.6.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.6.0
|
|
4
4
|
Summary: A tiny coding agent that goes where you point it
|
|
5
5
|
Author: Sergey Nes
|
|
6
6
|
License-Expression: MIT
|
|
@@ -135,7 +135,7 @@ Write your own rules in `~/.config/scoot/router.json` (or the file named by `SCO
|
|
|
135
135
|
],
|
|
136
136
|
"default": "ollama/llama3.2",
|
|
137
137
|
"classifier": {
|
|
138
|
-
"model": "
|
|
138
|
+
"model": "openai/gpt-5-mini",
|
|
139
139
|
"tiers": {"simple": "ollama/llama3.2", "coding": "openai/gpt-5.3-codex", "hard": "anthropic/claude-opus-5"}
|
|
140
140
|
}
|
|
141
141
|
}
|
|
@@ -145,7 +145,7 @@ Conditions: `complex`, `has_images`, `needs_tools`, `est_tokens_over`, `prompt_m
|
|
|
145
145
|
The optional `classifier` asks a small model one question per turn ("simple, coding, or hard?") and maps the answer to a tier; it adds a short call, and any failure falls through to the rules.
|
|
146
146
|
Expect it to be rough with a 3B local model: on a hand-labelled set of seven prompts, `llama3.2` and `qwen2.5` each got four right, mostly confusing "coding" with "hard".
|
|
147
147
|
The rules are deterministic, so put the decisions you care about there, and if you want a better judge, name a cheap hosted model as the classifier (`openai/gpt-5-mini`), which costs a few hundred tokens per turn.
|
|
148
|
-
`/route` shows the rules in force and why the current model was picked; `/status` shows tokens per model.
|
|
148
|
+
`/route` shows the rules in force and why the current model was picked; `/status` shows tokens, cached tokens, and cost per model, from the list prices in `pricing.py` (stamped with the date they were last checked; unknown models show `?`).
|
|
149
149
|
`SCOOT_EFFORT` (`low` | `medium` | `high` | `xhigh`, default `medium`) sets the reasoning effort for models that take it, on both OpenAI and Anthropic.
|
|
150
150
|
On Claude Opus 5 the server-side refusal fallback is requested by default, so a declined request is retried on another Claude model inside the same call; `SCOOT_ANTHROPIC_FALLBACKS=0` turns that off.
|
|
151
151
|
|
|
@@ -206,6 +206,8 @@ scoot --no-panel --no-dock # plain prompt, no status bar (also what yo
|
|
|
206
206
|
/model list or switch model /approve set mode (always|auto-read|auto-edits|yolo)
|
|
207
207
|
/yolo auto-approve all /worktree isolate work in a git worktree
|
|
208
208
|
/auth provider keys /logo show or toggle the mascot
|
|
209
|
+
/scope where file tools may go /hooks configured hooks and results
|
|
210
|
+
/route how auto picks a model
|
|
209
211
|
/sessions list saved sessions /resume resume a saved session ([id])
|
|
210
212
|
/forget delete session(s) /panel toggle the bottom status bar
|
|
211
213
|
/verbosity feed detail (full|compact|quiet)
|
|
@@ -213,6 +215,7 @@ scoot --no-panel --no-dock # plain prompt, no status bar (also what yo
|
|
|
213
215
|
```
|
|
214
216
|
|
|
215
217
|
Press **ESC** while a turn is running to interrupt it; the conversation is kept.
|
|
218
|
+
Press **Ctrl-N** while a turn is running to add a note: scoot asks for one line at the next model call and the model sees it before continuing ("use pytest, not unittest").
|
|
216
219
|
Type `/` and press **Tab** to complete slash commands.
|
|
217
220
|
|
|
218
221
|
**Terminals.** scoot works in macOS Terminal, iTerm2, and inside tmux; the status bar uses a scroll region, the input dock uses raw mode, and clipboard copy uses the system tool or an OSC-52 escape.
|
|
@@ -223,7 +226,11 @@ Without a TTY, scoot falls back to a plain prompt with no bar and no dock.
|
|
|
223
226
|
### Tools and approvals
|
|
224
227
|
|
|
225
228
|
The agent has eight tools: `read_file`, `list_dir`, `search`, `write_file`, `edit_file`, `run_shell`, `open_editor` (hands a file to IntelliJ IDEA's `idea -e` or to VS Code, `SCOOT_EDITOR` picks), and `update_plan` (a progress checklist for multi-step work).
|
|
226
|
-
|
|
229
|
+
Paths are resolved against the workspace root, and `~` works.
|
|
230
|
+
|
|
231
|
+
The workspace is where the agent works, not a wall.
|
|
232
|
+
When a tool needs a file outside it, in `~/.config`, another repo, `/etc`, scoot asks once: allow this path `[a]`, allow that directory for the session `[d]`, allow anywhere for the session `[A]`, skip `[s]`, or quit `[q]`.
|
|
233
|
+
`--scope anywhere`, `SCOOT_SCOPE=anywhere`, or `/scope anywhere` skip the question; `/scope` shows what has been granted.
|
|
227
234
|
|
|
228
235
|
When a call needs approval you can approve once `[a]`, trust that tool for the session `[t]`, approve everything this session `[A]`, edit the arguments `[e]`, skip `[s]`, or quit `[q]`.
|
|
229
236
|
`/approve <mode>` sets how much runs without asking: `always` prompts for everything, `auto-read` auto-approves reads, `auto-edits` also auto-approves file edits, `yolo` runs everything.
|
|
@@ -275,6 +282,9 @@ git clone https://github.com/sergenes/scootcli && cd scootcli && pip install -e
|
|
|
275
282
|
```
|
|
276
283
|
|
|
277
284
|
The install script needs only `curl` and Python 3.9+. `SCOOT_VERSION=v0.2.0` pins a release and `SCOOT_INSTALL_DIR` changes the target; read it before you run it, it is sixty lines.
|
|
285
|
+
**Upgrading.** `pipx upgrade scootcli`, or `pip install --upgrade scootcli`, or rerun the curl line, which always fetches the latest release; `scoot --version` shows what you have, and `scoot --check-update` asks PyPI whether a newer one exists. With `SCOOT_UPDATE_CHECK=1` the REPL checks once in the background at start and shows `⬆ x.y.z` in the status bar; nothing contacts PyPI otherwise.
|
|
286
|
+
`bash install.sh --uninstall` removes the command installed by the script (pipx users: `pipx uninstall scootcli`); config and sessions stay.
|
|
287
|
+
|
|
278
288
|
Both pipx and the script install into `~/.local/bin`. On a fresh Linux account that directory is added to PATH at login only if it already exists, so after the very first install either open a new login shell or run `pipx ensurepath`; the installer prints the exact line for your shell.
|
|
279
289
|
|
|
280
290
|
Requirements: Python 3.9 or newer on macOS or Linux.
|
|
@@ -110,7 +110,7 @@ Write your own rules in `~/.config/scoot/router.json` (or the file named by `SCO
|
|
|
110
110
|
],
|
|
111
111
|
"default": "ollama/llama3.2",
|
|
112
112
|
"classifier": {
|
|
113
|
-
"model": "
|
|
113
|
+
"model": "openai/gpt-5-mini",
|
|
114
114
|
"tiers": {"simple": "ollama/llama3.2", "coding": "openai/gpt-5.3-codex", "hard": "anthropic/claude-opus-5"}
|
|
115
115
|
}
|
|
116
116
|
}
|
|
@@ -120,7 +120,7 @@ Conditions: `complex`, `has_images`, `needs_tools`, `est_tokens_over`, `prompt_m
|
|
|
120
120
|
The optional `classifier` asks a small model one question per turn ("simple, coding, or hard?") and maps the answer to a tier; it adds a short call, and any failure falls through to the rules.
|
|
121
121
|
Expect it to be rough with a 3B local model: on a hand-labelled set of seven prompts, `llama3.2` and `qwen2.5` each got four right, mostly confusing "coding" with "hard".
|
|
122
122
|
The rules are deterministic, so put the decisions you care about there, and if you want a better judge, name a cheap hosted model as the classifier (`openai/gpt-5-mini`), which costs a few hundred tokens per turn.
|
|
123
|
-
`/route` shows the rules in force and why the current model was picked; `/status` shows tokens per model.
|
|
123
|
+
`/route` shows the rules in force and why the current model was picked; `/status` shows tokens, cached tokens, and cost per model, from the list prices in `pricing.py` (stamped with the date they were last checked; unknown models show `?`).
|
|
124
124
|
`SCOOT_EFFORT` (`low` | `medium` | `high` | `xhigh`, default `medium`) sets the reasoning effort for models that take it, on both OpenAI and Anthropic.
|
|
125
125
|
On Claude Opus 5 the server-side refusal fallback is requested by default, so a declined request is retried on another Claude model inside the same call; `SCOOT_ANTHROPIC_FALLBACKS=0` turns that off.
|
|
126
126
|
|
|
@@ -181,6 +181,8 @@ scoot --no-panel --no-dock # plain prompt, no status bar (also what yo
|
|
|
181
181
|
/model list or switch model /approve set mode (always|auto-read|auto-edits|yolo)
|
|
182
182
|
/yolo auto-approve all /worktree isolate work in a git worktree
|
|
183
183
|
/auth provider keys /logo show or toggle the mascot
|
|
184
|
+
/scope where file tools may go /hooks configured hooks and results
|
|
185
|
+
/route how auto picks a model
|
|
184
186
|
/sessions list saved sessions /resume resume a saved session ([id])
|
|
185
187
|
/forget delete session(s) /panel toggle the bottom status bar
|
|
186
188
|
/verbosity feed detail (full|compact|quiet)
|
|
@@ -188,6 +190,7 @@ scoot --no-panel --no-dock # plain prompt, no status bar (also what yo
|
|
|
188
190
|
```
|
|
189
191
|
|
|
190
192
|
Press **ESC** while a turn is running to interrupt it; the conversation is kept.
|
|
193
|
+
Press **Ctrl-N** while a turn is running to add a note: scoot asks for one line at the next model call and the model sees it before continuing ("use pytest, not unittest").
|
|
191
194
|
Type `/` and press **Tab** to complete slash commands.
|
|
192
195
|
|
|
193
196
|
**Terminals.** scoot works in macOS Terminal, iTerm2, and inside tmux; the status bar uses a scroll region, the input dock uses raw mode, and clipboard copy uses the system tool or an OSC-52 escape.
|
|
@@ -198,7 +201,11 @@ Without a TTY, scoot falls back to a plain prompt with no bar and no dock.
|
|
|
198
201
|
### Tools and approvals
|
|
199
202
|
|
|
200
203
|
The agent has eight tools: `read_file`, `list_dir`, `search`, `write_file`, `edit_file`, `run_shell`, `open_editor` (hands a file to IntelliJ IDEA's `idea -e` or to VS Code, `SCOOT_EDITOR` picks), and `update_plan` (a progress checklist for multi-step work).
|
|
201
|
-
|
|
204
|
+
Paths are resolved against the workspace root, and `~` works.
|
|
205
|
+
|
|
206
|
+
The workspace is where the agent works, not a wall.
|
|
207
|
+
When a tool needs a file outside it, in `~/.config`, another repo, `/etc`, scoot asks once: allow this path `[a]`, allow that directory for the session `[d]`, allow anywhere for the session `[A]`, skip `[s]`, or quit `[q]`.
|
|
208
|
+
`--scope anywhere`, `SCOOT_SCOPE=anywhere`, or `/scope anywhere` skip the question; `/scope` shows what has been granted.
|
|
202
209
|
|
|
203
210
|
When a call needs approval you can approve once `[a]`, trust that tool for the session `[t]`, approve everything this session `[A]`, edit the arguments `[e]`, skip `[s]`, or quit `[q]`.
|
|
204
211
|
`/approve <mode>` sets how much runs without asking: `always` prompts for everything, `auto-read` auto-approves reads, `auto-edits` also auto-approves file edits, `yolo` runs everything.
|
|
@@ -250,6 +257,9 @@ git clone https://github.com/sergenes/scootcli && cd scootcli && pip install -e
|
|
|
250
257
|
```
|
|
251
258
|
|
|
252
259
|
The install script needs only `curl` and Python 3.9+. `SCOOT_VERSION=v0.2.0` pins a release and `SCOOT_INSTALL_DIR` changes the target; read it before you run it, it is sixty lines.
|
|
260
|
+
**Upgrading.** `pipx upgrade scootcli`, or `pip install --upgrade scootcli`, or rerun the curl line, which always fetches the latest release; `scoot --version` shows what you have, and `scoot --check-update` asks PyPI whether a newer one exists. With `SCOOT_UPDATE_CHECK=1` the REPL checks once in the background at start and shows `⬆ x.y.z` in the status bar; nothing contacts PyPI otherwise.
|
|
261
|
+
`bash install.sh --uninstall` removes the command installed by the script (pipx users: `pipx uninstall scootcli`); config and sessions stay.
|
|
262
|
+
|
|
253
263
|
Both pipx and the script install into `~/.local/bin`. On a fresh Linux account that directory is added to PATH at login only if it already exists, so after the very first install either open a new login shell or run `pipx ensurepath`; the installer prints the exact line for your shell.
|
|
254
264
|
|
|
255
265
|
Requirements: Python 3.9 or newer on macOS or Linux.
|
|
@@ -70,6 +70,12 @@ class HeadlessUI:
|
|
|
70
70
|
self.events.append(("approve", tool.name, args))
|
|
71
71
|
return Approval(self.decision, args)
|
|
72
72
|
|
|
73
|
+
scope_decision = "dir" # what this headless UI answers to a scope question (tests override)
|
|
74
|
+
|
|
75
|
+
def approve_scope(self, tool, path, ctx) -> str:
|
|
76
|
+
self.events.append(("scope", tool.name, str(path)))
|
|
77
|
+
return self.scope_decision
|
|
78
|
+
|
|
73
79
|
def auto_approved(self, tool, args) -> None:
|
|
74
80
|
self.events.append(("auto", tool.name, args))
|
|
75
81
|
|
|
@@ -162,6 +168,9 @@ class Agent:
|
|
|
162
168
|
|
|
163
169
|
Returns ``(result, streamed)`` where ``streamed`` is True if content was already printed.
|
|
164
170
|
"""
|
|
171
|
+
taker = getattr(ui, "take_note", None)
|
|
172
|
+
if callable(taker):
|
|
173
|
+
taker(session) # a Ctrl-N pressed during the previous activity
|
|
165
174
|
messages = self._messages(session, cfg)
|
|
166
175
|
hints = {"task_text": self._last_user_text(session), "needs_tools": True, "step": steps}
|
|
167
176
|
if self._streaming and hasattr(ui, "stream") and hasattr(self.provider, "chat_stream"):
|
|
@@ -345,7 +354,16 @@ class Agent:
|
|
|
345
354
|
# ── tool execution ───────────────────────────────────────────────────────────
|
|
346
355
|
def _run_tools(self, session, tool_calls, ui, cancel_event, cfg=None) -> Optional[str]:
|
|
347
356
|
cfg = cfg or getattr(session, "config", None) or self.config
|
|
348
|
-
|
|
357
|
+
scope = getattr(session, "scope", None)
|
|
358
|
+
if scope is None:
|
|
359
|
+
from .tools.base import Scope
|
|
360
|
+
|
|
361
|
+
scope = Scope(cfg.root, everything=(getattr(cfg, "scope", "workspace") == "anywhere"))
|
|
362
|
+
try:
|
|
363
|
+
session.scope = scope
|
|
364
|
+
except Exception:
|
|
365
|
+
pass
|
|
366
|
+
ctx = ToolContext(root=cfg.root, config=cfg, cancel_event=cancel_event, scope=scope)
|
|
349
367
|
mode = getattr(session, "approval_mode", "always")
|
|
350
368
|
# Session trust-list: tools the user chose to auto-approve for the rest of the session.
|
|
351
369
|
trusted = getattr(session, "trusted_tools", None)
|
|
@@ -368,6 +386,17 @@ class Agent:
|
|
|
368
386
|
self._append_tool(session, tc_id, f"error: unknown tool '{name}'")
|
|
369
387
|
continue
|
|
370
388
|
|
|
389
|
+
# Paths outside the workspace: ask the user once (this path, its directory, or anywhere).
|
|
390
|
+
outside = self._outside_paths(tool, args, ctx)
|
|
391
|
+
if outside:
|
|
392
|
+
verdict = self._ask_scope(session, tool, outside, ctx, ui)
|
|
393
|
+
if verdict == "abort":
|
|
394
|
+
self._append_tool(session, tc_id, "user aborted the operation")
|
|
395
|
+
return "abort"
|
|
396
|
+
if verdict == "deny":
|
|
397
|
+
self._append_tool(session, tc_id, f"user declined access outside the workspace: {outside[0]}")
|
|
398
|
+
ui.tool_result(name, ToolResult(ok=False, summary="outside the workspace: declined"))
|
|
399
|
+
continue
|
|
371
400
|
# A PreToolUse hook may deny (skip the tool), allow (skip the prompt), or ask (force it).
|
|
372
401
|
hook_action, hook_reason = self._pre_tool_hook(session, tool, args, cancel_event)
|
|
373
402
|
if hook_action == "deny":
|
|
@@ -419,6 +448,46 @@ class Agent:
|
|
|
419
448
|
self._append_tool(session, tc_id, payload or "(no output)")
|
|
420
449
|
return None
|
|
421
450
|
|
|
451
|
+
@staticmethod
|
|
452
|
+
def _outside_paths(tool, args, ctx) -> "list":
|
|
453
|
+
from .tools.base import resolve_path
|
|
454
|
+
|
|
455
|
+
scope = ctx.scope
|
|
456
|
+
found = []
|
|
457
|
+
for raw in tool.paths(args):
|
|
458
|
+
try:
|
|
459
|
+
p = resolve_path(ctx.root, raw)
|
|
460
|
+
except Exception:
|
|
461
|
+
continue
|
|
462
|
+
if scope is None or not scope.allows(p):
|
|
463
|
+
found.append(p)
|
|
464
|
+
return found
|
|
465
|
+
|
|
466
|
+
def _ask_scope(self, session, tool, paths, ctx, ui) -> str:
|
|
467
|
+
"""Ask for the first outside path; grant per the answer. Returns once|dir|all|deny|abort."""
|
|
468
|
+
scope = ctx.scope
|
|
469
|
+
if getattr(session, "config", None) is not None and getattr(session.config, "scope", "") == "anywhere":
|
|
470
|
+
scope.grant_all()
|
|
471
|
+
return "all"
|
|
472
|
+
asker = getattr(ui, "approve_scope", None)
|
|
473
|
+
if asker is None:
|
|
474
|
+
return "deny"
|
|
475
|
+
from .hooks import notify
|
|
476
|
+
|
|
477
|
+
notify(session, "scope", f"{tool.name} wants {paths[0]} outside the workspace")
|
|
478
|
+
verdict = asker(tool, paths[0], ctx)
|
|
479
|
+
if verdict == "once":
|
|
480
|
+
for p in paths:
|
|
481
|
+
scope.grant(p)
|
|
482
|
+
elif verdict == "dir":
|
|
483
|
+
for p in paths:
|
|
484
|
+
scope.grant_dir(p)
|
|
485
|
+
ui.assistant(f"allowing {scope.granted[-1]} for the rest of this session.")
|
|
486
|
+
elif verdict == "all":
|
|
487
|
+
scope.grant_all()
|
|
488
|
+
ui.assistant("allowing access anywhere on this machine for the rest of this session.")
|
|
489
|
+
return verdict
|
|
490
|
+
|
|
422
491
|
@staticmethod
|
|
423
492
|
def _handle_result(session, name: str, result: ToolResult, ui) -> None:
|
|
424
493
|
"""Surface a tool result: render a plan update specially, else the generic result line."""
|
|
@@ -126,6 +126,40 @@ def _edit_args(args: dict) -> dict:
|
|
|
126
126
|
return args
|
|
127
127
|
|
|
128
128
|
|
|
129
|
+
SCOPE_CHOICES = ("once", "dir", "all", "deny", "abort")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def request_scope(tool: Tool, path, ctx: ToolContext) -> str:
|
|
133
|
+
"""Ask once about a path outside the workspace. Returns one of ``SCOPE_CHOICES``."""
|
|
134
|
+
from pathlib import Path as _P
|
|
135
|
+
|
|
136
|
+
p = _P(str(path))
|
|
137
|
+
where = p if p.is_dir() else p.parent
|
|
138
|
+
print(color(f" ⌂ {tool.name} wants {p}", "yellow") + color(" (outside the workspace)", "gray"))
|
|
139
|
+
while True:
|
|
140
|
+
print(
|
|
141
|
+
" "
|
|
142
|
+
+ color("allow this path", "green") + " [a] "
|
|
143
|
+
+ color(f"allow {where} this session", "green") + " [d] "
|
|
144
|
+
+ color("allow anywhere this session", "green") + " [A] "
|
|
145
|
+
+ color("skip", "yellow") + " [s] "
|
|
146
|
+
+ color("quit", "red") + " [q] › ",
|
|
147
|
+
end="", flush=True,
|
|
148
|
+
)
|
|
149
|
+
key = read_key()
|
|
150
|
+
print(key)
|
|
151
|
+
if key in ("a", "y", "\r", "\n", ""):
|
|
152
|
+
return "once"
|
|
153
|
+
if key == "d":
|
|
154
|
+
return "dir"
|
|
155
|
+
if key == "A":
|
|
156
|
+
return "all"
|
|
157
|
+
if key == "s":
|
|
158
|
+
return "deny"
|
|
159
|
+
if key == "q":
|
|
160
|
+
return "abort"
|
|
161
|
+
|
|
162
|
+
|
|
129
163
|
def request_approval(tool: Tool, args: dict, ctx: ToolContext) -> Approval:
|
|
130
164
|
"""Show the pending tool call and read the user's decision (with optional arg editing)."""
|
|
131
165
|
while True:
|
|
@@ -33,6 +33,8 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
33
33
|
)
|
|
34
34
|
# Global flags (CLI-flag layer of config precedence).
|
|
35
35
|
parser.add_argument("--version", action="version", version=f"scoot {__version__}")
|
|
36
|
+
parser.add_argument("--check-update", action="store_true",
|
|
37
|
+
help="Ask PyPI whether a newer scoot exists, print both versions, and exit.")
|
|
36
38
|
parser.add_argument("--model", help="Model as provider/model (e.g. openai/gpt-5.3-codex), 'default', or 'auto'.")
|
|
37
39
|
parser.add_argument("--provider", help="Default provider (openai, ollama, ...) for bare model names.")
|
|
38
40
|
parser.add_argument("--effort", choices=["low", "medium", "high", "xhigh"],
|
|
@@ -59,6 +61,8 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
59
61
|
help="Don't inject the repo map (git + file tree) into the agent prompt.")
|
|
60
62
|
parser.add_argument("--no-labels", action="store_true",
|
|
61
63
|
help="Hide the role labels/gutters (❯ you / ⏺ scoot) in the REPL transcript.")
|
|
64
|
+
parser.add_argument("--scope", choices=["workspace", "anywhere"],
|
|
65
|
+
help="Where file tools may go: workspace (default; the first access outside it asks once) or anywhere.")
|
|
62
66
|
parser.add_argument("--headless", action="store_true",
|
|
63
67
|
help="Line-delimited JSON on stdin/stdout instead of the REPL (see docs/headless-protocol.md).")
|
|
64
68
|
parser.add_argument("--no-logo", action="store_true",
|
|
@@ -92,6 +96,7 @@ def _config_from_args(args: argparse.Namespace) -> Config:
|
|
|
92
96
|
proxy=args.proxy,
|
|
93
97
|
root=args.root,
|
|
94
98
|
approval="yolo" if (args.yolo or args.yes) else args.approval,
|
|
99
|
+
scope="anywhere" if args.yes else args.scope,
|
|
95
100
|
verbose=True if args.verbose else None,
|
|
96
101
|
stream=False if args.no_stream else None,
|
|
97
102
|
panel=False if args.no_panel else None,
|
|
@@ -196,10 +201,13 @@ def _run_once(config: Config, pool: ProviderPool, prompt: str, as_json: bool, re
|
|
|
196
201
|
if outcome.content.strip() and not outcome.streamed:
|
|
197
202
|
print(outcome.content.strip())
|
|
198
203
|
if config.verbose:
|
|
204
|
+
from .pricing import fmt
|
|
205
|
+
|
|
199
206
|
u = session.last_usage
|
|
200
207
|
eprint(color(f"[{session.active_model}] steps={outcome.steps} "
|
|
201
208
|
f"prompt={u.get('prompt_tokens', '?')} "
|
|
202
|
-
f"completion={u.get('completion_tokens', '?')}
|
|
209
|
+
f"completion={u.get('completion_tokens', '?')} "
|
|
210
|
+
f"cost={fmt(session.session_cost())}", "gray"))
|
|
203
211
|
return 0
|
|
204
212
|
eprint(color(f"⚠ {redact(outcome.error or outcome.status)}", "red"))
|
|
205
213
|
return 1
|
|
@@ -267,6 +275,18 @@ def _resolve_prompt(args: argparse.Namespace) -> Optional[str]:
|
|
|
267
275
|
|
|
268
276
|
def main(argv: Optional[List[str]] = None) -> int:
|
|
269
277
|
args = _build_parser().parse_args(argv)
|
|
278
|
+
if args.check_update:
|
|
279
|
+
from .updates import check, is_newer, upgrade_hint
|
|
280
|
+
|
|
281
|
+
current, latest = check()
|
|
282
|
+
if latest is None:
|
|
283
|
+
print(f"scoot {current} (could not reach PyPI to check for updates)")
|
|
284
|
+
return 1
|
|
285
|
+
if is_newer(latest, current):
|
|
286
|
+
print(f"scoot {current}; newer release available: {latest}\n {upgrade_hint()}")
|
|
287
|
+
else:
|
|
288
|
+
print(f"scoot {current} is the latest release.")
|
|
289
|
+
return 0
|
|
270
290
|
try:
|
|
271
291
|
config = _config_from_args(args)
|
|
272
292
|
pool = ProviderPool(config)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""/scope — where file tools may go: the workspace, plus what you allowed, or anywhere."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..rendering import color, eprint
|
|
6
|
+
from . import register
|
|
7
|
+
from .base import SlashCommand
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _run(session, args: str):
|
|
11
|
+
scope = getattr(session, "scope", None)
|
|
12
|
+
if scope is None:
|
|
13
|
+
eprint(color("scope is not available in this context.", "yellow"))
|
|
14
|
+
return
|
|
15
|
+
want = (args or "").strip().lower()
|
|
16
|
+
if want in ("anywhere", "all", "everywhere"):
|
|
17
|
+
scope.grant_all()
|
|
18
|
+
print(color("file tools may now go anywhere on this machine for this session.", "gray"))
|
|
19
|
+
elif want == "workspace":
|
|
20
|
+
scope.everything = False
|
|
21
|
+
scope.granted.clear()
|
|
22
|
+
print(color("back to the workspace only; the next access outside it will ask.", "gray"))
|
|
23
|
+
elif want:
|
|
24
|
+
scope.grant_dir(want)
|
|
25
|
+
print(color(f"allowed {scope.granted[-1]} for this session.", "gray"))
|
|
26
|
+
print(color("scope: ", "bold") + scope.describe())
|
|
27
|
+
for g in scope.granted:
|
|
28
|
+
print(f" {color('+', 'green')} {g}")
|
|
29
|
+
print(color(" usage: /scope anywhere · /scope workspace · /scope <dir> (also --scope, SCOOT_SCOPE)", "gray"))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
register(SlashCommand("scope", "where file tools may go (workspace | anywhere | <dir>)", _run,
|
|
33
|
+
usage="[anywhere|workspace|<dir>]"))
|
|
@@ -23,8 +23,11 @@ def _run(session, args: str):
|
|
|
23
23
|
("model", session.active_model + (" (auto)" if session.model.lower() == "auto" else "")),
|
|
24
24
|
("effort", getattr(cfg, "effort", "medium")),
|
|
25
25
|
("env files", ", ".join(getattr(cfg, "env_files", ()) or ()) or "none"),
|
|
26
|
+
("update", (f"{session.update_available} available: pipx upgrade scootcli"
|
|
27
|
+
if getattr(session, "update_available", "") else "none known (scoot --check-update, or SCOOT_UPDATE_CHECK=1)")),
|
|
26
28
|
("root", str(cfg.root)),
|
|
27
29
|
("approval", session.approval_mode),
|
|
30
|
+
("scope", getattr(session, "scope", None).describe() if getattr(session, "scope", None) else "workspace"),
|
|
28
31
|
("streaming", "on" if getattr(cfg, "stream", True) else "off"),
|
|
29
32
|
("panel", "on" if getattr(session, "status_bar", None) and session.status_bar.enabled
|
|
30
33
|
else "off"),
|
|
@@ -43,10 +46,16 @@ def _run(session, args: str):
|
|
|
43
46
|
for label, value in rows:
|
|
44
47
|
print(f" {color(label + ':', 'gray'):<28} {value}")
|
|
45
48
|
by_model = getattr(session, "usage_by_model", None) or {}
|
|
46
|
-
if
|
|
47
|
-
|
|
49
|
+
if by_model:
|
|
50
|
+
from ..pricing import UPDATED, cost, fmt
|
|
51
|
+
|
|
52
|
+
print(f" {color('tokens and cost by model:', 'gray')}")
|
|
48
53
|
for name, u in sorted(by_model.items(), key=lambda kv: -(kv[1]['prompt'] + kv[1]['completion'])):
|
|
49
|
-
|
|
54
|
+
c = cost(name, u["prompt"], u["completion"], u.get("cached", 0))
|
|
55
|
+
cached = f" cached={u['cached']}" if u.get("cached") else ""
|
|
56
|
+
print(f" {color(name, 'cyan'):<40} prompt={u['prompt']}{cached} completion={u['completion']} calls={u['calls']} {fmt(c)}")
|
|
57
|
+
total = session.session_cost() if hasattr(session, "session_cost") else None
|
|
58
|
+
print(f" {color('cost (session):', 'gray'):<28} {fmt(total)} {color(f'list prices as of {UPDATED}; unknown models show ?', 'gray')}")
|
|
50
59
|
if session.model.lower() == "auto" and getattr(session, "route_reason", ""):
|
|
51
60
|
print(f" {color('routed by:', 'gray'):<28} {session.route_reason}")
|
|
52
61
|
trusted = getattr(session, "trusted_tools", None)
|
|
@@ -179,6 +179,7 @@ class Config:
|
|
|
179
179
|
vision_model: str = "auto" # provider/model for image descriptions, or "auto" → pick a capable one
|
|
180
180
|
image_max_bytes: int = 4 * 1024 * 1024 # per-image cap (no downscale without a 3rd-party lib)
|
|
181
181
|
editor: str = "idea" # external editor for the open_editor tool: idea | vscode
|
|
182
|
+
scope: str = "workspace" # where file tools may go: workspace (ask once outside it) | anywhere
|
|
182
183
|
env_files: Tuple[str, ...] = () # the .env files that were read, in load order (shown by /status)
|
|
183
184
|
logo: bool = True # mascot in the launch banner + status-bar face (--no-logo / SCOOT_LOGO / /logo)
|
|
184
185
|
emoji: bool = True # 🛴 transcript label; off → ⏺ for terminals without an emoji font (--no-emoji)
|
|
@@ -216,6 +217,7 @@ class Config:
|
|
|
216
217
|
vision_model=get("SCOOT_VISION_MODEL", "auto"),
|
|
217
218
|
image_max_bytes=int(get("SCOOT_IMAGE_MAX_BYTES", str(4 * 1024 * 1024))),
|
|
218
219
|
editor=get("SCOOT_EDITOR", "idea").strip().lower(),
|
|
220
|
+
scope=get("SCOOT_SCOPE", "workspace").strip().lower(),
|
|
219
221
|
logo=_resolve_logo_setting(get("SCOOT_LOGO")),
|
|
220
222
|
emoji=_as_bool(get("SCOOT_EMOJI", "true")),
|
|
221
223
|
)
|
|
@@ -136,6 +136,32 @@ class HeadlessUI:
|
|
|
136
136
|
auto_approved=False, decision=str(msg.get("decision")).lower())
|
|
137
137
|
return Approval(decision, new_args)
|
|
138
138
|
|
|
139
|
+
def approve_scope(self, tool, path, ctx) -> str:
|
|
140
|
+
req = self._call_id()
|
|
141
|
+
self.writer.emit("scope_request", id=req, name=tool.name, path=str(path),
|
|
142
|
+
options=["allow_once", "allow_dir", "allow_all", "deny", "abort"], timeout_s=self.timeout)
|
|
143
|
+
mapping = {"allow_once": "once", "allow_dir": "dir", "allow_all": "all", "deny": "deny", "abort": "abort"}
|
|
144
|
+
deadline = time.time() + self.timeout
|
|
145
|
+
while True:
|
|
146
|
+
remaining = deadline - time.time()
|
|
147
|
+
if remaining <= 0:
|
|
148
|
+
self.writer.emit("notice", message=f"scope request {req} timed out after {self.timeout:g}s; denied")
|
|
149
|
+
return "deny"
|
|
150
|
+
if self.cancel_event is not None and self.cancel_event.is_set():
|
|
151
|
+
return "abort"
|
|
152
|
+
try:
|
|
153
|
+
msg = self.answers.get(timeout=min(0.2, remaining))
|
|
154
|
+
except queue.Empty:
|
|
155
|
+
continue
|
|
156
|
+
if msg.get("id") not in (None, "", req):
|
|
157
|
+
self.writer.emit("error", message=f"answer for unknown request {msg.get('id')}", kind="protocol")
|
|
158
|
+
continue
|
|
159
|
+
verdict = mapping.get(str(msg.get("decision", "")).lower())
|
|
160
|
+
if verdict is None:
|
|
161
|
+
self.writer.emit("error", message=f"unknown scope decision {msg.get('decision')!r}; expected one of {list(mapping)}", kind="protocol")
|
|
162
|
+
continue
|
|
163
|
+
return verdict
|
|
164
|
+
|
|
139
165
|
def plan(self, plan) -> None:
|
|
140
166
|
self.writer.emit("plan", steps=plan)
|
|
141
167
|
|
|
@@ -24,6 +24,7 @@ except ImportError: # pragma: no cover - non-POSIX
|
|
|
24
24
|
_HAVE_TERMIOS = False
|
|
25
25
|
|
|
26
26
|
ESC = "\x1b"
|
|
27
|
+
NOTE_KEY = b"\x0e" # Ctrl-N: ask for a note at the next model call
|
|
27
28
|
|
|
28
29
|
|
|
29
30
|
def read_key() -> str:
|
|
@@ -54,8 +55,9 @@ class InterruptibleSection:
|
|
|
54
55
|
is simply unavailable — the work still runs normally.
|
|
55
56
|
"""
|
|
56
57
|
|
|
57
|
-
def __init__(self, cancel_event: threading.Event):
|
|
58
|
+
def __init__(self, cancel_event: threading.Event, note_event: "threading.Event | None" = None):
|
|
58
59
|
self.cancel_event = cancel_event
|
|
60
|
+
self.note_event = note_event
|
|
59
61
|
self.enabled = _HAVE_TERMIOS and sys.stdin.isatty()
|
|
60
62
|
self._fd = sys.stdin.fileno() if self.enabled else -1
|
|
61
63
|
self._old_attrs = None
|
|
@@ -88,6 +90,9 @@ class InterruptibleSection:
|
|
|
88
90
|
ch = os.read(self._fd, 1)
|
|
89
91
|
except (OSError, ValueError):
|
|
90
92
|
break
|
|
93
|
+
if ch == NOTE_KEY and self.note_event is not None:
|
|
94
|
+
self.note_event.set()
|
|
95
|
+
continue
|
|
91
96
|
if ch == b"\x1b":
|
|
92
97
|
# Distinguish a bare ESC (a real interrupt) from an escape sequence — arrow keys,
|
|
93
98
|
# mouse reports, or bracketed-paste markers (\x1b[201~) left in the buffer after a
|
|
@@ -108,6 +108,9 @@ def build_status_text(session, user: Optional[str] = None) -> str:
|
|
|
108
108
|
err = getattr(session, "last_error", None)
|
|
109
109
|
if err:
|
|
110
110
|
segments.append(f"⚠ {err}")
|
|
111
|
+
newer = getattr(session, "update_available", "")
|
|
112
|
+
if newer:
|
|
113
|
+
segments.append(f"⬆ {newer}")
|
|
111
114
|
# The mascot's face leads the bar; its eyes follow the turn (idle / thinking / stopped).
|
|
112
115
|
if getattr(getattr(session, "config", None), "logo", True):
|
|
113
116
|
from .logo import face
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Published list prices per million tokens, for the cost column in ``/status``.
|
|
2
|
+
|
|
3
|
+
This table goes stale; ``UPDATED`` says when it was last checked against the vendors' pricing pages.
|
|
4
|
+
Unknown models get no cost, never a guess. Local models cost nothing. Prices are USD.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Optional, Tuple
|
|
10
|
+
|
|
11
|
+
from .providers.base import split_model_id
|
|
12
|
+
|
|
13
|
+
UPDATED = "2026-09-06"
|
|
14
|
+
|
|
15
|
+
# (model id prefix, input, cached input, output). Longest matching prefix wins.
|
|
16
|
+
# Anthropic: cache reads are 10% of input; cache writes (25% premium) are counted as plain input here.
|
|
17
|
+
PRICES = [
|
|
18
|
+
# OpenAI (developers.openai.com/api/docs/pricing)
|
|
19
|
+
("gpt-5.6-astra", 10.00, 1.00, 50.00), ("gpt-5.6-sol", 4.00, 0.40, 20.00),
|
|
20
|
+
("gpt-5.6-terra", 2.00, 0.20, 12.00), ("gpt-5.6-luna", 0.20, 0.02, 1.20),
|
|
21
|
+
("gpt-5.5-pro", 30.00, 30.00, 180.00), ("gpt-5.5", 5.00, 0.50, 30.00),
|
|
22
|
+
("gpt-5.4-pro", 30.00, 30.00, 180.00), ("gpt-5.4-mini", 0.75, 0.075, 4.50),
|
|
23
|
+
("gpt-5.4-nano", 0.20, 0.02, 1.25), ("gpt-5.4", 2.50, 0.25, 15.00),
|
|
24
|
+
("gpt-5.3-codex", 1.75, 0.175, 14.00), ("gpt-5.2-pro", 21.00, 21.00, 168.00),
|
|
25
|
+
("gpt-5.2", 1.75, 0.175, 14.00), ("gpt-5.1", 1.25, 0.125, 10.00),
|
|
26
|
+
("gpt-5-pro", 15.00, 15.00, 120.00), ("gpt-5-mini", 0.25, 0.025, 2.00),
|
|
27
|
+
("gpt-5-nano", 0.05, 0.005, 0.40), ("gpt-5", 1.25, 0.125, 10.00),
|
|
28
|
+
("gpt-4.1-mini", 0.40, 0.10, 1.60), ("gpt-4.1", 2.00, 0.50, 8.00),
|
|
29
|
+
("gpt-4o-mini", 0.15, 0.075, 0.60), ("gpt-4o", 2.50, 1.25, 10.00),
|
|
30
|
+
# Anthropic (first-party API rates)
|
|
31
|
+
("claude-fable-5", 10.00, 1.00, 50.00), ("claude-mythos-5", 10.00, 1.00, 50.00),
|
|
32
|
+
("claude-opus-5", 5.00, 0.50, 25.00), ("claude-opus-4-8", 5.00, 0.50, 25.00),
|
|
33
|
+
("claude-opus-4-7", 5.00, 0.50, 25.00), ("claude-opus-4-6", 5.00, 0.50, 25.00),
|
|
34
|
+
("claude-sonnet-5", 2.00, 0.20, 10.00), ("claude-sonnet-4-6", 3.00, 0.30, 15.00),
|
|
35
|
+
("claude-haiku-4-5", 1.00, 0.10, 5.00),
|
|
36
|
+
]
|
|
37
|
+
_FREE_PROVIDERS = ("ollama",)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def price_for(model: str) -> Optional[Tuple[float, float, float]]:
|
|
41
|
+
"""``(input, cached_input, output)`` per million tokens, or ``None`` when unknown."""
|
|
42
|
+
provider, name = split_model_id(model)
|
|
43
|
+
if provider in _FREE_PROVIDERS:
|
|
44
|
+
return (0.0, 0.0, 0.0)
|
|
45
|
+
name = (name or model or "").lower()
|
|
46
|
+
best = None
|
|
47
|
+
for prefix, inp, cached, out in PRICES:
|
|
48
|
+
if name.startswith(prefix) and (best is None or len(prefix) > len(best[0])):
|
|
49
|
+
best = (prefix, inp, cached, out)
|
|
50
|
+
return (best[1], best[2], best[3]) if best else None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def cost(model: str, prompt: int, completion: int, cached: int = 0) -> Optional[float]:
|
|
54
|
+
"""USD for one or more calls; ``cached`` is the part of ``prompt`` served from cache."""
|
|
55
|
+
p = price_for(model)
|
|
56
|
+
if p is None:
|
|
57
|
+
return None
|
|
58
|
+
inp, cached_price, out = p
|
|
59
|
+
cached = max(0, min(int(cached or 0), int(prompt or 0)))
|
|
60
|
+
fresh = int(prompt or 0) - cached
|
|
61
|
+
return (fresh * inp + cached * cached_price + int(completion or 0) * out) / 1_000_000
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def cached_tokens(usage: dict) -> int:
|
|
65
|
+
"""Cached-input tokens from a normalized usage dict, whichever vendor shape it came in."""
|
|
66
|
+
if not isinstance(usage, dict):
|
|
67
|
+
return 0
|
|
68
|
+
n = usage.get("cache_read_input_tokens")
|
|
69
|
+
if n is None:
|
|
70
|
+
details = usage.get("input_tokens_details") or usage.get("prompt_tokens_details") or {}
|
|
71
|
+
n = details.get("cached_tokens") if isinstance(details, dict) else 0
|
|
72
|
+
return int(n or 0)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def fmt(usd: Optional[float]) -> str:
|
|
76
|
+
if usd is None:
|
|
77
|
+
return "?"
|
|
78
|
+
if usd == 0:
|
|
79
|
+
return "$0"
|
|
80
|
+
return f"${usd:.4f}" if usd < 0.01 else f"${usd:.2f}"
|
|
@@ -23,7 +23,7 @@ You run inside the user's workspace and act through tools. Be concise, correct,
|
|
|
23
23
|
ENVIRONMENT
|
|
24
24
|
- Working directory (root): {{workspace_root}}
|
|
25
25
|
- OS: {{os}} Model: {{model}}
|
|
26
|
-
-
|
|
26
|
+
- The root is your workspace, not a wall: when the task needs files elsewhere on this machine (~/.config, another repo, /etc), use them with absolute paths (~ is fine). The first access outside the workspace asks the user once; never tell the user you are not allowed to leave the workspace, and never hand them a script to run in your place because of it.
|
|
27
27
|
|
|
28
28
|
WORKSPACE (auto-generated map of the current repo — use it to orient before listing/reading files)
|
|
29
29
|
{{workspace}}
|
|
@@ -220,6 +220,9 @@ def describe(router: Router) -> List[str]:
|
|
|
220
220
|
if cfg.classifier:
|
|
221
221
|
lines.append(f"classifier: {cfg.classifier['model']} → " + ", ".join(
|
|
222
222
|
f"{k}={v}" for k, v in cfg.classifier["tiers"].items()))
|
|
223
|
+
if str(cfg.classifier["model"]).startswith("ollama/"):
|
|
224
|
+
lines.append(" note: small local models are rough judges here (about half right in our test); "
|
|
225
|
+
"a hosted mini model such as openai/gpt-5-mini classifies better for a few hundred tokens")
|
|
223
226
|
for i, rule in enumerate(cfg.rules, 1):
|
|
224
227
|
lines.append(f" {i}. when {json.dumps(rule.get('when') or {})} → {rule['use']}")
|
|
225
228
|
if cfg.default:
|