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