scootcli 0.1.0__tar.gz → 0.2.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 (100) hide show
  1. {scootcli-0.1.0 → scootcli-0.2.0}/LICENSE +1 -1
  2. {scootcli-0.1.0/src/scootcli.egg-info → scootcli-0.2.0}/PKG-INFO +14 -5
  3. {scootcli-0.1.0 → scootcli-0.2.0}/README.md +13 -4
  4. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/__init__.py +1 -1
  5. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/config.py +2 -0
  6. scootcli-0.2.0/src/scootcli/providers/anthropic.py +354 -0
  7. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/providers/base.py +15 -6
  8. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/providers/registry.py +12 -0
  9. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/status.py +14 -1
  10. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/tools/__init__.py +1 -0
  11. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/tools/base.py +28 -1
  12. scootcli-0.2.0/src/scootcli/tools/open_editor.py +123 -0
  13. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/tools/run_shell.py +3 -1
  14. {scootcli-0.1.0 → scootcli-0.2.0/src/scootcli.egg-info}/PKG-INFO +14 -5
  15. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli.egg-info/SOURCES.txt +3 -0
  16. scootcli-0.2.0/tests/test_anthropic.py +286 -0
  17. {scootcli-0.1.0 → scootcli-0.2.0}/tests/test_tools.py +64 -2
  18. {scootcli-0.1.0 → scootcli-0.2.0}/pyproject.toml +0 -0
  19. {scootcli-0.1.0 → scootcli-0.2.0}/setup.cfg +0 -0
  20. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/__main__.py +0 -0
  21. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/activity.py +0 -0
  22. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/agent.py +0 -0
  23. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/approvals.py +0 -0
  24. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/auth.py +0 -0
  25. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/cli.py +0 -0
  26. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/clipboard.py +0 -0
  27. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/commands/__init__.py +0 -0
  28. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/commands/approve.py +0 -0
  29. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/commands/auth.py +0 -0
  30. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/commands/base.py +0 -0
  31. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/commands/compact.py +0 -0
  32. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/commands/copy.py +0 -0
  33. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/commands/exit.py +0 -0
  34. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/commands/forget.py +0 -0
  35. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/commands/help.py +0 -0
  36. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/commands/init.py +0 -0
  37. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/commands/logo.py +0 -0
  38. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/commands/model.py +0 -0
  39. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/commands/panel.py +0 -0
  40. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/commands/reset.py +0 -0
  41. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/commands/resume.py +0 -0
  42. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/commands/save.py +0 -0
  43. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/commands/sessions.py +0 -0
  44. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/commands/status.py +0 -0
  45. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/commands/verbosity.py +0 -0
  46. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/commands/worktree.py +0 -0
  47. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/commands/yolo.py +0 -0
  48. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/context.py +0 -0
  49. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/credentials.py +0 -0
  50. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/errors.py +0 -0
  51. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/images.py +0 -0
  52. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/keys.py +0 -0
  53. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/lineeditor.py +0 -0
  54. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/logo.py +0 -0
  55. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/models.py +0 -0
  56. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/panel.py +0 -0
  57. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/preferences.py +0 -0
  58. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/presets.py +0 -0
  59. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/project.py +0 -0
  60. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/prompts.py +0 -0
  61. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/providers/__init__.py +0 -0
  62. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/providers/openai_chat.py +0 -0
  63. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/providers/openai_responses.py +0 -0
  64. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/rendering.py +0 -0
  65. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/repl.py +0 -0
  66. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/sessions.py +0 -0
  67. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/tools/edit_file.py +0 -0
  68. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/tools/list_dir.py +0 -0
  69. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/tools/read_file.py +0 -0
  70. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/tools/search.py +0 -0
  71. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/tools/update_plan.py +0 -0
  72. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/tools/write_file.py +0 -0
  73. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/transport.py +0 -0
  74. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/vision.py +0 -0
  75. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/workspace.py +0 -0
  76. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli/worktree.py +0 -0
  77. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli.egg-info/dependency_links.txt +0 -0
  78. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli.egg-info/entry_points.txt +0 -0
  79. {scootcli-0.1.0 → scootcli-0.2.0}/src/scootcli.egg-info/top_level.txt +0 -0
  80. {scootcli-0.1.0 → scootcli-0.2.0}/tests/test_agent.py +0 -0
  81. {scootcli-0.1.0 → scootcli-0.2.0}/tests/test_approvals.py +0 -0
  82. {scootcli-0.1.0 → scootcli-0.2.0}/tests/test_auth.py +0 -0
  83. {scootcli-0.1.0 → scootcli-0.2.0}/tests/test_clipboard.py +0 -0
  84. {scootcli-0.1.0 → scootcli-0.2.0}/tests/test_config.py +0 -0
  85. {scootcli-0.1.0 → scootcli-0.2.0}/tests/test_images.py +0 -0
  86. {scootcli-0.1.0 → scootcli-0.2.0}/tests/test_lineeditor.py +0 -0
  87. {scootcli-0.1.0 → scootcli-0.2.0}/tests/test_logo.py +0 -0
  88. {scootcli-0.1.0 → scootcli-0.2.0}/tests/test_models.py +0 -0
  89. {scootcli-0.1.0 → scootcli-0.2.0}/tests/test_panel.py +0 -0
  90. {scootcli-0.1.0 → scootcli-0.2.0}/tests/test_preferences.py +0 -0
  91. {scootcli-0.1.0 → scootcli-0.2.0}/tests/test_providers.py +0 -0
  92. {scootcli-0.1.0 → scootcli-0.2.0}/tests/test_repl.py +0 -0
  93. {scootcli-0.1.0 → scootcli-0.2.0}/tests/test_resilience.py +0 -0
  94. {scootcli-0.1.0 → scootcli-0.2.0}/tests/test_sessions.py +0 -0
  95. {scootcli-0.1.0 → scootcli-0.2.0}/tests/test_smoke.py +0 -0
  96. {scootcli-0.1.0 → scootcli-0.2.0}/tests/test_streaming.py +0 -0
  97. {scootcli-0.1.0 → scootcli-0.2.0}/tests/test_transport_native.py +0 -0
  98. {scootcli-0.1.0 → scootcli-0.2.0}/tests/test_vision.py +0 -0
  99. {scootcli-0.1.0 → scootcli-0.2.0}/tests/test_workspace.py +0 -0
  100. {scootcli-0.1.0 → scootcli-0.2.0}/tests/test_worktree.py +0 -0
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Sergey Neskoromny
3
+ Copyright (c) 2026 Sergey Nes
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scootcli
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: A tiny coding agent that goes where you point it
5
5
  Author: Sergey Nes
