scootcli 0.8.0__tar.gz → 0.8.1__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 (119) hide show
  1. {scootcli-0.8.0/src/scootcli.egg-info → scootcli-0.8.1}/PKG-INFO +5 -4
  2. {scootcli-0.8.0 → scootcli-0.8.1}/README.md +4 -3
  3. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/__init__.py +1 -1
  4. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/cli.py +4 -4
  5. scootcli-0.8.1/src/scootcli/commands/model.py +115 -0
  6. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/commands/status.py +12 -1
  7. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/config.py +31 -9
  8. scootcli-0.8.1/src/scootcli/preferences.py +144 -0
  9. {scootcli-0.8.0 → scootcli-0.8.1/src/scootcli.egg-info}/PKG-INFO +5 -4
  10. scootcli-0.8.1/tests/test_preferences.py +266 -0
  11. scootcli-0.8.0/src/scootcli/commands/model.py +0 -65
  12. scootcli-0.8.0/src/scootcli/preferences.py +0 -87
  13. scootcli-0.8.0/tests/test_preferences.py +0 -132
  14. {scootcli-0.8.0 → scootcli-0.8.1}/LICENSE +0 -0
  15. {scootcli-0.8.0 → scootcli-0.8.1}/pyproject.toml +0 -0
  16. {scootcli-0.8.0 → scootcli-0.8.1}/setup.cfg +0 -0
  17. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/__main__.py +0 -0
  18. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/activity.py +0 -0
  19. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/agent.py +0 -0
  20. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/approvals.py +0 -0
  21. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/auth.py +0 -0
  22. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/clipboard.py +0 -0
  23. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/commands/__init__.py +0 -0
  24. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/commands/approve.py +0 -0
  25. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/commands/auth.py +0 -0
  26. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/commands/base.py +0 -0
  27. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/commands/compact.py +0 -0
  28. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/commands/copy.py +0 -0
  29. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/commands/exit.py +0 -0
  30. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/commands/forget.py +0 -0
  31. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/commands/help.py +0 -0
  32. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/commands/hooks.py +0 -0
  33. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/commands/init.py +0 -0
  34. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/commands/logo.py +0 -0
  35. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/commands/panel.py +0 -0
  36. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/commands/reset.py +0 -0
  37. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/commands/resume.py +0 -0
  38. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/commands/route.py +0 -0
  39. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/commands/save.py +0 -0
  40. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/commands/scope.py +0 -0
  41. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/commands/sessions.py +0 -0
  42. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/commands/verbosity.py +0 -0
  43. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/commands/worktree.py +0 -0
  44. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/commands/yolo.py +0 -0
  45. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/context.py +0 -0
  46. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/credentials.py +0 -0
  47. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/errors.py +0 -0
  48. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/headless.py +0 -0
  49. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/hooks.py +0 -0
  50. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/images.py +0 -0
  51. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/keys.py +0 -0
  52. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/lineeditor.py +0 -0
  53. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/logo.py +0 -0
  54. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/models.py +0 -0
  55. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/panel.py +0 -0
  56. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/presets.py +0 -0
  57. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/pricing.py +0 -0
  58. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/project.py +0 -0
  59. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/prompts.py +0 -0
  60. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/providers/__init__.py +0 -0
  61. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/providers/anthropic.py +0 -0
  62. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/providers/base.py +0 -0
  63. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/providers/openai_chat.py +0 -0
  64. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/providers/openai_responses.py +0 -0
  65. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/providers/registry.py +0 -0
  66. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/providers/router.py +0 -0
  67. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/rendering.py +0 -0
  68. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/repl.py +0 -0
  69. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/resize.py +0 -0
  70. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/sessions.py +0 -0
  71. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/status.py +0 -0
  72. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/tools/__init__.py +0 -0
  73. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/tools/base.py +0 -0
  74. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/tools/edit_file.py +0 -0
  75. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/tools/list_dir.py +0 -0
  76. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/tools/open_editor.py +0 -0
  77. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/tools/read_file.py +0 -0
  78. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/tools/run_shell.py +0 -0
  79. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/tools/search.py +0 -0
  80. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/tools/update_plan.py +0 -0
  81. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/tools/write_file.py +0 -0
  82. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/transport.py +0 -0
  83. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/updates.py +0 -0
  84. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/vision.py +0 -0
  85. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/workspace.py +0 -0
  86. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli/worktree.py +0 -0
  87. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli.egg-info/SOURCES.txt +0 -0
  88. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli.egg-info/dependency_links.txt +0 -0
  89. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli.egg-info/entry_points.txt +0 -0
  90. {scootcli-0.8.0 → scootcli-0.8.1}/src/scootcli.egg-info/top_level.txt +0 -0
  91. {scootcli-0.8.0 → scootcli-0.8.1}/tests/test_agent.py +0 -0
  92. {scootcli-0.8.0 → scootcli-0.8.1}/tests/test_anthropic.py +0 -0
  93. {scootcli-0.8.0 → scootcli-0.8.1}/tests/test_approvals.py +0 -0
  94. {scootcli-0.8.0 → scootcli-0.8.1}/tests/test_auth.py +0 -0
  95. {scootcli-0.8.0 → scootcli-0.8.1}/tests/test_clipboard.py +0 -0
  96. {scootcli-0.8.0 → scootcli-0.8.1}/tests/test_config.py +0 -0
  97. {scootcli-0.8.0 → scootcli-0.8.1}/tests/test_headless.py +0 -0
  98. {scootcli-0.8.0 → scootcli-0.8.1}/tests/test_hooks.py +0 -0
  99. {scootcli-0.8.0 → scootcli-0.8.1}/tests/test_images.py +0 -0
  100. {scootcli-0.8.0 → scootcli-0.8.1}/tests/test_lineeditor.py +0 -0
  101. {scootcli-0.8.0 → scootcli-0.8.1}/tests/test_logo.py +0 -0
  102. {scootcli-0.8.0 → scootcli-0.8.1}/tests/test_models.py +0 -0
  103. {scootcli-0.8.0 → scootcli-0.8.1}/tests/test_panel.py +0 -0
  104. {scootcli-0.8.0 → scootcli-0.8.1}/tests/test_providers.py +0 -0
  105. {scootcli-0.8.0 → scootcli-0.8.1}/tests/test_readiness.py +0 -0
  106. {scootcli-0.8.0 → scootcli-0.8.1}/tests/test_repl.py +0 -0
  107. {scootcli-0.8.0 → scootcli-0.8.1}/tests/test_resilience.py +0 -0
  108. {scootcli-0.8.0 → scootcli-0.8.1}/tests/test_resize.py +0 -0
  109. {scootcli-0.8.0 → scootcli-0.8.1}/tests/test_router.py +0 -0
  110. {scootcli-0.8.0 → scootcli-0.8.1}/tests/test_scope.py +0 -0
  111. {scootcli-0.8.0 → scootcli-0.8.1}/tests/test_sessions.py +0 -0
  112. {scootcli-0.8.0 → scootcli-0.8.1}/tests/test_small_060.py +0 -0
  113. {scootcli-0.8.0 → scootcli-0.8.1}/tests/test_smoke.py +0 -0
  114. {scootcli-0.8.0 → scootcli-0.8.1}/tests/test_streaming.py +0 -0
  115. {scootcli-0.8.0 → scootcli-0.8.1}/tests/test_tools.py +0 -0
  116. {scootcli-0.8.0 → scootcli-0.8.1}/tests/test_transport_native.py +0 -0
  117. {scootcli-0.8.0 → scootcli-0.8.1}/tests/test_vision.py +0 -0
  118. {scootcli-0.8.0 → scootcli-0.8.1}/tests/test_workspace.py +0 -0
  119. {scootcli-0.8.0 → scootcli-0.8.1}/tests/test_worktree.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scootcli
