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.
- {scootcli-0.2.0/src/scootcli.egg-info → scootcli-0.4.0}/PKG-INFO +71 -4
- {scootcli-0.2.0 → scootcli-0.4.0}/README.md +70 -3
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/__init__.py +1 -1
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/agent.py +95 -10
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/auth.py +8 -3
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/cli.py +33 -7
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/commands/__init__.py +2 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/commands/auth.py +20 -2
- scootcli-0.4.0/src/scootcli/commands/hooks.py +37 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/commands/model.py +4 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/commands/reset.py +3 -0
- scootcli-0.4.0/src/scootcli/commands/route.py +21 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/commands/status.py +7 -0
- scootcli-0.4.0/src/scootcli/headless.py +329 -0
- scootcli-0.4.0/src/scootcli/hooks.py +264 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/panel.py +3 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/providers/base.py +21 -1
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/providers/registry.py +69 -2
- scootcli-0.4.0/src/scootcli/providers/router.py +231 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/repl.py +70 -12
- {scootcli-0.2.0 → scootcli-0.4.0/src/scootcli.egg-info}/PKG-INFO +71 -4
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli.egg-info/SOURCES.txt +9 -0
- scootcli-0.4.0/tests/test_headless.py +247 -0
- scootcli-0.4.0/tests/test_hooks.py +242 -0
- scootcli-0.4.0/tests/test_readiness.py +180 -0
- scootcli-0.4.0/tests/test_router.py +155 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/LICENSE +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/pyproject.toml +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/setup.cfg +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/__main__.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/activity.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/approvals.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/clipboard.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/commands/approve.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/commands/base.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/commands/compact.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/commands/copy.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/commands/exit.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/commands/forget.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/commands/help.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/commands/init.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/commands/logo.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/commands/panel.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/commands/resume.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/commands/save.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/commands/sessions.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/commands/verbosity.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/commands/worktree.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/commands/yolo.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/config.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/context.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/credentials.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/errors.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/images.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/keys.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/lineeditor.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/logo.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/models.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/preferences.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/presets.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/project.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/prompts.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/providers/__init__.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/providers/anthropic.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/providers/openai_chat.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/providers/openai_responses.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/rendering.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/sessions.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/status.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/tools/__init__.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/tools/base.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/tools/edit_file.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/tools/list_dir.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/tools/open_editor.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/tools/read_file.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/tools/run_shell.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/tools/search.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/tools/update_plan.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/tools/write_file.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/transport.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/vision.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/workspace.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli/worktree.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli.egg-info/dependency_links.txt +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli.egg-info/entry_points.txt +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/src/scootcli.egg-info/top_level.txt +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/tests/test_agent.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/tests/test_anthropic.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/tests/test_approvals.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/tests/test_auth.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/tests/test_clipboard.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/tests/test_config.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/tests/test_images.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/tests/test_lineeditor.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/tests/test_logo.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/tests/test_models.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/tests/test_panel.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/tests/test_preferences.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/tests/test_providers.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/tests/test_repl.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/tests/test_resilience.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/tests/test_sessions.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/tests/test_smoke.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/tests/test_streaming.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/tests/test_tools.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/tests/test_transport_native.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/tests/test_vision.py +0 -0
- {scootcli-0.2.0 → scootcli-0.4.0}/tests/test_workspace.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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.
|
|
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 .
|
|
242
|
+
from .providers.router import Router, compute_hints
|
|
194
243
|
|
|
195
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
209
|
-
|
|
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("
|
|
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]"))
|