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.
- scootcli/__init__.py +4 -0
- scootcli/__main__.py +9 -0
- scootcli/activity.py +26 -0
- scootcli/agent.py +350 -0
- scootcli/approvals.py +167 -0
- scootcli/auth.py +59 -0
- scootcli/cli.py +276 -0
- scootcli/clipboard.py +89 -0
- scootcli/commands/__init__.py +56 -0
- scootcli/commands/approve.py +38 -0
- scootcli/commands/auth.py +107 -0
- scootcli/commands/base.py +31 -0
- scootcli/commands/compact.py +40 -0
- scootcli/commands/copy.py +23 -0
- scootcli/commands/exit.py +14 -0
- scootcli/commands/forget.py +28 -0
- scootcli/commands/help.py +29 -0
- scootcli/commands/init.py +50 -0
- scootcli/commands/logo.py +51 -0
- scootcli/commands/model.py +61 -0
- scootcli/commands/panel.py +28 -0
- scootcli/commands/reset.py +20 -0
- scootcli/commands/resume.py +31 -0
- scootcli/commands/save.py +29 -0
- scootcli/commands/sessions.py +42 -0
- scootcli/commands/status.py +59 -0
- scootcli/commands/verbosity.py +57 -0
- scootcli/commands/worktree.py +64 -0
- scootcli/commands/yolo.py +20 -0
- scootcli/config.py +241 -0
- scootcli/context.py +82 -0
- scootcli/credentials.py +79 -0
- scootcli/errors.py +87 -0
- scootcli/images.py +169 -0
- scootcli/keys.py +119 -0
- scootcli/lineeditor.py +577 -0
- scootcli/logo.py +116 -0
- scootcli/models.py +120 -0
- scootcli/panel.py +263 -0
- scootcli/preferences.py +87 -0
- scootcli/presets.py +38 -0
- scootcli/project.py +94 -0
- scootcli/prompts.py +100 -0
- scootcli/providers/__init__.py +20 -0
- scootcli/providers/base.py +370 -0
- scootcli/providers/openai_chat.py +142 -0
- scootcli/providers/openai_responses.py +248 -0
- scootcli/providers/registry.py +173 -0
- scootcli/rendering.py +86 -0
- scootcli/repl.py +801 -0
- scootcli/sessions.py +186 -0
- scootcli/status.py +71 -0
- scootcli/tools/__init__.py +68 -0
- scootcli/tools/base.py +152 -0
- scootcli/tools/edit_file.py +72 -0
- scootcli/tools/list_dir.py +47 -0
- scootcli/tools/read_file.py +56 -0
- scootcli/tools/run_shell.py +73 -0
- scootcli/tools/search.py +170 -0
- scootcli/tools/update_plan.py +104 -0
- scootcli/tools/write_file.py +61 -0
- scootcli/transport.py +312 -0
- scootcli/vision.py +167 -0
- scootcli/workspace.py +105 -0
- scootcli/worktree.py +114 -0
- scootcli-0.1.0.dist-info/METADATA +238 -0
- scootcli-0.1.0.dist-info/RECORD +71 -0
- scootcli-0.1.0.dist-info/WHEEL +5 -0
- scootcli-0.1.0.dist-info/entry_points.txt +2 -0
- scootcli-0.1.0.dist-info/licenses/LICENSE +21 -0
- scootcli-0.1.0.dist-info/top_level.txt +1 -0
scootcli/config.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Configuration: ``.env`` files + environment + defaults with a clear precedence.
|
|
2
|
+
|
|
3
|
+
Precedence (highest wins):
|
|
4
|
+
CLI flag → environment variable → project ``.env`` → global ``~/.config/scoot/.env`` → default.
|
|
5
|
+
|
|
6
|
+
Two optional ``.env`` files are read: the global one in scoot's config home, and the nearest ``.env``
|
|
7
|
+
found walking up from the current directory (so a repo-root file applies from any subdirectory).
|
|
8
|
+
Only scoot's own keys are imported from them: ``SCOOT_*``, the providers' API-key variables
|
|
9
|
+
(``OPENAI_API_KEY``, ...), and the proxy variables. A project's other secrets never enter scoot's
|
|
10
|
+
process through a ``.env`` file.
|
|
11
|
+
|
|
12
|
+
The CLI-flag layer is applied by the caller (``cli.py``) via :meth:`Config.override`; this module
|
|
13
|
+
handles the lower layers.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
from dataclasses import dataclass, replace
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import List, Optional, Tuple
|
|
22
|
+
|
|
23
|
+
# No proxy by default (set HTTPS_PROXY / --proxy when one is required).
|
|
24
|
+
DEFAULT_PROXY = ""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# scoot's own config dir + securely-stored provider API keys (written by ``scoot auth set``).
|
|
28
|
+
SCOOT_CONFIG_DIR = Path.home() / ".config/scoot"
|
|
29
|
+
CREDENTIALS_FILE = SCOOT_CONFIG_DIR / "credentials.json"
|
|
30
|
+
# Persisted user preferences (e.g. the chosen model) — survives across launches.
|
|
31
|
+
PREFERENCES_FILE = SCOOT_CONFIG_DIR / "preferences.json"
|
|
32
|
+
|
|
33
|
+
# scoot session persistence: auto-saved conversations for --continue / --resume (see PLAN §14).
|
|
34
|
+
STATE_DIR = Path.home() / ".local/state/scoot"
|
|
35
|
+
SESSION_RETENTION = 20 # keep only the most recent N sessions on disk
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _config_home() -> Path:
|
|
39
|
+
"""User config dir for scoot (honours XDG_CONFIG_HOME)."""
|
|
40
|
+
base = os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config")
|
|
41
|
+
return Path(base) / "scoot"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
ENV_FILE_NAME = ".env"
|
|
45
|
+
_PROXY_KEYS = ("HTTPS_PROXY", "https_proxy", "NO_PROXY", "no_proxy")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def global_env_path() -> Path:
|
|
49
|
+
"""``~/.config/scoot/.env`` (honours ``XDG_CONFIG_HOME``): the stable place for keys."""
|
|
50
|
+
return _config_home() / ENV_FILE_NAME
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def find_project_env(start: Path) -> Optional[Path]:
|
|
54
|
+
"""The nearest ``.env`` in ``start`` or an ancestor directory, or ``None``."""
|
|
55
|
+
home = _config_home()
|
|
56
|
+
for directory in (start, *start.parents):
|
|
57
|
+
candidate = directory / ENV_FILE_NAME
|
|
58
|
+
if candidate.is_file() and candidate != home / ENV_FILE_NAME:
|
|
59
|
+
return candidate
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def env_files(start: Path) -> List[Path]:
|
|
64
|
+
"""Existing env files in *load order*: project first, then global.
|
|
65
|
+
|
|
66
|
+
Loading never overrides a variable that is already set, so the project file wins over the global
|
|
67
|
+
one, and the real environment wins over both.
|
|
68
|
+
"""
|
|
69
|
+
files: List[Path] = []
|
|
70
|
+
project = find_project_env(start)
|
|
71
|
+
if project is not None:
|
|
72
|
+
files.append(project)
|
|
73
|
+
glob = global_env_path()
|
|
74
|
+
if glob.is_file() and glob not in files:
|
|
75
|
+
files.append(glob)
|
|
76
|
+
return files
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def allowed_env_key(key: str) -> bool:
|
|
80
|
+
"""Whether a ``.env`` key is scoot's business: ``SCOOT_*``, provider API keys, proxy settings."""
|
|
81
|
+
if key.startswith("SCOOT_") or key in _PROXY_KEYS:
|
|
82
|
+
return True
|
|
83
|
+
try:
|
|
84
|
+
from .providers import registry
|
|
85
|
+
|
|
86
|
+
return any(key in spec.key_env for spec in registry.all_specs())
|
|
87
|
+
except Exception:
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def load_dotenv(env_path: Path, allow=allowed_env_key) -> List[str]:
|
|
92
|
+
"""Import allowed ``KEY=VALUE`` lines into ``os.environ`` (never overriding); return imported keys.
|
|
93
|
+
|
|
94
|
+
Accepts an optional ``export`` prefix and single or double quotes around the value.
|
|
95
|
+
"""
|
|
96
|
+
imported: List[str] = []
|
|
97
|
+
try:
|
|
98
|
+
lines = env_path.read_text().splitlines()
|
|
99
|
+
except OSError:
|
|
100
|
+
return imported
|
|
101
|
+
for line in lines:
|
|
102
|
+
line = line.strip()
|
|
103
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
104
|
+
continue
|
|
105
|
+
if line.startswith("export "):
|
|
106
|
+
line = line[len("export "):].lstrip()
|
|
107
|
+
key, _, value = line.partition("=")
|
|
108
|
+
key = key.strip()
|
|
109
|
+
if not key or not allow(key):
|
|
110
|
+
continue
|
|
111
|
+
value = value.strip()
|
|
112
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
|
113
|
+
value = value[1:-1]
|
|
114
|
+
if key not in os.environ:
|
|
115
|
+
os.environ[key] = value
|
|
116
|
+
imported.append(key)
|
|
117
|
+
return imported
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _as_bool(value: str) -> bool:
|
|
121
|
+
return value.strip().lower() not in ("false", "0", "no", "off", "")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
DEFAULT_MODEL_ALIAS = "default" # the default provider's preferred model (gpt-5.3-codex on OpenAI)
|
|
125
|
+
AUTO_MODEL_ALIAS = "auto" # opt-in: pick a model per turn from the live list (routing seed)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def is_model_alias(model: Optional[str]) -> bool:
|
|
129
|
+
return (model or "").strip().lower() in (DEFAULT_MODEL_ALIAS, AUTO_MODEL_ALIAS, "")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _resolve_model_setting(model_env: Optional[str]) -> str:
|
|
133
|
+
"""Model precedence (below the CLI flag, applied later): SCOOT_MODEL → saved pref → default."""
|
|
134
|
+
if model_env:
|
|
135
|
+
return model_env
|
|
136
|
+
try:
|
|
137
|
+
from .preferences import get_model
|
|
138
|
+
|
|
139
|
+
return get_model() or DEFAULT_MODEL_ALIAS
|
|
140
|
+
except Exception:
|
|
141
|
+
return DEFAULT_MODEL_ALIAS
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _resolve_logo_setting(logo_env: Optional[str]) -> bool:
|
|
145
|
+
"""Mascot on/off precedence (below the --no-logo flag): SCOOT_LOGO env → saved pref → on."""
|
|
146
|
+
if logo_env is not None and logo_env.strip() != "":
|
|
147
|
+
return _as_bool(logo_env)
|
|
148
|
+
try:
|
|
149
|
+
from .preferences import get_logo
|
|
150
|
+
|
|
151
|
+
saved = get_logo()
|
|
152
|
+
return True if saved is None else saved
|
|
153
|
+
except Exception:
|
|
154
|
+
return True
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass(frozen=True)
|
|
158
|
+
class Config:
|
|
159
|
+
"""Resolved runtime configuration."""
|
|
160
|
+
|
|
161
|
+
proxy: str = DEFAULT_PROXY
|
|
162
|
+
provider: str = "" # default provider name; empty → first provider with a key, else ollama
|
|
163
|
+
model: str = DEFAULT_MODEL_ALIAS # "default" | "auto" | a model id, ideally provider/model
|
|
164
|
+
effort: str = "medium" # reasoning effort for models that take it: low | medium | high | xhigh
|
|
165
|
+
timeout: int = 120
|
|
166
|
+
max_steps: int = 50
|
|
167
|
+
compact_at: int = 100000
|
|
168
|
+
approval: str = "yolo"
|
|
169
|
+
root: Path = Path.cwd()
|
|
170
|
+
verbose: bool = False
|
|
171
|
+
stream: bool = True # stream tokens live (SSE) when the UI/terminal supports it
|
|
172
|
+
panel: bool = True # show the persistent bottom status bar in the REPL (TTY only)
|
|
173
|
+
dock: bool = True # pin the prompt to a fixed bottom row; output scrolls above it (TTY only)
|
|
174
|
+
resume: str = "auto" # launch resume policy: hint (show last) | auto (reload last) | off
|
|
175
|
+
workspace_context: bool = True # inject a compact repo map (git + tree) into the agent prompt
|
|
176
|
+
labels: bool = True # show role labels/gutters (❯ you / ⏺ scoot) in the REPL transcript
|
|
177
|
+
verbosity: str = "full" # feed detail: full (tool log) | compact (transient tool line) | quiet (no reasoning)
|
|
178
|
+
images: bool = True # detect dropped image paths + describe them with a vision model (M22)
|
|
179
|
+
vision_model: str = "auto" # provider/model for image descriptions, or "auto" → pick a capable one
|
|
180
|
+
image_max_bytes: int = 4 * 1024 * 1024 # per-image cap (no downscale without a 3rd-party lib)
|
|
181
|
+
env_files: Tuple[str, ...] = () # the .env files that were read, in load order (shown by /status)
|
|
182
|
+
logo: bool = True # mascot in the launch banner + status-bar face (--no-logo / SCOOT_LOGO / /logo)
|
|
183
|
+
emoji: bool = True # 🛴 transcript label; off → ⏺ for terminals without an emoji font (--no-emoji)
|
|
184
|
+
|
|
185
|
+
# ── Loading ────────────────────────────────────────────────────────────────
|
|
186
|
+
@classmethod
|
|
187
|
+
def load(cls, cwd: Optional[Path] = None) -> "Config":
|
|
188
|
+
"""Load config from the ``.env`` files + environment variables, falling back to defaults."""
|
|
189
|
+
cwd = (cwd or Path.cwd()).resolve()
|
|
190
|
+
files = env_files(cwd)
|
|
191
|
+
for env_file in files:
|
|
192
|
+
load_dotenv(env_file)
|
|
193
|
+
|
|
194
|
+
get = os.environ.get
|
|
195
|
+
return cls(
|
|
196
|
+
proxy=get("HTTPS_PROXY") or get("https_proxy") or DEFAULT_PROXY,
|
|
197
|
+
env_files=tuple(str(f) for f in files),
|
|
198
|
+
provider=get("SCOOT_PROVIDER", "").strip().lower(),
|
|
199
|
+
model=_resolve_model_setting(get("SCOOT_MODEL")),
|
|
200
|
+
effort=get("SCOOT_EFFORT", "medium").strip().lower(),
|
|
201
|
+
timeout=int(get("SCOOT_TIMEOUT", "120")),
|
|
202
|
+
max_steps=int(get("SCOOT_MAX_STEPS", "50")),
|
|
203
|
+
compact_at=int(get("SCOOT_COMPACT_AT", "100000")),
|
|
204
|
+
approval=get("SCOOT_APPROVAL", "yolo"),
|
|
205
|
+
root=Path(get("SCOOT_ROOT", str(cwd))).resolve(),
|
|
206
|
+
verbose=_as_bool(get("SCOOT_VERBOSE", "false")),
|
|
207
|
+
stream=_as_bool(get("SCOOT_STREAM", "true")),
|
|
208
|
+
panel=_as_bool(get("SCOOT_PANEL", "true")),
|
|
209
|
+
dock=_as_bool(get("SCOOT_DOCK", "true")),
|
|
210
|
+
resume=get("SCOOT_RESUME", "auto").strip().lower(),
|
|
211
|
+
workspace_context=_as_bool(get("SCOOT_WORKSPACE_CONTEXT", "true")),
|
|
212
|
+
labels=_as_bool(get("SCOOT_LABELS", "true")),
|
|
213
|
+
verbosity=get("SCOOT_VERBOSITY", "full").strip().lower(),
|
|
214
|
+
images=_as_bool(get("SCOOT_IMAGES", "true")),
|
|
215
|
+
vision_model=get("SCOOT_VISION_MODEL", "auto"),
|
|
216
|
+
image_max_bytes=int(get("SCOOT_IMAGE_MAX_BYTES", str(4 * 1024 * 1024))),
|
|
217
|
+
logo=_resolve_logo_setting(get("SCOOT_LOGO")),
|
|
218
|
+
emoji=_as_bool(get("SCOOT_EMOJI", "true")),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def override(self, **kwargs) -> "Config":
|
|
222
|
+
"""Return a copy with the given (non-None) fields overridden — the CLI-flag layer."""
|
|
223
|
+
clean = {k: v for k, v in kwargs.items() if v is not None}
|
|
224
|
+
if "root" in clean:
|
|
225
|
+
clean["root"] = Path(clean["root"]).resolve()
|
|
226
|
+
return replace(self, **clean)
|
|
227
|
+
|
|
228
|
+
def resolve_model(self, task_hint: str = "") -> str:
|
|
229
|
+
"""The qualified ``provider/model`` for the configured preference.
|
|
230
|
+
|
|
231
|
+
``default`` is the default provider's preferred model (``gpt-5.3-codex`` on OpenAI). ``auto`` is
|
|
232
|
+
the per-turn heuristic over the live model list; before that list exists it resolves the same
|
|
233
|
+
way as ``default``.
|
|
234
|
+
"""
|
|
235
|
+
from .providers.base import qualify
|
|
236
|
+
from .providers.registry import default_provider_name, fallback_model
|
|
237
|
+
|
|
238
|
+
if is_model_alias(self.model):
|
|
239
|
+
return fallback_model(self)
|
|
240
|
+
return qualify(default_provider_name(self), self.model)
|
|
241
|
+
|
scootcli/context.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Conversation context management: token estimation + /compact (summarize → clear → reseed).
|
|
2
|
+
|
|
3
|
+
When the context grows large we ask the model to summarize the conversation, drop the old turns, and
|
|
4
|
+
keep the summary as the new base — freeing tokens without losing the thread (PLAN §6 /compact).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import threading
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
_COMPACT_SYSTEM = (
|
|
13
|
+
"You compress a coding assistant conversation into a compact hand-off note. "
|
|
14
|
+
"Capture: the user's goal, key decisions, files created/changed, commands run, current plan, and "
|
|
15
|
+
"any open TODOs. Be terse and factual. Output only the note."
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def estimate_context_tokens(session) -> int:
|
|
20
|
+
"""Best-effort current context size from the last API ``usage.prompt_tokens``.
|
|
21
|
+
|
|
22
|
+
Falls back to a rough char/4 estimate over the live messages when there has been no API call yet
|
|
23
|
+
(e.g. immediately after resuming a saved session, before the first turn of the new process).
|
|
24
|
+
"""
|
|
25
|
+
usage = session.last_usage or {}
|
|
26
|
+
tokens = int(usage.get("prompt_tokens", 0) or 0)
|
|
27
|
+
if tokens:
|
|
28
|
+
return tokens
|
|
29
|
+
return estimate_messages_tokens(getattr(session, "messages", None))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def estimate_messages_tokens(messages) -> int:
|
|
33
|
+
"""Rough token estimate (~4 chars/token) over a message list; used before any usage is known."""
|
|
34
|
+
if not messages:
|
|
35
|
+
return 0
|
|
36
|
+
chars = 0
|
|
37
|
+
for m in messages:
|
|
38
|
+
content = m.get("content")
|
|
39
|
+
if isinstance(content, str):
|
|
40
|
+
chars += len(content)
|
|
41
|
+
return chars // 4
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _render_conversation(messages) -> str:
|
|
45
|
+
parts = []
|
|
46
|
+
for m in messages:
|
|
47
|
+
role = m.get("role", "?")
|
|
48
|
+
content = m.get("content", "")
|
|
49
|
+
if not content and m.get("tool_calls"):
|
|
50
|
+
calls = ", ".join(c.get("function", {}).get("name", "?") for c in m["tool_calls"])
|
|
51
|
+
content = f"[called tools: {calls}]"
|
|
52
|
+
parts.append(f"{role}: {content}")
|
|
53
|
+
return "\n".join(parts)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def compact(session, cancel_event: Optional[threading.Event] = None) -> str:
|
|
57
|
+
"""Summarize and replace the session's messages. Returns the summary text (or "" if nothing)."""
|
|
58
|
+
if not session.messages:
|
|
59
|
+
return ""
|
|
60
|
+
conversation = _render_conversation(session.messages)
|
|
61
|
+
result = session.provider.chat(
|
|
62
|
+
[
|
|
63
|
+
{"role": "system", "content": _COMPACT_SYSTEM},
|
|
64
|
+
{"role": "user", "content": conversation},
|
|
65
|
+
],
|
|
66
|
+
model=session.active_model,
|
|
67
|
+
max_tokens=900,
|
|
68
|
+
cancel_event=cancel_event,
|
|
69
|
+
)
|
|
70
|
+
summary = (result.content or "").strip()
|
|
71
|
+
session.account(result.usage)
|
|
72
|
+
if not summary:
|
|
73
|
+
return ""
|
|
74
|
+
session.messages = [
|
|
75
|
+
{"role": "user", "content": "Summary of earlier conversation (context was compacted):\n" + summary},
|
|
76
|
+
{"role": "assistant", "content": "Understood — continuing with that context."},
|
|
77
|
+
]
|
|
78
|
+
# Drop the stale usage from the summarization call so the context estimate (bar ``ctx``,
|
|
79
|
+
# auto-compact check) reflects the new, small message set until the next real turn.
|
|
80
|
+
session.last_usage = {}
|
|
81
|
+
return summary
|
|
82
|
+
|
scootcli/credentials.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Secure-at-rest storage for provider API keys.
|
|
2
|
+
|
|
3
|
+
Stdlib-only and offline-friendly (no OS-keychain dependency, so the zipapp stays self-contained).
|
|
4
|
+
Keys live in ``~/.config/scoot/credentials.json`` as ``{"keys": {"openai": "sk-..."}}`` with owner-only
|
|
5
|
+
permissions (directory ``0700``, file ``0600``). Keys are never echoed; ``rendering.redact()`` masks them.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Dict, Optional
|
|
14
|
+
|
|
15
|
+
from . import config as _config
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _file() -> Path:
|
|
19
|
+
override = os.environ.get("SCOOT_CONFIG_DIR")
|
|
20
|
+
if override:
|
|
21
|
+
return Path(override).expanduser() / "credentials.json"
|
|
22
|
+
return _config.CREDENTIALS_FILE
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _load() -> Dict[str, str]:
|
|
26
|
+
try:
|
|
27
|
+
data = json.loads(_file().read_text())
|
|
28
|
+
except (OSError, json.JSONDecodeError):
|
|
29
|
+
return {}
|
|
30
|
+
keys = data.get("keys") if isinstance(data, dict) else None
|
|
31
|
+
return {k: v for k, v in keys.items() if isinstance(v, str) and v} if isinstance(keys, dict) else {}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _write(keys: Dict[str, str]) -> Path:
|
|
35
|
+
directory = _file().parent
|
|
36
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
try:
|
|
38
|
+
os.chmod(directory, 0o700)
|
|
39
|
+
except OSError:
|
|
40
|
+
pass # best-effort on exotic filesystems
|
|
41
|
+
# Open with a restrictive mode from the start so a key is never briefly world-readable.
|
|
42
|
+
fd = os.open(_file(), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
43
|
+
with os.fdopen(fd, "w") as fh:
|
|
44
|
+
json.dump({"keys": keys}, fh)
|
|
45
|
+
try:
|
|
46
|
+
os.chmod(_file(), 0o600) # tighten in case the file pre-existed
|
|
47
|
+
except OSError:
|
|
48
|
+
pass
|
|
49
|
+
return _file()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def load_key(provider: str) -> Optional[str]:
|
|
53
|
+
"""The saved API key for ``provider``, or ``None``."""
|
|
54
|
+
return _load().get(provider) or None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def save_key(provider: str, key: str) -> Path:
|
|
58
|
+
"""Persist ``key`` for ``provider`` (owner-only) and return the file path."""
|
|
59
|
+
keys = _load()
|
|
60
|
+
keys[provider] = key.strip()
|
|
61
|
+
return _write(keys)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def delete_key(provider: str) -> bool:
|
|
65
|
+
"""Forget the saved key for ``provider``. Return ``True`` if one was removed."""
|
|
66
|
+
keys = _load()
|
|
67
|
+
if provider not in keys:
|
|
68
|
+
return False
|
|
69
|
+
del keys[provider]
|
|
70
|
+
_write(keys)
|
|
71
|
+
return True
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def saved_providers() -> list:
|
|
75
|
+
return sorted(_load())
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def credentials_path() -> Path:
|
|
79
|
+
return _file()
|
scootcli/errors.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Typed errors for scoot, so callers can react to specific failure modes.
|
|
2
|
+
|
|
3
|
+
Each error can carry a short ``hint`` with an actionable remedy for the user (surfaced by the REPL).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ScootError(Exception):
|
|
10
|
+
"""Base class for all scoot errors."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, message: str, hint: str = ""):
|
|
13
|
+
super().__init__(message)
|
|
14
|
+
self.hint = hint
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ConfigError(ScootError):
|
|
18
|
+
"""Invalid or missing configuration."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ── Transport-level ────────────────────────────────────────────────────────────
|
|
22
|
+
class TransportError(ScootError):
|
|
23
|
+
"""Low-level transport failure (connection died, TLS failed, timed out)."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ProxyError(TransportError):
|
|
27
|
+
"""The HTTPS proxy refused the CONNECT (bad or missing credentials)."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class NetworkError(TransportError):
|
|
31
|
+
"""No connectivity: DNS resolution or TCP connect failed."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RequestTimeout(TransportError):
|
|
35
|
+
"""The request timed out."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SSLError(TransportError):
|
|
39
|
+
"""TLS/certificate failure."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ── Auth ───────────────────────────────────────────────────────────────────────
|
|
43
|
+
class AuthError(ScootError):
|
|
44
|
+
"""Authentication failed: no OAuth token, or token exchange/JWT rejected."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ── API-level ──────────────────────────────────────────────────────────────────
|
|
48
|
+
class ApiError(ScootError):
|
|
49
|
+
"""The API returned an error response."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, message: str, status: int = 0, payload: object = None, hint: str = "", code: str = ""):
|
|
52
|
+
super().__init__(message, hint=hint)
|
|
53
|
+
self.status = status
|
|
54
|
+
self.payload = payload
|
|
55
|
+
self.code = code
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class RateLimitError(ApiError):
|
|
59
|
+
"""HTTP 429 — too many requests."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ServerError(ApiError):
|
|
63
|
+
"""HTTP 5xx — server-side failure."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class QuotaError(ApiError):
|
|
67
|
+
"""HTTP 402/403 or quota exhaustion: billing, quota, or permission problem."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ContextLengthError(ApiError):
|
|
71
|
+
"""The request exceeded the model's context window."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ModelUnavailableError(ApiError):
|
|
75
|
+
"""The requested model is not supported/accessible on this endpoint."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ── Control flow ───────────────────────────────────────────────────────────────
|
|
79
|
+
class Interrupted(ScootError):
|
|
80
|
+
"""The in-flight request/tool was cancelled by the user (ESC)."""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# Errors worth retrying with backoff (transient).
|
|
84
|
+
RETRIABLE = (NetworkError, RequestTimeout, RateLimitError, ServerError)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
|
scootcli/images.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Detect image file paths dropped into a prompt and encode them as data URIs (PLAN §3 / M22).
|
|
2
|
+
|
|
3
|
+
Dragging a file into a terminal inserts its *path* as text (plain, backslash-escaped, quoted, or a
|
|
4
|
+
``file://`` URL) — never the bytes. This module is pure text-parsing + file reads: it pulls recognised
|
|
5
|
+
image paths out of a prompt and base64-encodes them for a vision model. No third-party deps.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import mimetypes
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import List, Optional, Tuple
|
|
16
|
+
from urllib.parse import unquote, urlparse
|
|
17
|
+
|
|
18
|
+
IMAGE_EXTENSIONS = {
|
|
19
|
+
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".tif", ".heic",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
DEFAULT_MAX_BYTES = 4 * 1024 * 1024 # 4 MB — we can't downscale (stdlib only), so cap + warn instead.
|
|
23
|
+
|
|
24
|
+
# Anchor detection on an image extension, then expand outward to whatever path actually exists on
|
|
25
|
+
# disk. This is robust to spaces in the path (escaped *or* not) and to leading text, which naive
|
|
26
|
+
# whitespace/quote tokenising gets wrong for real drag-and-drop paths (e.g. OneDrive screenshots).
|
|
27
|
+
_IMAGE_EXT_RE = re.compile(r"\.(?:png|jpe?g|gif|webp|bmp|tiff?|heic)\b", re.IGNORECASE)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ImageTooLargeError(ValueError):
|
|
31
|
+
"""Raised when an image exceeds the configured byte cap (we can't resize without a 3rd-party lib)."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class EncodedImage:
|
|
36
|
+
"""A base64 data-URI-encoded image ready to attach to a vision request."""
|
|
37
|
+
|
|
38
|
+
name: str
|
|
39
|
+
mime: str
|
|
40
|
+
data_uri: str
|
|
41
|
+
size: int
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _looks_like_image(path: Path) -> bool:
|
|
45
|
+
return path.suffix.lower() in IMAGE_EXTENSIONS
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _try_path(value: str, root: Optional[Path]) -> Optional[Path]:
|
|
49
|
+
"""Turn a candidate string into an existing image ``Path``, or ``None``."""
|
|
50
|
+
if not value:
|
|
51
|
+
return None
|
|
52
|
+
if value.startswith("file://"):
|
|
53
|
+
value = unquote(urlparse(value).path)
|
|
54
|
+
try:
|
|
55
|
+
p = Path(value).expanduser()
|
|
56
|
+
if not p.is_absolute() and root is not None:
|
|
57
|
+
p = Path(root) / p
|
|
58
|
+
if p.is_file() and _looks_like_image(p):
|
|
59
|
+
return p.resolve()
|
|
60
|
+
except OSError:
|
|
61
|
+
return None
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _normalize(text: str) -> str:
|
|
66
|
+
"""Make dropped paths detectable: expand ``file://`` URLs and unescape backslash escapes."""
|
|
67
|
+
text = re.sub(r"file://\S+", lambda m: unquote(urlparse(m.group(0)).path), text)
|
|
68
|
+
return re.sub(r"\\(.)", r"\1", text) # "\ " → " ", "\(" → "(", etc.
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _find_image_span(text: str, root: Optional[Path]) -> Optional[Tuple[int, int, Path]]:
|
|
72
|
+
"""Find the first ``(start, end, path)`` where ``text[start:end]`` is an existing image file.
|
|
73
|
+
|
|
74
|
+
Anchored on an image extension; the start is the left-most ``/``/``~``/beginning such that the
|
|
75
|
+
spanned substring exists on disk — so paths containing spaces are captured whole.
|
|
76
|
+
"""
|
|
77
|
+
for m in _IMAGE_EXT_RE.finditer(text):
|
|
78
|
+
end = m.end()
|
|
79
|
+
# Candidate starts: string start, each path separator, and each word start (for relative
|
|
80
|
+
# paths). Ascending order means we try the *longest* (left-most) span first, so a path with
|
|
81
|
+
# spaces is captured whole rather than truncated at a space.
|
|
82
|
+
starts = {0}
|
|
83
|
+
for i, ch in enumerate(text[:end]):
|
|
84
|
+
if ch in "/~":
|
|
85
|
+
starts.add(i)
|
|
86
|
+
elif ch.isspace() and i + 1 < end:
|
|
87
|
+
starts.add(i + 1)
|
|
88
|
+
for s in sorted(starts):
|
|
89
|
+
cand = text[s:end].strip().strip("'\"")
|
|
90
|
+
p = _try_path(cand, root)
|
|
91
|
+
if p is not None:
|
|
92
|
+
# Tighten the removal span: skip leading whitespace but keep a wrapping quote, and
|
|
93
|
+
# swallow a trailing quote, so `'…path…'` is removed whole (no stray quotes left).
|
|
94
|
+
while s < end and text[s] in " \t":
|
|
95
|
+
s += 1
|
|
96
|
+
ne = end + 1 if (end < len(text) and text[end] in "'\"") else end
|
|
97
|
+
return s, ne, p
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def extract_image_paths(text: str, root: Optional[Path] = None) -> Tuple[str, List[Path]]:
|
|
102
|
+
"""Split ``text`` into ``(clean_text, [image_paths])``.
|
|
103
|
+
|
|
104
|
+
Only substrings that resolve to an **existing** image file are treated as attachments and removed;
|
|
105
|
+
everything else is left in place. Handles plain / backslash-escaped / quoted / ``file://`` paths,
|
|
106
|
+
including filenames with spaces. Relative paths resolve against ``root`` when given.
|
|
107
|
+
"""
|
|
108
|
+
work = _normalize(text)
|
|
109
|
+
paths: List[Path] = []
|
|
110
|
+
while True:
|
|
111
|
+
span = _find_image_span(work, root)
|
|
112
|
+
if span is None:
|
|
113
|
+
break
|
|
114
|
+
s, e, p = span
|
|
115
|
+
paths.append(p)
|
|
116
|
+
work = work[:s] + " " + work[e:]
|
|
117
|
+
clean = re.sub(r"\s+", " ", work).strip()
|
|
118
|
+
return clean, paths
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def badge_text(text: str, root: Optional[Path] = None) -> str:
|
|
122
|
+
"""Return ``text`` with each detected image path replaced by a short ``[Image N]`` badge.
|
|
123
|
+
|
|
124
|
+
For display only (e.g. the REPL echo) — the real path is handled separately by
|
|
125
|
+
:func:`extract_image_paths`. If no image paths are present the original text is returned verbatim.
|
|
126
|
+
"""
|
|
127
|
+
work = _normalize(text)
|
|
128
|
+
n = 0
|
|
129
|
+
while True:
|
|
130
|
+
span = _find_image_span(work, root)
|
|
131
|
+
if span is None:
|
|
132
|
+
break
|
|
133
|
+
s, e, _ = span
|
|
134
|
+
n += 1
|
|
135
|
+
work = work[:s] + f"[Image {n}]" + work[e:]
|
|
136
|
+
if n == 0:
|
|
137
|
+
return text # nothing to badge — keep the prompt exactly as typed
|
|
138
|
+
return re.sub(r"\s+", " ", work).strip()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _sniff_mime(path: Path, raw: bytes) -> str:
|
|
142
|
+
"""Detect an image MIME type from magic bytes, falling back to the extension."""
|
|
143
|
+
if raw[:3] == b"\xff\xd8\xff":
|
|
144
|
+
return "image/jpeg"
|
|
145
|
+
if raw[:8] == b"\x89PNG\r\n\x1a\n":
|
|
146
|
+
return "image/png"
|
|
147
|
+
if raw[:4] == b"RIFF" and raw[8:12] == b"WEBP":
|
|
148
|
+
return "image/webp"
|
|
149
|
+
if raw[:6] in (b"GIF87a", b"GIF89a"):
|
|
150
|
+
return "image/gif"
|
|
151
|
+
guess = mimetypes.guess_type(str(path))[0]
|
|
152
|
+
if guess and guess.startswith("image/"):
|
|
153
|
+
return guess
|
|
154
|
+
return "image/png"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def to_data_uri(path: Path, max_bytes: int = DEFAULT_MAX_BYTES) -> EncodedImage:
|
|
158
|
+
"""Read + base64-encode an image into a ``data:`` URI. Raises :class:`ImageTooLargeError` over cap."""
|
|
159
|
+
path = Path(path)
|
|
160
|
+
raw = path.read_bytes()
|
|
161
|
+
if max_bytes and len(raw) > max_bytes:
|
|
162
|
+
raise ImageTooLargeError(
|
|
163
|
+
f"{path.name} is {len(raw) // 1024} KB, over the {max_bytes // 1024} KB limit"
|
|
164
|
+
)
|
|
165
|
+
mime = _sniff_mime(path, raw)
|
|
166
|
+
b64 = base64.b64encode(raw).decode("ascii")
|
|
167
|
+
return EncodedImage(name=path.name, mime=mime, data_uri=f"data:{mime};base64,{b64}", size=len(raw))
|
|
168
|
+
|
|
169
|
+
|