3
- Version: 0.8.0
3
+ Version: 0.8.1
4
4
  Summary: A tiny coding agent that goes where you point it
5
5
  Author: Sergey Nes
6
6
  License-Expression: MIT
@@ -115,8 +115,9 @@ scoot --model openai/gpt-5.3-codex "..."
115
115
  scoot --model auto "..." # pick a model per prompt from the live list (cheap for trivial, strong for edits)
116
116
  ```
117
117
 
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.
118
+ Inside the REPL, `/model <provider/model>` switches and is remembered for the folder you are in, so each project can have its own model; `/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 there; a one-shot `scoot --model X "prompt"` does not change the saved choice.
120
+ `/model X everywhere` makes `X` the model for every folder that has no choice of its own, `/model forget` drops the current folder's choice so it follows that, and `/status` says where the active model came from.
120
121
 
121
122
  ### Routing
122
123
 
@@ -171,7 +172,7 @@ The most useful settings (see [`.env.example`](./.env.example) for all of them):
171
172
  |---|---|---|
172
173
  | `OPENAI_API_KEY` | OpenAI key (or `scoot auth set openai`) | |
173
174
  | `SCOOT_PROVIDER` | default provider for bare model names | first with a key, else `ollama` |
174
- | `SCOOT_MODEL` | `default`, `auto`, or `provider/model` | `default` |
175
+ | `SCOOT_MODEL` | `default`, `auto`, or `provider/model`; overrides the saved per-folder and global choices | `default` |
175
176
  | `SCOOT_EFFORT` | reasoning effort | `medium` |
