scootcli 0.3.0__tar.gz → 0.5.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.5.0}/PKG-INFO +46 -4
- {scootcli-0.3.0 → scootcli-0.5.0}/README.md +45 -3
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/__init__.py +1 -1
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/agent.py +140 -6
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/approvals.py +34 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/cli.py +26 -8
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/commands/__init__.py +2 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/commands/auth.py +16 -0
- scootcli-0.5.0/src/scootcli/commands/hooks.py +37 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/commands/model.py +4 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/commands/reset.py +3 -0
- scootcli-0.5.0/src/scootcli/commands/scope.py +33 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/commands/status.py +1 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/config.py +2 -0
- scootcli-0.5.0/src/scootcli/headless.py +355 -0
- scootcli-0.5.0/src/scootcli/hooks.py +264 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/panel.py +3 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/prompts.py +1 -1
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/providers/base.py +7 -3
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/providers/registry.py +15 -4
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/repl.py +61 -7
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/tools/base.py +76 -11
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/tools/edit_file.py +1 -1
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/tools/list_dir.py +1 -1
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/tools/open_editor.py +1 -1
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/tools/read_file.py +1 -1
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/tools/run_shell.py +2 -2
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/tools/write_file.py +1 -1
- {scootcli-0.3.0 → scootcli-0.5.0/src/scootcli.egg-info}/PKG-INFO +46 -4
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli.egg-info/SOURCES.txt +7 -0
- scootcli-0.5.0/tests/test_headless.py +247 -0
- scootcli-0.5.0/tests/test_hooks.py +242 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_readiness.py +46 -0
- scootcli-0.5.0/tests/test_scope.py +225 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/LICENSE +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/pyproject.toml +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/setup.cfg +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/__main__.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/activity.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/auth.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/clipboard.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/commands/approve.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/commands/base.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/commands/compact.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/commands/copy.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/commands/exit.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/commands/forget.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/commands/help.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/commands/init.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/commands/logo.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/commands/panel.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/commands/resume.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/commands/route.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/commands/save.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/commands/sessions.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/commands/verbosity.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/commands/worktree.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/commands/yolo.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/context.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/credentials.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/errors.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/images.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/keys.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/lineeditor.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/logo.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/models.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/preferences.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/presets.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/project.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/providers/__init__.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/providers/anthropic.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/providers/openai_chat.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/providers/openai_responses.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/providers/router.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/rendering.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/sessions.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/status.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/tools/__init__.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/tools/search.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/tools/update_plan.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/transport.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/vision.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/workspace.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/worktree.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli.egg-info/dependency_links.txt +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli.egg-info/entry_points.txt +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli.egg-info/top_level.txt +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_agent.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_anthropic.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_approvals.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_auth.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_clipboard.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_config.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_images.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_lineeditor.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_logo.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_models.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_panel.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_preferences.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_providers.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_repl.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_resilience.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_router.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_sessions.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_smoke.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_streaming.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_tools.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_transport_native.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_vision.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_workspace.py +0 -0
- {scootcli-0.3.0 → scootcli-0.5.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.5.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
|
```
|
|
@@ -196,6 +206,8 @@ scoot --no-panel --no-dock # plain prompt, no status bar (also what yo
|
|
|
196
206
|
/model list or switch model /approve set mode (always|auto-read|auto-edits|yolo)
|
|
197
207
|
/yolo auto-approve all /worktree isolate work in a git worktree
|
|
198
208
|
/auth provider keys /logo show or toggle the mascot
|
|
209
|
+
/scope where file tools may go /hooks configured hooks and results
|
|
210
|
+
/route how auto picks a model
|
|
199
211
|
/sessions list saved sessions /resume resume a saved session ([id])
|
|
200
212
|
/forget delete session(s) /panel toggle the bottom status bar
|
|
201
213
|
/verbosity feed detail (full|compact|quiet)
|
|
@@ -213,7 +225,11 @@ Without a TTY, scoot falls back to a plain prompt with no bar and no dock.
|
|
|
213
225
|
### Tools and approvals
|
|
214
226
|
|
|
215
227
|
The agent has eight tools: `read_file`, `list_dir`, `search`, `write_file`, `edit_file`, `run_shell`, `open_editor` (hands a file to IntelliJ IDEA's `idea -e` or to VS Code, `SCOOT_EDITOR` picks), and `update_plan` (a progress checklist for multi-step work).
|
|
216
|
-
|
|
228
|
+
Paths are resolved against the workspace root, and `~` works.
|
|
229
|
+
|
|
230
|
+
The workspace is where the agent works, not a wall.
|
|
231
|
+
When a tool needs a file outside it, in `~/.config`, another repo, `/etc`, scoot asks once: allow this path `[a]`, allow that directory for the session `[d]`, allow anywhere for the session `[A]`, skip `[s]`, or quit `[q]`.
|
|
232
|
+
`--scope anywhere`, `SCOOT_SCOPE=anywhere`, or `/scope anywhere` skip the question; `/scope` shows what has been granted.
|
|
217
233
|
|
|
218
234
|
When a call needs approval you can approve once `[a]`, trust that tool for the session `[t]`, approve everything this session `[A]`, edit the arguments `[e]`, skip `[s]`, or quit `[q]`.
|
|
219
235
|
`/approve <mode>` sets how much runs without asking: `always` prompts for everything, `auto-read` auto-approves reads, `auto-edits` also auto-approves file edits, `yolo` runs everything.
|
|
@@ -227,6 +243,29 @@ Responses stream live and stay interruptible.
|
|
|
227
243
|
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
244
|
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
245
|
|
|
246
|
+
### Automation: hooks and headless mode
|
|
247
|
+
|
|
248
|
+
**Hooks** run your own scripts at lifecycle events: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`, `Notification`, `SessionEnd`.
|
|
249
|
+
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.
|
|
250
|
+
Put them in `~/.config/scoot/hooks.json` or `.scoot/hooks.json` in the project:
|
|
251
|
+
|
|
252
|
+
```json
|
|
253
|
+
{
|
|
254
|
+
"PreToolUse": [
|
|
255
|
+
{"matcher": "run_shell|write_file|edit_file", "hooks": [{"type": "command", "command": "~/bin/guard.py", "timeout": 30}]}
|
|
256
|
+
],
|
|
257
|
+
"Stop": [{"hooks": [{"type": "command", "command": "~/bin/notify.sh"}]}]
|
|
258
|
+
}
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
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.
|
|
262
|
+
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.
|
|
263
|
+
`/hooks` shows what is configured and what ran; `SCOOT_HOOKS=0` turns hooks off.
|
|
264
|
+
|
|
265
|
+
**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.
|
|
266
|
+
Same sessions, tools, approvals, routing, and hooks as the REPL.
|
|
267
|
+
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).
|
|
268
|
+
|
|
230
269
|
### The status bar
|
|
231
270
|
|
|
232
271
|
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 +273,7 @@ The bottom row shows the mascot's face (its eyes follow the turn: `o o` idle, `>
|
|
|
234
273
|
## Install options
|
|
235
274
|
|
|
236
275
|
```bash
|
|
237
|
-
pipx install scootcli
|
|
276
|
+
pipx install scootcli && pipx ensurepath # recommended: isolated; ensurepath puts ~/.local/bin on PATH for new shells
|
|
238
277
|
curl -fsSL https://raw.githubusercontent.com/sergenes/scootcli/main/install.sh | bash # no pipx: puts the zipapp at ~/.local/bin/scoot
|
|
239
278
|
pip install scootcli # anywhere
|
|
240
279
|
curl -LO https://github.com/sergenes/scootcli/releases/latest/download/scoot.pyz && python3 scoot.pyz # single file, no install
|
|
@@ -242,6 +281,9 @@ git clone https://github.com/sergenes/scootcli && cd scootcli && pip install -e
|
|
|
242
281
|
```
|
|
243
282
|
|
|
244
283
|
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.
|
|
284
|
+
**Upgrading.** `pipx upgrade scootcli`, or `pip install --upgrade scootcli`, or rerun the curl line, which always fetches the latest release; `scoot --version` shows what you have.
|
|
285
|
+
|
|
286
|
+
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
287
|
|
|
246
288
|
Requirements: Python 3.9 or newer on macOS or Linux.
|
|
247
289
|
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
|
```
|
|
@@ -171,6 +181,8 @@ scoot --no-panel --no-dock # plain prompt, no status bar (also what yo
|
|
|
171
181
|
/model list or switch model /approve set mode (always|auto-read|auto-edits|yolo)
|
|
172
182
|
/yolo auto-approve all /worktree isolate work in a git worktree
|
|
173
183
|
/auth provider keys /logo show or toggle the mascot
|
|
184
|
+
/scope where file tools may go /hooks configured hooks and results
|
|
185
|
+
/route how auto picks a model
|
|
174
186
|
/sessions list saved sessions /resume resume a saved session ([id])
|
|
175
187
|
/forget delete session(s) /panel toggle the bottom status bar
|
|
176
188
|
/verbosity feed detail (full|compact|quiet)
|
|
@@ -188,7 +200,11 @@ Without a TTY, scoot falls back to a plain prompt with no bar and no dock.
|
|
|
188
200
|
### Tools and approvals
|
|
189
201
|
|
|
190
202
|
The agent has eight tools: `read_file`, `list_dir`, `search`, `write_file`, `edit_file`, `run_shell`, `open_editor` (hands a file to IntelliJ IDEA's `idea -e` or to VS Code, `SCOOT_EDITOR` picks), and `update_plan` (a progress checklist for multi-step work).
|
|
191
|
-
|
|
203
|
+
Paths are resolved against the workspace root, and `~` works.
|
|
204
|
+
|
|
205
|
+
The workspace is where the agent works, not a wall.
|
|
206
|
+
When a tool needs a file outside it, in `~/.config`, another repo, `/etc`, scoot asks once: allow this path `[a]`, allow that directory for the session `[d]`, allow anywhere for the session `[A]`, skip `[s]`, or quit `[q]`.
|
|
207
|
+
`--scope anywhere`, `SCOOT_SCOPE=anywhere`, or `/scope anywhere` skip the question; `/scope` shows what has been granted.
|
|
192
208
|
|
|
193
209
|
When a call needs approval you can approve once `[a]`, trust that tool for the session `[t]`, approve everything this session `[A]`, edit the arguments `[e]`, skip `[s]`, or quit `[q]`.
|
|
194
210
|
`/approve <mode>` sets how much runs without asking: `always` prompts for everything, `auto-read` auto-approves reads, `auto-edits` also auto-approves file edits, `yolo` runs everything.
|
|
@@ -202,6 +218,29 @@ Responses stream live and stay interruptible.
|
|
|
202
218
|
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
219
|
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
220
|
|
|
221
|
+
### Automation: hooks and headless mode
|
|
222
|
+
|
|
223
|
+
**Hooks** run your own scripts at lifecycle events: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`, `Notification`, `SessionEnd`.
|
|
224
|
+
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.
|
|
225
|
+
Put them in `~/.config/scoot/hooks.json` or `.scoot/hooks.json` in the project:
|
|
226
|
+
|
|
227
|
+
```json
|
|
228
|
+
{
|
|
229
|
+
"PreToolUse": [
|
|
230
|
+
{"matcher": "run_shell|write_file|edit_file", "hooks": [{"type": "command", "command": "~/bin/guard.py", "timeout": 30}]}
|
|
231
|
+
],
|
|
232
|
+
"Stop": [{"hooks": [{"type": "command", "command": "~/bin/notify.sh"}]}]
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
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.
|
|
237
|
+
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.
|
|
238
|
+
`/hooks` shows what is configured and what ran; `SCOOT_HOOKS=0` turns hooks off.
|
|
239
|
+
|
|
240
|
+
**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.
|
|
241
|
+
Same sessions, tools, approvals, routing, and hooks as the REPL.
|
|
242
|
+
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).
|
|
243
|
+
|
|
205
244
|
### The status bar
|
|
206
245
|
|
|
207
246
|
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 +248,7 @@ The bottom row shows the mascot's face (its eyes follow the turn: `o o` idle, `>
|
|
|
209
248
|
## Install options
|
|
210
249
|
|
|
211
250
|
```bash
|
|
212
|
-
pipx install scootcli
|
|
251
|
+
pipx install scootcli && pipx ensurepath # recommended: isolated; ensurepath puts ~/.local/bin on PATH for new shells
|
|
213
252
|
curl -fsSL https://raw.githubusercontent.com/sergenes/scootcli/main/install.sh | bash # no pipx: puts the zipapp at ~/.local/bin/scoot
|
|
214
253
|
pip install scootcli # anywhere
|
|
215
254
|
curl -LO https://github.com/sergenes/scootcli/releases/latest/download/scoot.pyz && python3 scoot.pyz # single file, no install
|
|
@@ -217,6 +256,9 @@ git clone https://github.com/sergenes/scootcli && cd scootcli && pip install -e
|
|
|
217
256
|
```
|
|
218
257
|
|
|
219
258
|
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.
|
|
259
|
+
**Upgrading.** `pipx upgrade scootcli`, or `pip install --upgrade scootcli`, or rerun the curl line, which always fetches the latest release; `scoot --version` shows what you have.
|
|
260
|
+
|
|
261
|
+
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
262
|
|
|
221
263
|
Requirements: Python 3.9 or newer on macOS or Linux.
|
|
222
264
|
Nothing else: no compiler, no packages, no `curl`.
|
|
@@ -70,6 +70,12 @@ class HeadlessUI:
|
|
|
70
70
|
self.events.append(("approve", tool.name, args))
|
|
71
71
|
return Approval(self.decision, args)
|
|
72
72
|
|
|
73
|
+
scope_decision = "dir" # what this headless UI answers to a scope question (tests override)
|
|
74
|
+
|
|
75
|
+
def approve_scope(self, tool, path, ctx) -> str:
|
|
76
|
+
self.events.append(("scope", tool.name, str(path)))
|
|
77
|
+
return self.scope_decision
|
|
78
|
+
|
|
73
79
|
def auto_approved(self, tool, args) -> None:
|
|
74
80
|
self.events.append(("auto", tool.name, args))
|
|
75
81
|
|
|
@@ -97,6 +103,7 @@ class Agent:
|
|
|
97
103
|
self._refresh_workspace(session, cfg)
|
|
98
104
|
steps = 0
|
|
99
105
|
compacted = False
|
|
106
|
+
stop_blocks = 0
|
|
100
107
|
while True:
|
|
101
108
|
if cancel_event.is_set():
|
|
102
109
|
return AgentOutcome("interrupted", steps=steps)
|
|
@@ -144,10 +151,16 @@ class Agent:
|
|
|
144
151
|
return AgentOutcome(status, steps=steps)
|
|
145
152
|
continue
|
|
146
153
|
|
|
147
|
-
# No tool calls -> the model is done.
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
154
|
+
# No tool calls -> the model is done, unless a Stop hook asks for more (bounded).
|
|
155
|
+
content = _strip_done(result.content)
|
|
156
|
+
if stop_blocks < 3:
|
|
157
|
+
reason = self._stop_hook(session, content, steps)
|
|
158
|
+
if reason:
|
|
159
|
+
stop_blocks += 1
|
|
160
|
+
session.messages.append({"role": "user", "content": reason})
|
|
161
|
+
ui.assistant(f"continuing: {reason}")
|
|
162
|
+
continue
|
|
163
|
+
return AgentOutcome("done", content=content, steps=steps, streamed=streamed)
|
|
151
164
|
|
|
152
165
|
# ── model call (streaming or buffered) ───────────────────────────────────────
|
|
153
166
|
def _model_call(self, session, cfg, ui, cancel_event, steps):
|
|
@@ -181,6 +194,45 @@ class Agent:
|
|
|
181
194
|
)
|
|
182
195
|
return result, False
|
|
183
196
|
|
|
197
|
+
# ── hooks ────────────────────────────────────────────────────────────────────
|
|
198
|
+
@staticmethod
|
|
199
|
+
def _hooks(session):
|
|
200
|
+
from .hooks import for_session
|
|
201
|
+
|
|
202
|
+
return for_session(session)
|
|
203
|
+
|
|
204
|
+
def _stop_hook(self, session, content: str, steps: int) -> str:
|
|
205
|
+
hooks = self._hooks(session)
|
|
206
|
+
if hooks is None or not hooks.has("Stop"):
|
|
207
|
+
return ""
|
|
208
|
+
payload = hooks.payload(session, "Stop", last_assistant_message=content, steps=steps,
|
|
209
|
+
usage=getattr(session, "last_usage", {}) or {})
|
|
210
|
+
decision = hooks.run("Stop", payload)
|
|
211
|
+
return (decision.reason or "the Stop hook asked to continue") if decision.blocks else ""
|
|
212
|
+
|
|
213
|
+
def _pre_tool_hook(self, session, tool, args, cancel_event) -> "tuple[str, str]":
|
|
214
|
+
hooks = self._hooks(session)
|
|
215
|
+
if hooks is None or not hooks.has("PreToolUse"):
|
|
216
|
+
return "", ""
|
|
217
|
+
from .hooks import tool_kind
|
|
218
|
+
|
|
219
|
+
payload = hooks.payload(session, "PreToolUse", tool_name=tool.name, tool_input=args,
|
|
220
|
+
tool_kind=tool_kind(tool))
|
|
221
|
+
decision = hooks.run("PreToolUse", payload, cancel_event)
|
|
222
|
+
return decision.action, decision.reason
|
|
223
|
+
|
|
224
|
+
def _post_tool_hook(self, session, tool, args, result: ToolResult) -> None:
|
|
225
|
+
hooks = self._hooks(session)
|
|
226
|
+
if hooks is None or not hooks.has("PostToolUse"):
|
|
227
|
+
return
|
|
228
|
+
from .hooks import tool_kind
|
|
229
|
+
|
|
230
|
+
response = {"ok": result.ok, "summary": result.summary or "", "error": result.error or "",
|
|
231
|
+
"content": (result.content or "")[:4000]}
|
|
232
|
+
hooks.run("PostToolUse", hooks.payload(session, "PostToolUse", tool_name=tool.name,
|
|
233
|
+
tool_input=args, tool_kind=tool_kind(tool),
|
|
234
|
+
tool_response=response))
|
|
235
|
+
|
|
184
236
|
@staticmethod
|
|
185
237
|
def _last_user_text(session) -> str:
|
|
186
238
|
for m in reversed(getattr(session, "messages", []) or []):
|
|
@@ -236,6 +288,13 @@ class Agent:
|
|
|
236
288
|
|
|
237
289
|
def _messages(self, session, cfg=None) -> List[dict]:
|
|
238
290
|
cfg = cfg or getattr(session, "config", None) or self.config
|
|
291
|
+
notes = getattr(session, "pending_notes", None)
|
|
292
|
+
if notes:
|
|
293
|
+
joined = "\n".join(n for n in notes if n)
|
|
294
|
+
notes.clear()
|
|
295
|
+
if joined:
|
|
296
|
+
session.messages.append({"role": "user",
|
|
297
|
+
"content": "Note from the user while you work:\n" + joined})
|
|
239
298
|
system = build_agent_system_prompt(
|
|
240
299
|
root=cfg.root,
|
|
241
300
|
model=session.active_model,
|
|
@@ -292,7 +351,16 @@ class Agent:
|
|
|
292
351
|
# ── tool execution ───────────────────────────────────────────────────────────
|
|
293
352
|
def _run_tools(self, session, tool_calls, ui, cancel_event, cfg=None) -> Optional[str]:
|
|
294
353
|
cfg = cfg or getattr(session, "config", None) or self.config
|
|
295
|
-
|
|
354
|
+
scope = getattr(session, "scope", None)
|
|
355
|
+
if scope is None:
|
|
356
|
+
from .tools.base import Scope
|
|
357
|
+
|
|
358
|
+
scope = Scope(cfg.root, everything=(getattr(cfg, "scope", "workspace") == "anywhere"))
|
|
359
|
+
try:
|
|
360
|
+
session.scope = scope
|
|
361
|
+
except Exception:
|
|
362
|
+
pass
|
|
363
|
+
ctx = ToolContext(root=cfg.root, config=cfg, cancel_event=cancel_event, scope=scope)
|
|
296
364
|
mode = getattr(session, "approval_mode", "always")
|
|
297
365
|
# Session trust-list: tools the user chose to auto-approve for the rest of the session.
|
|
298
366
|
trusted = getattr(session, "trusted_tools", None)
|
|
@@ -315,11 +383,36 @@ class Agent:
|
|
|
315
383
|
self._append_tool(session, tc_id, f"error: unknown tool '{name}'")
|
|
316
384
|
continue
|
|
317
385
|
|
|
386
|
+
# Paths outside the workspace: ask the user once (this path, its directory, or anywhere).
|
|
387
|
+
outside = self._outside_paths(tool, args, ctx)
|
|
388
|
+
if outside:
|
|
389
|
+
verdict = self._ask_scope(session, tool, outside, ctx, ui)
|
|
390
|
+
if verdict == "abort":
|
|
391
|
+
self._append_tool(session, tc_id, "user aborted the operation")
|
|
392
|
+
return "abort"
|
|
393
|
+
if verdict == "deny":
|
|
394
|
+
self._append_tool(session, tc_id, f"user declined access outside the workspace: {outside[0]}")
|
|
395
|
+
ui.tool_result(name, ToolResult(ok=False, summary="outside the workspace: declined"))
|
|
396
|
+
continue
|
|
397
|
+
# A PreToolUse hook may deny (skip the tool), allow (skip the prompt), or ask (force it).
|
|
398
|
+
hook_action, hook_reason = self._pre_tool_hook(session, tool, args, cancel_event)
|
|
399
|
+
if hook_action == "deny":
|
|
400
|
+
self._append_tool(session, tc_id, f"user declined via hook: {hook_reason or 'no reason given'}")
|
|
401
|
+
ui.tool_result(name, ToolResult(ok=False, summary=f"denied by hook: {hook_reason}"[:80]))
|
|
402
|
+
continue
|
|
318
403
|
# Approval policy: auto-approve when the mode/trust allows it, else prompt.
|
|
319
|
-
|
|
404
|
+
must_prompt = needs_prompt(mode, tool, args, trusted)
|
|
405
|
+
if hook_action == "allow":
|
|
406
|
+
must_prompt = None
|
|
407
|
+
elif hook_action == "ask":
|
|
408
|
+
must_prompt = hook_reason or "hook asked for confirmation"
|
|
409
|
+
if must_prompt is None:
|
|
320
410
|
if not getattr(tool, "auto_approve", False):
|
|
321
411
|
ui.auto_approved(tool, args) # meta tools render their own output
|
|
322
412
|
else:
|
|
413
|
+
from .hooks import notify
|
|
414
|
+
|
|
415
|
+
notify(session, "approval", f"{name} needs approval: {must_prompt}")
|
|
323
416
|
approval = ui.approve(tool, args, ctx)
|
|
324
417
|
if approval.decision == Decision.ABORT:
|
|
325
418
|
self._append_tool(session, tc_id, "user aborted the operation")
|
|
@@ -347,10 +440,51 @@ class Agent:
|
|
|
347
440
|
result = ToolResult.fail(f"tool crashed: {exc}")
|
|
348
441
|
|
|
349
442
|
self._handle_result(session, name, result, ui)
|
|
443
|
+
self._post_tool_hook(session, tool, args, result)
|
|
350
444
|
payload = result.content if result.ok else f"ERROR: {result.error}"
|
|
351
445
|
self._append_tool(session, tc_id, payload or "(no output)")
|
|
352
446
|
return None
|
|
353
447
|
|
|
448
|
+
@staticmethod
|
|
449
|
+
def _outside_paths(tool, args, ctx) -> "list":
|
|
450
|
+
from .tools.base import resolve_path
|
|
451
|
+
|
|
452
|
+
scope = ctx.scope
|
|
453
|
+
found = []
|
|
454
|
+
for raw in tool.paths(args):
|
|
455
|
+
try:
|
|
456
|
+
p = resolve_path(ctx.root, raw)
|
|
457
|
+
except Exception:
|
|
458
|
+
continue
|
|
459
|
+
if scope is None or not scope.allows(p):
|
|
460
|
+
found.append(p)
|
|
461
|
+
return found
|
|
462
|
+
|
|
463
|
+
def _ask_scope(self, session, tool, paths, ctx, ui) -> str:
|
|
464
|
+
"""Ask for the first outside path; grant per the answer. Returns once|dir|all|deny|abort."""
|
|
465
|
+
scope = ctx.scope
|
|
466
|
+
if getattr(session, "config", None) is not None and getattr(session.config, "scope", "") == "anywhere":
|
|
467
|
+
scope.grant_all()
|
|
468
|
+
return "all"
|
|
469
|
+
asker = getattr(ui, "approve_scope", None)
|
|
470
|
+
if asker is None:
|
|
471
|
+
return "deny"
|
|
472
|
+
from .hooks import notify
|
|
473
|
+
|
|
474
|
+
notify(session, "scope", f"{tool.name} wants {paths[0]} outside the workspace")
|
|
475
|
+
verdict = asker(tool, paths[0], ctx)
|
|
476
|
+
if verdict == "once":
|
|
477
|
+
for p in paths:
|
|
478
|
+
scope.grant(p)
|
|
479
|
+
elif verdict == "dir":
|
|
480
|
+
for p in paths:
|
|
481
|
+
scope.grant_dir(p)
|
|
482
|
+
ui.assistant(f"allowing {scope.granted[-1]} for the rest of this session.")
|
|
483
|
+
elif verdict == "all":
|
|
484
|
+
scope.grant_all()
|
|
485
|
+
ui.assistant("allowing access anywhere on this machine for the rest of this session.")
|
|
486
|
+
return verdict
|
|
487
|
+
|
|
354
488
|
@staticmethod
|
|
355
489
|
def _handle_result(session, name: str, result: ToolResult, ui) -> None:
|
|
356
490
|
"""Surface a tool result: render a plan update specially, else the generic result line."""
|
|
@@ -126,6 +126,40 @@ def _edit_args(args: dict) -> dict:
|
|
|
126
126
|
return args
|
|
127
127
|
|
|
128
128
|
|
|
129
|
+
SCOPE_CHOICES = ("once", "dir", "all", "deny", "abort")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def request_scope(tool: Tool, path, ctx: ToolContext) -> str:
|
|
133
|
+
"""Ask once about a path outside the workspace. Returns one of ``SCOPE_CHOICES``."""
|
|
134
|
+
from pathlib import Path as _P
|
|
135
|
+
|
|
136
|
+
p = _P(str(path))
|
|
137
|
+
where = p if p.is_dir() else p.parent
|
|
138
|
+
print(color(f" ⌂ {tool.name} wants {p}", "yellow") + color(" (outside the workspace)", "gray"))
|
|
139
|
+
while True:
|
|
140
|
+
print(
|
|
141
|
+
" "
|
|
142
|
+
+ color("allow this path", "green") + " [a] "
|
|
143
|
+
+ color(f"allow {where} this session", "green") + " [d] "
|
|
144
|
+
+ color("allow anywhere this session", "green") + " [A] "
|
|
145
|
+
+ color("skip", "yellow") + " [s] "
|
|
146
|
+
+ color("quit", "red") + " [q] › ",
|
|
147
|
+
end="", flush=True,
|
|
148
|
+
)
|
|
149
|
+
key = read_key()
|
|
150
|
+
print(key)
|
|
151
|
+
if key in ("a", "y", "\r", "\n", ""):
|
|
152
|
+
return "once"
|
|
153
|
+
if key == "d":
|
|
154
|
+
return "dir"
|
|
155
|
+
if key == "A":
|
|
156
|
+
return "all"
|
|
157
|
+
if key == "s":
|
|
158
|
+
return "deny"
|
|
159
|
+
if key == "q":
|
|
160
|
+
return "abort"
|
|
161
|
+
|
|
162
|
+
|
|
129
163
|
def request_approval(tool: Tool, args: dict, ctx: ToolContext) -> Approval:
|
|
130
164
|
"""Show the pending tool call and read the user's decision (with optional arg editing)."""
|
|
131
165
|
while True:
|
|
@@ -59,6 +59,10 @@ 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("--scope", choices=["workspace", "anywhere"],
|
|
63
|
+
help="Where file tools may go: workspace (default; the first access outside it asks once) or anywhere.")
|
|
64
|
+
parser.add_argument("--headless", action="store_true",
|
|
65
|
+
help="Line-delimited JSON on stdin/stdout instead of the REPL (see docs/headless-protocol.md).")
|
|
62
66
|
parser.add_argument("--no-logo", action="store_true",
|
|
63
67
|
help="Hide the mascot (launch banner art + status-bar face). Persist with /logo off.")
|
|
64
68
|
parser.add_argument("--no-emoji", action="store_true",
|
|
@@ -90,6 +94,7 @@ def _config_from_args(args: argparse.Namespace) -> Config:
|
|
|
90
94
|
proxy=args.proxy,
|
|
91
95
|
root=args.root,
|
|
92
96
|
approval="yolo" if (args.yolo or args.yes) else args.approval,
|
|
97
|
+
scope="anywhere" if args.yes else args.scope,
|
|
93
98
|
verbose=True if args.verbose else None,
|
|
94
99
|
stream=False if args.no_stream else None,
|
|
95
100
|
panel=False if args.no_panel else None,
|
|
@@ -154,6 +159,17 @@ def _run_once(config: Config, pool: ProviderPool, prompt: str, as_json: bool, re
|
|
|
154
159
|
session = ReplSession(config, pool)
|
|
155
160
|
if resume is not None:
|
|
156
161
|
session.apply_record(resume)
|
|
162
|
+
from .hooks import Hooks, session_event, submit_prompt
|
|
163
|
+
|
|
164
|
+
session.hooks = Hooks(config.root)
|
|
165
|
+
session_event(session, "SessionStart", source="resume" if resume is not None else "startup")
|
|
166
|
+
submitted = submit_prompt(session, prompt)
|
|
167
|
+
if submitted is None:
|
|
168
|
+
reason = getattr(session, "hook_block_reason", "") or "a UserPromptSubmit hook blocked it"
|
|
169
|
+
eprint(color(f"⏹ prompt not sent: {reason}", "yellow"))
|
|
170
|
+
session_event(session, "SessionEnd", reason="blocked")
|
|
171
|
+
return 1
|
|
172
|
+
prompt = submitted
|
|
157
173
|
# Fold any dropped image paths into the prompt (best-effort; no-op when none/disabled).
|
|
158
174
|
try:
|
|
159
175
|
from .vision import fold_images_into_text
|
|
@@ -166,6 +182,7 @@ def _run_once(config: Config, pool: ProviderPool, prompt: str, as_json: bool, re
|
|
|
166
182
|
agent_config = config.override(stream=False) if as_json else config
|
|
167
183
|
outcome = Agent(agent_config, pool).run_turn(session, ReplUI(), threading.Event())
|
|
168
184
|
session.autosave() # persist so `scoot -c` can continue this conversation
|
|
185
|
+
session_event(session, "SessionEnd", reason=outcome.status)
|
|
169
186
|
|
|
170
187
|
if as_json:
|
|
171
188
|
print(_json.dumps({
|
|
@@ -214,14 +231,8 @@ def _interactive(pool: ProviderPool, resume=None) -> int:
|
|
|
214
231
|
|
|
215
232
|
resume = sessions.latest_for_root(str(config.root))
|
|
216
233
|
|
|
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"))
|
|
234
|
+
# First-run onboarding happens inside the REPL (banner + a setup block), because anything printed
|
|
235
|
+
# here would be wiped by the screen clear the REPL does on start.
|
|
225
236
|
return Repl(config, pool, resume=resume).run()
|
|
226
237
|
|
|
227
238
|
|
|
@@ -271,6 +282,13 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
271
282
|
|
|
272
283
|
prompt = _resolve_prompt(args)
|
|
273
284
|
resume = _resolve_resume(config, args)
|
|
285
|
+
if args.headless:
|
|
286
|
+
if prompt is not None:
|
|
287
|
+
eprint(color("--headless takes prompts on stdin, not as an argument.", "yellow"))
|
|
288
|
+
return 2
|
|
289
|
+
from .headless import run_headless
|
|
290
|
+
|
|
291
|
+
return run_headless(config.override(panel=False, dock=False, logo=False), pool, resume=resume)
|
|
274
292
|
if prompt is not None:
|
|
275
293
|
return _run_once(config, pool, prompt, args.json, resume=resume)
|
|
276
294
|
return _interactive(pool, resume=resume)
|
|
@@ -52,6 +52,8 @@ def load_builtins() -> None:
|
|
|
52
52
|
from . import copy as _copy # noqa: F401
|
|
53
53
|
from . import logo as _logo # noqa: F401
|
|
54
54
|
from . import route as _route # noqa: F401
|
|
55
|
+
from . import hooks as _hooks # noqa: F401
|
|
56
|
+
from . import scope as _scope # noqa: F401
|
|
55
57
|
|
|
56
58
|
_loaded = True
|
|
57
59
|
|
|
@@ -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]"))
|