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/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """scoot: a tiny coding agent that goes where you point it."""
2
+
3
+ __version__ = "0.1.0"
4
+
scootcli/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ """Enable ``python -m scootcli``."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
9
+
scootcli/activity.py ADDED
@@ -0,0 +1,26 @@
1
+ """Shared 'activity' context manager: spinner + ESC-interrupt around a blocking call.
2
+
3
+ Used by slash-commands (``/init``, ``/compact``) that call the model outside the agent loop. Yields a
4
+ ``cancel_event`` the caller passes to ``client.chat`` so ESC terminates it immediately.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import threading
10
+ from contextlib import contextmanager
11
+
12
+ from .keys import InterruptibleSection
13
+ from .status import Status
14
+
15
+
16
+ @contextmanager
17
+ def activity(message: str):
18
+ cancel = threading.Event()
19
+ status = Status()
20
+ status.start(message)
21
+ try:
22
+ with InterruptibleSection(cancel):
23
+ yield cancel
24
+ finally:
25
+ status.stop()
26
+
scootcli/agent.py ADDED
@@ -0,0 +1,350 @@
1
+ """The agentic loop (PLAN §7).
2
+
3
+ Each turn: build messages → call the model with the tool schemas → if it returns ``tool_calls``,
4
+ approve + execute each and loop; otherwise the plain-text reply is the completion (DONE sentinel
5
+ stripped). Bounded by ``max_steps``. ESC cancellation is honored around every blocking call.
6
+
7
+ The loop runs on the *calling* thread and delegates all terminal concerns (spinner, ESC listener,
8
+ approval prompts) to an injected ``ui`` object, so it stays decoupled and unit-testable (see
9
+ :class:`HeadlessUI`).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import threading
16
+ from contextlib import contextmanager
17
+ from dataclasses import dataclass
18
+ from typing import List, Optional
19
+
20
+ from . import tools
21
+ from .approvals import Approval, Decision, needs_prompt
22
+ from .config import Config
23
+ from .errors import ScootError, ContextLengthError, Interrupted, ModelUnavailableError
24
+ from .prompts import build_agent_system_prompt
25
+ from .tools.base import ToolContext, ToolResult
26
+
27
+
28
+ @dataclass
29
+ class AgentOutcome:
30
+ status: str # "done" | "interrupted" | "aborted" | "max_steps" | "error"
31
+ content: str = ""
32
+ error: str = ""
33
+ steps: int = 0
34
+ streamed: bool = False # True if the final content was already printed live (streaming)
35
+
36
+
37
+ def _strip_done(text: str) -> str:
38
+ """Remove a trailing ``DONE`` sentinel line from the model's final message."""
39
+ lines = (text or "").rstrip().splitlines()
40
+ while lines and lines[-1].strip() in ("DONE", ""):
41
+ if lines[-1].strip() == "DONE":
42
+ lines.pop()
43
+ break
44
+ lines.pop()
45
+ return "\n".join(lines).rstrip()
46
+
47
+
48
+ def _fmt_error(exc: Exception) -> str:
49
+ """Format an error with its actionable hint, if any."""
50
+ hint = getattr(exc, "hint", "")
51
+ return f"{exc}" + (f" — {hint}" if hint else "")
52
+
53
+
54
+ class HeadlessUI:
55
+ """A non-interactive UI for programmatic/testing use (no spinner; fixed approval decision)."""
56
+
57
+ def __init__(self, decision: Decision = Decision.APPROVE):
58
+ self.decision = decision
59
+ self.events: List[tuple] = []
60
+
61
+ @contextmanager
62
+ def activity(self, message: str, cancel_event: threading.Event):
63
+ self.events.append(("activity", message))
64
+ yield
65
+
66
+ def assistant(self, text: str) -> None:
67
+ self.events.append(("assistant", text))
68
+
69
+ def approve(self, tool, args, ctx) -> Approval:
70
+ self.events.append(("approve", tool.name, args))
71
+ return Approval(self.decision, args)
72
+
73
+ def auto_approved(self, tool, args) -> None:
74
+ self.events.append(("auto", tool.name, args))
75
+
76
+ def plan(self, plan) -> None:
77
+ self.events.append(("plan", plan))
78
+
79
+ def tool_result(self, name: str, result: ToolResult) -> None:
80
+ self.events.append(("tool_result", name, result.ok, result.summary))
81
+
82
+
83
+ class Agent:
84
+ """Runs the agentic loop for one user turn."""
85
+
86
+ def __init__(self, config: Config, provider):
87
+ self.config = config
88
+ self.provider = provider # a Provider (usually the ProviderPool); dispatches by provider/model
89
+ self._streaming = getattr(config, "stream", True)
90
+ tools.load_builtins()
91
+
92
+ # ── public API ───────────────────────────────────────────────────────────────
93
+ def run_turn(self, session, ui, cancel_event: Optional[threading.Event] = None) -> AgentOutcome:
94
+ cancel_event = cancel_event or threading.Event()
95
+ cfg = getattr(session, "config", None) or self.config
96
+ session.active_model = self._pick_model(session)
97
+ self._refresh_workspace(session, cfg)
98
+ steps = 0
99
+ compacted = False
100
+ while True:
101
+ if cancel_event.is_set():
102
+ return AgentOutcome("interrupted", steps=steps)
103
+ steps += 1
104
+ if steps > cfg.max_steps:
105
+ return AgentOutcome("max_steps", steps=steps - 1)
106
+
107
+ try:
108
+ result, streamed = self._model_call(session, cfg, ui, cancel_event, steps)
109
+ except Interrupted:
110
+ return AgentOutcome("interrupted", steps=steps)
111
+ except ModelUnavailableError as exc:
112
+ if self._fallback_model(session):
113
+ ui.assistant(f"model unavailable — switching to {session.active_model}.")
114
+ steps -= 1 # don't count the failed attempt
115
+ continue
116
+ return AgentOutcome("error", error=_fmt_error(exc), steps=steps)
117
+ except ContextLengthError as exc:
118
+ if not compacted and session.messages:
119
+ compacted = True
120
+ try:
121
+ from .context import compact
122
+ with ui.activity("context too long — compacting…", cancel_event):
123
+ compact(session, cancel_event)
124
+ steps -= 1
125
+ continue
126
+ except ScootError:
127
+ pass
128
+ return AgentOutcome("error", error=_fmt_error(exc), steps=steps)
129
+ except ScootError as exc:
130
+ return AgentOutcome("error", error=_fmt_error(exc), steps=steps)
131
+
132
+ session.account(result.usage)
133
+ session.messages.append(self._assistant_message(result))
134
+
135
+ if result.tool_calls:
136
+ if (result.content or "").strip() and not streamed:
137
+ ui.assistant(result.content.strip())
138
+ signal = self._run_tools(session, result.tool_calls, ui, cancel_event, cfg)
139
+ if signal in ("abort", "interrupted"):
140
+ status = "aborted" if signal == "abort" else "interrupted"
141
+ return AgentOutcome(status, steps=steps)
142
+ continue
143
+
144
+ # No tool calls -> the model is done.
145
+ return AgentOutcome(
146
+ "done", content=_strip_done(result.content), steps=steps, streamed=streamed
147
+ )
148
+
149
+ # ── model call (streaming or buffered) ───────────────────────────────────────
150
+ def _model_call(self, session, cfg, ui, cancel_event, steps):
151
+ """Call the model, streaming tokens live when the UI supports it.
152
+
153
+ Returns ``(result, streamed)`` where ``streamed`` is True if content was already printed.
154
+ """
155
+ messages = self._messages(session, cfg)
156
+ hints = {"task_text": self._last_user_text(session), "needs_tools": True, "step": steps}
157
+ if self._streaming and hasattr(ui, "stream") and hasattr(self.provider, "chat_stream"):
158
+ with ui.stream(cancel_event) as streamer:
159
+ result = self.provider.chat_stream(
160
+ messages,
161
+ model=session.active_model,
162
+ tools=tools.schemas(),
163
+ tool_choice="auto",
164
+ cancel_event=cancel_event,
165
+ on_delta=streamer.delta,
166
+ hints=hints,
167
+ )
168
+ return result, bool(getattr(streamer, "started", False))
169
+
170
+ with ui.activity(f"thinking… (step {steps})", cancel_event):
171
+ result = self.provider.chat(
172
+ messages,
173
+ model=session.active_model,
174
+ tools=tools.schemas(),
175
+ tool_choice="auto",
176
+ cancel_event=cancel_event,
177
+ hints=hints,
178
+ )
179
+ return result, False
180
+
181
+ @staticmethod
182
+ def _last_user_text(session) -> str:
183
+ for m in reversed(getattr(session, "messages", []) or []):
184
+ if m.get("role") == "user" and isinstance(m.get("content"), str):
185
+ return m["content"]
186
+ return ""
187
+
188
+ # ── message construction ─────────────────────────────────────────────────────
189
+ def _pick_model(self, session) -> str:
190
+ """Resolve the model for this turn. For ``auto``, use the heuristic over live models."""
191
+ if session.model.lower() != "auto":
192
+ return session.resolved_model()
193
+ from .models import resolve_auto
194
+
195
+ last_user = self._last_user_text(session)
196
+ try:
197
+ available = session.available_models()
198
+ except Exception:
199
+ available = []
200
+ bad = getattr(session, "bad_models", None) or set()
201
+ available = [m for m in available if m not in bad] or available
202
+ return resolve_auto(last_user, available, fallback=session.resolved_model())
203
+
204
+ def _fallback_model(self, session) -> bool:
205
+ """After a model-unavailable error, blacklist it and switch models. Returns False if stuck."""
206
+ bad = session.active_model
207
+ fallback = session.resolved_model()
208
+ if bad == fallback and session.model.lower() != "auto":
209
+ return False
210
+ if hasattr(session, "bad_models"):
211
+ session.bad_models.add(bad)
212
+ new = self._pick_model(session)
213
+ if new == bad:
214
+ if bad == fallback:
215
+ return False
216
+ new = fallback
217
+ session.active_model = new
218
+ return True
219
+
220
+ def _messages(self, session, cfg=None) -> List[dict]:
221
+ cfg = cfg or getattr(session, "config", None) or self.config
222
+ system = build_agent_system_prompt(
223
+ root=cfg.root,
224
+ model=session.active_model,
225
+ tool_list=tools.tool_list_text(),
226
+ workspace=getattr(session, "workspace_ctx", ""),
227
+ )
228
+ return [{"role": "system", "content": system}, *session.messages]
229
+
230
+ def _refresh_workspace(self, session, cfg) -> None:
231
+ """Recompute the workspace map once per turn (best-effort; stored on the session)."""
232
+ ctx = ""
233
+ if getattr(cfg, "workspace_context", True):
234
+ try:
235
+ from .workspace import workspace_context
236
+
237
+ ctx = workspace_context(cfg.root)
238
+ except Exception:
239
+ ctx = ""
240
+ try:
241
+ session.workspace_ctx = ctx
242
+ except Exception:
243
+ pass
244
+
245
+ @staticmethod
246
+ def _assistant_message(result) -> dict:
247
+ """The history entry for a model reply. ``provider_items`` (opaque items such as encrypted
248
+ reasoning) ride along so the adapter that produced them can replay them next call."""
249
+ msg = {"role": "assistant", "content": result.content or ""}
250
+ if result.tool_calls:
251
+ msg["tool_calls"] = result.tool_calls
252
+ raw = result.raw_message if isinstance(result.raw_message, dict) else {}
253
+ if raw.get("provider_items"):
254
+ msg["provider_items"] = raw["provider_items"]
255
+ return msg
256
+
257
+ @staticmethod
258
+ def _append_tool(session, tool_call_id: str, content: str) -> None:
259
+ session.messages.append(
260
+ {"role": "tool", "tool_call_id": tool_call_id, "content": content}
261
+ )
262
+
263
+ @staticmethod
264
+ def _parse_args(raw) -> dict:
265
+ if isinstance(raw, dict):
266
+ return raw
267
+ if not raw:
268
+ return {}
269
+ try:
270
+ parsed = json.loads(raw)
271
+ return parsed if isinstance(parsed, dict) else {}
272
+ except (json.JSONDecodeError, TypeError):
273
+ return {}
274
+
275
+ # ── tool execution ───────────────────────────────────────────────────────────
276
+ def _run_tools(self, session, tool_calls, ui, cancel_event, cfg=None) -> Optional[str]:
277
+ cfg = cfg or getattr(session, "config", None) or self.config
278
+ ctx = ToolContext(root=cfg.root, config=cfg, cancel_event=cancel_event)
279
+ mode = getattr(session, "approval_mode", "always")
280
+ # Session trust-list: tools the user chose to auto-approve for the rest of the session.
281
+ trusted = getattr(session, "trusted_tools", None)
282
+ if trusted is None:
283
+ trusted = set()
284
+ try:
285
+ session.trusted_tools = trusted
286
+ except Exception:
287
+ pass
288
+ for tc in tool_calls:
289
+ if cancel_event.is_set():
290
+ return "interrupted"
291
+ tc_id = tc.get("id", "")
292
+ fn = tc.get("function", {}) or {}
293
+ name = fn.get("name", "")
294
+ args = self._parse_args(fn.get("arguments"))
295
+
296
+ tool = tools.get(name)
297
+ if tool is None:
298
+ self._append_tool(session, tc_id, f"error: unknown tool '{name}'")
299
+ continue
300
+
301
+ # Approval policy: auto-approve when the mode/trust allows it, else prompt.
302
+ if needs_prompt(mode, tool, args, trusted) is None:
303
+ if not getattr(tool, "auto_approve", False):
304
+ ui.auto_approved(tool, args) # meta tools render their own output
305
+ else:
306
+ approval = ui.approve(tool, args, ctx)
307
+ if approval.decision == Decision.ABORT:
308
+ self._append_tool(session, tc_id, "user aborted the operation")
309
+ return "abort"
310
+ if approval.decision == Decision.SKIP:
311
+ self._append_tool(
312
+ session, tc_id, "user declined this action; consider another approach"
313
+ )
314
+ ui.tool_result(name, ToolResult(ok=False, summary="skipped"))
315
+ continue
316
+ if approval.decision == Decision.APPROVE_TOOL:
317
+ trusted.add(name)
318
+ ui.assistant(f"trusting {name} for the rest of this session.")
319
+ elif approval.decision == Decision.APPROVE_SESSION:
320
+ session.approval_mode = mode = "yolo"
321
+ ui.assistant("auto-approving all tool calls for this session (yolo).")
322
+ args = approval.args
323
+
324
+ try:
325
+ with ui.activity(f"running {name}…", cancel_event):
326
+ result = tool.run(args, ctx)
327
+ except Interrupted:
328
+ return "interrupted"
329
+ except Exception as exc: # a tool must never take down the loop
330
+ result = ToolResult.fail(f"tool crashed: {exc}")
331
+
332
+ self._handle_result(session, name, result, ui)
333
+ payload = result.content if result.ok else f"ERROR: {result.error}"
334
+ self._append_tool(session, tc_id, payload or "(no output)")
335
+ return None
336
+
337
+ @staticmethod
338
+ def _handle_result(session, name: str, result: ToolResult, ui) -> None:
339
+ """Surface a tool result: render a plan update specially, else the generic result line."""
340
+ plan = result.meta.get("plan") if (result.ok and isinstance(result.meta, dict)) else None
341
+ if plan is not None:
342
+ try:
343
+ session.plan = plan
344
+ except Exception:
345
+ pass
346
+ if hasattr(ui, "plan"):
347
+ ui.plan(plan)
348
+ return
349
+ ui.tool_result(name, result)
350
+
scootcli/approvals.py ADDED
@@ -0,0 +1,167 @@
1
+ """Human-in-the-loop approvals (PLAN §9).
2
+
3
+ Default policy is ``always``: every tool call is shown and must be approved. The prompt renders the
4
+ tool name + arguments and, for writes/shell, a rich preview (diff or command). Inline single-key
5
+ controls:
6
+
7
+ [a] approve once [t] trust this tool for the session [A] approve all this session (yolo)
8
+ [e] edit arguments [s] skip (tell the model no) [q] abort the agent
9
+
10
+ ``[t]`` and ``[A]`` fight approval fatigue on read-heavy tasks; the shell denylist still re-confirms
11
+ catastrophic commands even after ``[A]``.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import re
18
+ from dataclasses import dataclass
19
+ from enum import Enum
20
+
21
+ from .keys import read_key
22
+ from .rendering import color, eprint
23
+ from .tools.base import Tool, ToolContext
24
+
25
+
26
+ class Decision(Enum):
27
+ APPROVE = "approve"
28
+ APPROVE_TOOL = "approve_tool" # trust this tool for the rest of the session
29
+ APPROVE_SESSION = "approve_session" # approve everything for the rest of the session (yolo)
30
+ SKIP = "skip"
31
+ ABORT = "abort"
32
+
33
+
34
+ @dataclass
35
+ class Approval:
36
+ decision: Decision
37
+ args: dict # possibly edited by the user
38
+
39
+
40
+ # ── Approval modes (increasing autonomy) ───────────────────────────────────────
41
+ # always — prompt for every tool call
42
+ # auto-read — auto-approve read-only tools; prompt writes & shell
43
+ # auto-edits — auto-approve reads AND file writes/edits; prompt shell only
44
+ # yolo — auto-approve everything (denylisted shell commands still confirmed)
45
+ MODES = ("always", "auto-read", "auto-edits", "yolo")
46
+
47
+ # Catastrophic shell commands that are ALWAYS confirmed, even in yolo mode.
48
+ _DENYLIST = [
49
+ (re.compile(r"\brm\s+-[a-z]*r[a-z]*f|\brm\s+-[a-z]*f[a-z]*r", re.I), "recursive force delete"),
50
+ (re.compile(r":\(\)\s*\{.*\|.*&\s*\}\s*;", re.S), "fork bomb"),
51
+ (re.compile(r"\bgit\s+push\b", re.I), "git push"),
52
+ (re.compile(r"\bsudo\b", re.I), "sudo"),
53
+ (re.compile(r"\b(shutdown|reboot|halt|poweroff)\b", re.I), "power/shutdown"),
54
+ (re.compile(r"\bmkfs\b|\bdd\s+.*of=/dev/", re.I), "disk/format command"),
55
+ (re.compile(r"\b(curl|wget|nc|ncat|scp|sftp)\b", re.I), "outbound network command"),
56
+ (re.compile(r"\bchmod\s+-[a-z]*r|\bchown\s+-[a-z]*r", re.I), "recursive permission change"),
57
+ (re.compile(r">\s*/dev/(sd|nvme|disk)", re.I), "write to raw disk"),
58
+ ]
59
+
60
+
61
+ def denylisted_reason(command: str):
62
+ """Return a reason string if the shell command matches the always-confirm denylist, else None."""
63
+ for pattern, reason in _DENYLIST:
64
+ if pattern.search(command or ""):
65
+ return reason
66
+ return None
67
+
68
+
69
+ def needs_prompt(mode: str, tool: Tool, args: dict, trusted=()) -> "str | None":
70
+ """Decide whether a tool call must be prompted.
71
+
72
+ Returns ``None`` if it may be auto-approved, or a short reason string if a prompt is required.
73
+ ``trusted`` is the set of tool names the user chose to auto-approve for this session.
74
+ Denylisted shell commands are ALWAYS confirmed, regardless of mode or trust.
75
+ """
76
+ if tool.name == "run_shell":
77
+ reason = denylisted_reason(args.get("command", ""))
78
+ if reason:
79
+ return f"blocked command ({reason})"
80
+ if getattr(tool, "auto_approve", False):
81
+ return None # side-effect-free meta tools (e.g. update_plan) never prompt
82
+ if mode == "yolo":
83
+ return None
84
+ if tool.name in (trusted or ()):
85
+ return None
86
+ if mode == "auto-read":
87
+ return None if tool.risk == "read" else "write/shell action"
88
+ if mode == "auto-edits":
89
+ return None if tool.risk in ("read", "write") else "shell action"
90
+ return "approval required" # always
91
+
92
+
93
+ _RISK_COLOR = {"read": "cyan", "write": "yellow", "shell": "red"}
94
+
95
+
96
+ def _render(tool: Tool, args: dict, ctx: ToolContext) -> None:
97
+ dot = color("●", _RISK_COLOR.get(tool.risk, "cyan"))
98
+ print(f"{dot} {color(tool.name, 'bold')} {color(json.dumps(args, ensure_ascii=False), 'gray')}")
99
+ try:
100
+ preview = tool.preview(args, ctx)
101
+ except Exception as exc: # a preview must never crash the loop
102
+ preview = color(f"(preview unavailable: {exc})", "gray")
103
+ if preview:
104
+ for line in preview.splitlines():
105
+ print(" " + line)
106
+
107
+
108
+ def _edit_args(args: dict) -> dict:
109
+ """Let the user type replacement JSON for the tool arguments."""
110
+ print(color(" current args: ", "gray") + json.dumps(args, ensure_ascii=False))
111
+ print(color(" enter new JSON (blank to keep): ", "gray"), end="", flush=True)
112
+ try:
113
+ raw = input()
114
+ except (EOFError, KeyboardInterrupt):
115
+ return args
116
+ raw = raw.strip()
117
+ if not raw:
118
+ return args
119
+ try:
120
+ new = json.loads(raw)
121
+ if isinstance(new, dict):
122
+ return new
123
+ eprint(color(" (ignored: not a JSON object)", "yellow"))
124
+ except json.JSONDecodeError as exc:
125
+ eprint(color(f" (ignored: invalid JSON: {exc})", "yellow"))
126
+ return args
127
+
128
+
129
+ def request_approval(tool: Tool, args: dict, ctx: ToolContext) -> Approval:
130
+ """Show the pending tool call and read the user's decision (with optional arg editing)."""
131
+ while True:
132
+ _render(tool, args, ctx)
133
+ print(
134
+ " "
135
+ + color("approve", "green")
136
+ + " [a] "
137
+ + color(f"trust {tool.name}", "green")
138
+ + " [t] "
139
+ + color("all-session", "green")
140
+ + " [A] "
141
+ + color("edit", "cyan")
142
+ + " [e] "
143
+ + color("skip", "yellow")
144
+ + " [s] "
145
+ + color("quit", "red")
146
+ + " [q] › ",
147
+ end="",
148
+ flush=True,
149
+ )
150
+ key = read_key()
151
+ print(key) # echo the choice for a clean transcript
152
+
153
+ if key in ("a", "y", "\r", "\n", ""):
154
+ return Approval(Decision.APPROVE, args)
155
+ if key == "t":
156
+ return Approval(Decision.APPROVE_TOOL, args)
157
+ if key == "A":
158
+ return Approval(Decision.APPROVE_SESSION, args)
159
+ if key == "e":
160
+ args = _edit_args(args)
161
+ continue
162
+ if key == "s":
163
+ return Approval(Decision.SKIP, args)
164
+ if key in ("q", "\x1b"):
165
+ return Approval(Decision.ABORT, args)
166
+ # Unknown key: re-prompt.
167
+
scootcli/auth.py ADDED
@@ -0,0 +1,59 @@
1
+ """Where API keys come from, per provider.
2
+
3
+ Order: the provider's environment variables (the vendor's standard names, ``OPENAI_API_KEY`` and so
4
+ on, which a ``.env`` may have supplied), then scoot's own saved credentials (``scoot auth set``).
5
+ Providers that need no key (a local Ollama) are always considered configured.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from typing import List, Optional
12
+
13
+ from . import credentials
14
+ from .providers.base import ProviderSpec
15
+
16
+
17
+ def api_key_for(spec: ProviderSpec) -> Optional[str]:
18
+ for env in spec.key_env:
19
+ value = (os.environ.get(env) or "").strip()
20
+ if value:
21
+ return value
22
+ return credentials.load_key(spec.name)
23
+
24
+
25
+ def key_source(spec: ProviderSpec) -> Optional[str]:
26
+ """``"env:OPENAI_API_KEY"``, ``"saved"``, or ``None`` when no key is available."""
27
+ for env in spec.key_env:
28
+ if (os.environ.get(env) or "").strip():
29
+ return f"env:{env}"
30
+ if credentials.load_key(spec.name):
31
+ return "saved"
32
+ return None
33
+
34
+
35
+ def is_configured(spec: ProviderSpec) -> bool:
36
+ return (not spec.key_required) or api_key_for(spec) is not None
37
+
38
+
39
+ def status_rows(config) -> List[dict]:
40
+ """One row per registered provider: name, key source, whether a key is required, default flag."""
41
+ from .providers import registry
42
+
43
+ default = registry.default_provider_name(config)
44
+ rows = []
45
+ for spec in registry.all_specs():
46
+ rows.append({
47
+ "name": spec.name,
48
+ "source": key_source(spec),
49
+ "required": spec.key_required,
50
+ "configured": is_configured(spec),
51
+ "default": spec.name == default,
52
+ "base_url": registry.base_url_for(spec),
53
+ })
54
+ return rows
55
+
56
+
57
+ def missing_key_hint(spec: ProviderSpec) -> str:
58
+ env = spec.key_env[0] if spec.key_env else f"{spec.name.upper()}_API_KEY"
59
+ return f"set {env} (in the environment or ~/.config/scoot/.env) or run `scoot auth set {spec.name}`"