scootcli 0.3.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.3.0/src/scootcli.egg-info → scootcli-0.4.0}/PKG-INFO +37 -3
- {scootcli-0.3.0 → scootcli-0.4.0}/README.md +36 -2
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/__init__.py +1 -1
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/agent.py +73 -5
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/cli.py +23 -8
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/commands/__init__.py +1 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/commands/auth.py +16 -0
- scootcli-0.4.0/src/scootcli/commands/hooks.py +37 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/commands/model.py +4 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/commands/reset.py +3 -0
- scootcli-0.4.0/src/scootcli/headless.py +329 -0
- scootcli-0.4.0/src/scootcli/hooks.py +264 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/panel.py +3 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/providers/base.py +7 -3
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/providers/registry.py +15 -4
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/repl.py +52 -7
- {scootcli-0.3.0 → scootcli-0.4.0/src/scootcli.egg-info}/PKG-INFO +37 -3
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli.egg-info/SOURCES.txt +5 -0
- scootcli-0.4.0/tests/test_headless.py +247 -0
- scootcli-0.4.0/tests/test_hooks.py +242 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_readiness.py +46 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/LICENSE +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/pyproject.toml +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/setup.cfg +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/__main__.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/activity.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/approvals.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/auth.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/clipboard.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/commands/approve.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/commands/base.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/commands/compact.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/commands/copy.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/commands/exit.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/commands/forget.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/commands/help.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/commands/init.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/commands/logo.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/commands/panel.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/commands/resume.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/commands/route.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/commands/save.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/commands/sessions.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/commands/status.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/commands/verbosity.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/commands/worktree.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/commands/yolo.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/config.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/context.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/credentials.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/errors.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/images.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/keys.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/lineeditor.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/logo.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/models.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/preferences.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/presets.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/project.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/prompts.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/providers/__init__.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/providers/anthropic.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/providers/openai_chat.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/providers/openai_responses.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/providers/router.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/rendering.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/sessions.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/status.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/tools/__init__.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/tools/base.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/tools/edit_file.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/tools/list_dir.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/tools/open_editor.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/tools/read_file.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/tools/run_shell.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/tools/search.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/tools/update_plan.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/tools/write_file.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/transport.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/vision.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/workspace.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli/worktree.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli.egg-info/dependency_links.txt +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli.egg-info/entry_points.txt +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/src/scootcli.egg-info/top_level.txt +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_agent.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_anthropic.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_approvals.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_auth.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_clipboard.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_config.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_images.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_lineeditor.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_logo.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_models.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_panel.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_preferences.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_providers.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_repl.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_resilience.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_router.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_sessions.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_smoke.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_streaming.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_tools.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_transport_native.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_vision.py +0 -0
- {scootcli-0.3.0 → scootcli-0.4.0}/tests/test_workspace.py +0 -0
- {scootcli-0.3.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
|
|
@@ -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
|
```
|
|
@@ -227,6 +237,29 @@ Responses stream live and stay interruptible.
|
|
|
227
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.
|
|
228
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.
|
|
229
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
|
+
|
|
230
263
|
### The status bar
|
|
231
264
|
|
|
232
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.
|
|
@@ -234,7 +267,7 @@ The bottom row shows the mascot's face (its eyes follow the turn: `o o` idle, `>
|
|
|
234
267
|
## Install options
|
|
235
268
|
|
|
236
269
|
```bash
|
|
237
|
-
pipx install scootcli
|
|
270
|
+
pipx install scootcli && pipx ensurepath # recommended: isolated; ensurepath puts ~/.local/bin on PATH for new shells
|
|
238
271
|
curl -fsSL https://raw.githubusercontent.com/sergenes/scootcli/main/install.sh | bash # no pipx: puts the zipapp at ~/.local/bin/scoot
|
|
239
272
|
pip install scootcli # anywhere
|
|
240
273
|
curl -LO https://github.com/sergenes/scootcli/releases/latest/download/scoot.pyz && python3 scoot.pyz # single file, no install
|
|
@@ -242,6 +275,7 @@ git clone https://github.com/sergenes/scootcli && cd scootcli && pip install -e
|
|
|
242
275
|
```
|
|
243
276
|
|
|
244
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.
|
|
245
279
|
|
|
246
280
|
Requirements: Python 3.9 or newer on macOS or Linux.
|
|
247
281
|
Nothing else: no compiler, no packages, no `curl`.
|
|
@@ -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
|
```
|
|
@@ -202,6 +212,29 @@ Responses stream live and stay interruptible.
|
|
|
202
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.
|
|
203
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.
|
|
204
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
|
+
|
|
205
238
|
### The status bar
|
|
206
239
|
|
|
207
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.
|
|
@@ -209,7 +242,7 @@ The bottom row shows the mascot's face (its eyes follow the turn: `o o` idle, `>
|
|
|
209
242
|
## Install options
|
|
210
243
|
|
|
211
244
|
```bash
|
|
212
|
-
pipx install scootcli
|
|
245
|
+
pipx install scootcli && pipx ensurepath # recommended: isolated; ensurepath puts ~/.local/bin on PATH for new shells
|
|
213
246
|
curl -fsSL https://raw.githubusercontent.com/sergenes/scootcli/main/install.sh | bash # no pipx: puts the zipapp at ~/.local/bin/scoot
|
|
214
247
|
pip install scootcli # anywhere
|
|
215
248
|
curl -LO https://github.com/sergenes/scootcli/releases/latest/download/scoot.pyz && python3 scoot.pyz # single file, no install
|
|
@@ -217,6 +250,7 @@ git clone https://github.com/sergenes/scootcli && cd scootcli && pip install -e
|
|
|
217
250
|
```
|
|
218
251
|
|
|
219
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.
|
|
220
254
|
|
|
221
255
|
Requirements: Python 3.9 or newer on macOS or Linux.
|
|
222
256
|
Nothing else: no compiler, no packages, no `curl`.
|
|
@@ -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)
|
|
@@ -144,10 +145,16 @@ class Agent:
|
|
|
144
145
|
return AgentOutcome(status, steps=steps)
|
|
145
146
|
continue
|
|
146
147
|
|
|
147
|
-
# No tool calls -> the model is done.
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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)
|
|
151
158
|
|
|
152
159
|
# ── model call (streaming or buffered) ───────────────────────────────────────
|
|
153
160
|
def _model_call(self, session, cfg, ui, cancel_event, steps):
|
|
@@ -181,6 +188,45 @@ class Agent:
|
|
|
181
188
|
)
|
|
182
189
|
return result, False
|
|
183
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
|
+
|
|
184
230
|
@staticmethod
|
|
185
231
|
def _last_user_text(session) -> str:
|
|
186
232
|
for m in reversed(getattr(session, "messages", []) or []):
|
|
@@ -236,6 +282,13 @@ class Agent:
|
|
|
236
282
|
|
|
237
283
|
def _messages(self, session, cfg=None) -> List[dict]:
|
|
238
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})
|
|
239
292
|
system = build_agent_system_prompt(
|
|
240
293
|
root=cfg.root,
|
|
241
294
|
model=session.active_model,
|
|
@@ -315,11 +368,25 @@ class Agent:
|
|
|
315
368
|
self._append_tool(session, tc_id, f"error: unknown tool '{name}'")
|
|
316
369
|
continue
|
|
317
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
|
|
318
377
|
# Approval policy: auto-approve when the mode/trust allows it, else prompt.
|
|
319
|
-
|
|
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:
|
|
320
384
|
if not getattr(tool, "auto_approve", False):
|
|
321
385
|
ui.auto_approved(tool, args) # meta tools render their own output
|
|
322
386
|
else:
|
|
387
|
+
from .hooks import notify
|
|
388
|
+
|
|
389
|
+
notify(session, "approval", f"{name} needs approval: {must_prompt}")
|
|
323
390
|
approval = ui.approve(tool, args, ctx)
|
|
324
391
|
if approval.decision == Decision.ABORT:
|
|
325
392
|
self._append_tool(session, tc_id, "user aborted the operation")
|
|
@@ -347,6 +414,7 @@ class Agent:
|
|
|
347
414
|
result = ToolResult.fail(f"tool crashed: {exc}")
|
|
348
415
|
|
|
349
416
|
self._handle_result(session, name, result, ui)
|
|
417
|
+
self._post_tool_hook(session, tool, args, result)
|
|
350
418
|
payload = result.content if result.ok else f"ERROR: {result.error}"
|
|
351
419
|
self._append_tool(session, tc_id, payload or "(no output)")
|
|
352
420
|
return None
|
|
@@ -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",
|
|
@@ -154,6 +156,17 @@ def _run_once(config: Config, pool: ProviderPool, prompt: str, as_json: bool, re
|
|
|
154
156
|
session = ReplSession(config, pool)
|
|
155
157
|
if resume is not None:
|
|
156
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
|
|
157
170
|
# Fold any dropped image paths into the prompt (best-effort; no-op when none/disabled).
|
|
158
171
|
try:
|
|
159
172
|
from .vision import fold_images_into_text
|
|
@@ -166,6 +179,7 @@ def _run_once(config: Config, pool: ProviderPool, prompt: str, as_json: bool, re
|
|
|
166
179
|
agent_config = config.override(stream=False) if as_json else config
|
|
167
180
|
outcome = Agent(agent_config, pool).run_turn(session, ReplUI(), threading.Event())
|
|
168
181
|
session.autosave() # persist so `scoot -c` can continue this conversation
|
|
182
|
+
session_event(session, "SessionEnd", reason=outcome.status)
|
|
169
183
|
|
|
170
184
|
if as_json:
|
|
171
185
|
print(_json.dumps({
|
|
@@ -214,14 +228,8 @@ def _interactive(pool: ProviderPool, resume=None) -> int:
|
|
|
214
228
|
|
|
215
229
|
resume = sessions.latest_for_root(str(config.root))
|
|
216
230
|
|
|
217
|
-
|
|
218
|
-
|
|
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"))
|
|
223
|
-
except ScootError as exc:
|
|
224
|
-
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.
|
|
225
233
|
return Repl(config, pool, resume=resume).run()
|
|
226
234
|
|
|
227
235
|
|
|
@@ -271,6 +279,13 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
271
279
|
|
|
272
280
|
prompt = _resolve_prompt(args)
|
|
273
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)
|
|
274
289
|
if prompt is not None:
|
|
275
290
|
return _run_once(config, pool, prompt, args.json, resume=resume)
|
|
276
291
|
return _interactive(pool, resume=resume)
|
|
@@ -69,6 +69,7 @@ def _set(session, name: str) -> None:
|
|
|
69
69
|
if pool is not None and hasattr(pool, "_providers"):
|
|
70
70
|
pool._providers.pop(name, None) # rebuild with the new key on next use
|
|
71
71
|
print(color(f"✔ {name} key saved to {path} (owner-only).", "green"))
|
|
72
|
+
_after_change(session)
|
|
72
73
|
|
|
73
74
|
|
|
74
75
|
def _clear(session, name: str) -> None:
|
|
@@ -80,6 +81,7 @@ def _clear(session, name: str) -> None:
|
|
|
80
81
|
print(color(f"forgot the saved {name} key.", "green"))
|
|
81
82
|
else:
|
|
82
83
|
print(color(f"no saved {name} key.", "gray"))
|
|
84
|
+
_after_change(session)
|
|
83
85
|
pool = getattr(session, "provider", None)
|
|
84
86
|
if pool is not None and hasattr(pool, "_providers"):
|
|
85
87
|
pool._providers.pop(name, None)
|
|
@@ -90,6 +92,20 @@ def _clear(session, name: str) -> None:
|
|
|
90
92
|
print(color(f" {missing_key_hint(spec)}", "gray"))
|
|
91
93
|
|
|
92
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
|
+
|
|
93
109
|
def _run(session, args: str):
|
|
94
110
|
words = (args or "").split()
|
|
95
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]"))
|
|
@@ -40,6 +40,8 @@ def _run(session, args: str):
|
|
|
40
40
|
set_model(arg)
|
|
41
41
|
what = "picked per task from the live list" if arg == "auto" else f"the provider's preferred model, now {session.active_model}"
|
|
42
42
|
print(color(f"model preference set to {arg} ({what}); saved for next launch.", "gray"))
|
|
43
|
+
if hasattr(session, "refresh_readiness") and not session.refresh_readiness():
|
|
44
|
+
print(color(session.setup_message, "yellow"))
|
|
43
45
|
return
|
|
44
46
|
|
|
45
47
|
if arg not in available:
|
|
@@ -55,6 +57,8 @@ def _run(session, args: str):
|
|
|
55
57
|
session.active_model = arg
|
|
56
58
|
set_model(arg)
|
|
57
59
|
print(color(f"model set to {arg}; saved for next launch.", "gray"))
|
|
60
|
+
if hasattr(session, "refresh_readiness") and not session.refresh_readiness():
|
|
61
|
+
print(color(session.setup_message, "yellow"))
|
|
58
62
|
|
|
59
63
|
|
|
60
64
|
register(SlashCommand("model", "list or switch model (default|auto|provider/model)", _run, usage="[provider/model|default|auto]"))
|
|
@@ -9,6 +9,9 @@ from .base import SlashCommand
|
|
|
9
9
|
|
|
10
10
|
def _run(session, args: str):
|
|
11
11
|
session.reset()
|
|
12
|
+
from ..hooks import session_event
|
|
13
|
+
|
|
14
|
+
session_event(session, "SessionStart", source="reset")
|
|
12
15
|
redraw = getattr(session, "redraw_home", None)
|
|
13
16
|
if callable(redraw):
|
|
14
17
|
redraw("fresh session — conversation + context cleared.")
|