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.
Files changed (104) hide show
  1. {scootcli-0.1.0 → scootcli-0.3.0}/LICENSE +1 -1
  2. {scootcli-0.1.0/src/scootcli.egg-info → scootcli-0.3.0}/PKG-INFO +48 -6
  3. {scootcli-0.1.0 → scootcli-0.3.0}/README.md +47 -5
  4. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/__init__.py +1 -1
  5. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/agent.py +22 -5
  6. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/auth.py +8 -3
  7. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/cli.py +15 -4
  8. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/commands/__init__.py +1 -0
  9. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/commands/auth.py +4 -2
  10. scootcli-0.3.0/src/scootcli/commands/route.py +21 -0
  11. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/commands/status.py +7 -0
  12. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/config.py +2 -0
  13. scootcli-0.3.0/src/scootcli/providers/anthropic.py +354 -0
  14. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/providers/base.py +32 -7
  15. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/providers/registry.py +70 -2
  16. scootcli-0.3.0/src/scootcli/providers/router.py +231 -0
  17. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/repl.py +18 -5
  18. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/status.py +14 -1
  19. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/tools/__init__.py +1 -0
  20. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/tools/base.py +28 -1
  21. scootcli-0.3.0/src/scootcli/tools/open_editor.py +123 -0
  22. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/tools/run_shell.py +3 -1
  23. {scootcli-0.1.0 → scootcli-0.3.0/src/scootcli.egg-info}/PKG-INFO +48 -6
  24. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli.egg-info/SOURCES.txt +7 -0
  25. scootcli-0.3.0/tests/test_anthropic.py +286 -0
  26. scootcli-0.3.0/tests/test_readiness.py +134 -0
  27. scootcli-0.3.0/tests/test_router.py +155 -0
  28. {scootcli-0.1.0 → scootcli-0.3.0}/tests/test_tools.py +64 -2
  29. {scootcli-0.1.0 → scootcli-0.3.0}/pyproject.toml +0 -0
  30. {scootcli-0.1.0 → scootcli-0.3.0}/setup.cfg +0 -0
  31. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/__main__.py +0 -0
  32. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/activity.py +0 -0
  33. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/approvals.py +0 -0
  34. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/clipboard.py +0 -0
  35. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/commands/approve.py +0 -0
  36. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/commands/base.py +0 -0
  37. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/commands/compact.py +0 -0
  38. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/commands/copy.py +0 -0
  39. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/commands/exit.py +0 -0
  40. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/commands/forget.py +0 -0
  41. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/commands/help.py +0 -0
  42. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/commands/init.py +0 -0
  43. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/commands/logo.py +0 -0
  44. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/commands/model.py +0 -0
  45. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/commands/panel.py +0 -0
  46. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/commands/reset.py +0 -0
  47. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/commands/resume.py +0 -0
  48. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/commands/save.py +0 -0
  49. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/commands/sessions.py +0 -0
  50. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/commands/verbosity.py +0 -0
  51. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/commands/worktree.py +0 -0
  52. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/commands/yolo.py +0 -0
  53. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/context.py +0 -0
  54. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/credentials.py +0 -0
  55. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/errors.py +0 -0
  56. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/images.py +0 -0
  57. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/keys.py +0 -0
  58. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/lineeditor.py +0 -0
  59. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/logo.py +0 -0
  60. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/models.py +0 -0
  61. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/panel.py +0 -0
  62. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/preferences.py +0 -0
  63. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/presets.py +0 -0
  64. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/project.py +0 -0
  65. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/prompts.py +0 -0
  66. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/providers/__init__.py +0 -0
  67. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/providers/openai_chat.py +0 -0
  68. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/providers/openai_responses.py +0 -0
  69. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/rendering.py +0 -0
  70. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/sessions.py +0 -0
  71. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/tools/edit_file.py +0 -0
  72. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/tools/list_dir.py +0 -0
  73. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/tools/read_file.py +0 -0
  74. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/tools/search.py +0 -0
  75. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/tools/update_plan.py +0 -0
  76. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/tools/write_file.py +0 -0
  77. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/transport.py +0 -0
  78. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/vision.py +0 -0
  79. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/workspace.py +0 -0
  80. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli/worktree.py +0 -0
  81. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli.egg-info/dependency_links.txt +0 -0
  82. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli.egg-info/entry_points.txt +0 -0
  83. {scootcli-0.1.0 → scootcli-0.3.0}/src/scootcli.egg-info/top_level.txt +0 -0
  84. {scootcli-0.1.0 → scootcli-0.3.0}/tests/test_agent.py +0 -0
  85. {scootcli-0.1.0 → scootcli-0.3.0}/tests/test_approvals.py +0 -0
  86. {scootcli-0.1.0 → scootcli-0.3.0}/tests/test_auth.py +0 -0
  87. {scootcli-0.1.0 → scootcli-0.3.0}/tests/test_clipboard.py +0 -0
  88. {scootcli-0.1.0 → scootcli-0.3.0}/tests/test_config.py +0 -0
  89. {scootcli-0.1.0 → scootcli-0.3.0}/tests/test_images.py +0 -0
  90. {scootcli-0.1.0 → scootcli-0.3.0}/tests/test_lineeditor.py +0 -0
  91. {scootcli-0.1.0 → scootcli-0.3.0}/tests/test_logo.py +0 -0
  92. {scootcli-0.1.0 → scootcli-0.3.0}/tests/test_models.py +0 -0
  93. {scootcli-0.1.0 → scootcli-0.3.0}/tests/test_panel.py +0 -0
  94. {scootcli-0.1.0 → scootcli-0.3.0}/tests/test_preferences.py +0 -0
  95. {scootcli-0.1.0 → scootcli-0.3.0}/tests/test_providers.py +0 -0
  96. {scootcli-0.1.0 → scootcli-0.3.0}/tests/test_repl.py +0 -0
  97. {scootcli-0.1.0 → scootcli-0.3.0}/tests/test_resilience.py +0 -0
  98. {scootcli-0.1.0 → scootcli-0.3.0}/tests/test_sessions.py +0 -0
  99. {scootcli-0.1.0 → scootcli-0.3.0}/tests/test_smoke.py +0 -0
  100. {scootcli-0.1.0 → scootcli-0.3.0}/tests/test_streaming.py +0 -0
  101. {scootcli-0.1.0 → scootcli-0.3.0}/tests/test_transport_native.py +0 -0
  102. {scootcli-0.1.0 → scootcli-0.3.0}/tests/test_vision.py +0 -0
  103. {scootcli-0.1.0 → scootcli-0.3.0}/tests/test_workspace.py +0 -0
  104. {scootcli-0.1.0 → scootcli-0.3.0}/tests/test_worktree.py +0 -0
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Sergey Neskoromny
3
+ Copyright (c) 2026 Sergey Nes
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scootcli
3
- Version: 0.1.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
+ [![PyPI](https://img.shields.io/pypi/v/scootcli.svg?label=PyPI)](https://pypi.org/project/scootcli/)
29
+ [![tests](https://github.com/sergenes/scootcli/actions/workflows/tests.yml/badge.svg)](https://github.com/sergenes/scootcli/actions/workflows/tests.yml)
30
+ [![Python 3.9+](https://img.shields.io/badge/python-3.9%2B-blue.svg)](https://www.python.org/downloads/)
31
+ [![Dependencies: none](https://img.shields.io/badge/dependencies-none-brightgreen.svg)](#install-options)
32
+ [![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey.svg)](#install-options)
33
+ [![Providers](https://img.shields.io/badge/providers-OpenAI%20%7C%20Ollama%20%7C%20Anthropic-orange.svg)](#providers-and-models)
34
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./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 today, a local Ollama for free, Anthropic next) 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.
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` | arrives in 0.2.0 |
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
- `SCOOT_EFFORT` (`low` | `medium` | `high` | `xhigh`, default `medium`) sets the reasoning effort for models that take it.
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 seven tools: `read_file`, `list_dir`, `search`, `write_file`, `edit_file`, `run_shell`, and `update_plan` (a progress checklist for multi-step work).
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
+ [![PyPI](https://img.shields.io/pypi/v/scootcli.svg?label=PyPI)](https://pypi.org/project/scootcli/)
4
+ [![tests](https://github.com/sergenes/scootcli/actions/workflows/tests.yml/badge.svg)](https://github.com/sergenes/scootcli/actions/workflows/tests.yml)
5
+ [![Python 3.9+](https://img.shields.io/badge/python-3.9%2B-blue.svg)](https://www.python.org/downloads/)
6
+ [![Dependencies: none](https://img.shields.io/badge/dependencies-none-brightgreen.svg)](#install-options)
7
+ [![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey.svg)](#install-options)
8
+ [![Providers](https://img.shields.io/badge/providers-OpenAI%20%7C%20Ollama%20%7C%20Anthropic-orange.svg)](#providers-and-models)
9
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./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 today, a local Ollama for free, Anthropic next) 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.
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` | arrives in 0.2.0 |
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
- `SCOOT_EFFORT` (`low` | `medium` | `high` | `xhigh`, default `medium`) sets the reasoning effort for models that take it.
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 seven tools: `read_file`, `list_dir`, `search`, `write_file`, `edit_file`, `run_shell`, and `update_plan` (a progress checklist for multi-step work).
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
 
@@ -1,4 +1,4 @@
1
1
  """scoot: a tiny coding agent that goes where you point it."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.3.0"
4
4
 
@@ -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
- session.account(result.usage)
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. For ``auto``, use the heuristic over live models."""
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 .models import resolve_auto
196
+ from .providers.router import Router, compute_hints
194
197
 
195
- last_user = self._last_user_text(session)
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
- return resolve_auto(last_user, available, fallback=session.resolved_model())
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
- rows.append({
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": registry.base_url_for(spec),
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
- spec = pool.spec
210
- if not is_configured(spec): # first-run onboarding: guide, but still open the REPL
211
- eprint(color(f"No API key for {spec.name}: {missing_key_hint(spec)}", "yellow"))
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()
@@ -51,6 +51,7 @@ def load_builtins() -> None:
51
51
  from . import verbosity as _verbosity # noqa: F401
52
52
  from . import copy as _copy # noqa: F401
53
53
  from . import logo as _logo # noqa: F401
54
+ from . import route as _route # noqa: F401
54
55
 
55
56
  _loaded = True
56
57
 
@@ -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("no key needed", "gray")
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
  )