176
177
  | `SCOOT_APPROVAL` | `always` · `auto-read` · `auto-edits` · `yolo` | `yolo` |
177
178
  | `SCOOT_MAX_STEPS` | tool-call steps per turn before asking to continue | `50` |
@@ -90,8 +90,9 @@ scoot --model openai/gpt-5.3-codex "..."
90
90
  scoot --model auto "..." # pick a model per prompt from the live list (cheap for trivial, strong for edits)
91
91
  ```
92
92
 
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.
93
+ Inside the REPL, `/model <provider/model>` switches and is remembered for the folder you are in, so each project can have its own model; `/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 there; a one-shot `scoot --model X "prompt"` does not change the saved choice.
95
+ `/model X everywhere` makes `X` the model for every folder that has no choice of its own, `/model forget` drops the current folder's choice so it follows that, and `/status` says where the active model came from.
95
96
 
96
97
  ### Routing
97
98
 
@@ -146,7 +147,7 @@ The most useful settings (see [`.env.example`](./.env.example) for all of them):
146
147
  |---|---|---|
147
148
  | `OPENAI_API_KEY` | OpenAI key (or `scoot auth set openai`) | |
148
149
  | `SCOOT_PROVIDER` | default provider for bare model names | first with a key, else `ollama` |
149
- | `SCOOT_MODEL` | `default`, `auto`, or `provider/model` | `default` |
150
+ | `SCOOT_MODEL` | `default`, `auto`, or `provider/model`; overrides the saved per-folder and global choices | `default` |
150
151
  | `SCOOT_EFFORT` | reasoning effort | `medium` |
151
152
  | `SCOOT_APPROVAL` | `always` · `auto-read` · `auto-edits` · `yolo` | `yolo` |
152
153
  | `SCOOT_MAX_STEPS` | tool-call steps per turn before asking to continue | `50` |
@@ -1,4 +1,4 @@
1
1
  """scoot: a tiny coding agent that goes where you point it."""
2
2
 
3
- __version__ = "0.8.0"
3
+ __version__ = "0.8.1"
4
4
 
