scootcli 0.1.0__tar.gz → 0.3.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.1.0 → scootcli-0.3.0}/LICENSE +1 -1
- {scootcli-0.1.0/src/scootcli.egg-info → scootcli-0.3.0}/PKG-INFO +48 -6
- {scootcli-0.1.0 → scootcli-0.3.0}/README.md +47 -5
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/__init__.py +1 -1
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/agent.py +22 -5
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/auth.py +8 -3
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/cli.py +15 -4
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/commands/__init__.py +1 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/commands/auth.py +4 -2
- scootcli-0.3.0/src/scootcli/commands/route.py +21 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/commands/status.py +7 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/config.py +2 -0
- scootcli-0.3.0/src/scootcli/providers/anthropic.py +354 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/providers/base.py +32 -7
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/providers/registry.py +70 -2
- scootcli-0.3.0/src/scootcli/providers/router.py +231 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/repl.py +18 -5
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/status.py +14 -1
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/tools/__init__.py +1 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/tools/base.py +28 -1
- scootcli-0.3.0/src/scootcli/tools/open_editor.py +123 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/tools/run_shell.py +3 -1
- {scootcli-0.1.0 → scootcli-0.3.0/src/scootcli.egg-info}/PKG-INFO +48 -6
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli.egg-info/SOURCES.txt +7 -0
- scootcli-0.3.0/tests/test_anthropic.py +286 -0
- scootcli-0.3.0/tests/test_readiness.py +134 -0
- scootcli-0.3.0/tests/test_router.py +155 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/tests/test_tools.py +64 -2
- {scootcli-0.1.0 → scootcli-0.3.0}/pyproject.toml +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/setup.cfg +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/__main__.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/activity.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/approvals.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/clipboard.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/commands/approve.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/commands/base.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/commands/compact.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/commands/copy.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/commands/exit.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/commands/forget.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/commands/help.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/commands/init.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/commands/logo.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/commands/model.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/commands/panel.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/commands/reset.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/commands/resume.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/commands/save.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/commands/sessions.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/commands/verbosity.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/commands/worktree.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/commands/yolo.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/context.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/credentials.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/errors.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/images.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/keys.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/lineeditor.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/logo.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/models.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/panel.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/preferences.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/presets.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/project.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/prompts.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/providers/__init__.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/providers/openai_chat.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/providers/openai_responses.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/rendering.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/sessions.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/tools/edit_file.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/tools/list_dir.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/tools/read_file.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/tools/search.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/tools/update_plan.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/tools/write_file.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/transport.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/vision.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/workspace.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/worktree.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli.egg-info/dependency_links.txt +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli.egg-info/entry_points.txt +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli.egg-info/top_level.txt +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/tests/test_agent.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/tests/test_approvals.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/tests/test_auth.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/tests/test_clipboard.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/tests/test_config.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/tests/test_images.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/tests/test_lineeditor.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/tests/test_logo.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/tests/test_models.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/tests/test_panel.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/tests/test_preferences.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/tests/test_providers.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/tests/test_repl.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/tests/test_resilience.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/tests/test_sessions.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/tests/test_smoke.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/tests/test_streaming.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/tests/test_transport_native.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/tests/test_vision.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.0}/tests/test_workspace.py +0 -0
- {scootcli-0.1.0 → scootcli-0.3.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.3.0
|
|
4
4
|
Summary: A tiny coding agent that goes where you point it
|
|
5
5
|
Author: Sergey Nes
|
|
6
6
|
License-Expression: MIT
|
|
@@ -25,17 +25,25 @@ Dynamic: license-file
|
|
|
25
25
|
|
|
26
26
|
# scoot
|
|
27
27
|
|
|
28
|
+
[](https://pypi.org/project/scootcli/)
|
|
29
|
+
[](https://github.com/sergenes/scootcli/actions/workflows/tests.yml)
|
|
30
|
+
[](https://www.python.org/downloads/)
|
|
31
|
+
[](#install-options)
|
|
32
|
+
[](#install-options)
|
|
33
|
+
[](#providers-and-models)
|
|
34
|
+
[](./LICENSE)
|
|
35
|
+
|
|
28
36
|
```
|
|
29
37
|
╭───╮ scoot: a tiny coding agent that goes where you point it.
|
|
30
38
|
│o o│
|
|
31
|
-
T──┤───┤ pipx install scootcli
|
|
39
|
+
T──┤───┤ pipx install scootcli (or: curl -fsSL https://raw.githubusercontent.com/sergenes/scootcli/main/install.sh | bash)
|
|
32
40
|
│ ╰┬─┬╯ scoot
|
|
33
41
|
(o)═══╧═╧═(o)
|
|
34
42
|
```
|
|
35
43
|
|
|
36
44
|
`scoot` is a terminal coding agent in plain Python.
|
|
37
45
|
You type what you want in natural language; it reads, searches, edits, and runs things in your repo, asking before anything risky.
|
|
38
|
-
It talks to official model APIs (OpenAI
|
|
46
|
+
It talks to official model APIs (OpenAI, Anthropic, and a local Ollama for free) and has **zero third-party dependencies**: the whole tool is the Python standard library, and it ships as a single-file zipapp as well as a wheel.
|
|
39
47
|
|
|
40
48
|
## Where this comes from
|
|
41
49
|
|
|
@@ -88,7 +96,7 @@ A bare name means the default provider, which is the first provider that has a k
|
|
|
88
96
|
|---|---|---|---|
|
|
89
97
|
| `openai` | OpenAI Responses API | `OPENAI_API_KEY` | `gpt-5.3-codex` |
|
|
90
98
|
| `ollama` | local Ollama, Responses API | none | `llama3.2` |
|
|
91
|
-
| `anthropic` | Messages API | `ANTHROPIC_API_KEY` |
|
|
99
|
+
| `anthropic` | Anthropic Messages API | `ANTHROPIC_API_KEY` | `claude-opus-5` |
|
|
92
100
|
|
|
93
101
|
```bash
|
|
94
102
|
scoot models # every configured provider, grouped
|
|
@@ -98,7 +106,38 @@ scoot --model auto "..." # pick a model per prompt from the live lis
|
|
|
98
106
|
```
|
|
99
107
|
|
|
100
108
|
Inside the REPL, `/model <provider/model>` switches and is remembered for the next launch; `/model default` goes back to the provider's preferred model.
|
|
101
|
-
|
|
109
|
+
|
|
110
|
+
### Routing
|
|
111
|
+
|
|
112
|
+
`auto` is opt-in (`--model auto`, `SCOOT_MODEL=auto`, or `/model auto`) because choosing costs a decision per turn.
|
|
113
|
+
Without any configuration it uses a built-in heuristic over the models your providers actually list: a strong coding model for multi-step or editing prompts, a cheaper one for short questions, never a dated snapshot.
|
|
114
|
+
A turn is routed once, at its first model call, and stays on that model.
|
|
115
|
+
|
|
116
|
+
Write your own rules in `~/.config/scoot/router.json` (or the file named by `SCOOT_ROUTER`); first match wins, and a rule whose provider has no key is skipped:
|
|
117
|
+
|
|
118
|
+
```json
|
|
119
|
+
{
|
|
120
|
+
"rules": [
|
|
121
|
+
{"when": {"has_images": true}, "use": "anthropic/claude-opus-5"},
|
|
122
|
+
{"when": {"complex": true}, "use": "openai/gpt-5.3-codex"},
|
|
123
|
+
{"when": {"est_tokens_over": 60000}, "use": "anthropic/claude-sonnet-5"},
|
|
124
|
+
{"when": {"prompt_matches": "(?i)translate|summari[sz]e"}, "use": "ollama/qwen3"}
|
|
125
|
+
],
|
|
126
|
+
"default": "ollama/llama3.2",
|
|
127
|
+
"classifier": {
|
|
128
|
+
"model": "ollama/llama3.2",
|
|
129
|
+
"tiers": {"simple": "ollama/llama3.2", "coding": "openai/gpt-5.3-codex", "hard": "anthropic/claude-opus-5"}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Conditions: `complex`, `has_images`, `needs_tools`, `est_tokens_over`, `prompt_matches`.
|
|
135
|
+
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.
|
|
136
|
+
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".
|
|
137
|
+
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.
|
|
138
|
+
`/route` shows the rules in force and why the current model was picked; `/status` shows tokens per model.
|
|
139
|
+
`SCOOT_EFFORT` (`low` | `medium` | `high` | `xhigh`, default `medium`) sets the reasoning effort for models that take it, on both OpenAI and Anthropic.
|
|
140
|
+
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.
|
|
102
141
|
|
|
103
142
|
A note on how this is built: scoot speaks the OpenAI chat format internally and translates at the edge.
|
|
104
143
|
Adding a provider that speaks that format is one registry row; a different wire format is one small adapter (`src/scootcli/providers/`).
|
|
@@ -173,7 +212,7 @@ Without a TTY, scoot falls back to a plain prompt with no bar and no dock.
|
|
|
173
212
|
|
|
174
213
|
### Tools and approvals
|
|
175
214
|
|
|
176
|
-
The agent has
|
|
215
|
+
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).
|
|
177
216
|
Every tool is sandboxed to the workspace root.
|
|
178
217
|
|
|
179
218
|
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]`.
|
|
@@ -196,11 +235,14 @@ The bottom row shows the mascot's face (its eyes follow the turn: `o o` idle, `>
|
|
|
196
235
|
|
|
197
236
|
```bash
|
|
198
237
|
pipx install scootcli # recommended: isolated, `scoot` on PATH
|
|
238
|
+
curl -fsSL https://raw.githubusercontent.com/sergenes/scootcli/main/install.sh | bash # no pipx: puts the zipapp at ~/.local/bin/scoot
|
|
199
239
|
pip install scootcli # anywhere
|
|
200
240
|
curl -LO https://github.com/sergenes/scootcli/releases/latest/download/scoot.pyz && python3 scoot.pyz # single file, no install
|
|
201
241
|
git clone https://github.com/sergenes/scootcli && cd scootcli && pip install -e . # from source
|
|
202
242
|
```
|
|
203
243
|
|
|
244
|
+
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.
|
|
245
|
+
|
|
204
246
|
Requirements: Python 3.9 or newer on macOS or Linux.
|
|
205
247
|
Nothing else: no compiler, no packages, no `curl`.
|
|
206
248
|
|
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
# scoot
|
|
2
2
|
|
|
3
|
+
[](https://pypi.org/project/scootcli/)
|
|
4
|
+
[](https://github.com/sergenes/scootcli/actions/workflows/tests.yml)
|
|
5
|
+
[](https://www.python.org/downloads/)
|
|
6
|
+
[](#install-options)
|
|
7
|
+
[](#install-options)
|
|
8
|
+
[](#providers-and-models)
|
|
9
|
+
[](./LICENSE)
|
|
10
|
+
|
|
3
11
|
```
|
|
4
12
|
╭───╮ scoot: a tiny coding agent that goes where you point it.
|
|
5
13
|
│o o│
|
|
6
|
-
T──┤───┤ pipx install scootcli
|
|
14
|
+
T──┤───┤ pipx install scootcli (or: curl -fsSL https://raw.githubusercontent.com/sergenes/scootcli/main/install.sh | bash)
|
|
7
15
|
│ ╰┬─┬╯ scoot
|
|
8
16
|
(o)═══╧═╧═(o)
|
|
9
17
|
```
|
|
10
18
|
|
|
11
19
|
`scoot` is a terminal coding agent in plain Python.
|
|
12
20
|
You type what you want in natural language; it reads, searches, edits, and runs things in your repo, asking before anything risky.
|
|
13
|
-
It talks to official model APIs (OpenAI
|
|
21
|
+
It talks to official model APIs (OpenAI, Anthropic, and a local Ollama for free) and has **zero third-party dependencies**: the whole tool is the Python standard library, and it ships as a single-file zipapp as well as a wheel.
|
|
14
22
|
|
|
15
23
|
## Where this comes from
|
|
16
24
|
|
|
@@ -63,7 +71,7 @@ A bare name means the default provider, which is the first provider that has a k
|
|
|
63
71
|
|---|---|---|---|
|
|
64
72
|
| `openai` | OpenAI Responses API | `OPENAI_API_KEY` | `gpt-5.3-codex` |
|
|
65
73
|
| `ollama` | local Ollama, Responses API | none | `llama3.2` |
|
|
66
|
-
| `anthropic` | Messages API | `ANTHROPIC_API_KEY` |
|
|
74
|
+
| `anthropic` | Anthropic Messages API | `ANTHROPIC_API_KEY` | `claude-opus-5` |
|
|
67
75
|
|
|
68
76
|
```bash
|
|
69
77
|
scoot models # every configured provider, grouped
|
|
@@ -73,7 +81,38 @@ scoot --model auto "..." # pick a model per prompt from the live lis
|
|
|
73
81
|
```
|
|
74
82
|
|
|
75
83
|
Inside the REPL, `/model <provider/model>` switches and is remembered for the next launch; `/model default` goes back to the provider's preferred model.
|
|
76
|
-
|
|
84
|
+
|
|
85
|
+
### Routing
|
|
86
|
+
|
|
87
|
+
`auto` is opt-in (`--model auto`, `SCOOT_MODEL=auto`, or `/model auto`) because choosing costs a decision per turn.
|
|
88
|
+
Without any configuration it uses a built-in heuristic over the models your providers actually list: a strong coding model for multi-step or editing prompts, a cheaper one for short questions, never a dated snapshot.
|
|
89
|
+
A turn is routed once, at its first model call, and stays on that model.
|
|
90
|
+
|
|
91
|
+
Write your own rules in `~/.config/scoot/router.json` (or the file named by `SCOOT_ROUTER`); first match wins, and a rule whose provider has no key is skipped:
|
|
92
|
+
|
|
93
|
+
```json
|
|
94
|
+
{
|
|
95
|
+
"rules": [
|
|
96
|
+
{"when": {"has_images": true}, "use": "anthropic/claude-opus-5"},
|
|
97
|
+
{"when": {"complex": true}, "use": "openai/gpt-5.3-codex"},
|
|
98
|
+
{"when": {"est_tokens_over": 60000}, "use": "anthropic/claude-sonnet-5"},
|
|
99
|
+
{"when": {"prompt_matches": "(?i)translate|summari[sz]e"}, "use": "ollama/qwen3"}
|
|
100
|
+
],
|
|
101
|
+
"default": "ollama/llama3.2",
|
|
102
|
+
"classifier": {
|
|
103
|
+
"model": "ollama/llama3.2",
|
|
104
|
+
"tiers": {"simple": "ollama/llama3.2", "coding": "openai/gpt-5.3-codex", "hard": "anthropic/claude-opus-5"}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Conditions: `complex`, `has_images`, `needs_tools`, `est_tokens_over`, `prompt_matches`.
|
|
110
|
+
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.
|
|
111
|
+
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".
|
|
112
|
+
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.
|
|
113
|
+
`/route` shows the rules in force and why the current model was picked; `/status` shows tokens per model.
|
|
114
|
+
`SCOOT_EFFORT` (`low` | `medium` | `high` | `xhigh`, default `medium`) sets the reasoning effort for models that take it, on both OpenAI and Anthropic.
|
|
115
|
+
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.
|
|
77
116
|
|
|
78
117
|
A note on how this is built: scoot speaks the OpenAI chat format internally and translates at the edge.
|
|
79
118
|
Adding a provider that speaks that format is one registry row; a different wire format is one small adapter (`src/scootcli/providers/`).
|
|
@@ -148,7 +187,7 @@ Without a TTY, scoot falls back to a plain prompt with no bar and no dock.
|
|
|
148
187
|
|
|
149
188
|
### Tools and approvals
|
|
150
189
|
|
|
151
|
-
The agent has
|
|
190
|
+
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).
|
|
152
191
|
Every tool is sandboxed to the workspace root.
|
|
153
192
|
|
|
154
193
|
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]`.
|
|
@@ -171,11 +210,14 @@ The bottom row shows the mascot's face (its eyes follow the turn: `o o` idle, `>
|
|
|
171
210
|
|
|
172
211
|
```bash
|
|
173
212
|
pipx install scootcli # recommended: isolated, `scoot` on PATH
|
|
213
|
+
curl -fsSL https://raw.githubusercontent.com/sergenes/scootcli/main/install.sh | bash # no pipx: puts the zipapp at ~/.local/bin/scoot
|
|
174
214
|
pip install scootcli # anywhere
|
|
175
215
|
curl -LO https://github.com/sergenes/scootcli/releases/latest/download/scoot.pyz && python3 scoot.pyz # single file, no install
|
|
176
216
|
git clone https://github.com/sergenes/scootcli && cd scootcli && pip install -e . # from source
|
|
177
217
|
```
|
|
178
218
|
|
|
219
|
+
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.
|
|
220
|
+
|
|
179
221
|
Requirements: Python 3.9 or newer on macOS or Linux.
|
|
180
222
|
Nothing else: no compiler, no packages, no `curl`.
|
|
181
223
|
|
|
@@ -129,7 +129,10 @@ class Agent:
|
|
|
129
129
|
except ScootError as exc:
|
|
130
130
|
return AgentOutcome("error", error=_fmt_error(exc), steps=steps)
|
|
131
131
|
|
|
132
|
-
|
|
132
|
+
try:
|
|
133
|
+
session.account(result.usage, model=result.model or session.active_model)
|
|
134
|
+
except TypeError: # older/fake sessions with a one-argument account()
|
|
135
|
+
session.account(result.usage)
|
|
133
136
|
session.messages.append(self._assistant_message(result))
|
|
134
137
|
|
|
135
138
|
if result.tool_calls:
|
|
@@ -187,19 +190,33 @@ class Agent:
|
|
|
187
190
|
|
|
188
191
|
# ── message construction ─────────────────────────────────────────────────────
|
|
189
192
|
def _pick_model(self, session) -> str:
|
|
190
|
-
"""Resolve the model for this turn.
|
|
193
|
+
"""Resolve the model for this turn. ``auto`` routes once per turn (see ``providers.router``)."""
|
|
191
194
|
if session.model.lower() != "auto":
|
|
192
195
|
return session.resolved_model()
|
|
193
|
-
from .
|
|
196
|
+
from .providers.router import Router, compute_hints
|
|
194
197
|
|
|
195
|
-
|
|
198
|
+
router = getattr(session, "router", None)
|
|
199
|
+
if router is None:
|
|
200
|
+
router = Router()
|
|
201
|
+
try:
|
|
202
|
+
session.router = router
|
|
203
|
+
except Exception:
|
|
204
|
+
pass
|
|
196
205
|
try:
|
|
197
206
|
available = session.available_models()
|
|
198
207
|
except Exception:
|
|
199
208
|
available = []
|
|
200
209
|
bad = getattr(session, "bad_models", None) or set()
|
|
201
210
|
available = [m for m in available if m not in bad] or available
|
|
202
|
-
|
|
211
|
+
hints = compute_hints(session)
|
|
212
|
+
return router.choose(session, hints, available, session.resolved_model(),
|
|
213
|
+
classify=self._classify_with_provider).model
|
|
214
|
+
|
|
215
|
+
def _classify_with_provider(self, model: str, prompt: str) -> str:
|
|
216
|
+
"""One small, non-streaming call used by a configured router classifier."""
|
|
217
|
+
result = self.provider.chat([{"role": "user", "content": prompt}], model=model, max_tokens=5,
|
|
218
|
+
temperature=0, hints={"purpose": "route"})
|
|
219
|
+
return result.content or ""
|
|
203
220
|
|
|
204
221
|
def _fallback_model(self, session) -> bool:
|
|
205
222
|
"""After a model-unavailable error, blacklist it and switch models. Returns False if stuck."""
|
|
@@ -43,14 +43,19 @@ def status_rows(config) -> List[dict]:
|
|
|
43
43
|
default = registry.default_provider_name(config)
|
|
44
44
|
rows = []
|
|
45
45
|
for spec in registry.all_specs():
|
|
46
|
-
|
|
46
|
+
url = registry.base_url_for(spec)
|
|
47
|
+
row = {
|
|
47
48
|
"name": spec.name,
|
|
48
49
|
"source": key_source(spec),
|
|
49
50
|
"required": spec.key_required,
|
|
50
51
|
"configured": is_configured(spec),
|
|
51
52
|
"default": spec.name == default,
|
|
52
|
-
"base_url":
|
|
53
|
-
|
|
53
|
+
"base_url": url,
|
|
54
|
+
"reachable": None, # only probed for keyless local servers
|
|
55
|
+
}
|
|
56
|
+
if not spec.key_required and registry.is_local_url(url):
|
|
57
|
+
row["reachable"] = registry.reachable(url)
|
|
58
|
+
rows.append(row)
|
|
54
59
|
return rows
|
|
55
60
|
|
|
56
61
|
|
|
@@ -141,6 +141,16 @@ def _run_once(config: Config, pool: ProviderPool, prompt: str, as_json: bool, re
|
|
|
141
141
|
from .agent import Agent
|
|
142
142
|
from .repl import ReplSession, ReplUI
|
|
143
143
|
|
|
144
|
+
from .providers.registry import readiness
|
|
145
|
+
|
|
146
|
+
ready, message = readiness(config)
|
|
147
|
+
if not ready: # fail fast with guidance instead of a connection error after retries
|
|
148
|
+
if as_json:
|
|
149
|
+
print(_json.dumps({"status": "error", "model": "", "steps": 0, "content": "",
|
|
150
|
+
"error": message, "usage": {}}, indent=2))
|
|
151
|
+
else:
|
|
152
|
+
eprint(color(message, "yellow"))
|
|
153
|
+
return 1
|
|
144
154
|
session = ReplSession(config, pool)
|
|
145
155
|
if resume is not None:
|
|
146
156
|
session.apply_record(resume)
|
|
@@ -194,7 +204,6 @@ def _cmd_auth(config: Config, pool: ProviderPool, words: List[str]) -> int:
|
|
|
194
204
|
|
|
195
205
|
def _interactive(pool: ProviderPool, resume=None) -> int:
|
|
196
206
|
"""Launch the persistent REPL (banner, live status, ESC-interrupt, slash-commands)."""
|
|
197
|
-
from .auth import is_configured, missing_key_hint
|
|
198
207
|
from .repl import Repl
|
|
199
208
|
|
|
200
209
|
config = pool.config
|
|
@@ -206,9 +215,11 @@ def _interactive(pool: ProviderPool, resume=None) -> int:
|
|
|
206
215
|
resume = sessions.latest_for_root(str(config.root))
|
|
207
216
|
|
|
208
217
|
try:
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
218
|
+
from .providers.registry import readiness
|
|
219
|
+
|
|
220
|
+
ready, message = readiness(config)
|
|
221
|
+
if not ready: # first-run onboarding: guide, but still open the REPL so /auth is usable
|
|
222
|
+
eprint(color(message, "yellow"))
|
|
212
223
|
except ScootError as exc:
|
|
213
224
|
eprint(color(f"⚠ {redact(str(exc))}", "yellow"))
|
|
214
225
|
return Repl(config, pool, resume=resume).run()
|
|
@@ -24,8 +24,10 @@ def _show(session) -> None:
|
|
|
24
24
|
print(color("providers:", "bold"))
|
|
25
25
|
for row in rows:
|
|
26
26
|
name = row["name"] + (" (default)" if row["default"] else "")
|
|
27
|
-
if not row["required"]:
|
|
28
|
-
state = color("
|
|
27
|
+
if not row["required"] and row.get("reachable") is False:
|
|
28
|
+
state = color("not running (ollama serve; install from https://ollama.com)", "yellow")
|
|
29
|
+
elif not row["required"]:
|
|
30
|
+
state = color("no key needed" + (", running" if row.get("reachable") else ""), "gray")
|
|
29
31
|
elif row["source"]:
|
|
30
32
|
state = color(f"key from {row['source']}", "green")
|
|
31
33
|
else:
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""/route — show how the ``auto`` model alias routes: rule source, rules, classifier, last decision."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..providers.router import Router, config_path, describe
|
|
6
|
+
from ..rendering import color
|
|
7
|
+
from . import register
|
|
8
|
+
from .base import SlashCommand
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _run(session, args: str):
|
|
12
|
+
router = getattr(session, "router", None) or Router()
|
|
13
|
+
print(color("routing (for /model auto):", "bold"))
|
|
14
|
+
for line in describe(router):
|
|
15
|
+
print(" " + line)
|
|
16
|
+
if session.model.lower() != "auto":
|
|
17
|
+
print(color(f" auto is off; the preference is '{session.model}'. Turn it on with /model auto.", "gray"))
|
|
18
|
+
print(color(f" edit rules in {config_path()} (see README: Routing)", "gray"))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
register(SlashCommand("route", "show how 'auto' picks a model", _run))
|
|
@@ -42,6 +42,13 @@ def _run(session, args: str):
|
|
|
42
42
|
print(color("status:", "bold"))
|
|
43
43
|
for label, value in rows:
|
|
44
44
|
print(f" {color(label + ':', 'gray'):<28} {value}")
|
|
45
|
+
by_model = getattr(session, "usage_by_model", None) or {}
|
|
46
|
+
if len(by_model) > 1 or (by_model and session.model.lower() == "auto"):
|
|
47
|
+
print(f" {color('tokens by model:', 'gray')}")
|
|
48
|
+
for name, u in sorted(by_model.items(), key=lambda kv: -(kv[1]['prompt'] + kv[1]['completion'])):
|
|
49
|
+
print(f" {color(name, 'cyan'):<40} prompt={u['prompt']} completion={u['completion']} calls={u['calls']}")
|
|
50
|
+
if session.model.lower() == "auto" and getattr(session, "route_reason", ""):
|
|
51
|
+
print(f" {color('routed by:', 'gray'):<28} {session.route_reason}")
|
|
45
52
|
trusted = getattr(session, "trusted_tools", None)
|
|
46
53
|
if trusted:
|
|
47
54
|
print(f" {color('trusted tools:', 'gray'):<28} {', '.join(sorted(trusted))}")
|
|
@@ -178,6 +178,7 @@ class Config:
|
|
|
178
178
|
images: bool = True # detect dropped image paths + describe them with a vision model (M22)
|
|
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
|
+
editor: str = "idea" # external editor for the open_editor tool: idea | vscode
|
|
181
182
|
env_files: Tuple[str, ...] = () # the .env files that were read, in load order (shown by /status)
|
|
182
183
|
logo: bool = True # mascot in the launch banner + status-bar face (--no-logo / SCOOT_LOGO / /logo)
|
|
183
184
|
emoji: bool = True # 🛴 transcript label; off → ⏺ for terminals without an emoji font (--no-emoji)
|
|
@@ -214,6 +215,7 @@ class Config:
|
|
|
214
215
|
images=_as_bool(get("SCOOT_IMAGES", "true")),
|
|
215
216
|
vision_model=get("SCOOT_VISION_MODEL", "auto"),
|
|
216
217
|
image_max_bytes=int(get("SCOOT_IMAGE_MAX_BYTES", str(4 * 1024 * 1024))),
|
|
218
|
+
editor=get("SCOOT_EDITOR", "idea").strip().lower(),
|
|
217
219
|
logo=_resolve_logo_setting(get("SCOOT_LOGO")),
|
|
218
220
|
emoji=_as_bool(get("SCOOT_EMOJI", "true")),
|
|
219
221
|
)
|