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.
Files changed (114) hide show
  1. {scootcli-0.6.0/src/scootcli.egg-info → scootcli-0.7.0}/PKG-INFO +19 -6
  2. {scootcli-0.6.0 → scootcli-0.7.0}/README.md +18 -5
  3. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/__init__.py +1 -1
  4. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/agent.py +6 -2
  5. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/cli.py +1 -0
  6. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/headless.py +15 -0
  7. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/hooks.py +36 -6
  8. {scootcli-0.6.0 → scootcli-0.7.0/src/scootcli.egg-info}/PKG-INFO +19 -6
  9. {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_headless.py +1 -0
  10. {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_hooks.py +33 -0
  11. {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_small_060.py +20 -0
  12. {scootcli-0.6.0 → scootcli-0.7.0}/LICENSE +0 -0
  13. {scootcli-0.6.0 → scootcli-0.7.0}/pyproject.toml +0 -0
  14. {scootcli-0.6.0 → scootcli-0.7.0}/setup.cfg +0 -0
  15. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/__main__.py +0 -0
  16. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/activity.py +0 -0
  17. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/approvals.py +0 -0
  18. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/auth.py +0 -0
  19. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/clipboard.py +0 -0
  20. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/__init__.py +0 -0
  21. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/approve.py +0 -0
  22. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/auth.py +0 -0
  23. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/base.py +0 -0
  24. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/compact.py +0 -0
  25. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/copy.py +0 -0
  26. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/exit.py +0 -0
  27. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/forget.py +0 -0
  28. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/help.py +0 -0
  29. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/hooks.py +0 -0
  30. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/init.py +0 -0
  31. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/logo.py +0 -0
  32. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/model.py +0 -0
  33. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/panel.py +0 -0
  34. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/reset.py +0 -0
  35. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/resume.py +0 -0
  36. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/route.py +0 -0
  37. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/save.py +0 -0
  38. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/scope.py +0 -0
  39. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/sessions.py +0 -0
  40. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/status.py +0 -0
  41. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/verbosity.py +0 -0
  42. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/worktree.py +0 -0
  43. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/commands/yolo.py +0 -0
  44. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/config.py +0 -0
  45. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/context.py +0 -0
  46. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/credentials.py +0 -0
  47. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/errors.py +0 -0
  48. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/images.py +0 -0
  49. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/keys.py +0 -0
  50. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/lineeditor.py +0 -0
  51. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/logo.py +0 -0
  52. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/models.py +0 -0
  53. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/panel.py +0 -0
  54. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/preferences.py +0 -0
  55. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/presets.py +0 -0
  56. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/pricing.py +0 -0
  57. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/project.py +0 -0
  58. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/prompts.py +0 -0
  59. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/providers/__init__.py +0 -0
  60. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/providers/anthropic.py +0 -0
  61. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/providers/base.py +0 -0
  62. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/providers/openai_chat.py +0 -0
  63. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/providers/openai_responses.py +0 -0
  64. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/providers/registry.py +0 -0
  65. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/providers/router.py +0 -0
  66. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/rendering.py +0 -0
  67. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/repl.py +0 -0
  68. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/sessions.py +0 -0
  69. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/status.py +0 -0
  70. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/tools/__init__.py +0 -0
  71. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/tools/base.py +0 -0
  72. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/tools/edit_file.py +0 -0
  73. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/tools/list_dir.py +0 -0
  74. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/tools/open_editor.py +0 -0
  75. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/tools/read_file.py +0 -0
  76. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/tools/run_shell.py +0 -0
  77. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/tools/search.py +0 -0
  78. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/tools/update_plan.py +0 -0
  79. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/tools/write_file.py +0 -0
  80. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/transport.py +0 -0
  81. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/updates.py +0 -0
  82. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/vision.py +0 -0
  83. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/workspace.py +0 -0
  84. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli/worktree.py +0 -0
  85. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli.egg-info/SOURCES.txt +0 -0
  86. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli.egg-info/dependency_links.txt +0 -0
  87. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli.egg-info/entry_points.txt +0 -0
  88. {scootcli-0.6.0 → scootcli-0.7.0}/src/scootcli.egg-info/top_level.txt +0 -0
  89. {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_agent.py +0 -0
  90. {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_anthropic.py +0 -0
  91. {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_approvals.py +0 -0
  92. {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_auth.py +0 -0
  93. {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_clipboard.py +0 -0
  94. {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_config.py +0 -0
  95. {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_images.py +0 -0
  96. {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_lineeditor.py +0 -0
  97. {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_logo.py +0 -0
  98. {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_models.py +0 -0
  99. {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_panel.py +0 -0
  100. {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_preferences.py +0 -0
  101. {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_providers.py +0 -0
  102. {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_readiness.py +0 -0
  103. {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_repl.py +0 -0
  104. {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_resilience.py +0 -0
  105. {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_router.py +0 -0
  106. {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_scope.py +0 -0
  107. {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_sessions.py +0 -0
  108. {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_smoke.py +0 -0
  109. {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_streaming.py +0 -0
  110. {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_tools.py +0 -0
  111. {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_transport_native.py +0 -0
  112. {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_vision.py +0 -0
  113. {scootcli-0.6.0 → scootcli-0.7.0}/tests/test_workspace.py +0 -0
  114. {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.6.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 # anywhere
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
- **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, and `scoot --check-update` asks PyPI whether a newer one exists. 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.
286
- `bash install.sh --uninstall` removes the command installed by the script (pipx users: `pipx uninstall scootcli`); config and sessions stay.
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 # anywhere
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
- **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, and `scoot --check-update` asks PyPI whether a newer one exists. 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.
261
- `bash install.sh --uninstall` removes the command installed by the script (pipx users: `pipx uninstall scootcli`); config and sessions stay.
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
 
@@ -1,4 +1,4 @@
1
1
  """scoot: a tiny coding agent that goes where you point it."""
2
2
 
3
- __version__ = "0.6.0"
3
+ __version__ = "0.7.0"
4
4
 
@@ -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
- try:
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
- perm = str(data.get("permissionDecision") or "").lower()
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.6.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 # anywhere
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
- **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, and `scoot --check-update` asks PyPI whether a newer one exists. 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.
286
- `bash install.sh --uninstall` removes the command installed by the script (pipx users: `pipx uninstall scootcli`); config and sessions stay.
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