@@ -229,9 +229,9 @@ def _interactive(pool: ProviderPool, resume=None, remember_model: Optional[str]
229
229
  """Launch the persistent REPL (banner, live status, ESC-interrupt, slash-commands).
230
230
 
231
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.
232
+ with it counts as choosing it for this folder, exactly like ``/model`` inside the REPL, so a plain
233
+ ``scoot`` here next time starts on the same model. One-shot prompts and headless runs do not save
234
+ it: a flag on a single command is not a choice for the next session.
235
235
  """
236
236
  from .repl import Repl
237
237
 
@@ -239,7 +239,7 @@ def _interactive(pool: ProviderPool, resume=None, remember_model: Optional[str]
239
239
  if remember_model and remember_model.strip():
240
240
  from .preferences import set_model
241
241
 
242
- set_model(remember_model.strip())
242
+ set_model(remember_model.strip(), root=config.root)
243
243
  # resume=auto (SCOOT_RESUME / --resume-last): reload the latest session for this directory
244
244
  # unless the user already picked one explicitly via --resume/--continue.
245
245
  if resume is None and getattr(config, "resume", "hint") == "auto":
@@ -0,0 +1,115 @@
1
+ """/model: list available models or switch the active one.
2
+
3
+ Models are ``provider/model`` ids (``openai/gpt-5.3-codex``, ``ollama/llama3.2``); a bare name means
4
+ the default provider. A switch made here is **persisted** (``~/.config/scoot/preferences.json``) for
5
+ this folder, so the next launch here reuses it; ``/model X everywhere`` saves it for every folder
6
+ without a choice of its own, and ``/model forget`` drops this folder's choice so it follows that. An
7
+ explicit ``--model`` flag or ``SCOOT_MODEL`` still overrides it for that run.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from .. import preferences
13
+ from ..providers.base import qualify, split_model_id
14
+ from ..rendering import color
15
+ from . import register
16
+ from .base import SlashCommand
17
+
18
+ _USAGE = "/model <provider/model> · /model default · /model auto (pick per task) · add 'everywhere' for all folders · /model forget"
19
+
20
+
21
+ def _root(session):
22
+ return getattr(getattr(session, "config", None), "root", None)
23
+
24
+
25
+ def _saved_line(session) -> str:
26
+ folder, everywhere = preferences.saved_models(_root(session))
27
+ return f"saved: this folder {folder or '(none)'} · everywhere {everywhere or '(none)'}"
28
+
29
+
30
+ def _remember(session, name: str, everywhere: bool) -> str:
31
+ """Persist ``name`` for this folder (or every folder); return how to say so."""
32
+ root = _root(session)
33
+ if everywhere:
34
+ preferences.set_model_everywhere(name, root=root)
35
+ _note_origin(session, name, preferences.EVERYWHERE)
36
+ return "saved for every folder"
37
+ preferences.set_model(name, root=root)
38
+ _note_origin(session, name, preferences.FOLDER)
39
+ return "saved for this folder"
40
+
41
+
42
+ def _note_origin(session, name: str, source: str) -> None:
43
+ """Keep ``config.model_source`` truthful after a switch, so ``/status`` names the right layer."""
44
+ cfg = getattr(session, "config", None)
45
+ if cfg is not None and hasattr(cfg, "override"):
46
+ session.config = cfg.override(model=name, model_source=source)
47
+
48
+
49
+ def _run(session, args: str):
50
+ words = args.split()
51
+ everywhere = False
52
+ if words and words[-1].lower() == "everywhere":
53
+ everywhere = True
54
+ words = words[:-1]
55
+ arg = " ".join(words)
56
+ available = session.available_models()
57
+
58
+ if not arg:
59
+ print(color(f"models (preference: {session.model} → active: {session.active_model})", "bold"))
60
+ print(color(f" {_saved_line(session)}", "gray"))
61
+ current = ""
62
+ for mid in available:
63
+ head, _ = split_model_id(mid)
64
+ if head != current:
65
+ current = head
66
+ print(color(f" {head}", "bold"))
67
+ mark = color(" ← active", "green") if mid == session.active_model else ""
68
+ print(" " + color(mid, "cyan") + mark)
69
+ errors = getattr(getattr(session, "provider", None), "list_errors", None) or {}
70
+ for name, err in sorted(errors.items()):
71
+ print(color(f" {name}: unavailable ({err})", "yellow"))
72
+ print(color(f" use: {_USAGE}", "gray"))
73
+ return
74
+
75
+ if arg == "forget":
76
+ preferences.clear_model(root=_root(session))
77
+ session.model = preferences.get_model(_root(session)) or "default"
78
+ session.active_model = session.resolved_model()
79
+ _note_origin(session, session.model, preferences.model_source(_root(session)) or "default")
80
+ print(color(f"this folder's model choice forgotten; now {session.model} → {session.active_model}. "
81
+ f"{_saved_line(session)}", "gray"))
82
+ if hasattr(session, "refresh_readiness") and not session.refresh_readiness():
83
+ print(color(session.setup_message, "yellow"))
84
+ return
85
+
86
+ if arg in ("auto", "default"):
87
+ session.model = arg
88
+ session.active_model = session.resolved_model()
89
+ how = _remember(session, arg, everywhere)
90
+ what = "picked per task from the live list" if arg == "auto" else f"the provider's preferred model, now {session.active_model}"
91
+ print(color(f"model preference set to {arg} ({what}); {how}.", "gray"))
92
+ if hasattr(session, "refresh_readiness") and not session.refresh_readiness():
93
+ print(color(session.setup_message, "yellow"))
94
+ return
95
+
96
+ if arg not in available:
97
+ head, _ = split_model_id(arg)
98
+ if head is None:
99
+ try:
100
+ arg = qualify(session.provider.default_name, arg)
101
+ except Exception:
102
+ pass
103
+ if arg not in available:
104
+ print(color(f"warning: '{arg}' is not in the models list; setting it anyway.", "yellow"))
105
+ session.model = arg
106
+ session.active_model = arg
107
+ how = _remember(session, arg, everywhere)
108
+ print(color(f"model set to {arg}; {how}.", "gray"))
109
+ if hasattr(session, "refresh_readiness") and not session.refresh_readiness():
110
+ print(color(session.setup_message, "yellow"))
111
+
112
+
113
+ register(SlashCommand("model", "list or switch model (default|auto|provider/model), per folder or everywhere",
114
+ _run, usage="[provider/model|default|auto] [everywhere] | forget"))
115
+
@@ -7,6 +7,16 @@ from . import register
7
7
  from .base import SlashCommand
8
8
 
9
9
 
10
+ _ORIGINS = {"flag": "--model", "env": "SCOOT_MODEL", "folder": "saved for this folder",
11
+ "everywhere": "saved for every folder", "default": "the default"}
12
+
13
+
14
+ def _model_origin(session) -> str:
15
+ """``" (from --model)"`` and the like: which layer chose the model this run started with."""
16
+ src = getattr(getattr(session, "config", None), "model_source", "")
17
+ return f" (from {_ORIGINS[src]})" if src in _ORIGINS else ""
18
+
19
+
10
20
  def _run(session, args: str):
11
21
  cfg = session.config
12
22
  try:
@@ -20,7 +30,8 @@ def _run(session, args: str):
20
30
  provider_row = getattr(cfg, "provider", "") or "?"
21
31
  rows = [
22
32
  ("provider", provider_row),
23
- ("model", session.active_model + (" (auto)" if session.model.lower() == "auto" else "")),
33
+ ("model", session.active_model + (" (auto)" if session.model.lower() == "auto" else "")
34
+ + _model_origin(session)),
24
35
  ("effort", getattr(cfg, "effort", "medium")),
25
36
  ("env files", ", ".join(getattr(cfg, "env_files", ()) or ()) or "none"),
26
37
  ("update", (f"{session.update_available} available: pipx upgrade scootcli"
@@ -2,6 +2,9 @@
2
2
 
3
3
  Precedence (highest wins):
4
4
  CLI flag → environment variable → project ``.env`` → global ``~/.config/scoot/.env`` → default.
5
+ The model has two more layers between the ``.env`` files and the default: the model saved for this
6
+ workspace root, then the one saved for every folder (see ``preferences.py``); ``model_source`` says
7
+ which layer answered.
5
8
 
6
9
  Two optional ``.env`` files are read: the global one in scoot's config home, and the nearest ``.env``
7
10
  found walking up from the current directory (so a repo-root file applies from any subdirectory).
@@ -129,16 +132,23 @@ def is_model_alias(model: Optional[str]) -> bool:
129
132
  return (model or "").strip().lower() in (DEFAULT_MODEL_ALIAS, AUTO_MODEL_ALIAS, "")
130
133
 
131
134
 
132
- def _resolve_model_setting(model_env: Optional[str]) -> str:
133
- """Model precedence (below the CLI flag, applied later): SCOOT_MODEL → saved pref → default."""
135
+ MODEL_SOURCES = ("flag", "env", "folder", "everywhere", "default")
136
+
137
+
138
+ def _resolve_model_setting(model_env: Optional[str], root=None) -> Tuple[str, str]:
139
+ """``(model, source)`` below the CLI flag (applied later): SCOOT_MODEL → the model saved for
140
+ ``root`` → the model saved for every folder → default."""
134
141
  if model_env:
135
- return model_env
142
+ return model_env, "env"
136
143
  try:
137
- from .preferences import get_model
144
+ from .preferences import get_model, model_source
138
145
 
139
- return get_model() or DEFAULT_MODEL_ALIAS
146
+ saved = get_model(root)
147
+ if saved:
148
+ return saved, model_source(root) or "everywhere"
140
149
  except Exception:
141
- return DEFAULT_MODEL_ALIAS
150
+ pass
151
+ return DEFAULT_MODEL_ALIAS, "default"
142
152
 
143
153
 
144
154
  def _resolve_logo_setting(logo_env: Optional[str]) -> bool:
@@ -161,6 +171,7 @@ class Config:
161
171
  proxy: str = DEFAULT_PROXY
162
172
  provider: str = "" # default provider name; empty → first provider with a key, else ollama
163
173
  model: str = DEFAULT_MODEL_ALIAS # "default" | "auto" | a model id, ideally provider/model
174
+ model_source: str = "default" # where ``model`` came from: flag | env | folder | everywhere | default
164
175
  effort: str = "medium" # reasoning effort for models that take it: low | medium | high | xhigh
165
176
  timeout: int = 120
166
177
  max_steps: int = 50
@@ -194,17 +205,20 @@ class Config:
194
205
  load_dotenv(env_file)
195
206
 
196
207
  get = os.environ.get
208
+ root = Path(get("SCOOT_ROOT", str(cwd))).resolve()
209
+ model, model_source = _resolve_model_setting(get("SCOOT_MODEL"), root)
197
210
  return cls(
198
211
  proxy=get("HTTPS_PROXY") or get("https_proxy") or DEFAULT_PROXY,
199
212
  env_files=tuple(str(f) for f in files),
200
213
  provider=get("SCOOT_PROVIDER", "").strip().lower(),
201
- model=_resolve_model_setting(get("SCOOT_MODEL")),
214
+ model=model,
215
+ model_source=model_source,
202
216
  effort=get("SCOOT_EFFORT", "medium").strip().lower(),
203
217
  timeout=int(get("SCOOT_TIMEOUT", "120")),
204
218
  max_steps=int(get("SCOOT_MAX_STEPS", "50")),
205
219
  compact_at=int(get("SCOOT_COMPACT_AT", "100000")),
206
220
  approval=get("SCOOT_APPROVAL", "yolo"),
207
- root=Path(get("SCOOT_ROOT", str(cwd))).resolve(),
221
+ root=root,
208
222
  verbose=_as_bool(get("SCOOT_VERBOSE", "false")),
209
223
  stream=_as_bool(get("SCOOT_STREAM", "true")),
210
224
  panel=_as_bool(get("SCOOT_PANEL", "true")),
@@ -223,10 +237,18 @@ class Config:
223
237
  )
224
238
 
225
239
  def override(self, **kwargs) -> "Config":
226
- """Return a copy with the given (non-None) fields overridden — the CLI-flag layer."""
240
+ """Return a copy with the given (non-None) fields overridden: the CLI-flag layer.
241
+
242
+ A ``model`` here is the flag. A new ``root`` (``--root``) without one re-resolves the saved
243
+ model for that folder, unless the flag or ``SCOOT_MODEL`` already decided it.
244
+ """
227
245
  clean = {k: v for k, v in kwargs.items() if v is not None}
228
246
  if "root" in clean:
229
247
  clean["root"] = Path(clean["root"]).resolve()
248
+ if "model" in clean:
249
+ clean.setdefault("model_source", "flag")
250
+ elif "root" in clean and self.model_source not in ("flag", "env") and clean["root"] != self.root:
251
+ clean["model"], clean["model_source"] = _resolve_model_setting(None, clean["root"])
230
252
  return replace(self, **clean)
231
253
 
232
254
  def resolve_model(self, task_hint: str = "") -> str:
@@ -0,0 +1,144 @@
1
+ """Persisted user preferences (the chosen model, the mascot toggle), surviving across launches.
2
+
3
+ One file at ``~/.config/scoot/preferences.json`` (``SCOOT_CONFIG_DIR`` overrides the location, used by
4
+ tests). The model preference has two layers: ``models`` maps a workspace root to the model chosen
5
+ there (``/model`` or ``--model`` in that folder), and ``model`` is the choice for every folder without
6
+ one (``/model X everywhere``). Writing is best-effort: a failure never breaks the CLI.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ from .config import SCOOT_CONFIG_DIR
17
+
18
+
19
+ def _config_dir() -> Path:
20
+ override = os.environ.get("SCOOT_CONFIG_DIR")
21
+ return Path(override).expanduser() if override else SCOOT_CONFIG_DIR
22
+
23
+
24
+ def _prefs_file() -> Path:
25
+ return _config_dir() / "preferences.json"
26
+
27
+
28
+ def load_preferences() -> dict:
29
+ try:
30
+ data = json.loads(_prefs_file().read_text())
31
+ return data if isinstance(data, dict) else {}
32
+ except (OSError, json.JSONDecodeError, ValueError):
33
+ return {}
34
+
35
+
36
+ def _write(prefs: dict) -> None:
37
+ directory = _config_dir()
38
+ try:
39
+ directory.mkdir(parents=True, exist_ok=True)
40
+ try:
41
+ os.chmod(directory, 0o700)
42
+ except OSError:
43
+ pass
44
+ path = _prefs_file()
45
+ tmp = directory / ".preferences.tmp"
46
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
47
+ with os.fdopen(fd, "w") as fh:
48
+ json.dump(prefs, fh)
49
+ os.replace(tmp, path) # atomic
50
+ except OSError:
51
+ pass # preferences are best-effort
52
+
53
+
54
+ def save_preference(key: str, value) -> None:
55
+ prefs = load_preferences()
56
+ if value is None:
57
+ prefs.pop(key, None)
58
+ else:
59
+ prefs[key] = value
60
+ _write(prefs)
61
+
62
+
63
+ # ── model preference ────────────────────────────────────────────────────────────
64
+ FOLDER = "folder" # the model saved for this workspace root
65
+ EVERYWHERE = "everywhere" # the model saved for every folder without its own
66
+
67
+
68
+ def _root_key(root) -> str:
69
+ return str(Path(root).expanduser().resolve())
70
+
71
+
72
+ def _folder_models(prefs: dict) -> dict:
73
+ models = prefs.get("models")
74
+ return models if isinstance(models, dict) else {}
75
+
76
+
77
+ def saved_models(root=None):
78
+ """``(folder_model, global_model)``: what is saved for ``root`` (``None`` without a root) and for
79
+ everywhere; either may be ``None``."""
80
+ prefs = load_preferences()
81
+ folder = _folder_models(prefs).get(_root_key(root)) if root is not None else None
82
+ return (folder or None), (prefs.get("model") or None)
83
+
84
+
85
+ def get_model(root=None) -> Optional[str]:
86
+ """The saved model that applies in ``root``: the folder's own choice, else the global one."""
87
+ folder, everywhere = saved_models(root)
88
+ return folder or everywhere
89
+
90
+
91
+ def model_source(root=None) -> Optional[str]:
92
+ """Which layer :func:`get_model` answered from: ``"folder"``, ``"everywhere"``, or ``None``."""
93
+ folder, everywhere = saved_models(root)
94
+ return FOLDER if folder else (EVERYWHERE if everywhere else None)
95
+
96
+
97
+ def set_model(name: str, root=None) -> None:
98
+ """Persist the chosen model (including ``auto`` / ``default``) so the next launch reuses it: for
99
+ the folder ``root`` when given, otherwise for every folder (which also drops the folder's own
100
+ entry when ``root`` is passed as ``everywhere=True`` via :func:`set_model_everywhere`)."""
101
+ if root is None:
102
+ save_preference("model", name)
103
+ return
104
+ prefs = load_preferences()
105
+ models = dict(_folder_models(prefs))
106
+ models[_root_key(root)] = name
107
+ prefs["models"] = models
108
+ _write(prefs)
109
+
110
+
111
+ def set_model_everywhere(name: str, root=None) -> None:
112
+ """Make ``name`` the model for every folder, and forget ``root``'s own choice so it applies there too."""
113
+ prefs = load_preferences()
114
+ prefs["model"] = name
115
+ if root is not None:
116
+ models = dict(_folder_models(prefs))
117
+ models.pop(_root_key(root), None)
118
+ prefs["models"] = models
119
+ _write(prefs)
120
+
121
+
122
+ def clear_model(root=None) -> None:
123
+ """Forget the saved model: the folder's own entry when ``root`` is given, else the global one."""
124
+ if root is None:
125
+ save_preference("model", None)
126
+ return
127
+ prefs = load_preferences()
128
+ models = dict(_folder_models(prefs))
129
+ if models.pop(_root_key(root), None) is not None:
130
+ prefs["models"] = models
131
+ _write(prefs)
132
+
133
+
134
+ # ── mascot preference ───────────────────────────────────────────────────────────
135
+ def get_logo() -> Optional[bool]:
136
+ """``True``/``False`` if the user chose via ``/logo on|off``; ``None`` when unset (default on)."""
137
+ value = load_preferences().get("logo")
138
+ return value if isinstance(value, bool) else None
139
+
140
+
141
+ def set_logo(on: Optional[bool]) -> None:
142
+ """Persist the mascot toggle (``None`` forgets it → back to the default)."""
143
+ save_preference("logo", on)
144
+
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scootcli
3
- Version: 0.8.0
3
+ Version: 0.8.1
4
4
  Summary: A tiny coding agent that goes where you point it
5
5
  Author: Sergey Nes
6
6
  License-Expression: MIT
@@ -115,8 +115,9 @@ scoot --model openai/gpt-5.3-codex "..."
115
115
  scoot --model auto "..." # pick a model per prompt from the live list (cheap for trivial, strong for edits)
116
116
  ```
117
117
 
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.
118
+ Inside the REPL, `/model <provider/model>` switches and is remembered for the folder you are in, so each project can have its own model; `/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 there; a one-shot `scoot --model X "prompt"` does not change the saved choice.
120
+ `/model X everywhere` makes `X` the model for every folder that has no choice of its own, `/model forget` drops the current folder's choice so it follows that, and `/status` says where the active model came from.
120
121
 
121
122
  ### Routing
122
123
 
@@ -171,7 +172,7 @@ The most useful settings (see [`.env.example`](./.env.example) for all of them):
171
172
  |---|---|---|
172
173
  | `OPENAI_API_KEY` | OpenAI key (or `scoot auth set openai`) | |
173
174
  | `SCOOT_PROVIDER` | default provider for bare model names | first with a key, else `ollama` |
174
- | `SCOOT_MODEL` | `default`, `auto`, or `provider/model` | `default` |
175
+ | `SCOOT_MODEL` | `default`, `auto`, or `provider/model`; overrides the saved per-folder and global choices | `default` |
175
176
  | `SCOOT_EFFORT` | reasoning effort | `medium` |
176
177
  | `SCOOT_APPROVAL` | `always` · `auto-read` · `auto-edits` · `yolo` | `yolo` |
177
178
  | `SCOOT_MAX_STEPS` | tool-call steps per turn before asking to continue | `50` |