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
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
+
@@ -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
+