6
6
  License-Expression: MIT
@@ -25,6 +25,14 @@ Dynamic: license-file
25
25
 
26
26
  # scoot
27
27
 
28
+ [![PyPI](https://img.shields.io/pypi/v/scootcli.svg?label=PyPI)](https://pypi.org/project/scootcli/)
29
+ [![tests](https://github.com/sergenes/scootcli/actions/workflows/tests.yml/badge.svg)](https://github.com/sergenes/scootcli/actions/workflows/tests.yml)
30
+ [![Python 3.9+](https://img.shields.io/badge/python-3.9%2B-blue.svg)](https://www.python.org/downloads/)
31
+ [![Dependencies: none](https://img.shields.io/badge/dependencies-none-brightgreen.svg)](#install-options)
32
+ [![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey.svg)](#install-options)
33
+ [![Providers](https://img.shields.io/badge/providers-OpenAI%20%7C%20Ollama%20%7C%20Anthropic-orange.svg)](#providers-and-models)
34
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
35
+
28
36
  ```
29
37
  ╭───╮ scoot: a tiny coding agent that goes where you point it.
30
38
  │o o│
@@ -35,7 +43,7 @@ Dynamic: license-file
35
43
 
36
44
  `scoot` is a terminal coding agent in plain Python.
37
45
  You type what you want in natural language; it reads, searches, edits, and runs things in your repo, asking before anything risky.
38
- It talks to official model APIs (OpenAI today, a local Ollama for free, Anthropic next) and has **zero third-party dependencies**: the whole tool is the Python standard library, and it ships as a single-file zipapp as well as a wheel.
46
+ It talks to official model APIs (OpenAI, Anthropic, and a local Ollama for free) and has **zero third-party dependencies**: the whole tool is the Python standard library, and it ships as a single-file zipapp as well as a wheel.
39
47
 
40
48
  ## Where this comes from
41
49
 
@@ -88,7 +96,7 @@ A bare name means the default provider, which is the first provider that has a k
88
96
  |---|---|---|---|
89
97
  | `openai` | OpenAI Responses API | `OPENAI_API_KEY` | `gpt-5.3-codex` |
90
98
  | `ollama` | local Ollama, Responses API | none | `llama3.2` |
91
- | `anthropic` | Messages API | `ANTHROPIC_API_KEY` | arrives in 0.2.0 |
99
+ | `anthropic` | Anthropic Messages API | `ANTHROPIC_API_KEY` | `claude-opus-5` |
92
100
 
93
101
  ```bash
94
102
  scoot models # every configured provider, grouped
@@ -98,7 +106,8 @@ scoot --model auto "..." # pick a model per prompt from the live lis
98
106
  ```
99
107
 
100
108
  Inside the REPL, `/model <provider/model>` switches and is remembered for the next launch; `/model default` goes back to the provider's preferred model.
101
- `SCOOT_EFFORT` (`low` | `medium` | `high` | `xhigh`, default `medium`) sets the reasoning effort for models that take it.
109
+ `SCOOT_EFFORT` (`low` | `medium` | `high` | `xhigh`, default `medium`) sets the reasoning effort for models that take it, on both OpenAI and Anthropic.
110
+ On Claude Opus 5 the server-side refusal fallback is requested by default, so a declined request is retried on another Claude model inside the same call; `SCOOT_ANTHROPIC_FALLBACKS=0` turns that off.
102
111
 
103
112
  A note on how this is built: scoot speaks the OpenAI chat format internally and translates at the edge.
104
113
  Adding a provider that speaks that format is one registry row; a different wire format is one small adapter (`src/scootcli/providers/`).
@@ -173,7 +182,7 @@ Without a TTY, scoot falls back to a plain prompt with no bar and no dock.
173
182
 
174
183
  ### Tools and approvals
175
184
 
176
- The agent has seven tools: `read_file`, `list_dir`, `search`, `write_file`, `edit_file`, `run_shell`, and `update_plan` (a progress checklist for multi-step work).
185
+ 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).
177
186
  Every tool is sandboxed to the workspace root.
178
187
 
179
188
  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]`.
@@ -1,5 +1,13 @@
1
1
  # scoot
2
2
 
3
+ [![PyPI](https://img.shields.io/pypi/v/scootcli.svg?label=PyPI)](https://pypi.org/project/scootcli/)
4
+ [![tests](https://github.com/sergenes/scootcli/actions/workflows/tests.yml/badge.svg)](https://github.com/sergenes/scootcli/actions/workflows/tests.yml)
5
+ [![Python 3.9+](https://img.shields.io/badge/python-3.9%2B-blue.svg)](https://www.python.org/downloads/)
6
+ [![Dependencies: none](https://img.shields.io/badge/dependencies-none-brightgreen.svg)](#install-options)
7
+ [![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey.svg)](#install-options)
8
+ [![Providers](https://img.shields.io/badge/providers-OpenAI%20%7C%20Ollama%20%7C%20Anthropic-orange.svg)](#providers-and-models)
9
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
10
+
3
11
  ```
4
12
  ╭───╮ scoot: a tiny coding agent that goes where you point it.
5
13
  │o o│
@@ -10,7 +18,7 @@
10
18
 
11
19
  `scoot` is a terminal coding agent in plain Python.
12
20
  You type what you want in natural language; it reads, searches, edits, and runs things in your repo, asking before anything risky.
13
- It talks to official model APIs (OpenAI today, a local Ollama for free, Anthropic next) and has **zero third-party dependencies**: the whole tool is the Python standard library, and it ships as a single-file zipapp as well as a wheel.
21
+ It talks to official model APIs (OpenAI, Anthropic, and a local Ollama for free) and has **zero third-party dependencies**: the whole tool is the Python standard library, and it ships as a single-file zipapp as well as a wheel.
14
22
 
15
23
  ## Where this comes from
16
24
 
@@ -63,7 +71,7 @@ A bare name means the default provider, which is the first provider that has a k
63
71
  |---|---|---|---|
64
72
  | `openai` | OpenAI Responses API | `OPENAI_API_KEY` | `gpt-5.3-codex` |
65
73
  | `ollama` | local Ollama, Responses API | none | `llama3.2` |
66
- | `anthropic` | Messages API | `ANTHROPIC_API_KEY` | arrives in 0.2.0 |
74
+ | `anthropic` | Anthropic Messages API | `ANTHROPIC_API_KEY` | `claude-opus-5` |
67
75
 
68
76
  ```bash
69
77
  scoot models # every configured provider, grouped
@@ -73,7 +81,8 @@ scoot --model auto "..." # pick a model per prompt from the live lis
73
81
  ```
74
82
 
75
83
  Inside the REPL, `/model <provider/model>` switches and is remembered for the next launch; `/model default` goes back to the provider's preferred model.
76
- `SCOOT_EFFORT` (`low` | `medium` | `high` | `xhigh`, default `medium`) sets the reasoning effort for models that take it.
84
+ `SCOOT_EFFORT` (`low` | `medium` | `high` | `xhigh`, default `medium`) sets the reasoning effort for models that take it, on both OpenAI and Anthropic.
85
+ On Claude Opus 5 the server-side refusal fallback is requested by default, so a declined request is retried on another Claude model inside the same call; `SCOOT_ANTHROPIC_FALLBACKS=0` turns that off.
77
86
 
78
87
  A note on how this is built: scoot speaks the OpenAI chat format internally and translates at the edge.
79
88
  Adding a provider that speaks that format is one registry row; a different wire format is one small adapter (`src/scootcli/providers/`).
@@ -148,7 +157,7 @@ Without a TTY, scoot falls back to a plain prompt with no bar and no dock.
148
157
 
149
158
  ### Tools and approvals
150
159
 
151
- The agent has seven tools: `read_file`, `list_dir`, `search`, `write_file`, `edit_file`, `run_shell`, and `update_plan` (a progress checklist for multi-step work).
160
+ 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).
152
161
  Every tool is sandboxed to the workspace root.
153
162
 
154
163
  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]`.
@@ -1,4 +1,4 @@
1
1
  """scoot: a tiny coding agent that goes where you point it."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.0"
4
4
 
@@ -178,6 +178,7 @@ class Config:
178
178
  images: bool = True # detect dropped image paths + describe them with a vision model (M22)
179
179
  vision_model: str = "auto" # provider/model for image descriptions, or "auto" → pick a capable one
180
180
  image_max_bytes: int = 4 * 1024 * 1024 # per-image cap (no downscale without a 3rd-party lib)
181
+ editor: str = "idea" # external editor for the open_editor tool: idea | vscode
181
182
  env_files: Tuple[str, ...] = () # the .env files that were read, in load order (shown by /status)
182
183
  logo: bool = True # mascot in the launch banner + status-bar face (--no-logo / SCOOT_LOGO / /logo)
183
184
  emoji: bool = True # 🛴 transcript label; off → ⏺ for terminals without an emoji font (--no-emoji)
@@ -214,6 +215,7 @@ class Config:
214
215
  images=_as_bool(get("SCOOT_IMAGES", "true")),
215
216
  vision_model=get("SCOOT_VISION_MODEL", "auto"),
216
217
  image_max_bytes=int(get("SCOOT_IMAGE_MAX_BYTES", str(4 * 1024 * 1024))),
218
+ editor=get("SCOOT_EDITOR", "idea").strip().lower(),
217
219
  logo=_resolve_logo_setting(get("SCOOT_LOGO")),
218
220
  emoji=_as_bool(get("SCOOT_EMOJI", "true")),
219
221
  )
@@ -0,0 +1,354 @@
1
+ """Anthropic Messages API adapter (``POST {base}/messages``).
2
+
3
+ Anthropic has one chat endpoint and it is the same kind of API as OpenAI's Responses: stateless, a
4
+ turn is a list of typed content blocks, opaque reasoning (``thinking`` blocks) is echoed back to the
5
+ producing model. Translation from scoot's neutral format:
6
+
7
+ * ``system`` messages → the top-level ``system`` string
8
+ * ``user`` messages → ``text`` / ``image`` (base64) blocks
9
+ * ``assistant`` messages → ``text`` + ``tool_use`` blocks, or the verbatim ``provider_items`` blocks
10
+ (thinking included) when this very model produced them
11
+ * consecutive ``tool`` messages → one ``user`` message of ``tool_result`` blocks, never split
12
+ * tool schemas → ``{"name", "description", "input_schema"}``
13
+
14
+ Model-specific request rules (from the Claude API reference, 2026-06):
15
+ * ``max_tokens`` is required; we default it generously because we stream.
16
+ * Thinking: on by default on Claude Opus 5, Sonnet 5, and Fable, so no parameter is sent there;
17
+ Opus 4.6/4.7/4.8 and Sonnet 4.6 need ``{"type": "adaptive"}`` explicitly; Haiku gets nothing.
18
+ * ``output_config.effort`` carries ``SCOOT_EFFORT`` (``xhigh`` is downgraded to ``high`` on 4.6).
19
+ * Sampling parameters (``temperature``) are rejected on 4.6+, so none are sent.
20
+ * ``cache_control: {"type": "ephemeral"}`` at the top level caches the stable prefix (system prompt
21
+ and tool list) automatically.
22
+ * On Claude Opus 5 and Fable the server-side refusal fallback is requested by default
23
+ (``fallbacks: "default"`` with its beta header); ``SCOOT_ANTHROPIC_FALLBACKS=0`` turns it off.
24
+ * ``stop_reason: "refusal"`` becomes a clear error instead of an empty answer.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import os
31
+ from typing import Callable, List, Optional, Tuple
32
+
33
+ from ..errors import ApiError
34
+ from .base import (
35
+ BaseProvider,
36
+ ChatRequest,
37
+ ChatResult,
38
+ decode_json,
39
+ finish_stream,
40
+ iter_sse_json,
41
+ normalize_usage,
42
+ raise_for_status,
43
+ )
44
+
45
+ API_VERSION = "2023-06-01"
46
+ FALLBACK_BETA = "server-side-fallback-2026-07-01"
47
+ DEFAULT_MAX_TOKENS = 32000
48
+
49
+ # Models where omitting ``thinking`` means *no* thinking: send adaptive explicitly.
50
+ _EXPLICIT_ADAPTIVE = ("claude-opus-4-6", "claude-opus-4-7", "claude-opus-4-8", "claude-sonnet-4-6")
51
+ # Models that get the server-side refusal fallback by default.
52
+ _FALLBACK_MODELS = ("claude-opus-5", "claude-fable", "claude-mythos")
53
+ # 4.6-generation models cap effort at ``high``.
54
+ _EFFORT_HIGH_MAX = ("claude-opus-4-6", "claude-sonnet-4-6")
55
+
56
+
57
+ def thinking_param(model: str) -> Optional[dict]:
58
+ m = (model or "").lower()
59
+ if any(m.startswith(p) for p in _EXPLICIT_ADAPTIVE):
60
+ return {"type": "adaptive"}
61
+ return None
62
+
63
+
64
+ def effort_param(model: str, effort: str) -> Optional[str]:
65
+ m = (model or "").lower()
66
+ e = (effort or "").strip().lower()
67
+ if not e or "haiku" in m:
68
+ return None
69
+ if e == "xhigh" and any(m.startswith(p) for p in _EFFORT_HIGH_MAX):
70
+ return "high"
71
+ return e if e in ("low", "medium", "high", "xhigh", "max") else None
72
+
73
+
74
+ def fallbacks_enabled(model: str) -> bool:
75
+ m = (model or "").lower()
76
+ if not any(m.startswith(p) for p in _FALLBACK_MODELS):
77
+ return False
78
+ return (os.environ.get("SCOOT_ANTHROPIC_FALLBACKS", "1").strip().lower()
79
+ not in ("0", "false", "no", "off"))
80
+
81
+
82
+ # ── request translation ─────────────────────────────────────────────────────────
83
+ def _image_block(url: str) -> dict:
84
+ if url.startswith("data:"):
85
+ head, _, data = url.partition(",")
86
+ media = head[len("data:"):].split(";")[0] or "image/png"
87
+ return {"type": "image", "source": {"type": "base64", "media_type": media, "data": data}}
88
+ return {"type": "image", "source": {"type": "url", "url": url}}
89
+
90
+
91
+ def _user_blocks(content) -> object:
92
+ if isinstance(content, str) or content is None:
93
+ return content or ""
94
+ blocks = []
95
+ for part in content:
96
+ if not isinstance(part, dict):
97
+ continue
98
+ ptype = part.get("type")
99
+ if ptype == "text":
100
+ blocks.append({"type": "text", "text": part.get("text", "")})
101
+ elif ptype == "image_url":
102
+ img = part.get("image_url") or {}
103
+ url = img.get("url") if isinstance(img, dict) else img
104
+ if url:
105
+ blocks.append(_image_block(url))
106
+ elif ptype in ("image", "document", "tool_result"):
107
+ blocks.append(part)
108
+ return blocks
109
+
110
+
111
+ def _arguments_dict(raw) -> dict:
112
+ if isinstance(raw, dict):
113
+ return raw
114
+ if not raw:
115
+ return {}
116
+ try:
117
+ parsed = json.loads(raw)
118
+ return parsed if isinstance(parsed, dict) else {}
119
+ except (json.JSONDecodeError, TypeError):
120
+ return {}
121
+
122
+
123
+ def translate_messages(messages: List[dict], qualified_model: str) -> Tuple[str, List[dict]]:
124
+ """Return ``(system, messages)`` for the Messages request."""
125
+ system: List[str] = []
126
+ out: List[dict] = []
127
+ pending_results: List[dict] = []
128
+
129
+ def flush_results() -> None:
130
+ if pending_results:
131
+ out.append({"role": "user", "content": list(pending_results)})
132
+ pending_results.clear()
133
+
134
+ for m in messages:
135
+ role = m.get("role")
136
+ if role in ("system", "developer"):
137
+ text = m.get("content")
138
+ if isinstance(text, list):
139
+ text = "\n".join(p.get("text", "") for p in text if isinstance(p, dict))
140
+ if text:
141
+ system.append(str(text))
142
+ continue
143
+ if role == "tool":
144
+ content = m.get("content")
145
+ if not isinstance(content, str):
146
+ content = json.dumps(content) if content is not None else ""
147
+ pending_results.append({"type": "tool_result", "tool_use_id": m.get("tool_call_id", ""),
148
+ "content": content})
149
+ continue
150
+ flush_results()
151
+ if role == "user":
152
+ out.append({"role": "user", "content": _user_blocks(m.get("content"))})
153
+ elif role == "assistant":
154
+ replay = m.get("provider_items")
155
+ if isinstance(replay, dict) and replay.get("model") == qualified_model and replay.get("items"):
156
+ out.append({"role": "assistant", "content": list(replay["items"])})
157
+ continue
158
+ blocks: List[dict] = []
159
+ text = m.get("content")
160
+ if isinstance(text, list):
161
+ blocks.extend(b for b in _user_blocks(text) if b.get("type") == "text")
162
+ elif text:
163
+ blocks.append({"type": "text", "text": str(text)})
164
+ for tc in m.get("tool_calls") or []:
165
+ fn = tc.get("function") or {}
166
+ blocks.append({"type": "tool_use", "id": tc.get("id", ""), "name": fn.get("name", ""),
167
+ "input": _arguments_dict(fn.get("arguments"))})
168
+ if blocks:
169
+ out.append({"role": "assistant", "content": blocks})
170
+ flush_results()
171
+ return "\n\n".join(system), out
172
+
173
+
174
+ def translate_tool(tool: dict) -> dict:
175
+ fn = tool.get("function") if isinstance(tool, dict) else None
176
+ if not isinstance(fn, dict):
177
+ return tool
178
+ return {
179
+ "name": fn.get("name", ""),
180
+ "description": fn.get("description", ""),
181
+ "input_schema": fn.get("parameters") or {"type": "object", "properties": {}},
182
+ }
183
+
184
+
185
+ # ── response parsing ────────────────────────────────────────────────────────────
186
+ _STOP = {"end_turn": "stop", "tool_use": "tool_calls", "max_tokens": "length", "stop_sequence": "stop",
187
+ "pause_turn": "stop", "refusal": "refusal"}
188
+
189
+
190
+ def total_input_usage(usage: Optional[dict]) -> dict:
191
+ """Anthropic's ``input_tokens`` excludes cached input; scoot's ``prompt_tokens`` is the whole prompt.
192
+
193
+ The context gauge and the session totals need the full size, so fold ``cache_read_input_tokens``
194
+ and ``cache_creation_input_tokens`` back in. The raw fields stay for cost analysis.
195
+ """
196
+ if not isinstance(usage, dict):
197
+ return {}
198
+ out = dict(usage)
199
+ total = (int(out.get("input_tokens") or 0) + int(out.get("cache_read_input_tokens") or 0)
200
+ + int(out.get("cache_creation_input_tokens") or 0))
201
+ out["prompt_tokens"] = total
202
+ return out
203
+
204
+
205
+ def build_result(blocks: List[dict], model: str, usage: Optional[dict], stop_reason: str,
206
+ stop_details: Optional[dict] = None, streamed_text: str = "") -> ChatResult:
207
+ if stop_reason == "refusal":
208
+ detail = (stop_details or {}).get("explanation") or (stop_details or {}).get("category") or ""
209
+ raise ApiError("the model declined this request" + (f": {detail}" if detail else ""),
210
+ code="refusal", hint="rephrase the request or switch models with /model")
211
+ texts = [b.get("text", "") for b in blocks if b.get("type") == "text"]
212
+ tool_calls = [{"id": b.get("id", ""), "type": "function",
213
+ "function": {"name": b.get("name", ""), "arguments": json.dumps(b.get("input") or {})}}
214
+ for b in blocks if b.get("type") == "tool_use"]
215
+ content = streamed_text or "".join(texts)
216
+ finish = "tool_calls" if tool_calls else _STOP.get(stop_reason, "stop")
217
+ message: dict = {"role": "assistant", "content": content}
218
+ if tool_calls:
219
+ message["tool_calls"] = tool_calls
220
+ if blocks:
221
+ message["provider_items"] = {"model": model, "items": blocks}
222
+ return ChatResult(content=content, model=model, tool_calls=tool_calls, finish_reason=finish,
223
+ usage=normalize_usage(total_input_usage(usage)), raw_message=message)
224
+
225
+
226
+ class _StreamState:
227
+ """Accumulates Messages SSE events into content blocks + live text."""
228
+
229
+ def __init__(self) -> None:
230
+ self.blocks: dict = {} # index -> block dict
231
+ self._json: dict = {} # index -> partial tool input JSON
232
+ self.text: List[str] = []
233
+ self.usage: dict = {}
234
+ self.model = ""
235
+ self.stop_reason = ""
236
+ self.stop_details: Optional[dict] = None
237
+ self.error: Optional[dict] = None
238
+
239
+ def add(self, obj: dict) -> str:
240
+ etype = obj.get("type", "")
241
+ if etype == "message_start":
242
+ msg = obj.get("message") or {}
243
+ self.model = msg.get("model") or self.model
244
+ self.usage.update(msg.get("usage") or {})
245
+ elif etype == "content_block_start":
246
+ idx = obj.get("index", 0)
247
+ block = dict(obj.get("content_block") or {})
248
+ if block.get("type") == "tool_use":
249
+ self._json[idx] = ""
250
+ block.setdefault("input", {})
251
+ self.blocks[idx] = block
252
+ elif etype == "content_block_delta":
253
+ idx = obj.get("index", 0)
254
+ block = self.blocks.setdefault(idx, {"type": "text", "text": ""})
255
+ delta = obj.get("delta") or {}
256
+ dtype = delta.get("type")
257
+ if dtype == "text_delta":
258
+ text = delta.get("text") or ""
259
+ block["text"] = block.get("text", "") + text
260
+ self.text.append(text)
261
+ return text
262
+ if dtype == "input_json_delta":
263
+ self._json[idx] = self._json.get(idx, "") + (delta.get("partial_json") or "")
264
+ elif dtype == "thinking_delta":
265
+ block["thinking"] = block.get("thinking", "") + (delta.get("thinking") or "")
266
+ elif dtype == "signature_delta":
267
+ block["signature"] = delta.get("signature") or block.get("signature", "")
268
+ elif etype == "content_block_stop":
269
+ idx = obj.get("index", 0)
270
+ if idx in self._json:
271
+ raw = self._json.pop(idx)
272
+ try:
273
+ self.blocks[idx]["input"] = json.loads(raw) if raw.strip() else {}
274
+ except json.JSONDecodeError:
275
+ self.blocks[idx]["input"] = {}
276
+ elif etype == "message_delta":
277
+ delta = obj.get("delta") or {}
278
+ self.stop_reason = delta.get("stop_reason") or self.stop_reason
279
+ if delta.get("stop_details"):
280
+ self.stop_details = delta["stop_details"]
281
+ self.usage.update(obj.get("usage") or {})
282
+ elif etype == "error":
283
+ self.error = obj.get("error") or {"message": "stream error"}
284
+ return ""
285
+
286
+ def ordered_blocks(self) -> List[dict]:
287
+ return [self.blocks[i] for i in sorted(self.blocks)]
288
+
289
+
290
+ class AnthropicProvider(BaseProvider):
291
+ wire = "anthropic"
292
+
293
+ # ── auth + headers ────────────────────────────────────────────────────────────
294
+ def _auth_token(self) -> str:
295
+ self._auth() # raises the same "no API key" error as the other providers
296
+ return "" # the key travels in x-api-key, not Authorization
297
+
298
+ def _headers_for(self, payload: Optional[dict]) -> List[str]:
299
+ headers = [f"x-api-key: {self._auth()}", f"anthropic-version: {API_VERSION}"]
300
+ if payload and payload.get("fallbacks"):
301
+ headers.append(f"anthropic-beta: {FALLBACK_BETA}")
302
+ return headers + self._extra_headers()
303
+
304
+ # ── request ───────────────────────────────────────────────────────────────────
305
+ def _payload(self, req: ChatRequest, stream: bool) -> dict:
306
+ system, messages = translate_messages(req.messages, self.qualified(req.model))
307
+ payload: dict = {
308
+ "model": req.model,
309
+ "max_tokens": req.max_tokens or DEFAULT_MAX_TOKENS,
310
+ "messages": messages,
311
+ "cache_control": {"type": "ephemeral"},
312
+ }
313
+ if system:
314
+ payload["system"] = system
315
+ if stream:
316
+ payload["stream"] = True
317
+ if req.tools:
318
+ payload["tools"] = [translate_tool(t) for t in req.tools]
319
+ payload["tool_choice"] = {"type": "auto"}
320
+ thinking = thinking_param(req.model)
321
+ if thinking:
322
+ payload["thinking"] = thinking
323
+ effort = effort_param(req.model, getattr(self.config, "effort", ""))
324
+ if effort:
325
+ payload["output_config"] = {"effort": effort}
326
+ if fallbacks_enabled(req.model):
327
+ payload["fallbacks"] = "default"
328
+ return payload
329
+
330
+ def _complete(self, req: ChatRequest) -> ChatResult:
331
+ payload = self._payload(req, stream=False)
332
+ status, body = self._http("POST", "/messages", payload, req.cancel_event)
333
+ data = decode_json(status, body)
334
+ if data.get("type") == "error" or data.get("error"):
335
+ raise_for_status(400, json.dumps({"error": data.get("error")}), self.name)
336
+ return build_result(data.get("content") or [], self.qualified(data.get("model") or req.model),
337
+ data.get("usage"), data.get("stop_reason") or "", data.get("stop_details"))
338
+
339
+ def _stream(self, req: ChatRequest, on_delta: Callable[[str], None]) -> ChatResult:
340
+ payload = self._payload(req, stream=True)
341
+ state = _StreamState()
342
+ holder: dict = {}
343
+ leftovers: List[str] = []
344
+ for obj in iter_sse_json(self._http_stream("/messages", payload, req.cancel_event), holder,
345
+ leftovers):
346
+ text = state.add(obj)
347
+ if text:
348
+ on_delta(text)
349
+ finish_stream(holder, leftovers, self.name)
350
+ if state.error:
351
+ raise_for_status(400, json.dumps({"error": state.error}), self.name)
352
+ raise ApiError(str(state.error))
353
+ return build_result(state.ordered_blocks(), self.qualified(state.model or req.model), state.usage,
354
+ state.stop_reason, state.stop_details, streamed_text="".join(state.text))
@@ -154,11 +154,12 @@ def raise_for_status(status: int, body: str, provider: str = "") -> None:
154
154
  hint=f"{who} issue; backing off and retrying")
155
155
  if status >= 400 or msg:
156
156
  low = (code + " " + msg).lower()
157
- if "context" in low and any(w in low for w in ("length", "exceed", "maximum", "token")):
157
+ if ("prompt is too long" in low or "too many tokens" in low
158
+ or ("context" in low and any(w in low for w in ("length", "exceed", "maximum", "token")))):
158
159
  raise ContextLengthError(msg or "context length exceeded", status=status, code=code,
159
160
  hint="run /compact to shrink context, then retry")
160
161
  if "model" in low and any(w in low for w in
161
- ("not supported", "not accessible", "not found", "unsupported",
162
+ ("not supported", "not accessible", "not found", "not_found", "unsupported",
162
163
  "does not exist", "not exist")):
163
164
  raise ModelUnavailableError(msg or "model unavailable", status=status, code=code,
164
165
  hint="switching to a supported model")
@@ -295,19 +296,27 @@ class BaseProvider:
295
296
  def _extra_headers(self) -> List[str]:
296
297
  return [f"{k}: {v}" for k, v in self.spec.extra_headers]
297
298
 
299
+ # Two hooks let an adapter change how the key travels: by default it is a Bearer token in
300
+ # ``Authorization``; Anthropic overrides both to send ``x-api-key`` plus its version header.
301
+ def _auth_token(self) -> str:
302
+ return self._auth()
303
+
304
+ def _headers_for(self, payload: Optional[dict]) -> List[str]:
305
+ return self._extra_headers()
306
+
298
307
  def _http(self, method: str, path: str, payload: Optional[dict], cancel_event) -> Tuple[int, str]:
299
308
  """One request, status-checked. Callers wrap it in :meth:`_with_retry` when appropriate."""
300
309
  status, body = self.transport.request(
301
- method, self._url(path), self._auth(), extra_headers=self._extra_headers(), body=payload,
302
- cancel_event=cancel_event,
310
+ method, self._url(path), self._auth_token(), extra_headers=self._headers_for(payload),
311
+ body=payload, cancel_event=cancel_event,
303
312
  )
304
313
  raise_for_status(status, body, self.name)
305
314
  return status, body
306
315
 
307
316
  def _http_stream(self, path: str, payload: dict, cancel_event) -> Iterator[str]:
308
317
  return self.transport.stream_request(
309
- "POST", self._url(path), self._auth(), extra_headers=self._extra_headers(), body=payload,
310
- cancel_event=cancel_event,
318
+ "POST", self._url(path), self._auth_token(), extra_headers=self._headers_for(payload),
319
+ body=payload, cancel_event=cancel_event,
311
320
  )
312
321
 
313
322
  # ── retry policy ─────────────────────────────────────────────────────────────
@@ -44,11 +44,13 @@ def load_builtins() -> None:
44
44
  global _loaded
45
45
  if _loaded:
46
46
  return
47
+ from .anthropic import AnthropicProvider
47
48
  from .openai_chat import OpenAIChatProvider
48
49
  from .openai_responses import OpenAIResponsesProvider
49
50
 
50
51
  _WIRES["openai_responses"] = OpenAIResponsesProvider
51
52
  _WIRES["openai_chat"] = OpenAIChatProvider
53
+ _WIRES["anthropic"] = AnthropicProvider
52
54
  register(ProviderSpec(
53
55
  name="openai",
54
56
  base_url="https://api.openai.com/v1",
@@ -69,6 +71,16 @@ def load_builtins() -> None:
69
71
  vision_models=("qwen2.5vl", "llama3.2-vision", "llava", "gemma3", "minicpm-v"),
70
72
  capabilities=frozenset({"tools", "streaming", "vision", "temperature"}),
71
73
  ))
74
+ register(ProviderSpec(
75
+ name="anthropic",
76
+ base_url="https://api.anthropic.com/v1",
77
+ wire="anthropic",
78
+ key_env=("ANTHROPIC_API_KEY",),
79
+ key_required=True,
80
+ preferred_models=("claude-opus-5", "claude-sonnet-5", "claude-opus-4-8", "claude-haiku-4-5"),
81
+ vision_models=("claude",),
82
+ capabilities=frozenset({"tools", "streaming", "vision", "reasoning"}),
83
+ ))
72
84
  _loaded = True
73
85
 
74
86
 
@@ -16,6 +16,13 @@ _FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
16
16
  _CLEAR_LINE = "\r\033[K"
17
17
 
18
18
 
19
+ def _fmt_elapsed(seconds: int) -> str:
20
+ """Compact elapsed-time label: ``8s`` or ``1m03s``."""
21
+ if seconds < 60:
22
+ return f"{seconds}s"
23
+ return f"{seconds // 60}m{seconds % 60:02d}s"
24
+
25
+
19
26
  class Status:
20
27
  """A threaded spinner with an updatable message and a persistent step hint."""
21
28
 
@@ -23,6 +30,7 @@ class Status:
23
30
  self.enabled = enabled and sys.stdout.isatty()
24
31
  self._message = ""
25
32
  self._hint = ""
33
+ self._started = 0.0
26
34
  self._thread = None
27
35
  self._stop = threading.Event()
28
36
  self._lock = threading.Lock()
@@ -30,6 +38,7 @@ class Status:
30
38
  def start(self, message: str = "thinking…", hint: str = "esc to stop") -> None:
31
39
  self._message = message
32
40
  self._hint = hint
41
+ self._started = time.time()
33
42
  if not self.enabled:
34
43
  return
35
44
  self._stop.clear()
@@ -47,9 +56,13 @@ class Status:
47
56
  while not self._stop.is_set():
48
57
  with self._lock:
49
58
  message, hint = self._message, self._hint
59
+ elapsed = int(time.time() - self._started) if self._started else 0
50
60
  frame = color(_FRAMES[i % len(_FRAMES)], "cyan")
61
+ timer = color(f" ({_fmt_elapsed(elapsed)})", "gray") if elapsed >= 3 else ""
62
+ if elapsed >= 30:
63
+ hint = "still working · ctrl+c to force" + (f" — {hint}" if hint else "")
51
64
  tail = color(f" · {hint}", "gray") if hint else ""
52
- sys.stdout.write(f"{_CLEAR_LINE} {frame} {message}{tail}")
65
+ sys.stdout.write(f"{_CLEAR_LINE} {frame} {message}{timer}{tail}")
53
66
  sys.stdout.flush()
54
67
  i += 1
55
68
  time.sleep(0.08)
@@ -62,6 +62,7 @@ def load_builtins() -> None:
62
62
  from . import write_file # noqa: F401
63
63
  from . import edit_file # noqa: F401
64
64
  from . import run_shell # noqa: F401
65
+ from . import open_editor # noqa: F401
65
66
  from . import update_plan # noqa: F401
66
67
 
67
68
  _loaded = True