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/__init__.py
ADDED
scootcli/__main__.py
ADDED
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}`"
|