scootcli 0.1.0__py3-none-any.whl

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 (71) hide show
  1. scootcli/__init__.py +4 -0
  2. scootcli/__main__.py +9 -0
  3. scootcli/activity.py +26 -0
  4. scootcli/agent.py +350 -0
  5. scootcli/approvals.py +167 -0
  6. scootcli/auth.py +59 -0
  7. scootcli/cli.py +276 -0
  8. scootcli/clipboard.py +89 -0
  9. scootcli/commands/__init__.py +56 -0
  10. scootcli/commands/approve.py +38 -0
  11. scootcli/commands/auth.py +107 -0
  12. scootcli/commands/base.py +31 -0
  13. scootcli/commands/compact.py +40 -0
  14. scootcli/commands/copy.py +23 -0
  15. scootcli/commands/exit.py +14 -0
  16. scootcli/commands/forget.py +28 -0
  17. scootcli/commands/help.py +29 -0
  18. scootcli/commands/init.py +50 -0
  19. scootcli/commands/logo.py +51 -0
  20. scootcli/commands/model.py +61 -0
  21. scootcli/commands/panel.py +28 -0
  22. scootcli/commands/reset.py +20 -0
  23. scootcli/commands/resume.py +31 -0
  24. scootcli/commands/save.py +29 -0
  25. scootcli/commands/sessions.py +42 -0
  26. scootcli/commands/status.py +59 -0
  27. scootcli/commands/verbosity.py +57 -0
  28. scootcli/commands/worktree.py +64 -0
  29. scootcli/commands/yolo.py +20 -0
  30. scootcli/config.py +241 -0
  31. scootcli/context.py +82 -0
  32. scootcli/credentials.py +79 -0
  33. scootcli/errors.py +87 -0
  34. scootcli/images.py +169 -0
  35. scootcli/keys.py +119 -0
  36. scootcli/lineeditor.py +577 -0
  37. scootcli/logo.py +116 -0
  38. scootcli/models.py +120 -0
  39. scootcli/panel.py +263 -0
  40. scootcli/preferences.py +87 -0
  41. scootcli/presets.py +38 -0
  42. scootcli/project.py +94 -0
  43. scootcli/prompts.py +100 -0
  44. scootcli/providers/__init__.py +20 -0
  45. scootcli/providers/base.py +370 -0
  46. scootcli/providers/openai_chat.py +142 -0
  47. scootcli/providers/openai_responses.py +248 -0
  48. scootcli/providers/registry.py +173 -0
  49. scootcli/rendering.py +86 -0
  50. scootcli/repl.py +801 -0
  51. scootcli/sessions.py +186 -0
  52. scootcli/status.py +71 -0
  53. scootcli/tools/__init__.py +68 -0
  54. scootcli/tools/base.py +152 -0
  55. scootcli/tools/edit_file.py +72 -0
  56. scootcli/tools/list_dir.py +47 -0
  57. scootcli/tools/read_file.py +56 -0
  58. scootcli/tools/run_shell.py +73 -0
  59. scootcli/tools/search.py +170 -0
  60. scootcli/tools/update_plan.py +104 -0
  61. scootcli/tools/write_file.py +61 -0
  62. scootcli/transport.py +312 -0
  63. scootcli/vision.py +167 -0
  64. scootcli/workspace.py +105 -0
  65. scootcli/worktree.py +114 -0
  66. scootcli-0.1.0.dist-info/METADATA +238 -0
  67. scootcli-0.1.0.dist-info/RECORD +71 -0
  68. scootcli-0.1.0.dist-info/WHEEL +5 -0
  69. scootcli-0.1.0.dist-info/entry_points.txt +2 -0
  70. scootcli-0.1.0.dist-info/licenses/LICENSE +21 -0
  71. scootcli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,142 @@
