scootcli 0.6.0__tar.gz → 0.8.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.6.0/src/scootcli.egg-info → scootcli-0.8.0}/PKG-INFO +21 -6
- {scootcli-0.6.0 → scootcli-0.8.0}/README.md +20 -5
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/__init__.py +1 -1
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/agent.py +6 -2
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/cli.py +14 -3
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/headless.py +15 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/hooks.py +36 -6
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/keys.py +50 -1
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/lineeditor.py +181 -11
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/panel.py +9 -7
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/repl.py +56 -1
- scootcli-0.8.0/src/scootcli/resize.py +164 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/status.py +17 -7
- {scootcli-0.6.0 → scootcli-0.8.0/src/scootcli.egg-info}/PKG-INFO +21 -6
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli.egg-info/SOURCES.txt +2 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/tests/test_headless.py +1 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/tests/test_hooks.py +33 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/tests/test_preferences.py +51 -0
- scootcli-0.8.0/tests/test_resize.py +141 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/tests/test_small_060.py +20 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/LICENSE +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/pyproject.toml +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/setup.cfg +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/__main__.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/activity.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/approvals.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/auth.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/clipboard.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/commands/__init__.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/commands/approve.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/commands/auth.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/commands/base.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/commands/compact.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/commands/copy.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/commands/exit.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/commands/forget.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/commands/help.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/commands/hooks.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/commands/init.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/commands/logo.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/commands/model.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/commands/panel.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/commands/reset.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/commands/resume.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/commands/route.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/commands/save.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/commands/scope.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/commands/sessions.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/commands/status.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/commands/verbosity.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/commands/worktree.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/commands/yolo.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/config.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/context.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/credentials.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/errors.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/images.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/logo.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/models.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/preferences.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/presets.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/pricing.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/project.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/prompts.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/providers/__init__.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/providers/anthropic.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/providers/base.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/providers/openai_chat.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/providers/openai_responses.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/providers/registry.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/providers/router.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/rendering.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/sessions.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/tools/__init__.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/tools/base.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/tools/edit_file.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/tools/list_dir.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/tools/open_editor.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/tools/read_file.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/tools/run_shell.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/tools/search.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/tools/update_plan.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/tools/write_file.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/transport.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/updates.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/vision.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/workspace.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli/worktree.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli.egg-info/dependency_links.txt +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli.egg-info/entry_points.txt +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/src/scootcli.egg-info/top_level.txt +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/tests/test_agent.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/tests/test_anthropic.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/tests/test_approvals.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/tests/test_auth.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/tests/test_clipboard.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/tests/test_config.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/tests/test_images.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/tests/test_lineeditor.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/tests/test_logo.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/tests/test_models.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/tests/test_panel.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/tests/test_providers.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/tests/test_readiness.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/tests/test_repl.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/tests/test_resilience.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/tests/test_router.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/tests/test_scope.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/tests/test_sessions.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/tests/test_smoke.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/tests/test_streaming.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/tests/test_tools.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/tests/test_transport_native.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/tests/test_vision.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.0}/tests/test_workspace.py +0 -0
- {scootcli-0.6.0 → scootcli-0.8.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.8.0
|
|
4
4
|
Summary: A tiny coding agent that goes where you point it
|
|
5
5
|
Author: Sergey Nes
|
|
6
6
|
License-Expression: MIT
|
|
@@ -116,6 +116,7 @@ scoot --model auto "..." # pick a model per prompt from the live lis
|
|
|
116
116
|
```
|
|
117
117
|
|
|
118
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
|
+
Starting the REPL with `--model` is remembered the same way, so `scoot --model ollama/llama3.2` once and plain `scoot` afterwards keep using it; a one-shot `scoot --model X "prompt"` does not change the saved choice.
|
|
119
120
|
|
|
120
121
|
### Routing
|
|
121
122
|
|
|
@@ -219,6 +220,7 @@ Press **Ctrl-N** while a turn is running to add a note: scoot asks for one line
|
|
|
219
220
|
Type `/` and press **Tab** to complete slash commands.
|
|
220
221
|
|
|
221
222
|
**Terminals.** scoot works in macOS Terminal, iTerm2, and inside tmux; the status bar uses a scroll region, the input dock uses raw mode, and clipboard copy uses the system tool or an OSC-52 escape.
|
|
223
|
+
Resizing the window or zooming the font (also under tmux) repaints the dock at once: the transcript stays where it was, the input keeps its place and the bar is redrawn, at the prompt and while a turn runs.
|
|
222
224
|
Under tmux, ESC reaches scoot only after tmux's `escape-time` has passed, so with the default 500 ms the interrupt feels delayed; `set -sg escape-time 10` in `~/.tmux.conf` makes it immediate.
|
|
223
225
|
For clipboard copy through tmux, `set -g set-clipboard on` (or `external`) lets the OSC-52 escape reach the outer terminal.
|
|
224
226
|
Without a TTY, scoot falls back to a plain prompt with no bar and no dock.
|
|
@@ -259,12 +261,13 @@ Put them in `~/.config/scoot/hooks.json` or `.scoot/hooks.json` in the project:
|
|
|
259
261
|
}
|
|
260
262
|
```
|
|
261
263
|
|
|
262
|
-
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.
|
|
264
|
+
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. Claude Code's nested `hookSpecificOutput` answer is accepted as is, so a hook written for Claude Code works without edits.
|
|
263
265
|
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.
|
|
266
|
+
Matchers understand Claude Code's tool names as well as scoot's, so `"matcher": "Bash|Write|Edit"` fires for `run_shell`, `write_file`, and `edit_file`, and the payload carries the alias as `tool_alias`: one `hooks.json` and one script can serve both tools.
|
|
264
267
|
`/hooks` shows what is configured and what ran; `SCOOT_HOOKS=0` turns hooks off.
|
|
265
268
|
|
|
266
269
|
**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.
|
|
267
|
-
Same sessions, tools, approvals, routing, and hooks as the REPL.
|
|
270
|
+
Same sessions, tools, approvals, routing, and hooks as the REPL; every `turn_end` carries the turn's tokens and cost, and `--json` one-shot output carries `cost` too.
|
|
268
271
|
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).
|
|
269
272
|
|
|
270
273
|
### The status bar
|
|
@@ -276,14 +279,26 @@ The bottom row shows the mascot's face (its eyes follow the turn: `o o` idle, `>
|
|
|
276
279
|
```bash
|
|
277
280
|
pipx install scootcli && pipx ensurepath # recommended: isolated; ensurepath puts ~/.local/bin on PATH for new shells
|
|
278
281
|
curl -fsSL https://raw.githubusercontent.com/sergenes/scootcli/main/install.sh | bash # no pipx: puts the zipapp at ~/.local/bin/scoot
|
|
279
|
-
pip install scootcli #
|
|
282
|
+
pip install scootcli # inside a venv, container, or CI job (system Pythons on recent Linux and Homebrew block pip outside a venv)
|
|
280
283
|
curl -LO https://github.com/sergenes/scootcli/releases/latest/download/scoot.pyz && python3 scoot.pyz # single file, no install
|
|
281
284
|
git clone https://github.com/sergenes/scootcli && cd scootcli && pip install -e . # from source
|
|
282
285
|
```
|
|
283
286
|
|
|
284
287
|
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.
|
|
285
|
-
|
|
286
|
-
|
|
288
|
+
## Upgrade options
|
|
289
|
+
|
|
290
|
+
Use the same tool you installed with:
|
|
291
|
+
|
|
292
|
+
```bash
|
|
293
|
+
pipx upgrade scootcli # pipx install
|
|
294
|
+
pip install --upgrade scootcli # pip install
|
|
295
|
+
curl -fsSL https://raw.githubusercontent.com/sergenes/scootcli/main/install.sh | bash # the curl installer: rerun it, it always fetches the latest release
|
|
296
|
+
scoot --version # what you have
|
|
297
|
+
scoot --check-update # ask PyPI whether a newer release exists
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
With `SCOOT_UPDATE_CHECK=1` the REPL checks once in the background at start and shows `⬆ x.y.z` in the status bar; nothing contacts PyPI otherwise.
|
|
301
|
+
To remove scoot: `pipx uninstall scootcli`, `pip uninstall scootcli`, or `bash install.sh --uninstall` for the curl installer; config in `~/.config/scoot` and sessions in `~/.local/state/scoot` stay either way.
|
|
287
302
|
|
|
288
303
|
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.
|
|
289
304
|
|
|
@@ -91,6 +91,7 @@ scoot --model auto "..." # pick a model per prompt from the live lis
|
|
|
91
91
|
```
|
|
92
92
|
|
|
93
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
|
+
Starting the REPL with `--model` is remembered the same way, so `scoot --model ollama/llama3.2` once and plain `scoot` afterwards keep using it; a one-shot `scoot --model X "prompt"` does not change the saved choice.
|
|
94
95
|
|
|
95
96
|
### Routing
|
|
96
97
|
|
|
@@ -194,6 +195,7 @@ Press **Ctrl-N** while a turn is running to add a note: scoot asks for one line
|
|
|
194
195
|
Type `/` and press **Tab** to complete slash commands.
|
|
195
196
|
|
|
196
197
|
**Terminals.** scoot works in macOS Terminal, iTerm2, and inside tmux; the status bar uses a scroll region, the input dock uses raw mode, and clipboard copy uses the system tool or an OSC-52 escape.
|
|
198
|
+
Resizing the window or zooming the font (also under tmux) repaints the dock at once: the transcript stays where it was, the input keeps its place and the bar is redrawn, at the prompt and while a turn runs.
|
|
197
199
|
Under tmux, ESC reaches scoot only after tmux's `escape-time` has passed, so with the default 500 ms the interrupt feels delayed; `set -sg escape-time 10` in `~/.tmux.conf` makes it immediate.
|
|
198
200
|
For clipboard copy through tmux, `set -g set-clipboard on` (or `external`) lets the OSC-52 escape reach the outer terminal.
|
|
199
201
|
Without a TTY, scoot falls back to a plain prompt with no bar and no dock.
|
|
@@ -234,12 +236,13 @@ Put them in `~/.config/scoot/hooks.json` or `.scoot/hooks.json` in the project:
|
|
|
234
236
|
}
|
|
235
237
|
```
|
|
236
238
|
|
|
237
|
-
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.
|
|
239
|
+
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. Claude Code's nested `hookSpecificOutput` answer is accepted as is, so a hook written for Claude Code works without edits.
|
|
238
240
|
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.
|
|
241
|
+
Matchers understand Claude Code's tool names as well as scoot's, so `"matcher": "Bash|Write|Edit"` fires for `run_shell`, `write_file`, and `edit_file`, and the payload carries the alias as `tool_alias`: one `hooks.json` and one script can serve both tools.
|
|
239
242
|
`/hooks` shows what is configured and what ran; `SCOOT_HOOKS=0` turns hooks off.
|
|
240
243
|
|
|
241
244
|
**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.
|
|
242
|
-
Same sessions, tools, approvals, routing, and hooks as the REPL.
|
|
245
|
+
Same sessions, tools, approvals, routing, and hooks as the REPL; every `turn_end` carries the turn's tokens and cost, and `--json` one-shot output carries `cost` too.
|
|
243
246
|
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).
|
|
244
247
|
|
|
245
248
|
### The status bar
|
|
@@ -251,14 +254,26 @@ The bottom row shows the mascot's face (its eyes follow the turn: `o o` idle, `>
|
|
|
251
254
|
```bash
|
|
252
255
|
pipx install scootcli && pipx ensurepath # recommended: isolated; ensurepath puts ~/.local/bin on PATH for new shells
|
|
253
256
|
curl -fsSL https://raw.githubusercontent.com/sergenes/scootcli/main/install.sh | bash # no pipx: puts the zipapp at ~/.local/bin/scoot
|
|
254
|
-
pip install scootcli #
|
|
257
|
+
pip install scootcli # inside a venv, container, or CI job (system Pythons on recent Linux and Homebrew block pip outside a venv)
|
|
255
258
|
curl -LO https://github.com/sergenes/scootcli/releases/latest/download/scoot.pyz && python3 scoot.pyz # single file, no install
|
|
256
259
|
git clone https://github.com/sergenes/scootcli && cd scootcli && pip install -e . # from source
|
|
257
260
|
```
|
|
258
261
|
|
|
259
262
|
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.
|
|
260
|
-
|
|
261
|
-
|
|
263
|
+
## Upgrade options
|
|
264
|
+
|
|
265
|
+
Use the same tool you installed with:
|
|
266
|
+
|
|
267
|
+
```bash
|
|
268
|
+
pipx upgrade scootcli # pipx install
|
|
269
|
+
pip install --upgrade scootcli # pip install
|
|
270
|
+
curl -fsSL https://raw.githubusercontent.com/sergenes/scootcli/main/install.sh | bash # the curl installer: rerun it, it always fetches the latest release
|
|
271
|
+
scoot --version # what you have
|
|
272
|
+
scoot --check-update # ask PyPI whether a newer release exists
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
With `SCOOT_UPDATE_CHECK=1` the REPL checks once in the background at start and shows `⬆ x.y.z` in the status bar; nothing contacts PyPI otherwise.
|
|
276
|
+
To remove scoot: `pipx uninstall scootcli`, `pip uninstall scootcli`, or `bash install.sh --uninstall` for the curl installer; config in `~/.config/scoot` and sessions in `~/.local/state/scoot` stay either way.
|
|
262
277
|
|
|
263
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.
|
|
264
279
|
|
|
@@ -219,8 +219,10 @@ class Agent:
|
|
|
219
219
|
return "", ""
|
|
220
220
|
from .hooks import tool_kind
|
|
221
221
|
|
|
222
|
+
from .hooks import tool_alias
|
|
223
|
+
|
|
222
224
|
payload = hooks.payload(session, "PreToolUse", tool_name=tool.name, tool_input=args,
|
|
223
|
-
tool_kind=tool_kind(tool))
|
|
225
|
+
tool_kind=tool_kind(tool), tool_alias=tool_alias(tool.name))
|
|
224
226
|
decision = hooks.run("PreToolUse", payload, cancel_event)
|
|
225
227
|
return decision.action, decision.reason
|
|
226
228
|
|
|
@@ -232,9 +234,11 @@ class Agent:
|
|
|
232
234
|
|
|
233
235
|
response = {"ok": result.ok, "summary": result.summary or "", "error": result.error or "",
|
|
234
236
|
"content": (result.content or "")[:4000]}
|
|
237
|
+
from .hooks import tool_alias
|
|
238
|
+
|
|
235
239
|
hooks.run("PostToolUse", hooks.payload(session, "PostToolUse", tool_name=tool.name,
|
|
236
240
|
tool_input=args, tool_kind=tool_kind(tool),
|
|
237
|
-
tool_response=response))
|
|
241
|
+
tool_alias=tool_alias(tool.name), tool_response=response))
|
|
238
242
|
|
|
239
243
|
@staticmethod
|
|
240
244
|
def _last_user_text(session) -> str:
|
|
@@ -194,6 +194,7 @@ def _run_once(config: Config, pool: ProviderPool, prompt: str, as_json: bool, re
|
|
|
194
194
|
"content": outcome.content,
|
|
195
195
|
"error": outcome.error,
|
|
196
196
|
"usage": session.last_usage,
|
|
197
|
+
"cost": session.session_cost(),
|
|
197
198
|
}, indent=2))
|
|
198
199
|
return 0 if outcome.status == "done" else 1
|
|
199
200
|
|
|
@@ -224,11 +225,21 @@ def _cmd_auth(config: Config, pool: ProviderPool, words: List[str]) -> int:
|
|
|
224
225
|
return 0
|
|
225
226
|
|
|
226
227
|
|
|
227
|
-
def _interactive(pool: ProviderPool, resume=None) -> int:
|
|
228
|
-
"""Launch the persistent REPL (banner, live status, ESC-interrupt, slash-commands).
|
|
228
|
+
def _interactive(pool: ProviderPool, resume=None, remember_model: Optional[str] = None) -> int:
|
|
229
|
+
"""Launch the persistent REPL (banner, live status, ESC-interrupt, slash-commands).
|
|
230
|
+
|
|
231
|
+
``remember_model`` is the model named on the command line (``--model``), if any. Launching the REPL
|
|
232
|
+
with it counts as choosing it, exactly like ``/model`` inside the REPL, so it is saved as the
|
|
233
|
+
preference and a plain ``scoot`` next time starts on the same model. One-shot prompts and headless
|
|
234
|
+
runs do not save it: a flag on a single command is not a choice for the next session.
|
|
235
|
+
"""
|
|
229
236
|
from .repl import Repl
|
|
230
237
|
|
|
231
238
|
config = pool.config
|
|
239
|
+
if remember_model and remember_model.strip():
|
|
240
|
+
from .preferences import set_model
|
|
241
|
+
|
|
242
|
+
set_model(remember_model.strip())
|
|
232
243
|
# resume=auto (SCOOT_RESUME / --resume-last): reload the latest session for this directory
|
|
233
244
|
# unless the user already picked one explicitly via --resume/--continue.
|
|
234
245
|
if resume is None and getattr(config, "resume", "hint") == "auto":
|
|
@@ -308,7 +319,7 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
308
319
|
return run_headless(config.override(panel=False, dock=False, logo=False), pool, resume=resume)
|
|
309
320
|
if prompt is not None:
|
|
310
321
|
return _run_once(config, pool, prompt, args.json, resume=resume)
|
|
311
|
-
return _interactive(pool, resume=resume)
|
|
322
|
+
return _interactive(pool, resume=resume, remember_model=args.model)
|
|
312
323
|
except ScootError as exc:
|
|
313
324
|
eprint(color(f"⚠ {redact(str(exc))}", "red"))
|
|
314
325
|
return 1
|
|
@@ -285,6 +285,7 @@ class Headless:
|
|
|
285
285
|
self.cancel = threading.Event()
|
|
286
286
|
self.ui.cancel_event = self.cancel
|
|
287
287
|
self.session.last_error = ""
|
|
288
|
+
before = self._session_cost()
|
|
288
289
|
try:
|
|
289
290
|
with redirect_stdout(sys.stderr): # any stray print from a tool or command stays off the protocol
|
|
290
291
|
try:
|
|
@@ -303,6 +304,7 @@ class Headless:
|
|
|
303
304
|
self.writer.emit("error", message=outcome.error, kind="turn")
|
|
304
305
|
self.writer.emit("turn_end", turn=self._turn, status=outcome.status, steps=outcome.steps,
|
|
305
306
|
model=self.session.active_model, usage=self.session.last_usage,
|
|
307
|
+
cost=self._turn_cost(before), cost_session=self._session_cost(),
|
|
306
308
|
content=outcome.content, error=outcome.error)
|
|
307
309
|
except ScootError as exc:
|
|
308
310
|
self.writer.emit("error", message=str(exc), hint=getattr(exc, "hint", ""), kind="turn")
|
|
@@ -313,6 +315,19 @@ class Headless:
|
|
|
313
315
|
self.cancel = None
|
|
314
316
|
self.ui.cancel_event = None
|
|
315
317
|
|
|
318
|
+
def _session_cost(self):
|
|
319
|
+
fn = getattr(self.session, "session_cost", None)
|
|
320
|
+
try:
|
|
321
|
+
return fn() if callable(fn) else None
|
|
322
|
+
except Exception:
|
|
323
|
+
return None
|
|
324
|
+
|
|
325
|
+
def _turn_cost(self, before):
|
|
326
|
+
after = self._session_cost()
|
|
327
|
+
if after is None or before is None:
|
|
328
|
+
return None
|
|
329
|
+
return round(after - before, 6)
|
|
330
|
+
|
|
316
331
|
def _command(self, msg: dict) -> None:
|
|
317
332
|
from . import commands
|
|
318
333
|
import io
|
|
@@ -36,6 +36,12 @@ EVENTS = ("SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop
|
|
|
36
36
|
DEFAULT_TIMEOUT = 60
|
|
37
37
|
_MAX_CAPTURE = 16_000
|
|
38
38
|
_TOOL_KINDS = {"read": "read", "write": "write", "shell": "shell"}
|
|
39
|
+
# Claude Code's names for the same jobs, so a hooks.json written for it ("matcher": "Bash|Write|Edit")
|
|
40
|
+
# fires for scoot's tools too, and a shared script can switch on ``tool_alias``.
|
|
41
|
+
TOOL_ALIASES = {
|
|
42
|
+
"read_file": "Read", "list_dir": "LS", "search": "Grep", "write_file": "Write", "edit_file": "Edit",
|
|
43
|
+
"run_shell": "Bash", "open_editor": "Open", "update_plan": "TodoWrite",
|
|
44
|
+
}
|
|
39
45
|
|
|
40
46
|
|
|
41
47
|
@dataclass
|
|
@@ -96,6 +102,19 @@ def tool_kind(tool) -> str:
|
|
|
96
102
|
return _TOOL_KINDS.get(getattr(tool, "risk", ""), "read")
|
|
97
103
|
|
|
98
104
|
|
|
105
|
+
def tool_alias(name: str) -> str:
|
|
106
|
+
return TOOL_ALIASES.get(name, name)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def matcher_hits(matcher: str, tool_name: str) -> bool:
|
|
110
|
+
"""A matcher regex matches scoot's tool name or its Claude Code alias."""
|
|
111
|
+
try:
|
|
112
|
+
rx = re.compile(str(matcher))
|
|
113
|
+
except re.error:
|
|
114
|
+
return False
|
|
115
|
+
return bool(rx.search(tool_name) or rx.search(tool_alias(tool_name)))
|
|
116
|
+
|
|
117
|
+
|
|
99
118
|
class Hooks:
|
|
100
119
|
"""The merged hook configuration for one workspace, plus a record of what ran."""
|
|
101
120
|
|
|
@@ -149,10 +168,7 @@ class Hooks:
|
|
|
149
168
|
for entry in self.config.get(event, []):
|
|
150
169
|
matcher = entry.get("matcher")
|
|
151
170
|
if matcher and event in ("PreToolUse", "PostToolUse"):
|
|
152
|
-
|
|
153
|
-
if not re.search(str(matcher), tool_name):
|
|
154
|
-
continue
|
|
155
|
-
except re.error:
|
|
171
|
+
if not matcher_hits(matcher, tool_name):
|
|
156
172
|
continue
|
|
157
173
|
for hook in entry.get("hooks", []):
|
|
158
174
|
if not isinstance(hook, dict) or hook.get("type", "command") != "command":
|
|
@@ -212,7 +228,21 @@ class Hooks:
|
|
|
212
228
|
@staticmethod
|
|
213
229
|
def _interpret(event: str, data: dict) -> Decision:
|
|
214
230
|
reason = str(data.get("reason") or "")
|
|
215
|
-
|
|
231
|
+
# Claude Code nests a hook's decision under "hookSpecificOutput" (with the reason as
|
|
232
|
+
# "permissionDecisionReason" and extra context as "additionalContext"); scoot's own flat
|
|
233
|
+
# shape puts the same keys at the top level. Read both, nested first.
|
|
234
|
+
hso = data.get("hookSpecificOutput")
|
|
235
|
+
if isinstance(hso, dict):
|
|
236
|
+
if not reason:
|
|
237
|
+
reason = str(hso.get("permissionDecisionReason") or hso.get("reason") or "")
|
|
238
|
+
perm_src = hso.get("permissionDecision")
|
|
239
|
+
context_src = hso.get("additionalContext")
|
|
240
|
+
else:
|
|
241
|
+
perm_src = data.get("permissionDecision")
|
|
242
|
+
context_src = None
|
|
243
|
+
perm = str(perm_src or "").lower()
|
|
244
|
+
if perm == "approve":
|
|
245
|
+
perm = "allow" # Claude Code's older synonym
|
|
216
246
|
if event == "PreToolUse" and perm in ("allow", "deny", "ask"):
|
|
217
247
|
return Decision(action=perm, reason=reason)
|
|
218
248
|
raw = str(data.get("decision") or "").lower()
|
|
@@ -220,7 +250,7 @@ class Hooks:
|
|
|
220
250
|
return Decision(action="deny" if event == "PreToolUse" else "block", reason=reason)
|
|
221
251
|
if raw == "approve" and event == "PreToolUse":
|
|
222
252
|
return Decision(action="allow", reason=reason)
|
|
223
|
-
context = data.get("additionalContext") or data.get("context") or ""
|
|
253
|
+
context = context_src or data.get("additionalContext") or data.get("context") or ""
|
|
224
254
|
return Decision(context=str(context) if context else "")
|
|
225
255
|
|
|
226
256
|
def _record(self, event: str, command: str, outcome: str, detail: str, started: float) -> None:
|
|
@@ -6,13 +6,19 @@ the in-flight request (and, later, tool) immediately.
|
|
|
6
6
|
|
|
7
7
|
Line editing for typing prompts happens in normal cooked mode (plain ``input()``); raw mode is only
|
|
8
8
|
active *while the agent works*, so we never interfere with the user typing.
|
|
9
|
+
|
|
10
|
+
While a section is active it is the only reader of stdin, so it also relays the terminal's answer to
|
|
11
|
+
a cursor-position request (``ESC[6n`` → ``ESC[row;colR``); :func:`query_cursor` is how the REPL finds
|
|
12
|
+
out where output is after a resize that happened mid-turn.
|
|
9
13
|
"""
|
|
10
14
|
|
|
11
15
|
from __future__ import annotations
|
|
12
16
|
|
|
13
17
|
import os
|
|
18
|
+
import re
|
|
14
19
|
import sys
|
|
15
20
|
import threading
|
|
21
|
+
from typing import Optional, Tuple
|
|
16
22
|
|
|
17
23
|
try:
|
|
18
24
|
import termios
|
|
@@ -25,6 +31,35 @@ except ImportError: # pragma: no cover - non-POSIX
|
|
|
25
31
|
|
|
26
32
|
ESC = "\x1b"
|
|
27
33
|
NOTE_KEY = b"\x0e" # Ctrl-N: ask for a note at the next model call
|
|
34
|
+
_CURSOR_REPORT = re.compile(rb"\x1b\[(\d+);(\d+)R") # the terminal's answer to ESC[6n
|
|
35
|
+
|
|
36
|
+
_active_section: "Optional[InterruptibleSection]" = None # the section currently reading stdin
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_cursor_report(data: bytes) -> Optional[Tuple[int, int]]:
|
|
40
|
+
"""``(row, col)`` from a byte stream holding ``ESC[row;colR``, or ``None``."""
|
|
41
|
+
m = _CURSOR_REPORT.search(data)
|
|
42
|
+
return (int(m.group(1)), int(m.group(2))) if m else None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def query_cursor(timeout: float = 0.2) -> Optional[Tuple[int, int]]:
|
|
46
|
+
"""Ask the terminal where the cursor is, while a section's listener can read the answer.
|
|
47
|
+
|
|
48
|
+
Returns ``(row, col)`` (1-based) or ``None`` when no section is active (so nothing would read the
|
|
49
|
+
reply and it would leak into the next prompt) or the terminal did not answer in time.
|
|
50
|
+
"""
|
|
51
|
+
section = _active_section
|
|
52
|
+
if section is None or not section.enabled:
|
|
53
|
+
return None
|
|
54
|
+
from .resize import CURSOR_REPORT
|
|
55
|
+
|
|
56
|
+
section.cursor_event.clear()
|
|
57
|
+
section.cursor_report = None
|
|
58
|
+
sys.stdout.write(CURSOR_REPORT)
|
|
59
|
+
sys.stdout.flush()
|
|
60
|
+
if section.cursor_event.wait(timeout):
|
|
61
|
+
return section.cursor_report
|
|
62
|
+
return None
|
|
28
63
|
|
|
29
64
|
|
|
30
65
|
def read_key() -> str:
|
|
@@ -63,8 +98,11 @@ class InterruptibleSection:
|
|
|
63
98
|
self._old_attrs = None
|
|
64
99
|
self._thread = None
|
|
65
100
|
self._stop = threading.Event()
|
|
101
|
+
self.cursor_report: Optional[Tuple[int, int]] = None # last ESC[row;colR seen (see query_cursor)
|
|
102
|
+
self.cursor_event = threading.Event()
|
|
66
103
|
|
|
67
104
|
def __enter__(self) -> "InterruptibleSection":
|
|
105
|
+
global _active_section
|
|
68
106
|
if not self.enabled:
|
|
69
107
|
return self
|
|
70
108
|
try:
|
|
@@ -76,6 +114,7 @@ class InterruptibleSection:
|
|
|
76
114
|
return self
|
|
77
115
|
self._thread = threading.Thread(target=self._listen, daemon=True)
|
|
78
116
|
self._thread.start()
|
|
117
|
+
_active_section = self
|
|
79
118
|
return self
|
|
80
119
|
|
|
81
120
|
def _listen(self) -> None:
|
|
@@ -102,17 +141,27 @@ class InterruptibleSection:
|
|
|
102
141
|
except (OSError, ValueError):
|
|
103
142
|
more = None
|
|
104
143
|
if more:
|
|
144
|
+
seq = bytearray(ch)
|
|
105
145
|
try:
|
|
106
146
|
while select.select([sys.stdin], [], [], 0)[0]:
|
|
107
|
-
|
|
147
|
+
byte = os.read(self._fd, 1)
|
|
148
|
+
if not byte:
|
|
108
149
|
break
|
|
150
|
+
seq += byte
|
|
109
151
|
except (OSError, ValueError):
|
|
110
152
|
pass
|
|
153
|
+
report = parse_cursor_report(bytes(seq))
|
|
154
|
+
if report is not None: # the terminal answering query_cursor
|
|
155
|
+
self.cursor_report = report
|
|
156
|
+
self.cursor_event.set()
|
|
111
157
|
continue
|
|
112
158
|
self.cancel_event.set()
|
|
113
159
|
break
|
|
114
160
|
|
|
115
161
|
def __exit__(self, *exc) -> None:
|
|
162
|
+
global _active_section
|
|
163
|
+
if _active_section is self:
|
|
164
|
+
_active_section = None
|
|
116
165
|
self._stop.set()
|
|
117
166
|
if self._thread is not None:
|
|
118
167
|
self._thread.join(timeout=0.3)
|