scootcli 0.6.0__tar.gz → 0.7.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.7.0}/PKG-INFO +19 -6
- {scootcli-0.6.0 → scootcli-0.7.0}/README.md +18 -5
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/__init__.py +1 -1
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/agent.py +6 -2
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/cli.py +1 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/headless.py +15 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/hooks.py +36 -6
- {scootcli-0.6.0 → scootcli-0.7.0/src/scootcli.egg-info}/PKG-INFO +19 -6
- {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_headless.py +1 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_hooks.py +33 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_small_060.py +20 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/LICENSE +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/pyproject.toml +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/setup.cfg +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/__main__.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/activity.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/approvals.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/auth.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/clipboard.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/__init__.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/approve.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/auth.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/base.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/compact.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/copy.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/exit.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/forget.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/help.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/hooks.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/init.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/logo.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/model.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/panel.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/reset.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/resume.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/route.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/save.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/scope.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/sessions.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/status.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/verbosity.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/worktree.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/yolo.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/config.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/context.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/credentials.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/errors.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/images.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/keys.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/lineeditor.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/logo.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/models.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/panel.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/preferences.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/presets.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/pricing.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/project.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/prompts.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/providers/__init__.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/providers/anthropic.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/providers/base.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/providers/openai_chat.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/providers/openai_responses.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/providers/registry.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/providers/router.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/rendering.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/repl.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/sessions.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/status.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/tools/__init__.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/tools/base.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/tools/edit_file.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/tools/list_dir.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/tools/open_editor.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/tools/read_file.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/tools/run_shell.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/tools/search.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/tools/update_plan.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/tools/write_file.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/transport.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/updates.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/vision.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/workspace.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/worktree.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli.egg-info/SOURCES.txt +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli.egg-info/dependency_links.txt +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli.egg-info/entry_points.txt +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli.egg-info/top_level.txt +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_agent.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_anthropic.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_approvals.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_auth.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_clipboard.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_config.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_images.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_lineeditor.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_logo.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_models.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_panel.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_preferences.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_providers.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_readiness.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_repl.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_resilience.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_router.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_scope.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_sessions.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_smoke.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_streaming.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_tools.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_transport_native.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_vision.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_workspace.py +0 -0
- {scootcli-0.6.0 → scootcli-0.7.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.7.0
|
|
4
4
|
Summary: A tiny coding agent that goes where you point it
|
|
5
5
|
Author: Sergey Nes
|
|
6
6
|
License-Expression: MIT
|
|
@@ -259,12 +259,13 @@ Put them in `~/.config/scoot/hooks.json` or `.scoot/hooks.json` in the project:
|
|
|
259
259
|
}
|
|
260
260
|
```
|
|
261
261
|
|
|
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.
|
|
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. Claude Code's nested `hookSpecificOutput` answer is accepted as is, so a hook written for Claude Code works without edits.
|
|
263
263
|
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.
|
|
264
|
+
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
265
|
`/hooks` shows what is configured and what ran; `SCOOT_HOOKS=0` turns hooks off.
|
|
265
266
|
|
|
266
267
|
**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.
|
|
268
|
+
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
269
|
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
270
|
|
|
270
271
|
### The status bar
|
|
@@ -276,14 +277,26 @@ The bottom row shows the mascot's face (its eyes follow the turn: `o o` idle, `>
|
|
|
276
277
|
```bash
|
|
277
278
|
pipx install scootcli && pipx ensurepath # recommended: isolated; ensurepath puts ~/.local/bin on PATH for new shells
|
|
278
279
|
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 #
|
|
280
|
+
pip install scootcli # inside a venv, container, or CI job (system Pythons on recent Linux and Homebrew block pip outside a venv)
|
|
280
281
|
curl -LO https://github.com/sergenes/scootcli/releases/latest/download/scoot.pyz && python3 scoot.pyz # single file, no install
|
|
281
282
|
git clone https://github.com/sergenes/scootcli && cd scootcli && pip install -e . # from source
|
|
282
283
|
```
|
|
283
284
|
|
|
284
285
|
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
|
-
|
|
286
|
+
## Upgrade options
|
|
287
|
+
|
|
288
|
+
Use the same tool you installed with:
|
|
289
|
+
|
|
290
|
+
```bash
|
|
291
|
+
pipx upgrade scootcli # pipx install
|
|
292
|
+
pip install --upgrade scootcli # pip install
|
|
293
|
+
curl -fsSL https://raw.githubusercontent.com/sergenes/scootcli/main/install.sh | bash # the curl installer: rerun it, it always fetches the latest release
|
|
294
|
+
scoot --version # what you have
|
|
295
|
+
scoot --check-update # ask PyPI whether a newer release exists
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
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.
|
|
299
|
+
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
300
|
|
|
288
301
|
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
302
|
|
|
@@ -234,12 +234,13 @@ Put them in `~/.config/scoot/hooks.json` or `.scoot/hooks.json` in the project:
|
|
|
234
234
|
}
|
|
235
235
|
```
|
|
236
236
|
|
|
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.
|
|
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. Claude Code's nested `hookSpecificOutput` answer is accepted as is, so a hook written for Claude Code works without edits.
|
|
238
238
|
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.
|
|
239
|
+
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
240
|
`/hooks` shows what is configured and what ran; `SCOOT_HOOKS=0` turns hooks off.
|
|
240
241
|
|
|
241
242
|
**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.
|
|
243
|
+
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
244
|
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
245
|
|
|
245
246
|
### The status bar
|
|
@@ -251,14 +252,26 @@ The bottom row shows the mascot's face (its eyes follow the turn: `o o` idle, `>
|
|
|
251
252
|
```bash
|
|
252
253
|
pipx install scootcli && pipx ensurepath # recommended: isolated; ensurepath puts ~/.local/bin on PATH for new shells
|
|
253
254
|
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 #
|
|
255
|
+
pip install scootcli # inside a venv, container, or CI job (system Pythons on recent Linux and Homebrew block pip outside a venv)
|
|
255
256
|
curl -LO https://github.com/sergenes/scootcli/releases/latest/download/scoot.pyz && python3 scoot.pyz # single file, no install
|
|
256
257
|
git clone https://github.com/sergenes/scootcli && cd scootcli && pip install -e . # from source
|
|
257
258
|
```
|
|
258
259
|
|
|
259
260
|
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
|
-
|
|
261
|
+
## Upgrade options
|
|
262
|
+
|
|
263
|
+
Use the same tool you installed with:
|
|
264
|
+
|
|
265
|
+
```bash
|
|
266
|
+
pipx upgrade scootcli # pipx install
|
|
267
|
+
pip install --upgrade scootcli # pip install
|
|
268
|
+
curl -fsSL https://raw.githubusercontent.com/sergenes/scootcli/main/install.sh | bash # the curl installer: rerun it, it always fetches the latest release
|
|
269
|
+
scoot --version # what you have
|
|
270
|
+
scoot --check-update # ask PyPI whether a newer release exists
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
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.
|
|
274
|
+
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
275
|
|
|
263
276
|
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
277
|
|
|
@@ -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
|
|
|
@@ -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:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scootcli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: A tiny coding agent that goes where you point it
|
|
5
5
|
Author: Sergey Nes
|
|
6
6
|
License-Expression: MIT
|
|
@@ -259,12 +259,13 @@ Put them in `~/.config/scoot/hooks.json` or `.scoot/hooks.json` in the project:
|
|
|
259
259
|
}
|
|
260
260
|
```
|
|
261
261
|
|
|
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.
|
|
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. Claude Code's nested `hookSpecificOutput` answer is accepted as is, so a hook written for Claude Code works without edits.
|
|
263
263
|
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.
|
|
264
|
+
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
265
|
`/hooks` shows what is configured and what ran; `SCOOT_HOOKS=0` turns hooks off.
|
|
265
266
|
|
|
266
267
|
**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.
|
|
268
|
+
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
269
|
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
270
|
|
|
270
271
|
### The status bar
|
|
@@ -276,14 +277,26 @@ The bottom row shows the mascot's face (its eyes follow the turn: `o o` idle, `>
|
|
|
276
277
|
```bash
|
|
277
278
|
pipx install scootcli && pipx ensurepath # recommended: isolated; ensurepath puts ~/.local/bin on PATH for new shells
|
|
278
279
|
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 #
|
|
280
|
+
pip install scootcli # inside a venv, container, or CI job (system Pythons on recent Linux and Homebrew block pip outside a venv)
|
|
280
281
|
curl -LO https://github.com/sergenes/scootcli/releases/latest/download/scoot.pyz && python3 scoot.pyz # single file, no install
|
|
281
282
|
git clone https://github.com/sergenes/scootcli && cd scootcli && pip install -e . # from source
|
|
282
283
|
```
|
|
283
284
|
|
|
284
285
|
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
|
-
|
|
286
|
+
## Upgrade options
|
|
287
|
+
|
|
288
|
+
Use the same tool you installed with:
|
|
289
|
+
|
|
290
|
+
```bash
|
|
291
|
+
pipx upgrade scootcli # pipx install
|
|
292
|
+
pip install --upgrade scootcli # pip install
|
|
293
|
+
curl -fsSL https://raw.githubusercontent.com/sergenes/scootcli/main/install.sh | bash # the curl installer: rerun it, it always fetches the latest release
|
|
294
|
+
scoot --version # what you have
|
|
295
|
+
scoot --check-update # ask PyPI whether a newer release exists
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
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.
|
|
299
|
+
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
300
|
|
|
288
301
|
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
302
|
|
|
@@ -129,6 +129,7 @@ def test_full_turn_with_approval_round_trip(tmp_path):
|
|
|
129
129
|
stdin.send({"type": "approve", "id": req["id"], "decision": "allow"})
|
|
130
130
|
end = out.wait_for("turn_end")
|
|
131
131
|
assert end["status"] == "done" and end["content"] == "It says hello." and end["turn"] == 1
|
|
132
|
+
assert "cost" in end and "cost_session" in end # None here (model "m" has no price), present always
|
|
132
133
|
types = [e["type"] for e in out.events()]
|
|
133
134
|
assert types.index("tool_call") < types.index("tool_result") < types.index("turn_end")
|
|
134
135
|
assert out.wait_for("tool_result")["ok"] is True and "hello" in out.wait_for("tool_result")["content"]
|
|
@@ -240,3 +240,36 @@ def test_tool_kind_mapping():
|
|
|
240
240
|
assert H.tool_kind(tools.get("edit_file")) == "write"
|
|
241
241
|
assert H.tool_kind(tools.get("run_shell")) == "shell"
|
|
242
242
|
assert H.tool_kind(tools.get("update_plan")) == "meta"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_interpret_accepts_claude_codes_nested_output():
|
|
246
|
+
from scootcli.hooks import Hooks
|
|
247
|
+
|
|
248
|
+
i = Hooks._interpret
|
|
249
|
+
assert i("PreToolUse", {"hookSpecificOutput": {"permissionDecision": "allow"}}).action == "allow"
|
|
250
|
+
d = i("PreToolUse", {"hookSpecificOutput": {"permissionDecision": "deny", "permissionDecisionReason": "no"}})
|
|
251
|
+
assert d.action == "deny" and d.reason == "no"
|
|
252
|
+
assert i("PreToolUse", {"hookSpecificOutput": {"permissionDecision": "approve"}}).action == "allow"
|
|
253
|
+
assert i("PreToolUse", {"hookSpecificOutput": {"permissionDecision": "ask"}}).action == "ask"
|
|
254
|
+
# extra context nested the way Claude Code emits it for UserPromptSubmit / PostToolUse
|
|
255
|
+
c = i("UserPromptSubmit", {"hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": "branch: main"}})
|
|
256
|
+
assert c.action == "" and c.context == "branch: main"
|
|
257
|
+
# the pre-existing flat shapes still work
|
|
258
|
+
assert i("PreToolUse", {"permissionDecision": "deny", "reason": "flat"}).reason == "flat"
|
|
259
|
+
assert i("Stop", {"decision": "block", "reason": "more"}).action == "block"
|
|
260
|
+
assert i("PreToolUse", {"decision": "approve"}).action == "allow"
|
|
261
|
+
assert i("PreToolUse", {"additionalContext": "x"}).context == "x"
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def test_matcher_matches_claude_code_tool_names(tmp_path):
|
|
265
|
+
from scootcli.hooks import matcher_hits, tool_alias
|
|
266
|
+
|
|
267
|
+
assert tool_alias("run_shell") == "Bash" and tool_alias("edit_file") == "Edit" and tool_alias("nope") == "nope"
|
|
268
|
+
assert matcher_hits("Bash|Write|Edit", "run_shell") and matcher_hits("Bash|Write|Edit", "edit_file")
|
|
269
|
+
assert not matcher_hits("Bash|Write|Edit", "read_file")
|
|
270
|
+
assert matcher_hits("run_shell", "run_shell") and matcher_hits("^Read$", "read_file")
|
|
271
|
+
assert not matcher_hits("[", "run_shell") # a broken regex never matches
|
|
272
|
+
deny = _script(tmp_path, "denybash", 'print(json.dumps({"permissionDecision": "deny", "reason": "claude-style matcher"}))')
|
|
273
|
+
h = _hooks(tmp_path, {"PreToolUse": [{"matcher": "Bash", "hooks": [{"command": deny}]}]})
|
|
274
|
+
assert h.run("PreToolUse", {"tool_name": "run_shell"}).action == "deny"
|
|
275
|
+
assert h.run("PreToolUse", {"tool_name": "read_file"}).action == ""
|
|
@@ -180,3 +180,23 @@ def test_install_script_uninstall(tmp_path):
|
|
|
180
180
|
out = subprocess.run(["bash", str(script), "--uninstall"], capture_output=True, text=True,
|
|
181
181
|
env={"SCOOT_INSTALL_DIR": str(tmp_path), "PATH": "/usr/bin:/bin", "HOME": str(tmp_path)})
|
|
182
182
|
assert "nothing to remove" in out.stdout
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_one_shot_json_includes_cost(monkeypatch, tmp_path, capsys):
|
|
186
|
+
from scootcli import cli
|
|
187
|
+
from scootcli.providers import ChatResult, registry
|
|
188
|
+
|
|
189
|
+
monkeypatch.setenv("OPENAI_API_KEY", "sk-test-000000000000")
|
|
190
|
+
|
|
191
|
+
class _P:
|
|
192
|
+
def chat(self, messages, **k):
|
|
193
|
+
return ChatResult(content="pong", model="openai/gpt-5.3-codex",
|
|
194
|
+
usage={"prompt_tokens": 1_000_000, "completion_tokens": 0})
|
|
195
|
+
|
|
196
|
+
def list_models(self, cancel_event=None):
|
|
197
|
+
return []
|
|
198
|
+
|
|
199
|
+
monkeypatch.setattr(registry, "make_provider", lambda name, config, transport=None: _P())
|
|
200
|
+
rc = cli.main(["--root", str(tmp_path), "--no-workspace", "--json", "--model", "openai/gpt-5.3-codex", "ping"])
|
|
201
|
+
out = json.loads(capsys.readouterr().out)
|
|
202
|
+
assert rc == 0 and abs(out["cost"] - 1.75) < 1e-9
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|