1
+ """OpenAI Chat Completions adapter (``POST {base}/chat/completions``).
2
+
3
+ Scoot's neutral format *is* this wire format, so translation is a pass-through apart from stripping
4
+ scoot-private keys. Kept for OpenAI-compatible endpoints that do not implement the Responses API
5
+ (``wire="openai_chat"`` on their registry row); nothing in the default set uses it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Callable, List
11
+
12
+ from ..errors import ApiError
13
+ from .base import (
14
+ BaseProvider,
15
+ ChatRequest,
16
+ ChatResult,
17
+ decode_json,
18
+ finish_stream,
19
+ iter_sse_json,
20
+ normalize_usage,
21
+ )
22
+
23
+ _PRIVATE_KEYS = ("provider_items",)
24
+
25
+
26
+ def strip_private(messages: List[dict]) -> List[dict]:
27
+ """Drop scoot-private keys the API would reject."""
28
+ out = []
29
+ for m in messages:
30
+ if any(k in m for k in _PRIVATE_KEYS):
31
+ m = {k: v for k, v in m.items() if k not in _PRIVATE_KEYS}
32
+ out.append(m)
33
+ return out
34
+
35
+
36
+ class StreamAccumulator:
37
+ """Reassembles a streamed chat completion from SSE ``choices[0].delta`` fragments."""
38
+
39
+ def __init__(self) -> None:
40
+ self._content: List[str] = []
41
+ self._tool_calls: dict = {} # index -> {id, type, function:{name, arguments}}
42
+ self.finish_reason = ""
43
+ self.model = ""
44
+ self.usage: dict = {}
45
+
46
+ def add(self, obj: dict) -> str:
47
+ """Fold one SSE chunk in; return any new content text to emit live."""
48
+ if obj.get("model"):
49
+ self.model = obj["model"]
50
+ if obj.get("usage"):
51
+ self.usage = obj["usage"]
52
+ choices = obj.get("choices") or []
53
+ if not choices:
54
+ return ""
55
+ choice = choices[0]
56
+ if choice.get("finish_reason"):
57
+ self.finish_reason = choice["finish_reason"]
58
+ delta = choice.get("delta") or {}
59
+ for tc in delta.get("tool_calls") or []:
60
+ idx = tc.get("index", 0)
61
+ slot = self._tool_calls.setdefault(
62
+ idx, {"id": "", "type": "function", "function": {"name": "", "arguments": ""}}
63
+ )
64
+ if tc.get("id"):
65
+ slot["id"] = tc["id"]
66
+ if tc.get("type"):
67
+ slot["type"] = tc["type"]
68
+ fn = tc.get("function") or {}
69
+ if fn.get("name"):
70
+ slot["function"]["name"] = fn["name"]
71
+ if fn.get("arguments"):
72
+ slot["function"]["arguments"] += fn["arguments"]
73
+ text = delta.get("content") or ""
74
+ if text:
75
+ self._content.append(text)
76
+ return text
77
+
78
+ def result(self, model_sent: str) -> ChatResult:
79
+ calls = [self._tool_calls[i] for i in sorted(self._tool_calls)]
80
+ content = "".join(self._content)
81
+ message: dict = {"role": "assistant", "content": content}
82
+ if calls:
83
+ message["tool_calls"] = calls
84
+ return ChatResult(content=content, model=self.model or model_sent, tool_calls=calls,
85
+ finish_reason=self.finish_reason, usage=normalize_usage(self.usage),
86
+ raw_message=message)
87
+
88
+
89
+ def parse_chat(data: dict, model_sent: str) -> ChatResult:
90
+ try:
91
+ choice = data["choices"][0]
92
+ message = choice.get("message", {})
93
+ except (KeyError, IndexError, TypeError) as exc:
94
+ raise ApiError(f"unexpected chat response shape: {str(data)[:200]}") from exc
95
+ return ChatResult(
96
+ content=message.get("content") or "",
97
+ model=data.get("model", model_sent),
98
+ tool_calls=message.get("tool_calls") or [],
99
+ finish_reason=choice.get("finish_reason", ""),
100
+ usage=normalize_usage(data.get("usage", {})),
101
+ raw_message=message,
102
+ )
103
+
104
+
105
+ class OpenAIChatProvider(BaseProvider):
106
+ wire = "openai_chat"
107
+
108
+ def _payload(self, req: ChatRequest, stream: bool) -> dict:
109
+ payload: dict = {"model": req.model, "messages": strip_private(req.messages)}
110
+ if req.temperature is not None and self.spec.has("temperature"):
111
+ payload["temperature"] = req.temperature
112
+ if req.max_tokens is not None:
113
+ payload["max_tokens"] = req.max_tokens
114
+ if req.tools:
115
+ payload["tools"] = req.tools
116
+ payload["tool_choice"] = req.tool_choice or "auto"
117
+ if stream:
118
+ payload["stream"] = True
119
+ payload["stream_options"] = {"include_usage": True}
120
+ return payload
121
+
122
+ def _complete(self, req: ChatRequest) -> ChatResult:
123
+ payload = self._payload(req, stream=False)
124
+ status, body = self._http("POST", "/chat/completions", payload, req.cancel_event)
125
+ result = parse_chat(decode_json(status, body), req.model)
126
+ result.model = self.qualified(result.model)
127
+ return result
128
+
129
+ def _stream(self, req: ChatRequest, on_delta: Callable[[str], None]) -> ChatResult:
130
+ payload = self._payload(req, stream=True)
131
+ acc = StreamAccumulator()
132
+ holder: dict = {}
133
+ leftovers: List[str] = []
134
+ for obj in iter_sse_json(self._http_stream("/chat/completions", payload, req.cancel_event),
135
+ holder, leftovers):
136
+ text = acc.add(obj)
137
+ if text:
138
+ on_delta(text)
139
+ finish_stream(holder, leftovers, self.name)
140
+ result = acc.result(req.model)
141
+ result.model = self.qualified(result.model)
142
+ return result
@@ -0,0 +1,248 @@
1
+ """OpenAI Responses API adapter (``POST {base}/responses``).
2
+
3
+ This is the wire OpenAI recommends for new work and the only one that serves its Codex models. Ollama
4
+ (0.32+) implements the same endpoint, so one adapter covers both. Translation from scoot's neutral
5
+ format:
6
+
7
+ * ``system`` messages → ``instructions``
8
+ * ``user`` messages → message items (``input_text`` / ``input_image`` parts)
9
+ * ``assistant`` messages → an ``output_text`` message item plus one ``function_call`` item per tool
10
+ call, or, when the message carries ``provider_items`` produced by this very model, those items are
11
+ replayed verbatim (that is how encrypted ``reasoning`` items travel across tool calls)
12
+ * ``tool`` messages → ``function_call_output`` items
13
+ * tool schemas → flat ``{"type": "function", "name", "description", "parameters"}``
14
+
15
+ Requests are stateless (``store: false``); scoot replays history itself.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from typing import Callable, List, Optional, Tuple
22
+
23
+ from ..errors import ApiError
24
+ from .base import (
25
+ BaseProvider,
26
+ ChatRequest,
27
+ ChatResult,
28
+ decode_json,
29
+ finish_stream,
30
+ iter_sse_json,
31
+ normalize_usage,
32
+ raise_for_status,
33
+ )
34
+
35
+ # Models that accept the ``reasoning`` parameter (others reject it with a 400).
36
+ _REASONING_PREFIXES = ("gpt-5", "o1", "o3", "o4", "codex")
37
+
38
+
39
+ def is_reasoning_model(model: str) -> bool:
40
+ m = (model or "").lower()
41
+ return any(m.startswith(p) or p in m for p in _REASONING_PREFIXES)
42
+
43
+
44
+ # ── request translation ─────────────────────────────────────────────────────────
45
+ def _content_parts(content, role: str) -> object:
46
+ """Neutral content (str or OpenAI-chat parts) → Responses content for ``role``."""
47
+ if isinstance(content, str) or content is None:
48
+ return content or ""
49
+ text_type = "output_text" if role == "assistant" else "input_text"
50
+ parts = []
51
+ for part in content:
52
+ if not isinstance(part, dict):
53
+ continue
54
+ ptype = part.get("type")
55
+ if ptype == "text":
56
+ parts.append({"type": text_type, "text": part.get("text", "")})
57
+ elif ptype == "image_url":
58
+ img = part.get("image_url") or {}
59
+ url = img.get("url") if isinstance(img, dict) else img
60
+ item = {"type": "input_image", "image_url": url}
61
+ if isinstance(img, dict) and img.get("detail"):
62
+ item["detail"] = img["detail"]
63
+ parts.append(item)
64
+ elif ptype in ("input_text", "output_text", "input_image"):
65
+ parts.append(part)
66
+ return parts
67
+
68
+
69
+ def _arguments(raw) -> str:
70
+ if isinstance(raw, str):
71
+ return raw
72
+ return json.dumps(raw or {})
73
+
74
+
75
+ def translate_messages(messages: List[dict], qualified_model: str) -> Tuple[str, List[dict]]:
76
+ """Return ``(instructions, input_items)`` for the Responses request."""
77
+ instructions: List[str] = []
78
+ items: List[dict] = []
79
+ for m in messages:
80
+ role = m.get("role")
81
+ if role in ("system", "developer"):
82
+ text = m.get("content")
83
+ if isinstance(text, list):
84
+ text = "\n".join(p.get("text", "") for p in text if isinstance(p, dict))
85
+ if text:
86
+ instructions.append(str(text))
87
+ elif role == "user":
88
+ items.append({"role": "user", "content": _content_parts(m.get("content"), "user")})
89
+ elif role == "assistant":
90
+ replay = m.get("provider_items")
91
+ if isinstance(replay, dict) and replay.get("model") == qualified_model and replay.get("items"):
92
+ items.extend(replay["items"])
93
+ continue
94
+ text = m.get("content")
95
+ if isinstance(text, list):
96
+ items.append({"role": "assistant", "content": _content_parts(text, "assistant")})
97
+ elif text:
98
+ items.append({"role": "assistant",
99
+ "content": [{"type": "output_text", "text": str(text)}]})
100
+ for tc in m.get("tool_calls") or []:
101
+ fn = tc.get("function") or {}
102
+ items.append({
103
+ "type": "function_call",
104
+ "call_id": tc.get("id", ""),
105
+ "name": fn.get("name", ""),
106
+ "arguments": _arguments(fn.get("arguments")),
107
+ })
108
+ elif role == "tool":
109
+ content = m.get("content")
110
+ if not isinstance(content, str):
111
+ content = json.dumps(content) if content is not None else ""
112
+ items.append({"type": "function_call_output", "call_id": m.get("tool_call_id", ""),
113
+ "output": content})
114
+ return "\n\n".join(instructions), items
115
+
116
+
117
+ def translate_tool(tool: dict) -> dict:
118
+ """Neutral ``{"type": "function", "function": {...}}`` → flat Responses tool definition."""
119
+ fn = tool.get("function") if isinstance(tool, dict) else None
120
+ if not isinstance(fn, dict):
121
+ return tool
122
+ return {
123
+ "type": "function",
124
+ "name": fn.get("name", ""),
125
+ "description": fn.get("description", ""),
126
+ "parameters": fn.get("parameters") or {"type": "object", "properties": {}},
127
+ }
128
+
129
+
130
+ # ── response parsing ────────────────────────────────────────────────────────────
131
+ def _text_of(item: dict) -> str:
132
+ parts = item.get("content") or []
133
+ if isinstance(parts, str):
134
+ return parts
135
+ return "".join(p.get("text", "") for p in parts if isinstance(p, dict) and p.get("type") == "output_text")
136
+
137
+
138
+ def _tool_call_of(item: dict) -> dict:
139
+ return {
140
+ "id": item.get("call_id") or item.get("id") or "",
141
+ "type": "function",
142
+ "function": {"name": item.get("name", ""), "arguments": item.get("arguments") or "{}"},
143
+ }
144
+
145
+
146
+ def build_result(items: List[dict], model: str, usage: Optional[dict], status: str,
147
+ streamed_text: str = "") -> ChatResult:
148
+ """Fold output items into a :class:`ChatResult`; ``provider_items`` keeps them for replay."""
149
+ texts = [_text_of(it) for it in items if it.get("type") == "message"]
150
+ tool_calls = [_tool_call_of(it) for it in items if it.get("type") == "function_call"]
151
+ content = streamed_text or "".join(texts)
152
+ if tool_calls:
153
+ finish = "tool_calls"
154
+ elif status == "incomplete":
155
+ finish = "length"
156
+ else:
157
+ finish = "stop"
158
+ message: dict = {"role": "assistant", "content": content}
159
+ if tool_calls:
160
+ message["tool_calls"] = tool_calls
161
+ if items:
162
+ message["provider_items"] = {"model": model, "items": items}
163
+ return ChatResult(content=content, model=model, tool_calls=tool_calls, finish_reason=finish,
164
+ usage=normalize_usage(usage), raw_message=message)
165
+
166
+
167
+ class _StreamState:
168
+ """Accumulates Responses SSE events into output items + live text."""
169
+
170
+ def __init__(self) -> None:
171
+ self.items: List[dict] = []
172
+ self.text: List[str] = []
173
+ self.usage: dict = {}
174
+ self.model = ""
175
+ self.status = ""
176
+ self.error: Optional[dict] = None
177
+
178
+ def add(self, obj: dict) -> str:
179
+ etype = obj.get("type", "")
180
+ if etype == "response.output_text.delta":
181
+ delta = obj.get("delta") or ""
182
+ if delta:
183
+ self.text.append(delta)
184
+ return delta
185
+ if etype == "response.output_item.done":
186
+ item = obj.get("item")
187
+ if isinstance(item, dict):
188
+ self.items.append(item)
189
+ elif etype in ("response.completed", "response.incomplete", "response.failed"):
190
+ resp = obj.get("response") or {}
191
+ self.usage = resp.get("usage") or self.usage
192
+ self.model = resp.get("model") or self.model
193
+ self.status = resp.get("status") or etype.rsplit(".", 1)[-1]
194
+ if resp.get("error"):
195
+ self.error = resp["error"]
196
+ if not self.items and resp.get("output"):
197
+ self.items = list(resp["output"])
198
+ elif etype == "error":
199
+ self.error = {"message": obj.get("message") or obj.get("error") or "stream error",
200
+ "code": obj.get("code", "")}
201
+ return ""
202
+
203
+
204
+ class OpenAIResponsesProvider(BaseProvider):
205
+ wire = "openai_responses"
206
+
207
+ def _payload(self, req: ChatRequest, stream: bool) -> dict:
208
+ instructions, items = translate_messages(req.messages, self.qualified(req.model))
209
+ payload: dict = {"model": req.model, "input": items, "store": False, "stream": stream}
210
+ if instructions:
211
+ payload["instructions"] = instructions
212
+ if req.tools:
213
+ payload["tools"] = [translate_tool(t) for t in req.tools]
214
+ payload["tool_choice"] = req.tool_choice or "auto"
215
+ if req.max_tokens:
216
+ payload["max_output_tokens"] = req.max_tokens
217
+ if req.temperature is not None and self.spec.has("temperature"):
218
+ payload["temperature"] = req.temperature
219
+ effort = getattr(self.config, "effort", "") or ""
220
+ if self.spec.has("reasoning") and is_reasoning_model(req.model) and effort:
221
+ payload["reasoning"] = {"effort": effort}
222
+ return payload
223
+
224
+ def _complete(self, req: ChatRequest) -> ChatResult:
225
+ payload = self._payload(req, stream=False)
226
+ status, body = self._http("POST", "/responses", payload, req.cancel_event)
227
+ data = decode_json(status, body)
228
+ if data.get("error"):
229
+ raise_for_status(400, json.dumps({"error": data["error"]}), self.name)
230
+ return build_result(data.get("output") or [], self.qualified(data.get("model") or req.model),
231
+ data.get("usage"), data.get("status", ""))
232
+
233
+ def _stream(self, req: ChatRequest, on_delta: Callable[[str], None]) -> ChatResult:
234
+ payload = self._payload(req, stream=True)
235
+ state = _StreamState()
236
+ holder: dict = {}
237
+ leftovers: List[str] = []
238
+ for obj in iter_sse_json(self._http_stream("/responses", payload, req.cancel_event), holder,
239
+ leftovers):
240
+ text = state.add(obj)
241
+ if text:
242
+ on_delta(text)
243
+ finish_stream(holder, leftovers, self.name)
244
+ if state.error:
245
+ raise_for_status(400, json.dumps({"error": state.error}), self.name)
246
+ raise ApiError(str(state.error))
247
+ return build_result(state.items, self.qualified(state.model or req.model), state.usage,
248
+ state.status, streamed_text="".join(state.text))
@@ -0,0 +1,173 @@
1
+ """Provider registry and the pool the application holds.
2
+
3
+ Adding a provider is one :func:`register` call with a :class:`ProviderSpec`; the wire adapters are
4
+ selected by ``spec.wire``. Base URLs can be overridden per provider with ``SCOOT_<NAME>_BASE_URL``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from dataclasses import replace
11
+ from typing import Dict, List, Optional
12
+
13
+ from ..errors import ConfigError, ScootError
14
+ from .base import BaseProvider, ChatResult, ModelInfo, ProviderSpec, qualify, split_model_id
15
+
16
+ _SPECS: Dict[str, ProviderSpec] = {}
17
+ _WIRES: Dict[str, type] = {}
18
+
19
+
20
+ def register(spec: ProviderSpec) -> None:
21
+ _SPECS[spec.name] = spec
22
+
23
+
24
+ def get(name: str) -> Optional[ProviderSpec]:
25
+ load_builtins()
26
+ return _SPECS.get(name)
27
+
28
+
29
+ def names() -> List[str]:
30
+ load_builtins()
31
+ return list(_SPECS)
32
+
33
+
34
+ def all_specs() -> List[ProviderSpec]:
35
+ load_builtins()
36
+ return list(_SPECS.values())
37
+
38
+
39
+ _loaded = False
40
+
41
+
42
+ def load_builtins() -> None:
43
+ """Register the built-in providers and wire adapters (idempotent)."""
44
+ global _loaded
45
+ if _loaded:
46
+ return
47
+ from .openai_chat import OpenAIChatProvider
48
+ from .openai_responses import OpenAIResponsesProvider
49
+
50
+ _WIRES["openai_responses"] = OpenAIResponsesProvider
51
+ _WIRES["openai_chat"] = OpenAIChatProvider
52
+ register(ProviderSpec(
53
+ name="openai",
54
+ base_url="https://api.openai.com/v1",
55
+ wire="openai_responses",
56
+ key_env=("OPENAI_API_KEY",),
57
+ key_required=True,
58
+ preferred_models=("gpt-5.3-codex", "gpt-5.5", "gpt-5.4", "gpt-5", "gpt-4.1"),
59
+ vision_models=("gpt-5.3-codex", "gpt-5", "gpt-4.1", "gpt-4o"),
60
+ capabilities=frozenset({"tools", "streaming", "vision", "reasoning"}),
61
+ ))
62
+ register(ProviderSpec(
63
+ name="ollama",
64
+ base_url="http://localhost:11434/v1",
65
+ wire="openai_responses",
66
+ key_env=("OLLAMA_API_KEY",),
67
+ key_required=False,
68
+ preferred_models=("llama3.2", "qwen3", "llama3.1", "mistral"),
69
+ vision_models=("qwen2.5vl", "llama3.2-vision", "llava", "gemma3", "minicpm-v"),
70
+ capabilities=frozenset({"tools", "streaming", "vision", "temperature"}),
71
+ ))
72
+ _loaded = True
73
+
74
+
75
+ def base_url_for(spec: ProviderSpec) -> str:
76
+ override = os.environ.get(f"SCOOT_{spec.name.upper()}_BASE_URL", "").strip()
77
+ return override or spec.base_url
78
+
79
+
80
+ def make_provider(name: str, config, transport=None) -> BaseProvider:
81
+ """Instantiate the adapter for ``name`` with any base-URL override applied."""
82
+ spec = get(name)
83
+ if spec is None:
84
+ raise ConfigError(f"unknown provider '{name}'", hint=f"known providers: {', '.join(names())}")
85
+ cls = _WIRES.get(spec.wire)
86
+ if cls is None:
87
+ raise ConfigError(f"provider '{name}' uses unknown wire '{spec.wire}'")
88
+ return cls(replace(spec, base_url=base_url_for(spec)), config, transport=transport)
89
+
90
+
91
+ def default_provider_name(config) -> str:
92
+ """``SCOOT_PROVIDER`` if set, else the first provider with a key configured, else ``ollama``."""
93
+ from ..auth import is_configured
94
+
95
+ wanted = (getattr(config, "provider", "") or "").strip().lower()
96
+ if wanted:
97
+ if get(wanted) is None:
98
+ raise ConfigError(f"unknown provider '{wanted}'", hint=f"known providers: {', '.join(names())}")
99
+ return wanted
100
+ for spec in all_specs():
101
+ if spec.key_required and is_configured(spec):
102
+ return spec.name
103
+ return "ollama"
104
+
105
+
106
+ def fallback_model(config) -> str:
107
+ """The qualified model ``auto`` resolves to before any live model list is known."""
108
+ name = default_provider_name(config)
109
+ spec = get(name)
110
+ head = spec.preferred_models[0] if spec and spec.preferred_models else "default"
111
+ return qualify(name, head)
112
+
113
+
114
+ class ProviderPool:
115
+ """Holds one adapter per provider, built lazily, and dispatches by the ``provider/`` prefix.
116
+
117
+ Implements the same ``chat`` / ``chat_stream`` / ``list_models`` surface as a single provider, so
118
+ consumers never care whether one or many providers are in play. This is also where a routing
119
+ provider would plug in later: a router is just another entry the pool can dispatch to.
120
+ """
121
+
122
+ def __init__(self, config, transport=None):
123
+ self.config = config
124
+ self._transport = transport
125
+ self._providers: Dict[str, BaseProvider] = {}
126
+ self.list_errors: Dict[str, str] = {}
127
+
128
+ @property
129
+ def default_name(self) -> str:
130
+ return default_provider_name(self.config)
131
+
132
+ @property
133
+ def spec(self) -> ProviderSpec:
134
+ return self.provider(self.default_name).spec
135
+
136
+ def provider(self, name: Optional[str] = None) -> BaseProvider:
137
+ name = name or self.default_name
138
+ if name not in self._providers:
139
+ self._providers[name] = make_provider(name, self.config, transport=self._transport)
140
+ return self._providers[name]
141
+
142
+ def for_model(self, model: Optional[str]) -> "tuple[BaseProvider, str]":
143
+ """Resolve ``provider/model`` (or a bare name) to ``(adapter, bare_model)``."""
144
+ head, bare = split_model_id(model)
145
+ if head and get(head) is None:
146
+ raise ConfigError(f"unknown provider '{head}' in model '{model}'",
147
+ hint=f"known providers: {', '.join(names())}")
148
+ return self.provider(head), bare
149
+
150
+ def chat(self, messages, model=None, **kwargs) -> ChatResult:
151
+ p, bare = self.for_model(model or self.config.resolve_model())
152
+ return p.chat(messages, model=bare, **kwargs)
153
+
154
+ def chat_stream(self, messages, model=None, **kwargs) -> ChatResult:
155
+ p, bare = self.for_model(model or self.config.resolve_model())
156
+ return p.chat_stream(messages, model=bare, **kwargs)
157
+
158
+ def list_models(self, provider: Optional[str] = None, cancel_event=None) -> List[ModelInfo]:
159
+ """Models from one provider, or from every configured provider (failures are recorded, not raised)."""
160
+ from ..auth import is_configured
161
+
162
+ if provider:
163
+ return self.provider(provider).list_models(cancel_event=cancel_event)
164
+ out: List[ModelInfo] = []
165
+ self.list_errors = {}
166
+ for spec in all_specs():
167
+ if not is_configured(spec):
168
+ continue
169
+ try:
170
+ out.extend(self.provider(spec.name).list_models(cancel_event=cancel_event))
171
+ except ScootError as exc:
172
+ self.list_errors[spec.name] = str(exc)
173
+ return out
scootcli/rendering.py ADDED
@@ -0,0 +1,86 @@
1
+ """Minimal stdlib rendering helpers: ANSI colors + secret redaction. No third-party deps."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ import os
7
+ import re
8
+ import sys
9
+
10
+ _COLOR_ENABLED = sys.stdout.isatty() and os.environ.get("NO_COLOR") is None
11
+
12
+ _CODES = {
13
+ "reset": "\033[0m",
14
+ "bold": "\033[1m",
15
+ "dim": "\033[2m",
16
+ "red": "\033[31m",
17
+ "green": "\033[32m",
18
+ "yellow": "\033[33m",
19
+ "blue": "\033[34m",
20
+ "magenta": "\033[35m",
21
+ "cyan": "\033[36m",
22
+ "gray": "\033[90m",
23
+ }
24
+
25
+
26
+ def color(text: str, *styles: str) -> str:
27
+ """Wrap ``text`` in the given ANSI styles (no-op when color is disabled)."""
28
+ if not _COLOR_ENABLED or not styles:
29
+ return text
30
+ prefix = "".join(_CODES.get(s, "") for s in styles)
31
+ return f"{prefix}{text}{_CODES['reset']}"
32
+
33
+
34
+ # Redact anything that looks like a token/secret before printing (defense in depth): provider API
35
+ # keys (OpenAI ``sk-…``, Anthropic ``sk-ant-…``), GitHub tokens, Bearer values, and JWTs. Bearer values
36
+ # may contain separators, so the whole non-whitespace run is masked.
37
+ _SECRET_RE = re.compile(
38
+ r"(sk-[A-Za-z0-9_\-]{12,}|gh[opsu]_[A-Za-z0-9]{8,}|Bearer\s+\S+|tid=\S+|eyJ[A-Za-z0-9._\-]{20,})"
39
+ )
40
+
41
+
42
+ def redact(text: str) -> str:
43
+ """Mask token-like substrings so secrets never reach the terminal/logs."""
44
+ return _SECRET_RE.sub("<redacted>", text)
45
+
46
+
47
+ def eprint(*args, **kwargs) -> None:
48
+ """Print to stderr."""
49
+ print(*args, file=sys.stderr, **kwargs)
50
+
51
+
52
+ def diff_stats(old: str, new: str) -> "tuple[int, int]":
53
+ """Return ``(added, removed)`` line counts between two texts."""
54
+ added = removed = 0
55
+ for line in difflib.unified_diff(old.splitlines(), new.splitlines(), lineterm=""):
56
+ if line.startswith("+") and not line.startswith("+++"):
57
+ added += 1
58
+ elif line.startswith("-") and not line.startswith("---"):
59
+ removed += 1
60
+ return added, removed
61
+
62
+
63
+ def unified_diff(old: str, new: str, path: str = "", context: int = 3) -> str:
64
+ """Return a colorized unified diff between ``old`` and ``new``."""
65
+ from_name = f"a/{path}" if path else "old"
66
+ to_name = f"b/{path}" if path else "new"
67
+ lines = difflib.unified_diff(
68
+ old.splitlines(), new.splitlines(),
69
+ fromfile=from_name, tofile=to_name, lineterm="", n=context,
70
+ )
71
+ out = []
72
+ for line in lines:
73
+ if line.startswith("+++") or line.startswith("---"):
74
+ out.append(color(line, "bold"))
75
+ elif line.startswith("@@"):
76
+ out.append(color(line, "cyan"))
77
+ elif line.startswith("+"):
78
+ out.append(color(line, "green"))
79
+ elif line.startswith("-"):
80
+ out.append(color(line, "red"))
81
+ else:
82
+ out.append(color(line, "gray"))
83
+ return "\n".join(out)
84
+
85
+
86
+