scootcli 0.2.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. {scootcli-0.2.0/src/scootcli.egg-info → scootcli-0.4.0}/PKG-INFO +71 -4
  2. {scootcli-0.2.0 → scootcli-0.4.0}/README.md +70 -3
  3. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/__init__.py +1 -1
  4. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/agent.py +95 -10
  5. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/auth.py +8 -3
  6. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/cli.py +33 -7
  7. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/commands/__init__.py +2 -0
  8. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/commands/auth.py +20 -2
  9. scootcli-0.4.0/src/scootcli/commands/hooks.py +37 -0
  10. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/commands/model.py +4 -0
  11. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/commands/reset.py +3 -0
  12. scootcli-0.4.0/src/scootcli/commands/route.py +21 -0
  13. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/commands/status.py +7 -0
  14. scootcli-0.4.0/src/scootcli/headless.py +329 -0
  15. scootcli-0.4.0/src/scootcli/hooks.py +264 -0
  16. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/panel.py +3 -0
  17. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/providers/base.py +21 -1
  18. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/providers/registry.py +69 -2
  19. scootcli-0.4.0/src/scootcli/providers/router.py +231 -0
  20. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/repl.py +70 -12
  21. {scootcli-0.2.0 → scootcli-0.4.0/src/scootcli.egg-info}/PKG-INFO +71 -4
  22. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli.egg-info/SOURCES.txt +9 -0
  23. scootcli-0.4.0/tests/test_headless.py +247 -0
  24. scootcli-0.4.0/tests/test_hooks.py +242 -0
  25. scootcli-0.4.0/tests/test_readiness.py +180 -0
  26. scootcli-0.4.0/tests/test_router.py +155 -0
  27. {scootcli-0.2.0 → scootcli-0.4.0}/LICENSE +0 -0
  28. {scootcli-0.2.0 → scootcli-0.4.0}/pyproject.toml +0 -0
  29. {scootcli-0.2.0 → scootcli-0.4.0}/setup.cfg +0 -0
  30. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/__main__.py +0 -0
  31. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/activity.py +0 -0
  32. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/approvals.py +0 -0
  33. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/clipboard.py +0 -0
  34. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/commands/approve.py +0 -0
  35. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/commands/base.py +0 -0
  36. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/commands/compact.py +0 -0
  37. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/commands/copy.py +0 -0
  38. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/commands/exit.py +0 -0
  39. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/commands/forget.py +0 -0
  40. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/commands/help.py +0 -0
  41. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/commands/init.py +0 -0
  42. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/commands/logo.py +0 -0
  43. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/commands/panel.py +0 -0
  44. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/commands/resume.py +0 -0
  45. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/commands/save.py +0 -0
  46. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/commands/sessions.py +0 -0
  47. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/commands/verbosity.py +0 -0
  48. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/commands/worktree.py +0 -0
  49. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/commands/yolo.py +0 -0
  50. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/config.py +0 -0
  51. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/context.py +0 -0
  52. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/credentials.py +0 -0
  53. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/errors.py +0 -0
  54. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/images.py +0 -0
  55. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/keys.py +0 -0
  56. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/lineeditor.py +0 -0
  57. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/logo.py +0 -0
  58. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/models.py +0 -0
  59. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/preferences.py +0 -0
  60. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/presets.py +0 -0
  61. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/project.py +0 -0
  62. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/prompts.py +0 -0
  63. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/providers/__init__.py +0 -0
  64. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/providers/anthropic.py +0 -0
  65. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/providers/openai_chat.py +0 -0
  66. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/providers/openai_responses.py +0 -0
  67. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/rendering.py +0 -0
  68. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/sessions.py +0 -0
  69. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/status.py +0 -0
  70. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/tools/__init__.py +0 -0
  71. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/tools/base.py +0 -0
  72. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/tools/edit_file.py +0 -0
  73. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/tools/list_dir.py +0 -0
  74. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/tools/open_editor.py +0 -0
  75. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/tools/read_file.py +0 -0
  76. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/tools/run_shell.py +0 -0
  77. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/tools/search.py +0 -0
  78. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/tools/update_plan.py +0 -0
  79. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/tools/write_file.py +0 -0
  80. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/transport.py +0 -0
  81. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/vision.py +0 -0
  82. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/workspace.py +0 -0
  83. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/worktree.py +0 -0
  84. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli.egg-info/dependency_links.txt +0 -0
  85. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli.egg-info/entry_points.txt +0 -0
  86. {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli.egg-info/top_level.txt +0 -0
  87. {scootcli-0.2.0 → scootcli-0.4.0}/tests/test_agent.py +0 -0
  88. {scootcli-0.2.0 → scootcli-0.4.0}/tests/test_anthropic.py +0 -0
  89. {scootcli-0.2.0 → scootcli-0.4.0}/tests/test_approvals.py +0 -0
  90. {scootcli-0.2.0 → scootcli-0.4.0}/tests/test_auth.py +0 -0
  91. {scootcli-0.2.0 → scootcli-0.4.0}/tests/test_clipboard.py +0 -0
  92. {scootcli-0.2.0 → scootcli-0.4.0}/tests/test_config.py +0 -0
  93. {scootcli-0.2.0 → scootcli-0.4.0}/tests/test_images.py +0 -0
  94. {scootcli-0.2.0 → scootcli-0.4.0}/tests/test_lineeditor.py +0 -0
  95. {scootcli-0.2.0 → scootcli-0.4.0}/tests/test_logo.py +0 -0
  96. {scootcli-0.2.0 → scootcli-0.4.0}/tests/test_models.py +0 -0
  97. {scootcli-0.2.0 → scootcli-0.4.0}/tests/test_panel.py +0 -0
  98. {scootcli-0.2.0 → scootcli-0.4.0}/tests/test_preferences.py +0 -0
  99. {scootcli-0.2.0 → scootcli-0.4.0}/tests/test_providers.py +0 -0
  100. {scootcli-0.2.0 → scootcli-0.4.0}/tests/test_repl.py +0 -0
  101. {scootcli-0.2.0 → scootcli-0.4.0}/tests/test_resilience.py +0 -0
  102. {scootcli-0.2.0 → scootcli-0.4.0}/tests/test_sessions.py +0 -0
  103. {scootcli-0.2.0 → scootcli-0.4.0}/tests/test_smoke.py +0 -0
  104. {scootcli-0.2.0 → scootcli-0.4.0}/tests/test_streaming.py +0 -0
  105. {scootcli-0.2.0 → scootcli-0.4.0}/tests/test_tools.py +0 -0
  106. {scootcli-0.2.0 → scootcli-0.4.0}/tests/test_transport_native.py +0 -0
  107. {scootcli-0.2.0 → scootcli-0.4.0}/tests/test_vision.py +0 -0
  108. {scootcli-0.2.0 → scootcli-0.4.0}/tests/test_workspace.py +0 -0
  109. {scootcli-0.2.0 → scootcli-0.4.0}/tests/test_worktree.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scootcli
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: A tiny coding agent that goes where you point it
5
5
  Author: Sergey Nes
6
6
  License-Expression: MIT
@@ -36,7 +36,7 @@ Dynamic: license-file
36
36
  ```
37
37
  ╭───╮ scoot: a tiny coding agent that goes where you point it.
38
38
  │o o│
39
- T──┤───┤ pipx install scootcli
39
+ T──┤───┤ pipx install scootcli (or: curl -fsSL https://raw.githubusercontent.com/sergenes/scootcli/main/install.sh | bash)
40
40
  │ ╰┬─┬╯ scoot
41
41
  (o)═══╧═╧═(o)
42
42
  ```
@@ -61,12 +61,19 @@ The naive version did real work in my repos for long enough that the next step w
61
61
  ## Quick start
62
62
 
63
63
  ```bash
64
- pipx install scootcli # or: pip install scootcli
64
+ pipx install scootcli && pipx ensurepath # then open a new terminal so `scoot` is on PATH
65
65
  scoot auth set openai # paste your OpenAI API key once (hidden input, validated, stored 0600)
66
66
  cd ~/code/your-project
67
67
  scoot # open the REPL
68
68
  ```
69
69
 
70
+ No pipx yet? On Debian, Ubuntu, and Pop!_OS it is `sudo apt install pipx`; on macOS `brew install pipx`.
71
+ Or skip pipx entirely with the one-line installer, which needs only `curl` and Python 3.9+:
72
+
73
+ ```bash
74
+ curl -fsSL https://raw.githubusercontent.com/sergenes/scootcli/main/install.sh | bash
75
+ ```
76
+
70
77
  Or run entirely local with [Ollama](https://ollama.com), no key at all:
71
78
 
72
79
  ```bash
@@ -74,6 +81,9 @@ ollama pull llama3.2
74
81
  scoot --model ollama/llama3.2
75
82
  ```
76
83
 
84
+ **First run.** With no key saved and no Ollama running, scoot still opens: the banner says "no provider set up yet", a short block lists the three ways to set one up, and the bar shows `not set up` until a provider can answer.
85
+ Run `scoot auth set openai` (or `anthropic`) inside the REPL and the bar switches to the real provider and model at once.
86
+
77
87
  The first prompt:
78
88
 
79
89
  ```
@@ -106,6 +116,36 @@ scoot --model auto "..." # pick a model per prompt from the live lis
106
116
  ```
107
117
 
108
118
  Inside the REPL, `/model <provider/model>` switches and is remembered for the next launch; `/model default` goes back to the provider's preferred model.
119
+
120
+ ### Routing
121
+
122
+ `auto` is opt-in (`--model auto`, `SCOOT_MODEL=auto`, or `/model auto`) because choosing costs a decision per turn.
123
+ 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.
124
+ A turn is routed once, at its first model call, and stays on that model.
125
+
126
+ 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:
127
+
128
+ ```json
129
+ {
130
+ "rules": [
131
+ {"when": {"has_images": true}, "use": "anthropic/claude-opus-5"},
132
+ {"when": {"complex": true}, "use": "openai/gpt-5.3-codex"},
133
+ {"when": {"est_tokens_over": 60000}, "use": "anthropic/claude-sonnet-5"},
134
+ {"when": {"prompt_matches": "(?i)translate|summari[sz]e"}, "use": "ollama/qwen3"}
135
+ ],
136
+ "default": "ollama/llama3.2",
137
+ "classifier": {
138
+ "model": "ollama/llama3.2",
139
+ "tiers": {"simple": "ollama/llama3.2", "coding": "openai/gpt-5.3-codex", "hard": "anthropic/claude-opus-5"}
140
+ }
141
+ }
142
+ ```
143
+
144
+ Conditions: `complex`, `has_images`, `needs_tools`, `est_tokens_over`, `prompt_matches`.
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
+ 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
+ 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.
109
149
  `SCOOT_EFFORT` (`low` | `medium` | `high` | `xhigh`, default `medium`) sets the reasoning effort for models that take it, on both OpenAI and Anthropic.
110
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.
111
151
 
@@ -197,6 +237,29 @@ Responses stream live and stay interruptible.
197
237
  Drag an image into the prompt and a vision-capable model describes it into the turn as text, so even a text-only coding model can act on it; `SCOOT_VISION_MODEL` pins the describer, `--no-images` turns the feature off.
198
238
  Every turn auto-saves under `~/.local/state/scoot/sessions/` (owner-only, secrets redacted, last 20 kept); `scoot --continue` or `/resume` picks up where you left off.
199
239
 
240
+ ### Automation: hooks and headless mode
241
+
242
+ **Hooks** run your own scripts at lifecycle events: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`, `Notification`, `SessionEnd`.
243
+ A hook gets a JSON payload on stdin and answers with an exit code or JSON on stdout; the shapes follow the convention Claude Code established, so a script written for one works with the other.
244
+ Put them in `~/.config/scoot/hooks.json` or `.scoot/hooks.json` in the project:
245
+
246
+ ```json
247
+ {
248
+ "PreToolUse": [
249
+ {"matcher": "run_shell|write_file|edit_file", "hooks": [{"type": "command", "command": "~/bin/guard.py", "timeout": 30}]}
250
+ ],
251
+ "Stop": [{"hooks": [{"type": "command", "command": "~/bin/notify.sh"}]}]
252
+ }
253
+ ```
254
+
255
+ A `PreToolUse` hook can answer `{"permissionDecision": "deny", "reason": "..."}` to skip a tool (the model is told why), `allow` to skip the approval prompt, or `ask` to force one even in `yolo`; exit code 2 denies with stderr as the reason.
256
+ A `Stop` hook that answers `{"decision": "block", "reason": "run the tests first"}` sends the agent back to work with that instruction, at most three times per turn.
257
+ `/hooks` shows what is configured and what ran; `SCOOT_HOOKS=0` turns hooks off.
258
+
259
+ **Headless mode** is for editors, automation, and remote-control tools: `scoot --headless` reads JSON lines on stdin (`prompt`, `approve`, `note`, `interrupt`, `command`, `shutdown`) and writes JSON lines on stdout (streamed text, tool calls, approval requests, results, plan updates, usage, errors, a heartbeat), with nothing else ever printed there.
260
+ Same sessions, tools, approvals, routing, and hooks as the REPL.
261
+ The message tables and a full transcript are in [`docs/headless-protocol.md`](./docs/headless-protocol.md); an unanswered approval is denied after `SCOOT_APPROVAL_TIMEOUT` seconds (default 120).
262
+
200
263
  ### The status bar
201
264
 
202
265
  The bottom row shows the mascot's face (its eyes follow the turn: `o o` idle, `> >` thinking, `- -` stopped), the provider, the workspace and session id, the model, the approval mode, context size against the auto-compact threshold, cumulative tokens, message count, and the last error if any.
@@ -204,12 +267,16 @@ The bottom row shows the mascot's face (its eyes follow the turn: `o o` idle, `>
204
267
  ## Install options
205
268
 
206
269
  ```bash
207
- pipx install scootcli # recommended: isolated, `scoot` on PATH
270
+ pipx install scootcli && pipx ensurepath # recommended: isolated; ensurepath puts ~/.local/bin on PATH for new shells
271
+ curl -fsSL https://raw.githubusercontent.com/sergenes/scootcli/main/install.sh | bash # no pipx: puts the zipapp at ~/.local/bin/scoot
208
272
  pip install scootcli # anywhere
209
273
  curl -LO https://github.com/sergenes/scootcli/releases/latest/download/scoot.pyz && python3 scoot.pyz # single file, no install
210
274
  git clone https://github.com/sergenes/scootcli && cd scootcli && pip install -e . # from source
211
275
  ```
212
276
 
277
+ 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.
278
+ 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
+
213
280
  Requirements: Python 3.9 or newer on macOS or Linux.
214
281
  Nothing else: no compiler, no packages, no `curl`.
215
282
 
@@ -11,7 +11,7 @@
11
11
  ```
12
12
  ╭───╮ scoot: a tiny coding agent that goes where you point it.
13
13
  │o o│
14
- T──┤───┤ pipx install scootcli
14
+ T──┤───┤ pipx install scootcli (or: curl -fsSL https://raw.githubusercontent.com/sergenes/scootcli/main/install.sh | bash)
15
15
  │ ╰┬─┬╯ scoot
16
16
  (o)═══╧═╧═(o)
17
17
  ```
@@ -36,12 +36,19 @@ The naive version did real work in my repos for long enough that the next step w
36
36
  ## Quick start
37
37
 
38
38
  ```bash
39
- pipx install scootcli # or: pip install scootcli
39
+ pipx install scootcli && pipx ensurepath # then open a new terminal so `scoot` is on PATH
40
40
  scoot auth set openai # paste your OpenAI API key once (hidden input, validated, stored 0600)
41
41
  cd ~/code/your-project
42
42
  scoot # open the REPL
43
43
  ```
44
44
 
45
+ No pipx yet? On Debian, Ubuntu, and Pop!_OS it is `sudo apt install pipx`; on macOS `brew install pipx`.
46
+ Or skip pipx entirely with the one-line installer, which needs only `curl` and Python 3.9+:
47
+
48
+ ```bash
49
+ curl -fsSL https://raw.githubusercontent.com/sergenes/scootcli/main/install.sh | bash
50
+ ```
51
+
45
52
  Or run entirely local with [Ollama](https://ollama.com), no key at all:
46
53
 
47
54
  ```bash
@@ -49,6 +56,9 @@ ollama pull llama3.2
49
56
  scoot --model ollama/llama3.2
50
57
  ```
51
58
 
59
+ **First run.** With no key saved and no Ollama running, scoot still opens: the banner says "no provider set up yet", a short block lists the three ways to set one up, and the bar shows `not set up` until a provider can answer.
60
+ Run `scoot auth set openai` (or `anthropic`) inside the REPL and the bar switches to the real provider and model at once.
61
+
52
62
  The first prompt:
53
63
 
54
64
  ```
@@ -81,6 +91,36 @@ scoot --model auto "..." # pick a model per prompt from the live lis
81
91
  ```
82
92
 
83
93
  Inside the REPL, `/model <provider/model>` switches and is remembered for the next launch; `/model default` goes back to the provider's preferred model.
94
+
95
+ ### Routing
96
+
97
+ `auto` is opt-in (`--model auto`, `SCOOT_MODEL=auto`, or `/model auto`) because choosing costs a decision per turn.
98
+ 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.
99
+ A turn is routed once, at its first model call, and stays on that model.
100
+
101
+ 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:
102
+
103
+ ```json
104
+ {
105
+ "rules": [
106
+ {"when": {"has_images": true}, "use": "anthropic/claude-opus-5"},
107
+ {"when": {"complex": true}, "use": "openai/gpt-5.3-codex"},
108
+ {"when": {"est_tokens_over": 60000}, "use": "anthropic/claude-sonnet-5"},
109
+ {"when": {"prompt_matches": "(?i)translate|summari[sz]e"}, "use": "ollama/qwen3"}
110
+ ],
111
+ "default": "ollama/llama3.2",
112
+ "classifier": {
113
+ "model": "ollama/llama3.2",
114
+ "tiers": {"simple": "ollama/llama3.2", "coding": "openai/gpt-5.3-codex", "hard": "anthropic/claude-opus-5"}
115
+ }
116
+ }
117
+ ```
118
+
119
+ Conditions: `complex`, `has_images`, `needs_tools`, `est_tokens_over`, `prompt_matches`.
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
+ 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
+ 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.
84
124
  `SCOOT_EFFORT` (`low` | `medium` | `high` | `xhigh`, default `medium`) sets the reasoning effort for models that take it, on both OpenAI and Anthropic.
85
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.
86
126
 
@@ -172,6 +212,29 @@ Responses stream live and stay interruptible.
172
212
  Drag an image into the prompt and a vision-capable model describes it into the turn as text, so even a text-only coding model can act on it; `SCOOT_VISION_MODEL` pins the describer, `--no-images` turns the feature off.
173
213
  Every turn auto-saves under `~/.local/state/scoot/sessions/` (owner-only, secrets redacted, last 20 kept); `scoot --continue` or `/resume` picks up where you left off.
174
214
 
215
+ ### Automation: hooks and headless mode
216
+
217
+ **Hooks** run your own scripts at lifecycle events: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`, `Notification`, `SessionEnd`.
218
+ A hook gets a JSON payload on stdin and answers with an exit code or JSON on stdout; the shapes follow the convention Claude Code established, so a script written for one works with the other.
219
+ Put them in `~/.config/scoot/hooks.json` or `.scoot/hooks.json` in the project:
220
+
221
+ ```json
222
+ {
223
+ "PreToolUse": [
224
+ {"matcher": "run_shell|write_file|edit_file", "hooks": [{"type": "command", "command": "~/bin/guard.py", "timeout": 30}]}
225
+ ],
226
+ "Stop": [{"hooks": [{"type": "command", "command": "~/bin/notify.sh"}]}]
227
+ }
228
+ ```
229
+
230
+ A `PreToolUse` hook can answer `{"permissionDecision": "deny", "reason": "..."}` to skip a tool (the model is told why), `allow` to skip the approval prompt, or `ask` to force one even in `yolo`; exit code 2 denies with stderr as the reason.
231
+ A `Stop` hook that answers `{"decision": "block", "reason": "run the tests first"}` sends the agent back to work with that instruction, at most three times per turn.
232
+ `/hooks` shows what is configured and what ran; `SCOOT_HOOKS=0` turns hooks off.
233
+
234
+ **Headless mode** is for editors, automation, and remote-control tools: `scoot --headless` reads JSON lines on stdin (`prompt`, `approve`, `note`, `interrupt`, `command`, `shutdown`) and writes JSON lines on stdout (streamed text, tool calls, approval requests, results, plan updates, usage, errors, a heartbeat), with nothing else ever printed there.
235
+ Same sessions, tools, approvals, routing, and hooks as the REPL.
236
+ The message tables and a full transcript are in [`docs/headless-protocol.md`](./docs/headless-protocol.md); an unanswered approval is denied after `SCOOT_APPROVAL_TIMEOUT` seconds (default 120).
237
+
175
238
  ### The status bar
176
239
 
177
240
  The bottom row shows the mascot's face (its eyes follow the turn: `o o` idle, `> >` thinking, `- -` stopped), the provider, the workspace and session id, the model, the approval mode, context size against the auto-compact threshold, cumulative tokens, message count, and the last error if any.
@@ -179,12 +242,16 @@ The bottom row shows the mascot's face (its eyes follow the turn: `o o` idle, `>
179
242
  ## Install options
180
243
 
181
244
  ```bash
182
- pipx install scootcli # recommended: isolated, `scoot` on PATH
245
+ pipx install scootcli && pipx ensurepath # recommended: isolated; ensurepath puts ~/.local/bin on PATH for new shells
246
+ curl -fsSL https://raw.githubusercontent.com/sergenes/scootcli/main/install.sh | bash # no pipx: puts the zipapp at ~/.local/bin/scoot
183
247
  pip install scootcli # anywhere
184
248
  curl -LO https://github.com/sergenes/scootcli/releases/latest/download/scoot.pyz && python3 scoot.pyz # single file, no install
185
249
  git clone https://github.com/sergenes/scootcli && cd scootcli && pip install -e . # from source
186
250
  ```
187
251
 
252
+ 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.
253
+ 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
+
188
255
  Requirements: Python 3.9 or newer on macOS or Linux.
189
256
  Nothing else: no compiler, no packages, no `curl`.
190
257
 
@@ -1,4 +1,4 @@
1
1
  """scoot: a tiny coding agent that goes where you point it."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.4.0"
4
4
 
@@ -97,6 +97,7 @@ class Agent:
97
97
  self._refresh_workspace(session, cfg)
98
98
  steps = 0
99
99
  compacted = False
100
+ stop_blocks = 0
100
101
  while True:
101
102
  if cancel_event.is_set():
102
103
  return AgentOutcome("interrupted", steps=steps)
@@ -129,7 +130,10 @@ class Agent:
129
130
  except ScootError as exc:
130
131
  return AgentOutcome("error", error=_fmt_error(exc), steps=steps)
131
132
 
132
- session.account(result.usage)
133
+ try:
134
+ session.account(result.usage, model=result.model or session.active_model)
135
+ except TypeError: # older/fake sessions with a one-argument account()
136
+ session.account(result.usage)
133
137
  session.messages.append(self._assistant_message(result))
134
138
 
135
139
  if result.tool_calls:
@@ -141,10 +145,16 @@ class Agent:
141
145
  return AgentOutcome(status, steps=steps)
142
146
  continue
143
147
 
144
- # No tool calls -> the model is done.
145
- return AgentOutcome(
146
- "done", content=_strip_done(result.content), steps=steps, streamed=streamed
147
- )
148
+ # No tool calls -> the model is done, unless a Stop hook asks for more (bounded).
149
+ content = _strip_done(result.content)
150
+ if stop_blocks < 3:
151
+ reason = self._stop_hook(session, content, steps)
152
+ if reason:
153
+ stop_blocks += 1
154
+ session.messages.append({"role": "user", "content": reason})
155
+ ui.assistant(f"continuing: {reason}")
156
+ continue
157
+ return AgentOutcome("done", content=content, steps=steps, streamed=streamed)
148
158
 
149
159
  # ── model call (streaming or buffered) ───────────────────────────────────────
150
160
  def _model_call(self, session, cfg, ui, cancel_event, steps):
@@ -178,6 +188,45 @@ class Agent:
178
188
  )
179
189
  return result, False
180
190
 
191
+ # ── hooks ────────────────────────────────────────────────────────────────────
192
+ @staticmethod
193
+ def _hooks(session):
194
+ from .hooks import for_session
195
+
196
+ return for_session(session)
197
+
198
+ def _stop_hook(self, session, content: str, steps: int) -> str:
199
+ hooks = self._hooks(session)
200
+ if hooks is None or not hooks.has("Stop"):
201
+ return ""
202
+ payload = hooks.payload(session, "Stop", last_assistant_message=content, steps=steps,
203
+ usage=getattr(session, "last_usage", {}) or {})
204
+ decision = hooks.run("Stop", payload)
205
+ return (decision.reason or "the Stop hook asked to continue") if decision.blocks else ""
206
+
207
+ def _pre_tool_hook(self, session, tool, args, cancel_event) -> "tuple[str, str]":
208
+ hooks = self._hooks(session)
209
+ if hooks is None or not hooks.has("PreToolUse"):
210
+ return "", ""
211
+ from .hooks import tool_kind
212
+
213
+ payload = hooks.payload(session, "PreToolUse", tool_name=tool.name, tool_input=args,
214
+ tool_kind=tool_kind(tool))
215
+ decision = hooks.run("PreToolUse", payload, cancel_event)
216
+ return decision.action, decision.reason
217
+
218
+ def _post_tool_hook(self, session, tool, args, result: ToolResult) -> None:
219
+ hooks = self._hooks(session)
220
+ if hooks is None or not hooks.has("PostToolUse"):
221
+ return
222
+ from .hooks import tool_kind
223
+
224
+ response = {"ok": result.ok, "summary": result.summary or "", "error": result.error or "",
225
+ "content": (result.content or "")[:4000]}
226
+ hooks.run("PostToolUse", hooks.payload(session, "PostToolUse", tool_name=tool.name,
227
+ tool_input=args, tool_kind=tool_kind(tool),
228
+ tool_response=response))
229
+
181
230
  @staticmethod
182
231
  def _last_user_text(session) -> str:
183
232
  for m in reversed(getattr(session, "messages", []) or []):
@@ -187,19 +236,33 @@ class Agent:
187
236
 
188
237
  # ── message construction ─────────────────────────────────────────────────────
189
238
  def _pick_model(self, session) -> str:
190
- """Resolve the model for this turn. For ``auto``, use the heuristic over live models."""
239
+ """Resolve the model for this turn. ``auto`` routes once per turn (see ``providers.router``)."""
191
240
  if session.model.lower() != "auto":
192
241
  return session.resolved_model()
193
- from .models import resolve_auto
242
+ from .providers.router import Router, compute_hints
194
243
 
195
- last_user = self._last_user_text(session)
244
+ router = getattr(session, "router", None)
245
+ if router is None:
246
+ router = Router()
247
+ try:
248
+ session.router = router
249
+ except Exception:
250
+ pass
196
251
  try:
197
252
  available = session.available_models()
198
253
  except Exception:
199
254
  available = []
200
255
  bad = getattr(session, "bad_models", None) or set()
201
256
  available = [m for m in available if m not in bad] or available
202
- return resolve_auto(last_user, available, fallback=session.resolved_model())
257
+ hints = compute_hints(session)
258
+ return router.choose(session, hints, available, session.resolved_model(),
259
+ classify=self._classify_with_provider).model
260
+
261
+ def _classify_with_provider(self, model: str, prompt: str) -> str:
262
+ """One small, non-streaming call used by a configured router classifier."""
263
+ result = self.provider.chat([{"role": "user", "content": prompt}], model=model, max_tokens=5,
264
+ temperature=0, hints={"purpose": "route"})
265
+ return result.content or ""
203
266
 
204
267
  def _fallback_model(self, session) -> bool:
205
268
  """After a model-unavailable error, blacklist it and switch models. Returns False if stuck."""
@@ -219,6 +282,13 @@ class Agent:
219
282
 
220
283
  def _messages(self, session, cfg=None) -> List[dict]:
221
284
  cfg = cfg or getattr(session, "config", None) or self.config
285
+ notes = getattr(session, "pending_notes", None)
286
+ if notes:
287
+ joined = "\n".join(n for n in notes if n)
288
+ notes.clear()
289
+ if joined:
290
+ session.messages.append({"role": "user",
291
+ "content": "Note from the user while you work:\n" + joined})
222
292
  system = build_agent_system_prompt(
223
293
  root=cfg.root,
224
294
  model=session.active_model,
@@ -298,11 +368,25 @@ class Agent:
298
368
  self._append_tool(session, tc_id, f"error: unknown tool '{name}'")
299
369
  continue
300
370
 
371
+ # A PreToolUse hook may deny (skip the tool), allow (skip the prompt), or ask (force it).
372
+ hook_action, hook_reason = self._pre_tool_hook(session, tool, args, cancel_event)
373
+ if hook_action == "deny":
374
+ self._append_tool(session, tc_id, f"user declined via hook: {hook_reason or 'no reason given'}")
375
+ ui.tool_result(name, ToolResult(ok=False, summary=f"denied by hook: {hook_reason}"[:80]))
376
+ continue
301
377
  # Approval policy: auto-approve when the mode/trust allows it, else prompt.
302
- if needs_prompt(mode, tool, args, trusted) is None:
378
+ must_prompt = needs_prompt(mode, tool, args, trusted)
379
+ if hook_action == "allow":
380
+ must_prompt = None
381
+ elif hook_action == "ask":
382
+ must_prompt = hook_reason or "hook asked for confirmation"
383
+ if must_prompt is None:
303
384
  if not getattr(tool, "auto_approve", False):
304
385
  ui.auto_approved(tool, args) # meta tools render their own output
305
386
  else:
387
+ from .hooks import notify
388
+
389
+ notify(session, "approval", f"{name} needs approval: {must_prompt}")
306
390
  approval = ui.approve(tool, args, ctx)
307
391
  if approval.decision == Decision.ABORT:
308
392
  self._append_tool(session, tc_id, "user aborted the operation")
@@ -330,6 +414,7 @@ class Agent:
330
414
  result = ToolResult.fail(f"tool crashed: {exc}")
331
415
 
332
416
  self._handle_result(session, name, result, ui)
417
+ self._post_tool_hook(session, tool, args, result)
333
418
  payload = result.content if result.ok else f"ERROR: {result.error}"
334
419
  self._append_tool(session, tc_id, payload or "(no output)")
335
420
  return None
@@ -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
 
@@ -59,6 +59,8 @@ def _build_parser() -> argparse.ArgumentParser:
59
59
  help="Don't inject the repo map (git + file tree) into the agent prompt.")
60
60
  parser.add_argument("--no-labels", action="store_true",
61
61
  help="Hide the role labels/gutters (❯ you / ⏺ scoot) in the REPL transcript.")
62
+ parser.add_argument("--headless", action="store_true",
63
+ help="Line-delimited JSON on stdin/stdout instead of the REPL (see docs/headless-protocol.md).")
62
64
  parser.add_argument("--no-logo", action="store_true",
63
65
  help="Hide the mascot (launch banner art + status-bar face). Persist with /logo off.")
64
66
  parser.add_argument("--no-emoji", action="store_true",
@@ -141,9 +143,30 @@ def _run_once(config: Config, pool: ProviderPool, prompt: str, as_json: bool, re
141
143
  from .agent import Agent
142
144
  from .repl import ReplSession, ReplUI
143
145
 
146
+ from .providers.registry import readiness
147
+
148
+ ready, message = readiness(config)
149
+ if not ready: # fail fast with guidance instead of a connection error after retries
150
+ if as_json:
151
+ print(_json.dumps({"status": "error", "model": "", "steps": 0, "content": "",
152
+ "error": message, "usage": {}}, indent=2))
153
+ else:
154
+ eprint(color(message, "yellow"))
155
+ return 1
144
156
  session = ReplSession(config, pool)
145
157
  if resume is not None:
146
158
  session.apply_record(resume)
159
+ from .hooks import Hooks, session_event, submit_prompt
160
+
161
+ session.hooks = Hooks(config.root)
162
+ session_event(session, "SessionStart", source="resume" if resume is not None else "startup")
163
+ submitted = submit_prompt(session, prompt)
164
+ if submitted is None:
165
+ reason = getattr(session, "hook_block_reason", "") or "a UserPromptSubmit hook blocked it"
166
+ eprint(color(f"⏹ prompt not sent: {reason}", "yellow"))
167
+ session_event(session, "SessionEnd", reason="blocked")
168
+ return 1
169
+ prompt = submitted
147
170
  # Fold any dropped image paths into the prompt (best-effort; no-op when none/disabled).
148
171
  try:
149
172
  from .vision import fold_images_into_text
@@ -156,6 +179,7 @@ def _run_once(config: Config, pool: ProviderPool, prompt: str, as_json: bool, re
156
179
  agent_config = config.override(stream=False) if as_json else config
157
180
  outcome = Agent(agent_config, pool).run_turn(session, ReplUI(), threading.Event())
158
181
  session.autosave() # persist so `scoot -c` can continue this conversation
182
+ session_event(session, "SessionEnd", reason=outcome.status)
159
183
 
160
184
  if as_json:
161
185
  print(_json.dumps({
@@ -194,7 +218,6 @@ def _cmd_auth(config: Config, pool: ProviderPool, words: List[str]) -> int:
194
218
 
195
219
  def _interactive(pool: ProviderPool, resume=None) -> int:
196
220
  """Launch the persistent REPL (banner, live status, ESC-interrupt, slash-commands)."""
197
- from .auth import is_configured, missing_key_hint
198
221
  from .repl import Repl
199
222
 
200
223
  config = pool.config
@@ -205,12 +228,8 @@ def _interactive(pool: ProviderPool, resume=None) -> int:
205
228
 
206
229
  resume = sessions.latest_for_root(str(config.root))
207
230
 
208
- 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"))
212
- except ScootError as exc:
213
- eprint(color(f"⚠ {redact(str(exc))}", "yellow"))
231
+ # First-run onboarding happens inside the REPL (banner + a setup block), because anything printed
232
+ # here would be wiped by the screen clear the REPL does on start.
214
233
  return Repl(config, pool, resume=resume).run()
215
234
 
216
235
 
@@ -260,6 +279,13 @@ def main(argv: Optional[List[str]] = None) -> int:
260
279
 
261
280
  prompt = _resolve_prompt(args)
262
281
  resume = _resolve_resume(config, args)
282
+ if args.headless:
283
+ if prompt is not None:
284
+ eprint(color("--headless takes prompts on stdin, not as an argument.", "yellow"))
285
+ return 2
286
+ from .headless import run_headless
287
+
288
+ return run_headless(config.override(panel=False, dock=False, logo=False), pool, resume=resume)
263
289
  if prompt is not None:
264
290
  return _run_once(config, pool, prompt, args.json, resume=resume)
265
291
  return _interactive(pool, resume=resume)
@@ -51,6 +51,8 @@ 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
55
+ from . import hooks as _hooks # noqa: F401
54
56
 
55
57
  _loaded = True
56
58
 
@@ -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:
@@ -67,6 +69,7 @@ def _set(session, name: str) -> None:
67
69
  if pool is not None and hasattr(pool, "_providers"):
68
70
  pool._providers.pop(name, None) # rebuild with the new key on next use
69
71
  print(color(f"✔ {name} key saved to {path} (owner-only).", "green"))
72
+ _after_change(session)
70
73
 
71
74
 
72
75
  def _clear(session, name: str) -> None:
@@ -78,6 +81,7 @@ def _clear(session, name: str) -> None:
78
81
  print(color(f"forgot the saved {name} key.", "green"))
79
82
  else:
80
83
  print(color(f"no saved {name} key.", "gray"))
84
+ _after_change(session)
81
85
  pool = getattr(session, "provider", None)
82
86
  if pool is not None and hasattr(pool, "_providers"):
83
87
  pool._providers.pop(name, None)
@@ -88,6 +92,20 @@ def _clear(session, name: str) -> None:
88
92
  print(color(f" {missing_key_hint(spec)}", "gray"))
89
93
 
90
94
 
95
+ def _after_change(session) -> None:
96
+ """A key came or went: the default provider may have changed, so re-resolve and re-check."""
97
+ try:
98
+ if session.model.lower() in ("default", "auto"):
99
+ session.active_model = session.resolved_model()
100
+ session._available = None # the model list depends on which providers are configured
101
+ if session.refresh_readiness():
102
+ print(color(f"ready: {session.active_model}", "gray"))
103
+ else:
104
+ print(color(session.setup_message, "yellow"))
105
+ except Exception:
106
+ pass
107
+
108
+
91
109
  def _run(session, args: str):
92
110
  words = (args or "").split()
93
111
  if not words:
@@ -0,0 +1,37 @@
1
+ """/hooks — list the configured hooks and what they did last."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..hooks import EVENTS, enabled, global_path, project_path
6
+ from ..rendering import color
7
+ from . import register
8
+ from .base import SlashCommand
9
+
10
+
11
+ def _run(session, args: str):
12
+ hooks = getattr(session, "hooks", None)
13
+ if (args or "").strip() == "reload" and hooks is not None:
14
+ hooks.reload()
15
+ print(color("hooks reloaded.", "gray"))
16
+ print(color("hooks:", "bold"))
17
+ if not enabled():
18
+ print(color(" disabled by SCOOT_HOOKS=0", "yellow"))
19
+ root = getattr(getattr(session, "config", None), "root", ".")
20
+ print(color(f" files: {project_path(root)} (project), {global_path()} (global)", "gray"))
21
+ if hooks is None or not hooks.config:
22
+ print(color(" none configured", "gray"))
23
+ else:
24
+ for event in EVENTS:
25
+ for entry in hooks.config.get(event, []):
26
+ matcher = f" [{entry['matcher']}]" if entry.get("matcher") else ""
27
+ for hook in entry.get("hooks", []):
28
+ print(f" {color(event, 'cyan')}{matcher} {hook.get('command', '')}")
29
+ if hooks.history:
30
+ print(color(" recent:", "gray"))
31
+ for r in hooks.history[-8:]:
32
+ detail = f" {r.detail[:60]}" if r.detail else ""
33
+ print(f" {r.event:<16} {r.outcome:<8} {r.seconds:>5.2f}s {r.command[:40]}{detail}")
34
+ print(color(" usage: /hooks [reload]", "gray"))
35
+
36
+
37
+ register(SlashCommand("hooks", "list configured hooks and recent results", _run, usage="[reload]"))