scootcli 0.3.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. {scootcli-0.3.0/src/scootcli.egg-info → scootcli-0.5.0}/PKG-INFO +46 -4
  2. {scootcli-0.3.0 → scootcli-0.5.0}/README.md +45 -3
  3. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/__init__.py +1 -1
  4. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/agent.py +140 -6
  5. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/approvals.py +34 -0
  6. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/cli.py +26 -8
  7. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/commands/__init__.py +2 -0
  8. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/commands/auth.py +16 -0
  9. scootcli-0.5.0/src/scootcli/commands/hooks.py +37 -0
  10. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/commands/model.py +4 -0
  11. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/commands/reset.py +3 -0
  12. scootcli-0.5.0/src/scootcli/commands/scope.py +33 -0
  13. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/commands/status.py +1 -0
  14. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/config.py +2 -0
  15. scootcli-0.5.0/src/scootcli/headless.py +355 -0
  16. scootcli-0.5.0/src/scootcli/hooks.py +264 -0
  17. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/panel.py +3 -0
  18. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/prompts.py +1 -1
  19. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/providers/base.py +7 -3
  20. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/providers/registry.py +15 -4
  21. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/repl.py +61 -7
  22. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/tools/base.py +76 -11
  23. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/tools/edit_file.py +1 -1
  24. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/tools/list_dir.py +1 -1
  25. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/tools/open_editor.py +1 -1
  26. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/tools/read_file.py +1 -1
  27. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/tools/run_shell.py +2 -2
  28. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/tools/write_file.py +1 -1
  29. {scootcli-0.3.0 → scootcli-0.5.0/src/scootcli.egg-info}/PKG-INFO +46 -4
  30. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli.egg-info/SOURCES.txt +7 -0
  31. scootcli-0.5.0/tests/test_headless.py +247 -0
  32. scootcli-0.5.0/tests/test_hooks.py +242 -0
  33. {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_readiness.py +46 -0
  34. scootcli-0.5.0/tests/test_scope.py +225 -0
  35. {scootcli-0.3.0 → scootcli-0.5.0}/LICENSE +0 -0
  36. {scootcli-0.3.0 → scootcli-0.5.0}/pyproject.toml +0 -0
  37. {scootcli-0.3.0 → scootcli-0.5.0}/setup.cfg +0 -0
  38. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/__main__.py +0 -0
  39. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/activity.py +0 -0
  40. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/auth.py +0 -0
  41. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/clipboard.py +0 -0
  42. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/commands/approve.py +0 -0
  43. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/commands/base.py +0 -0
  44. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/commands/compact.py +0 -0
  45. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/commands/copy.py +0 -0
  46. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/commands/exit.py +0 -0
  47. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/commands/forget.py +0 -0
  48. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/commands/help.py +0 -0
  49. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/commands/init.py +0 -0
  50. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/commands/logo.py +0 -0
  51. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/commands/panel.py +0 -0
  52. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/commands/resume.py +0 -0
  53. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/commands/route.py +0 -0
  54. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/commands/save.py +0 -0
  55. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/commands/sessions.py +0 -0
  56. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/commands/verbosity.py +0 -0
  57. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/commands/worktree.py +0 -0
  58. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/commands/yolo.py +0 -0
  59. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/context.py +0 -0
  60. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/credentials.py +0 -0
  61. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/errors.py +0 -0
  62. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/images.py +0 -0
  63. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/keys.py +0 -0
  64. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/lineeditor.py +0 -0
  65. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/logo.py +0 -0
  66. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/models.py +0 -0
  67. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/preferences.py +0 -0
  68. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/presets.py +0 -0
  69. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/project.py +0 -0
  70. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/providers/__init__.py +0 -0
  71. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/providers/anthropic.py +0 -0
  72. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/providers/openai_chat.py +0 -0
  73. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/providers/openai_responses.py +0 -0
  74. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/providers/router.py +0 -0
  75. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/rendering.py +0 -0
  76. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/sessions.py +0 -0
  77. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/status.py +0 -0
  78. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/tools/__init__.py +0 -0
  79. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/tools/search.py +0 -0
  80. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/tools/update_plan.py +0 -0
  81. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/transport.py +0 -0
  82. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/vision.py +0 -0
  83. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/workspace.py +0 -0
  84. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli/worktree.py +0 -0
  85. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli.egg-info/dependency_links.txt +0 -0
  86. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli.egg-info/entry_points.txt +0 -0
  87. {scootcli-0.3.0 → scootcli-0.5.0}/src/scootcli.egg-info/top_level.txt +0 -0
  88. {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_agent.py +0 -0
  89. {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_anthropic.py +0 -0
  90. {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_approvals.py +0 -0
  91. {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_auth.py +0 -0
  92. {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_clipboard.py +0 -0
  93. {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_config.py +0 -0
  94. {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_images.py +0 -0
  95. {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_lineeditor.py +0 -0
  96. {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_logo.py +0 -0
  97. {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_models.py +0 -0
  98. {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_panel.py +0 -0
  99. {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_preferences.py +0 -0
  100. {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_providers.py +0 -0
  101. {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_repl.py +0 -0
  102. {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_resilience.py +0 -0
  103. {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_router.py +0 -0
  104. {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_sessions.py +0 -0
  105. {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_smoke.py +0 -0
  106. {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_streaming.py +0 -0
  107. {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_tools.py +0 -0
  108. {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_transport_native.py +0 -0
  109. {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_vision.py +0 -0
  110. {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_workspace.py +0 -0
  111. {scootcli-0.3.0 → scootcli-0.5.0}/tests/test_worktree.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scootcli
3
- Version: 0.3.0
3
+ Version: 0.5.0
4
4
  Summary: A tiny coding agent that goes where you point it
5
5
  Author: Sergey Nes
6
6
  License-Expression: MIT
@@ -61,12 +61,19 @@ The naive version did real work in my repos for long enough that the next step w
61
61
  ## Quick start
62
62
 
63
63
  ```bash
64
- pipx install scootcli # or: pip install scootcli
64
+ pipx install scootcli && pipx ensurepath # then open a new terminal so `scoot` is on PATH
65
65
  scoot auth set openai # paste your OpenAI API key once (hidden input, validated, stored 0600)
66
66
  cd ~/code/your-project
67
67
  scoot # open the REPL
68
68
  ```
69
69
 
70
+ No pipx yet? On Debian, Ubuntu, and Pop!_OS it is `sudo apt install pipx`; on macOS `brew install pipx`.
71
+ Or skip pipx entirely with the one-line installer, which needs only `curl` and Python 3.9+:
72
+
73
+ ```bash
74
+ curl -fsSL https://raw.githubusercontent.com/sergenes/scootcli/main/install.sh | bash
75
+ ```
76
+
70
77
  Or run entirely local with [Ollama](https://ollama.com), no key at all:
71
78
 
72
79
  ```bash
@@ -74,6 +81,9 @@ ollama pull llama3.2
74
81
  scoot --model ollama/llama3.2
75
82
  ```
76
83
 
84
+ **First run.** With no key saved and no Ollama running, scoot still opens: the banner says "no provider set up yet", a short block lists the three ways to set one up, and the bar shows `not set up` until a provider can answer.
85
+ Run `scoot auth set openai` (or `anthropic`) inside the REPL and the bar switches to the real provider and model at once.
86
+
77
87
  The first prompt:
78
88
 
79
89
  ```
@@ -196,6 +206,8 @@ scoot --no-panel --no-dock # plain prompt, no status bar (also what yo
196
206
  /model list or switch model /approve set mode (always|auto-read|auto-edits|yolo)
197
207
  /yolo auto-approve all /worktree isolate work in a git worktree
198
208
  /auth provider keys /logo show or toggle the mascot
209
+ /scope where file tools may go /hooks configured hooks and results
210
+ /route how auto picks a model
199
211
  /sessions list saved sessions /resume resume a saved session ([id])
200
212
  /forget delete session(s) /panel toggle the bottom status bar
201
213
  /verbosity feed detail (full|compact|quiet)
@@ -213,7 +225,11 @@ Without a TTY, scoot falls back to a plain prompt with no bar and no dock.
213
225
  ### Tools and approvals
214
226
 
215
227
  The agent has eight tools: `read_file`, `list_dir`, `search`, `write_file`, `edit_file`, `run_shell`, `open_editor` (hands a file to IntelliJ IDEA's `idea -e` or to VS Code, `SCOOT_EDITOR` picks), and `update_plan` (a progress checklist for multi-step work).
216
- Every tool is sandboxed to the workspace root.
228
+ Paths are resolved against the workspace root, and `~` works.
229
+
230
+ The workspace is where the agent works, not a wall.
231
+ When a tool needs a file outside it, in `~/.config`, another repo, `/etc`, scoot asks once: allow this path `[a]`, allow that directory for the session `[d]`, allow anywhere for the session `[A]`, skip `[s]`, or quit `[q]`.
232
+ `--scope anywhere`, `SCOOT_SCOPE=anywhere`, or `/scope anywhere` skip the question; `/scope` shows what has been granted.
217
233
 
218
234
  When a call needs approval you can approve once `[a]`, trust that tool for the session `[t]`, approve everything this session `[A]`, edit the arguments `[e]`, skip `[s]`, or quit `[q]`.
219
235
  `/approve <mode>` sets how much runs without asking: `always` prompts for everything, `auto-read` auto-approves reads, `auto-edits` also auto-approves file edits, `yolo` runs everything.
@@ -227,6 +243,29 @@ Responses stream live and stay interruptible.
227
243
  Drag an image into the prompt and a vision-capable model describes it into the turn as text, so even a text-only coding model can act on it; `SCOOT_VISION_MODEL` pins the describer, `--no-images` turns the feature off.
228
244
  Every turn auto-saves under `~/.local/state/scoot/sessions/` (owner-only, secrets redacted, last 20 kept); `scoot --continue` or `/resume` picks up where you left off.
229
245
 
246
+ ### Automation: hooks and headless mode
247
+
248
+ **Hooks** run your own scripts at lifecycle events: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`, `Notification`, `SessionEnd`.
249
+ A hook gets a JSON payload on stdin and answers with an exit code or JSON on stdout; the shapes follow the convention Claude Code established, so a script written for one works with the other.
250
+ Put them in `~/.config/scoot/hooks.json` or `.scoot/hooks.json` in the project:
251
+
252
+ ```json
253
+ {
254
+ "PreToolUse": [
255
+ {"matcher": "run_shell|write_file|edit_file", "hooks": [{"type": "command", "command": "~/bin/guard.py", "timeout": 30}]}
256
+ ],
257
+ "Stop": [{"hooks": [{"type": "command", "command": "~/bin/notify.sh"}]}]
258
+ }
259
+ ```
260
+
261
+ A `PreToolUse` hook can answer `{"permissionDecision": "deny", "reason": "..."}` to skip a tool (the model is told why), `allow` to skip the approval prompt, or `ask` to force one even in `yolo`; exit code 2 denies with stderr as the reason.
262
+ A `Stop` hook that answers `{"decision": "block", "reason": "run the tests first"}` sends the agent back to work with that instruction, at most three times per turn.
263
+ `/hooks` shows what is configured and what ran; `SCOOT_HOOKS=0` turns hooks off.
264
+
265
+ **Headless mode** is for editors, automation, and remote-control tools: `scoot --headless` reads JSON lines on stdin (`prompt`, `approve`, `note`, `interrupt`, `command`, `shutdown`) and writes JSON lines on stdout (streamed text, tool calls, approval requests, results, plan updates, usage, errors, a heartbeat), with nothing else ever printed there.
266
+ Same sessions, tools, approvals, routing, and hooks as the REPL.
267
+ The message tables and a full transcript are in [`docs/headless-protocol.md`](./docs/headless-protocol.md); an unanswered approval is denied after `SCOOT_APPROVAL_TIMEOUT` seconds (default 120).
268
+
230
269
  ### The status bar
231
270
 
232
271
  The bottom row shows the mascot's face (its eyes follow the turn: `o o` idle, `> >` thinking, `- -` stopped), the provider, the workspace and session id, the model, the approval mode, context size against the auto-compact threshold, cumulative tokens, message count, and the last error if any.
@@ -234,7 +273,7 @@ The bottom row shows the mascot's face (its eyes follow the turn: `o o` idle, `>
234
273
  ## Install options
235
274
 
236
275
  ```bash
237
- pipx install scootcli # recommended: isolated, `scoot` on PATH
276
+ pipx install scootcli && pipx ensurepath # recommended: isolated; ensurepath puts ~/.local/bin on PATH for new shells
238
277
  curl -fsSL https://raw.githubusercontent.com/sergenes/scootcli/main/install.sh | bash # no pipx: puts the zipapp at ~/.local/bin/scoot
239
278
  pip install scootcli # anywhere
240
279
  curl -LO https://github.com/sergenes/scootcli/releases/latest/download/scoot.pyz && python3 scoot.pyz # single file, no install
@@ -242,6 +281,9 @@ git clone https://github.com/sergenes/scootcli && cd scootcli && pip install -e
242
281
  ```
243
282
 
244
283
  The install script needs only `curl` and Python 3.9+. `SCOOT_VERSION=v0.2.0` pins a release and `SCOOT_INSTALL_DIR` changes the target; read it before you run it, it is sixty lines.
284
+ **Upgrading.** `pipx upgrade scootcli`, or `pip install --upgrade scootcli`, or rerun the curl line, which always fetches the latest release; `scoot --version` shows what you have.
285
+
286
+ Both pipx and the script install into `~/.local/bin`. On a fresh Linux account that directory is added to PATH at login only if it already exists, so after the very first install either open a new login shell or run `pipx ensurepath`; the installer prints the exact line for your shell.
245
287
 
246
288
  Requirements: Python 3.9 or newer on macOS or Linux.
247
289
  Nothing else: no compiler, no packages, no `curl`.
@@ -36,12 +36,19 @@ The naive version did real work in my repos for long enough that the next step w
36
36
  ## Quick start
37
37
 
38
38
  ```bash
39
- pipx install scootcli # or: pip install scootcli
39
+ pipx install scootcli && pipx ensurepath # then open a new terminal so `scoot` is on PATH
40
40
  scoot auth set openai # paste your OpenAI API key once (hidden input, validated, stored 0600)
41
41
  cd ~/code/your-project
42
42
  scoot # open the REPL
43
43
  ```
44
44
 
45
+ No pipx yet? On Debian, Ubuntu, and Pop!_OS it is `sudo apt install pipx`; on macOS `brew install pipx`.
46
+ Or skip pipx entirely with the one-line installer, which needs only `curl` and Python 3.9+:
47
+
48
+ ```bash
49
+ curl -fsSL https://raw.githubusercontent.com/sergenes/scootcli/main/install.sh | bash
50
+ ```
51
+
45
52
  Or run entirely local with [Ollama](https://ollama.com), no key at all:
46
53
 
47
54
  ```bash
@@ -49,6 +56,9 @@ ollama pull llama3.2
49
56
  scoot --model ollama/llama3.2
50
57
  ```
51
58
 
59
+ **First run.** With no key saved and no Ollama running, scoot still opens: the banner says "no provider set up yet", a short block lists the three ways to set one up, and the bar shows `not set up` until a provider can answer.
60
+ Run `scoot auth set openai` (or `anthropic`) inside the REPL and the bar switches to the real provider and model at once.
61
+
52
62
  The first prompt:
53
63
 
54
64
  ```
@@ -171,6 +181,8 @@ scoot --no-panel --no-dock # plain prompt, no status bar (also what yo
171
181
  /model list or switch model /approve set mode (always|auto-read|auto-edits|yolo)
172
182
  /yolo auto-approve all /worktree isolate work in a git worktree
173
183
  /auth provider keys /logo show or toggle the mascot
184
+ /scope where file tools may go /hooks configured hooks and results
185
+ /route how auto picks a model
174
186
  /sessions list saved sessions /resume resume a saved session ([id])
175
187
  /forget delete session(s) /panel toggle the bottom status bar
176
188
  /verbosity feed detail (full|compact|quiet)
@@ -188,7 +200,11 @@ Without a TTY, scoot falls back to a plain prompt with no bar and no dock.
188
200
  ### Tools and approvals
189
201
 
190
202
  The agent has eight tools: `read_file`, `list_dir`, `search`, `write_file`, `edit_file`, `run_shell`, `open_editor` (hands a file to IntelliJ IDEA's `idea -e` or to VS Code, `SCOOT_EDITOR` picks), and `update_plan` (a progress checklist for multi-step work).
191
- Every tool is sandboxed to the workspace root.
203
+ Paths are resolved against the workspace root, and `~` works.
204
+
205
+ The workspace is where the agent works, not a wall.
206
+ When a tool needs a file outside it, in `~/.config`, another repo, `/etc`, scoot asks once: allow this path `[a]`, allow that directory for the session `[d]`, allow anywhere for the session `[A]`, skip `[s]`, or quit `[q]`.
207
+ `--scope anywhere`, `SCOOT_SCOPE=anywhere`, or `/scope anywhere` skip the question; `/scope` shows what has been granted.
192
208
 
193
209
  When a call needs approval you can approve once `[a]`, trust that tool for the session `[t]`, approve everything this session `[A]`, edit the arguments `[e]`, skip `[s]`, or quit `[q]`.
194
210
  `/approve <mode>` sets how much runs without asking: `always` prompts for everything, `auto-read` auto-approves reads, `auto-edits` also auto-approves file edits, `yolo` runs everything.
@@ -202,6 +218,29 @@ Responses stream live and stay interruptible.
202
218
  Drag an image into the prompt and a vision-capable model describes it into the turn as text, so even a text-only coding model can act on it; `SCOOT_VISION_MODEL` pins the describer, `--no-images` turns the feature off.
203
219
  Every turn auto-saves under `~/.local/state/scoot/sessions/` (owner-only, secrets redacted, last 20 kept); `scoot --continue` or `/resume` picks up where you left off.
204
220
 
221
+ ### Automation: hooks and headless mode
222
+
223
+ **Hooks** run your own scripts at lifecycle events: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`, `Notification`, `SessionEnd`.
224
+ A hook gets a JSON payload on stdin and answers with an exit code or JSON on stdout; the shapes follow the convention Claude Code established, so a script written for one works with the other.
225
+ Put them in `~/.config/scoot/hooks.json` or `.scoot/hooks.json` in the project:
226
+
227
+ ```json
228
+ {
229
+ "PreToolUse": [
230
+ {"matcher": "run_shell|write_file|edit_file", "hooks": [{"type": "command", "command": "~/bin/guard.py", "timeout": 30}]}
231
+ ],
232
+ "Stop": [{"hooks": [{"type": "command", "command": "~/bin/notify.sh"}]}]
233
+ }
234
+ ```
235
+
236
+ A `PreToolUse` hook can answer `{"permissionDecision": "deny", "reason": "..."}` to skip a tool (the model is told why), `allow` to skip the approval prompt, or `ask` to force one even in `yolo`; exit code 2 denies with stderr as the reason.
237
+ A `Stop` hook that answers `{"decision": "block", "reason": "run the tests first"}` sends the agent back to work with that instruction, at most three times per turn.
238
+ `/hooks` shows what is configured and what ran; `SCOOT_HOOKS=0` turns hooks off.
239
+
240
+ **Headless mode** is for editors, automation, and remote-control tools: `scoot --headless` reads JSON lines on stdin (`prompt`, `approve`, `note`, `interrupt`, `command`, `shutdown`) and writes JSON lines on stdout (streamed text, tool calls, approval requests, results, plan updates, usage, errors, a heartbeat), with nothing else ever printed there.
241
+ Same sessions, tools, approvals, routing, and hooks as the REPL.
242
+ The message tables and a full transcript are in [`docs/headless-protocol.md`](./docs/headless-protocol.md); an unanswered approval is denied after `SCOOT_APPROVAL_TIMEOUT` seconds (default 120).
243
+
205
244
  ### The status bar
206
245
 
207
246
  The bottom row shows the mascot's face (its eyes follow the turn: `o o` idle, `> >` thinking, `- -` stopped), the provider, the workspace and session id, the model, the approval mode, context size against the auto-compact threshold, cumulative tokens, message count, and the last error if any.
@@ -209,7 +248,7 @@ The bottom row shows the mascot's face (its eyes follow the turn: `o o` idle, `>
209
248
  ## Install options
210
249
 
211
250
  ```bash
212
- pipx install scootcli # recommended: isolated, `scoot` on PATH
251
+ pipx install scootcli && pipx ensurepath # recommended: isolated; ensurepath puts ~/.local/bin on PATH for new shells
213
252
  curl -fsSL https://raw.githubusercontent.com/sergenes/scootcli/main/install.sh | bash # no pipx: puts the zipapp at ~/.local/bin/scoot
214
253
  pip install scootcli # anywhere
215
254
  curl -LO https://github.com/sergenes/scootcli/releases/latest/download/scoot.pyz && python3 scoot.pyz # single file, no install
@@ -217,6 +256,9 @@ git clone https://github.com/sergenes/scootcli && cd scootcli && pip install -e
217
256
  ```
218
257
 
219
258
  The install script needs only `curl` and Python 3.9+. `SCOOT_VERSION=v0.2.0` pins a release and `SCOOT_INSTALL_DIR` changes the target; read it before you run it, it is sixty lines.
259
+ **Upgrading.** `pipx upgrade scootcli`, or `pip install --upgrade scootcli`, or rerun the curl line, which always fetches the latest release; `scoot --version` shows what you have.
260
+
261
+ Both pipx and the script install into `~/.local/bin`. On a fresh Linux account that directory is added to PATH at login only if it already exists, so after the very first install either open a new login shell or run `pipx ensurepath`; the installer prints the exact line for your shell.
220
262
 
221
263
  Requirements: Python 3.9 or newer on macOS or Linux.
222
264
  Nothing else: no compiler, no packages, no `curl`.
@@ -1,4 +1,4 @@
1
1
  """scoot: a tiny coding agent that goes where you point it."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.5.0"
4
4
 
@@ -70,6 +70,12 @@ class HeadlessUI:
70
70
  self.events.append(("approve", tool.name, args))
71
71
  return Approval(self.decision, args)
72
72
 
73
+ scope_decision = "dir" # what this headless UI answers to a scope question (tests override)
74
+
75
+ def approve_scope(self, tool, path, ctx) -> str:
76
+ self.events.append(("scope", tool.name, str(path)))
77
+ return self.scope_decision
78
+
73
79
  def auto_approved(self, tool, args) -> None:
74
80
  self.events.append(("auto", tool.name, args))
75
81
 
@@ -97,6 +103,7 @@ class Agent:
97
103
  self._refresh_workspace(session, cfg)
98
104
  steps = 0
99
105
  compacted = False
106
+ stop_blocks = 0
100
107
  while True:
101
108
  if cancel_event.is_set():
102
109
  return AgentOutcome("interrupted", steps=steps)
@@ -144,10 +151,16 @@ class Agent:
144
151
  return AgentOutcome(status, steps=steps)
145
152
  continue
146
153
 
147
- # No tool calls -> the model is done.
148
- return AgentOutcome(
149
- "done", content=_strip_done(result.content), steps=steps, streamed=streamed
150
- )
154
+ # No tool calls -> the model is done, unless a Stop hook asks for more (bounded).
155
+ content = _strip_done(result.content)
156
+ if stop_blocks < 3:
157
+ reason = self._stop_hook(session, content, steps)
158
+ if reason:
159
+ stop_blocks += 1
160
+ session.messages.append({"role": "user", "content": reason})
161
+ ui.assistant(f"continuing: {reason}")
162
+ continue
163
+ return AgentOutcome("done", content=content, steps=steps, streamed=streamed)
151
164
 
152
165
  # ── model call (streaming or buffered) ───────────────────────────────────────
153
166
  def _model_call(self, session, cfg, ui, cancel_event, steps):
@@ -181,6 +194,45 @@ class Agent:
181
194
  )
182
195
  return result, False
183
196
 
197
+ # ── hooks ────────────────────────────────────────────────────────────────────
198
+ @staticmethod
199
+ def _hooks(session):
200
+ from .hooks import for_session
201
+
202
+ return for_session(session)
203
+
204
+ def _stop_hook(self, session, content: str, steps: int) -> str:
205
+ hooks = self._hooks(session)
206
+ if hooks is None or not hooks.has("Stop"):
207
+ return ""
208
+ payload = hooks.payload(session, "Stop", last_assistant_message=content, steps=steps,
209
+ usage=getattr(session, "last_usage", {}) or {})
210
+ decision = hooks.run("Stop", payload)
211
+ return (decision.reason or "the Stop hook asked to continue") if decision.blocks else ""
212
+
213
+ def _pre_tool_hook(self, session, tool, args, cancel_event) -> "tuple[str, str]":
214
+ hooks = self._hooks(session)
215
+ if hooks is None or not hooks.has("PreToolUse"):
216
+ return "", ""
217
+ from .hooks import tool_kind
218
+
219
+ payload = hooks.payload(session, "PreToolUse", tool_name=tool.name, tool_input=args,
220
+ tool_kind=tool_kind(tool))
221
+ decision = hooks.run("PreToolUse", payload, cancel_event)
222
+ return decision.action, decision.reason
223
+
224
+ def _post_tool_hook(self, session, tool, args, result: ToolResult) -> None:
225
+ hooks = self._hooks(session)
226
+ if hooks is None or not hooks.has("PostToolUse"):
227
+ return
228
+ from .hooks import tool_kind
229
+
230
+ response = {"ok": result.ok, "summary": result.summary or "", "error": result.error or "",
231
+ "content": (result.content or "")[:4000]}
232
+ hooks.run("PostToolUse", hooks.payload(session, "PostToolUse", tool_name=tool.name,
233
+ tool_input=args, tool_kind=tool_kind(tool),
234
+ tool_response=response))
235
+
184
236
  @staticmethod
185
237
  def _last_user_text(session) -> str:
186
238
  for m in reversed(getattr(session, "messages", []) or []):
@@ -236,6 +288,13 @@ class Agent:
236
288
 
237
289
  def _messages(self, session, cfg=None) -> List[dict]:
238
290
  cfg = cfg or getattr(session, "config", None) or self.config
291
+ notes = getattr(session, "pending_notes", None)
292
+ if notes:
293
+ joined = "\n".join(n for n in notes if n)
294
+ notes.clear()
295
+ if joined:
296
+ session.messages.append({"role": "user",
297
+ "content": "Note from the user while you work:\n" + joined})
239
298
  system = build_agent_system_prompt(
240
299
  root=cfg.root,
241
300
  model=session.active_model,
@@ -292,7 +351,16 @@ class Agent:
292
351
  # ── tool execution ───────────────────────────────────────────────────────────
293
352
  def _run_tools(self, session, tool_calls, ui, cancel_event, cfg=None) -> Optional[str]:
294
353
  cfg = cfg or getattr(session, "config", None) or self.config
295
- ctx = ToolContext(root=cfg.root, config=cfg, cancel_event=cancel_event)
354
+ scope = getattr(session, "scope", None)
355
+ if scope is None:
356
+ from .tools.base import Scope
357
+
358
+ scope = Scope(cfg.root, everything=(getattr(cfg, "scope", "workspace") == "anywhere"))
359
+ try:
360
+ session.scope = scope
361
+ except Exception:
362
+ pass
363
+ ctx = ToolContext(root=cfg.root, config=cfg, cancel_event=cancel_event, scope=scope)
296
364
  mode = getattr(session, "approval_mode", "always")
297
365
  # Session trust-list: tools the user chose to auto-approve for the rest of the session.
298
366
  trusted = getattr(session, "trusted_tools", None)
@@ -315,11 +383,36 @@ class Agent:
315
383
  self._append_tool(session, tc_id, f"error: unknown tool '{name}'")
316
384
  continue
317
385
 
386
+ # Paths outside the workspace: ask the user once (this path, its directory, or anywhere).
387
+ outside = self._outside_paths(tool, args, ctx)
388
+ if outside:
389
+ verdict = self._ask_scope(session, tool, outside, ctx, ui)
390
+ if verdict == "abort":
391
+ self._append_tool(session, tc_id, "user aborted the operation")
392
+ return "abort"
393
+ if verdict == "deny":
394
+ self._append_tool(session, tc_id, f"user declined access outside the workspace: {outside[0]}")
395
+ ui.tool_result(name, ToolResult(ok=False, summary="outside the workspace: declined"))
396
+ continue
397
+ # A PreToolUse hook may deny (skip the tool), allow (skip the prompt), or ask (force it).
398
+ hook_action, hook_reason = self._pre_tool_hook(session, tool, args, cancel_event)
399
+ if hook_action == "deny":
400
+ self._append_tool(session, tc_id, f"user declined via hook: {hook_reason or 'no reason given'}")
401
+ ui.tool_result(name, ToolResult(ok=False, summary=f"denied by hook: {hook_reason}"[:80]))
402
+ continue
318
403
  # Approval policy: auto-approve when the mode/trust allows it, else prompt.
319
- if needs_prompt(mode, tool, args, trusted) is None:
404
+ must_prompt = needs_prompt(mode, tool, args, trusted)
405
+ if hook_action == "allow":
406
+ must_prompt = None
407
+ elif hook_action == "ask":
408
+ must_prompt = hook_reason or "hook asked for confirmation"
409
+ if must_prompt is None:
320
410
  if not getattr(tool, "auto_approve", False):
321
411
  ui.auto_approved(tool, args) # meta tools render their own output
322
412
  else:
413
+ from .hooks import notify
414
+
415
+ notify(session, "approval", f"{name} needs approval: {must_prompt}")
323
416
  approval = ui.approve(tool, args, ctx)
324
417
  if approval.decision == Decision.ABORT:
325
418
  self._append_tool(session, tc_id, "user aborted the operation")
@@ -347,10 +440,51 @@ class Agent:
347
440
  result = ToolResult.fail(f"tool crashed: {exc}")
348
441
 
349
442
  self._handle_result(session, name, result, ui)
443
+ self._post_tool_hook(session, tool, args, result)
350
444
  payload = result.content if result.ok else f"ERROR: {result.error}"
351
445
  self._append_tool(session, tc_id, payload or "(no output)")
352
446
  return None
353
447
 
448
+ @staticmethod
449
+ def _outside_paths(tool, args, ctx) -> "list":
450
+ from .tools.base import resolve_path
451
+
452
+ scope = ctx.scope
453
+ found = []
454
+ for raw in tool.paths(args):
455
+ try:
456
+ p = resolve_path(ctx.root, raw)
457
+ except Exception:
458
+ continue
459
+ if scope is None or not scope.allows(p):
460
+ found.append(p)
461
+ return found
462
+
463
+ def _ask_scope(self, session, tool, paths, ctx, ui) -> str:
464
+ """Ask for the first outside path; grant per the answer. Returns once|dir|all|deny|abort."""
465
+ scope = ctx.scope
466
+ if getattr(session, "config", None) is not None and getattr(session.config, "scope", "") == "anywhere":
467
+ scope.grant_all()
468
+ return "all"
469
+ asker = getattr(ui, "approve_scope", None)
470
+ if asker is None:
471
+ return "deny"
472
+ from .hooks import notify
473
+
474
+ notify(session, "scope", f"{tool.name} wants {paths[0]} outside the workspace")
475
+ verdict = asker(tool, paths[0], ctx)
476
+ if verdict == "once":
477
+ for p in paths:
478
+ scope.grant(p)
479
+ elif verdict == "dir":
480
+ for p in paths:
481
+ scope.grant_dir(p)
482
+ ui.assistant(f"allowing {scope.granted[-1]} for the rest of this session.")
483
+ elif verdict == "all":
484
+ scope.grant_all()
485
+ ui.assistant("allowing access anywhere on this machine for the rest of this session.")
486
+ return verdict
487
+
354
488
  @staticmethod
355
489
  def _handle_result(session, name: str, result: ToolResult, ui) -> None:
356
490
  """Surface a tool result: render a plan update specially, else the generic result line."""
@@ -126,6 +126,40 @@ def _edit_args(args: dict) -> dict:
126
126
  return args
127
127
 
128
128
 
129
+ SCOPE_CHOICES = ("once", "dir", "all", "deny", "abort")
130
+
131
+
132
+ def request_scope(tool: Tool, path, ctx: ToolContext) -> str:
133
+ """Ask once about a path outside the workspace. Returns one of ``SCOPE_CHOICES``."""
134
+ from pathlib import Path as _P
135
+
136
+ p = _P(str(path))
137
+ where = p if p.is_dir() else p.parent
138
+ print(color(f" ⌂ {tool.name} wants {p}", "yellow") + color(" (outside the workspace)", "gray"))
139
+ while True:
140
+ print(
141
+ " "
142
+ + color("allow this path", "green") + " [a] "
143
+ + color(f"allow {where} this session", "green") + " [d] "
144
+ + color("allow anywhere this session", "green") + " [A] "
145
+ + color("skip", "yellow") + " [s] "
146
+ + color("quit", "red") + " [q] › ",
147
+ end="", flush=True,
148
+ )
149
+ key = read_key()
150
+ print(key)
151
+ if key in ("a", "y", "\r", "\n", ""):
152
+ return "once"
153
+ if key == "d":
154
+ return "dir"
155
+ if key == "A":
156
+ return "all"
157
+ if key == "s":
158
+ return "deny"
159
+ if key == "q":
160
+ return "abort"
161
+
162
+
129
163
  def request_approval(tool: Tool, args: dict, ctx: ToolContext) -> Approval:
130
164
  """Show the pending tool call and read the user's decision (with optional arg editing)."""
131
165
  while True:
@@ -59,6 +59,10 @@ def _build_parser() -> argparse.ArgumentParser:
59
59
  help="Don't inject the repo map (git + file tree) into the agent prompt.")
60
60
  parser.add_argument("--no-labels", action="store_true",
61
61
  help="Hide the role labels/gutters (❯ you / ⏺ scoot) in the REPL transcript.")
62
+ parser.add_argument("--scope", choices=["workspace", "anywhere"],
63
+ help="Where file tools may go: workspace (default; the first access outside it asks once) or anywhere.")
64
+ parser.add_argument("--headless", action="store_true",
65
+ help="Line-delimited JSON on stdin/stdout instead of the REPL (see docs/headless-protocol.md).")
62
66
  parser.add_argument("--no-logo", action="store_true",
63
67
  help="Hide the mascot (launch banner art + status-bar face). Persist with /logo off.")
64
68
  parser.add_argument("--no-emoji", action="store_true",
@@ -90,6 +94,7 @@ def _config_from_args(args: argparse.Namespace) -> Config:
90
94
  proxy=args.proxy,
91
95
  root=args.root,
92
96
  approval="yolo" if (args.yolo or args.yes) else args.approval,
97
+ scope="anywhere" if args.yes else args.scope,
93
98
  verbose=True if args.verbose else None,
94
99
  stream=False if args.no_stream else None,
95
100
  panel=False if args.no_panel else None,
@@ -154,6 +159,17 @@ def _run_once(config: Config, pool: ProviderPool, prompt: str, as_json: bool, re
154
159
  session = ReplSession(config, pool)
155
160
  if resume is not None:
156
161
  session.apply_record(resume)
162
+ from .hooks import Hooks, session_event, submit_prompt
163
+
164
+ session.hooks = Hooks(config.root)
165
+ session_event(session, "SessionStart", source="resume" if resume is not None else "startup")
166
+ submitted = submit_prompt(session, prompt)
167
+ if submitted is None:
168
+ reason = getattr(session, "hook_block_reason", "") or "a UserPromptSubmit hook blocked it"
169
+ eprint(color(f"⏹ prompt not sent: {reason}", "yellow"))
170
+ session_event(session, "SessionEnd", reason="blocked")
171
+ return 1
172
+ prompt = submitted
157
173
  # Fold any dropped image paths into the prompt (best-effort; no-op when none/disabled).
158
174
  try:
159
175
  from .vision import fold_images_into_text
@@ -166,6 +182,7 @@ def _run_once(config: Config, pool: ProviderPool, prompt: str, as_json: bool, re
166
182
  agent_config = config.override(stream=False) if as_json else config
167
183
  outcome = Agent(agent_config, pool).run_turn(session, ReplUI(), threading.Event())
168
184
  session.autosave() # persist so `scoot -c` can continue this conversation
185
+ session_event(session, "SessionEnd", reason=outcome.status)
169
186
 
170
187
  if as_json:
171
188
  print(_json.dumps({
@@ -214,14 +231,8 @@ def _interactive(pool: ProviderPool, resume=None) -> int:
214
231
 
215
232
  resume = sessions.latest_for_root(str(config.root))
216
233
 
217
- try:
218
- from .providers.registry import readiness
219
-
220
- ready, message = readiness(config)
221
- if not ready: # first-run onboarding: guide, but still open the REPL so /auth is usable
222
- eprint(color(message, "yellow"))
223
- except ScootError as exc:
224
- eprint(color(f"⚠ {redact(str(exc))}", "yellow"))
234
+ # First-run onboarding happens inside the REPL (banner + a setup block), because anything printed
235
+ # here would be wiped by the screen clear the REPL does on start.
225
236
  return Repl(config, pool, resume=resume).run()
226
237
 
227
238
 
@@ -271,6 +282,13 @@ def main(argv: Optional[List[str]] = None) -> int:
271
282
 
272
283
  prompt = _resolve_prompt(args)
273
284
  resume = _resolve_resume(config, args)
285
+ if args.headless:
286
+ if prompt is not None:
287
+ eprint(color("--headless takes prompts on stdin, not as an argument.", "yellow"))
288
+ return 2
289
+ from .headless import run_headless
290
+
291
+ return run_headless(config.override(panel=False, dock=False, logo=False), pool, resume=resume)
274
292
  if prompt is not None:
275
293
  return _run_once(config, pool, prompt, args.json, resume=resume)
276
294
  return _interactive(pool, resume=resume)
@@ -52,6 +52,8 @@ def load_builtins() -> None:
52
52
  from . import copy as _copy # noqa: F401
53
53
  from . import logo as _logo # noqa: F401
54
54
  from . import route as _route # noqa: F401
55
+ from . import hooks as _hooks # noqa: F401
56
+ from . import scope as _scope # noqa: F401
55
57
 
56
58
  _loaded = True
57
59
 
@@ -69,6 +69,7 @@ def _set(session, name: str) -> None:
69
69
  if pool is not None and hasattr(pool, "_providers"):
70
70
  pool._providers.pop(name, None) # rebuild with the new key on next use
71
71
  print(color(f"✔ {name} key saved to {path} (owner-only).", "green"))
72
+ _after_change(session)
72
73
 
73
74
 
74
75
  def _clear(session, name: str) -> None:
@@ -80,6 +81,7 @@ def _clear(session, name: str) -> None:
80
81
  print(color(f"forgot the saved {name} key.", "green"))
81
82
  else:
82
83
  print(color(f"no saved {name} key.", "gray"))
84
+ _after_change(session)
83
85
  pool = getattr(session, "provider", None)
84
86
  if pool is not None and hasattr(pool, "_providers"):
85
87
  pool._providers.pop(name, None)
@@ -90,6 +92,20 @@ def _clear(session, name: str) -> None:
90
92
  print(color(f" {missing_key_hint(spec)}", "gray"))
91
93
 
92
94
 
95
+ def _after_change(session) -> None:
96
+ """A key came or went: the default provider may have changed, so re-resolve and re-check."""
97
+ try:
98
+ if session.model.lower() in ("default", "auto"):
99
+ session.active_model = session.resolved_model()
100
+ session._available = None # the model list depends on which providers are configured
101
+ if session.refresh_readiness():
102
+ print(color(f"ready: {session.active_model}", "gray"))
103
+ else:
104
+ print(color(session.setup_message, "yellow"))
105
+ except Exception:
106
+ pass
107
+
108
+
93
109
  def _run(session, args: str):
94
110
  words = (args or "").split()
95
111
  if not words:
@@ -0,0 +1,37 @@
1
+ """/hooks — list the configured hooks and what they did last."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..hooks import EVENTS, enabled, global_path, project_path
6
+ from ..rendering import color
7
+ from . import register
8
+ from .base import SlashCommand
9
+
10
+
11
+ def _run(session, args: str):
12
+ hooks = getattr(session, "hooks", None)
13
+ if (args or "").strip() == "reload" and hooks is not None:
14
+ hooks.reload()
15
+ print(color("hooks reloaded.", "gray"))
16
+ print(color("hooks:", "bold"))
17
+ if not enabled():
18
+ print(color(" disabled by SCOOT_HOOKS=0", "yellow"))
19
+ root = getattr(getattr(session, "config", None), "root", ".")
20
+ print(color(f" files: {project_path(root)} (project), {global_path()} (global)", "gray"))
21
+ if hooks is None or not hooks.config:
22
+ print(color(" none configured", "gray"))
23
+ else:
24
+ for event in EVENTS:
25
+ for entry in hooks.config.get(event, []):
26
+ matcher = f" [{entry['matcher']}]" if entry.get("matcher") else ""
27
+ for hook in entry.get("hooks", []):
28
+ print(f" {color(event, 'cyan')}{matcher} {hook.get('command', '')}")
29
+ if hooks.history:
30
+ print(color(" recent:", "gray"))
31
+ for r in hooks.history[-8:]:
32
+ detail = f" {r.detail[:60]}" if r.detail else ""
33
+ print(f" {r.event:<16} {r.outcome:<8} {r.seconds:>5.2f}s {r.command[:40]}{detail}")
34
+ print(color(" usage: /hooks [reload]", "gray"))
35
+
36
+
37
+ register(SlashCommand("hooks", "list configured hooks and recent results", _run, usage="[reload]"))
@@ -40,6 +40,8 @@ def _run(session, args: str):
40
40
  set_model(arg)
41
41
  what = "picked per task from the live list" if arg == "auto" else f"the provider's preferred model, now {session.active_model}"
42
42
  print(color(f"model preference set to {arg} ({what}); saved for next launch.", "gray"))
43
+ if hasattr(session, "refresh_readiness") and not session.refresh_readiness():
44
+ print(color(session.setup_message, "yellow"))
43
45
  return
44
46
 
45
47
  if arg not in available:
@@ -55,6 +57,8 @@ def _run(session, args: str):
55
57
  session.active_model = arg
56
58
  set_model(arg)
57
59
  print(color(f"model set to {arg}; saved for next launch.", "gray"))
60
+ if hasattr(session, "refresh_readiness") and not session.refresh_readiness():
61
+ print(color(session.setup_message, "yellow"))
58
62
 
59
63
 
60
64
  register(SlashCommand("model", "list or switch model (default|auto|provider/model)", _run, usage="[provider/model|default|auto]"))