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/cli.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""scoot command-line entrypoint.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
* ``scoot`` — open the interactive REPL (banner, ESC-interrupt, slash-cmds)
|
|
5
|
+
* ``scoot "a prompt"`` — one-shot agentic turn (tools + approvals), then exit
|
|
6
|
+
* ``scoot explain <path>`` — preset: explain file(s) (read-only)
|
|
7
|
+
* ``scoot edit <path> -m "..."`` — preset: edit file(s) with an instruction
|
|
8
|
+
* ``scoot models [--provider X]`` — list available models (per provider)
|
|
9
|
+
* ``scoot auth [set|clear <provider>]`` — show provider keys, or save / forget one
|
|
10
|
+
|
|
11
|
+
Global flags override config: ``--model``, ``--proxy``, ``--root``,
|
|
12
|
+
``--approval``/``--yolo``, ``--verbose``, ``--json``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json as _json
|
|
19
|
+
import sys
|
|
20
|
+
from typing import List, Optional
|
|
21
|
+
|
|
22
|
+
from .providers import ProviderPool
|
|
23
|
+
from . import __version__
|
|
24
|
+
from .config import Config
|
|
25
|
+
from .errors import ScootError
|
|
26
|
+
from .rendering import color, eprint, redact
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
30
|
+
parser = argparse.ArgumentParser(
|
|
31
|
+
prog="scoot",
|
|
32
|
+
description="scoot: a tiny coding agent that goes where you point it.",
|
|
33
|
+
)
|
|
34
|
+
# Global flags (CLI-flag layer of config precedence).
|
|
35
|
+
parser.add_argument("--version", action="version", version=f"scoot {__version__}")
|
|
36
|
+
parser.add_argument("--model", help="Model as provider/model (e.g. openai/gpt-5.3-codex), 'default', or 'auto'.")
|
|
37
|
+
parser.add_argument("--provider", help="Default provider (openai, ollama, ...) for bare model names.")
|
|
38
|
+
parser.add_argument("--effort", choices=["low", "medium", "high", "xhigh"],
|
|
39
|
+
help="Reasoning effort for models that support it (default medium).")
|
|
40
|
+
parser.add_argument("--proxy", help="HTTPS proxy URL.")
|
|
41
|
+
parser.add_argument("--root", help="Workspace root directory.")
|
|
42
|
+
parser.add_argument("--approval", choices=["always", "auto-read", "auto-edits", "yolo"],
|
|
43
|
+
help="Approval mode for tool calls.")
|
|
44
|
+
parser.add_argument("--yolo", action="store_true", help="Auto-approve all tool calls (shortcut).")
|
|
45
|
+
parser.add_argument("--yes", "-y", action="store_true",
|
|
46
|
+
help="Auto-approve all tool calls for this run (handy for one-shot/scripted use).")
|
|
47
|
+
parser.add_argument("-m", "--message", help="Instruction for 'edit' / focus for 'explain'.")
|
|
48
|
+
parser.add_argument("--json", action="store_true", help="Machine-readable output (models / one-shot).")
|
|
49
|
+
parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output.")
|
|
50
|
+
parser.add_argument("--no-stream", action="store_true",
|
|
51
|
+
help="Disable live token streaming (wait for the full response).")
|
|
52
|
+
parser.add_argument("--no-panel", action="store_true",
|
|
53
|
+
help="Disable the persistent bottom status bar in the REPL.")
|
|
54
|
+
parser.add_argument("--no-dock", action="store_true",
|
|
55
|
+
help="Disable the fixed bottom input line (use a plain inline prompt).")
|
|
56
|
+
parser.add_argument("--vision", action="store_true",
|
|
57
|
+
help="With 'models': list only vision-capable models.")
|
|
58
|
+
parser.add_argument("--no-workspace", action="store_true",
|
|
59
|
+
help="Don't inject the repo map (git + file tree) into the agent prompt.")
|
|
60
|
+
parser.add_argument("--no-labels", action="store_true",
|
|
61
|
+
help="Hide the role labels/gutters (❯ you / ⏺ scoot) in the REPL transcript.")
|
|
62
|
+
parser.add_argument("--no-logo", action="store_true",
|
|
63
|
+
help="Hide the mascot (launch banner art + status-bar face). Persist with /logo off.")
|
|
64
|
+
parser.add_argument("--no-emoji", action="store_true",
|
|
65
|
+
help="Use the plain ⏺ transcript label instead of 🛴 (terminals without emoji).")
|
|
66
|
+
parser.add_argument("--no-images", action="store_true",
|
|
67
|
+
help="Don't detect/describe image files dropped into the prompt.")
|
|
68
|
+
parser.add_argument("--vision-model",
|
|
69
|
+
help="Vision model id for describing dropped images (or 'auto').")
|
|
70
|
+
parser.add_argument("-c", "--continue", dest="continue_latest", action="store_true",
|
|
71
|
+
help="Resume the most recent session for this directory.")
|
|
72
|
+
parser.add_argument("--resume", metavar="ID", help="Resume a specific saved session by id.")
|
|
73
|
+
parser.add_argument("--resume-last", action="store_true",
|
|
74
|
+
help="Auto-resume the latest session for this directory on launch (resume=auto).")
|
|
75
|
+
|
|
76
|
+
# Positional: a reserved word ('models'), a preset ('explain'/'edit'), or a free-form prompt.
|
|
77
|
+
parser.add_argument(
|
|
78
|
+
"prompt",
|
|
79
|
+
nargs="*",
|
|
80
|
+
help="A prompt, the word 'models', or a preset ('explain <path>' / 'edit <path> -m ...').",
|
|
81
|
+
)
|
|
82
|
+
return parser
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _config_from_args(args: argparse.Namespace) -> Config:
|
|
86
|
+
return Config.load().override(
|
|
87
|
+
model=args.model,
|
|
88
|
+
provider=args.provider,
|
|
89
|
+
effort=args.effort,
|
|
90
|
+
proxy=args.proxy,
|
|
91
|
+
root=args.root,
|
|
92
|
+
approval="yolo" if (args.yolo or args.yes) else args.approval,
|
|
93
|
+
verbose=True if args.verbose else None,
|
|
94
|
+
stream=False if args.no_stream else None,
|
|
95
|
+
panel=False if args.no_panel else None,
|
|
96
|
+
dock=False if args.no_dock else None,
|
|
97
|
+
resume="auto" if args.resume_last else None,
|
|
98
|
+
workspace_context=False if args.no_workspace else None,
|
|
99
|
+
labels=False if args.no_labels else None,
|
|
100
|
+
images=False if args.no_images else None,
|
|
101
|
+
logo=False if args.no_logo else None,
|
|
102
|
+
emoji=False if args.no_emoji else None,
|
|
103
|
+
vision_model=args.vision_model,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _cmd_models(pool: ProviderPool, as_json: bool, vision_only: bool = False,
|
|
108
|
+
provider: Optional[str] = None) -> int:
|
|
109
|
+
models = pool.list_models(provider=provider)
|
|
110
|
+
if vision_only:
|
|
111
|
+
from .models import is_vision_model
|
|
112
|
+
|
|
113
|
+
models = [m for m in models if is_vision_model({"id": m.id, **m.raw})]
|
|
114
|
+
if as_json:
|
|
115
|
+
print(_json.dumps({
|
|
116
|
+
"models": [{"id": m.id, "provider": m.provider, "name": m.name} for m in models],
|
|
117
|
+
"errors": {k: redact(v) for k, v in pool.list_errors.items()},
|
|
118
|
+
}, indent=2))
|
|
119
|
+
return 0
|
|
120
|
+
for name, err in sorted(pool.list_errors.items()):
|
|
121
|
+
eprint(color(f"⚠ {name}: {redact(err)}", "yellow"))
|
|
122
|
+
if not models:
|
|
123
|
+
print("No models returned.")
|
|
124
|
+
return 0
|
|
125
|
+
by_provider: dict = {}
|
|
126
|
+
for m in models:
|
|
127
|
+
by_provider.setdefault(m.provider, []).append(m)
|
|
128
|
+
default = pool.default_name
|
|
129
|
+
for name in sorted(by_provider):
|
|
130
|
+
tag = " (default)" if name == default else ""
|
|
131
|
+
print(color(f"{name}{tag}", "bold"))
|
|
132
|
+
for m in sorted(by_provider[name], key=lambda x: x.name):
|
|
133
|
+
print(f" {color(m.id, 'cyan')}")
|
|
134
|
+
return 0
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _run_once(config: Config, pool: ProviderPool, prompt: str, as_json: bool, resume=None) -> int:
|
|
138
|
+
"""Run a single agentic turn (tools + approvals) and print the result, then exit."""
|
|
139
|
+
import threading
|
|
140
|
+
|
|
141
|
+
from .agent import Agent
|
|
142
|
+
from .repl import ReplSession, ReplUI
|
|
143
|
+
|
|
144
|
+
session = ReplSession(config, pool)
|
|
145
|
+
if resume is not None:
|
|
146
|
+
session.apply_record(resume)
|
|
147
|
+
# Fold any dropped image paths into the prompt (best-effort; no-op when none/disabled).
|
|
148
|
+
try:
|
|
149
|
+
from .vision import fold_images_into_text
|
|
150
|
+
|
|
151
|
+
prompt = fold_images_into_text(prompt, config, pool)
|
|
152
|
+
except Exception:
|
|
153
|
+
pass
|
|
154
|
+
session.messages.append({"role": "user", "content": prompt})
|
|
155
|
+
# JSON output must be clean, so never stream tokens to stdout in that mode.
|
|
156
|
+
agent_config = config.override(stream=False) if as_json else config
|
|
157
|
+
outcome = Agent(agent_config, pool).run_turn(session, ReplUI(), threading.Event())
|
|
158
|
+
session.autosave() # persist so `scoot -c` can continue this conversation
|
|
159
|
+
|
|
160
|
+
if as_json:
|
|
161
|
+
print(_json.dumps({
|
|
162
|
+
"status": outcome.status,
|
|
163
|
+
"model": session.active_model,
|
|
164
|
+
"steps": outcome.steps,
|
|
165
|
+
"content": outcome.content,
|
|
166
|
+
"error": outcome.error,
|
|
167
|
+
"usage": session.last_usage,
|
|
168
|
+
}, indent=2))
|
|
169
|
+
return 0 if outcome.status == "done" else 1
|
|
170
|
+
|
|
171
|
+
if outcome.status == "done":
|
|
172
|
+
if outcome.content.strip() and not outcome.streamed:
|
|
173
|
+
print(outcome.content.strip())
|
|
174
|
+
if config.verbose:
|
|
175
|
+
u = session.last_usage
|
|
176
|
+
eprint(color(f"[{session.active_model}] steps={outcome.steps} "
|
|
177
|
+
f"prompt={u.get('prompt_tokens', '?')} "
|
|
178
|
+
f"completion={u.get('completion_tokens', '?')}", "gray"))
|
|
179
|
+
return 0
|
|
180
|
+
eprint(color(f"⚠ {redact(outcome.error or outcome.status)}", "red"))
|
|
181
|
+
return 1
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _cmd_auth(config: Config, pool: ProviderPool, words: List[str]) -> int:
|
|
185
|
+
"""``scoot auth [set|clear <provider>]``: the /auth slash-command from the shell."""
|
|
186
|
+
from . import commands
|
|
187
|
+
from .repl import ReplSession
|
|
188
|
+
|
|
189
|
+
commands.load_builtins()
|
|
190
|
+
session = ReplSession(config, pool)
|
|
191
|
+
commands.get("auth").handler(session, " ".join(words))
|
|
192
|
+
return 0
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _interactive(pool: ProviderPool, resume=None) -> int:
|
|
196
|
+
"""Launch the persistent REPL (banner, live status, ESC-interrupt, slash-commands)."""
|
|
197
|
+
from .auth import is_configured, missing_key_hint
|
|
198
|
+
from .repl import Repl
|
|
199
|
+
|
|
200
|
+
config = pool.config
|
|
201
|
+
# resume=auto (SCOOT_RESUME / --resume-last): reload the latest session for this directory
|
|
202
|
+
# unless the user already picked one explicitly via --resume/--continue.
|
|
203
|
+
if resume is None and getattr(config, "resume", "hint") == "auto":
|
|
204
|
+
from . import sessions
|
|
205
|
+
|
|
206
|
+
resume = sessions.latest_for_root(str(config.root))
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
spec = pool.spec
|
|
210
|
+
if not is_configured(spec): # first-run onboarding: guide, but still open the REPL
|
|
211
|
+
eprint(color(f"No API key for {spec.name}: {missing_key_hint(spec)}", "yellow"))
|
|
212
|
+
except ScootError as exc:
|
|
213
|
+
eprint(color(f"⚠ {redact(str(exc))}", "yellow"))
|
|
214
|
+
return Repl(config, pool, resume=resume).run()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _resolve_resume(config: Config, args: argparse.Namespace):
|
|
218
|
+
"""Return a SessionRecord to resume (from --resume/--continue), or None. Exits on a bad id."""
|
|
219
|
+
from . import sessions
|
|
220
|
+
|
|
221
|
+
if args.resume:
|
|
222
|
+
record = sessions.load(args.resume)
|
|
223
|
+
if record is None:
|
|
224
|
+
eprint(color(f"no session with id '{args.resume}' (try running /sessions).", "yellow"))
|
|
225
|
+
raise SystemExit(1)
|
|
226
|
+
return record
|
|
227
|
+
if args.continue_latest:
|
|
228
|
+
record = sessions.latest_for_root(str(config.root))
|
|
229
|
+
if record is None:
|
|
230
|
+
eprint(color("no saved session to continue for this directory.", "yellow"))
|
|
231
|
+
raise SystemExit(1)
|
|
232
|
+
return record
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _resolve_prompt(args: argparse.Namespace) -> Optional[str]:
|
|
237
|
+
"""Turn positional args (+ presets) into a task prompt, or None for the REPL."""
|
|
238
|
+
from . import presets
|
|
239
|
+
|
|
240
|
+
words = args.prompt
|
|
241
|
+
if not words:
|
|
242
|
+
return None
|
|
243
|
+
head, rest = words[0], words[1:]
|
|
244
|
+
if presets.is_preset(head):
|
|
245
|
+
return presets.build_prompt(head, rest, args.message)
|
|
246
|
+
return " ".join(words)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
250
|
+
args = _build_parser().parse_args(argv)
|
|
251
|
+
try:
|
|
252
|
+
config = _config_from_args(args)
|
|
253
|
+
pool = ProviderPool(config)
|
|
254
|
+
|
|
255
|
+
if args.prompt and args.prompt[0] == "models":
|
|
256
|
+
return _cmd_models(pool, args.json, vision_only=args.vision, provider=args.provider)
|
|
257
|
+
|
|
258
|
+
if args.prompt and args.prompt[0] == "auth":
|
|
259
|
+
return _cmd_auth(config, pool, args.prompt[1:])
|
|
260
|
+
|
|
261
|
+
prompt = _resolve_prompt(args)
|
|
262
|
+
resume = _resolve_resume(config, args)
|
|
263
|
+
if prompt is not None:
|
|
264
|
+
return _run_once(config, pool, prompt, args.json, resume=resume)
|
|
265
|
+
return _interactive(pool, resume=resume)
|
|
266
|
+
except ScootError as exc:
|
|
267
|
+
eprint(color(f"⚠ {redact(str(exc))}", "red"))
|
|
268
|
+
return 1
|
|
269
|
+
except KeyboardInterrupt:
|
|
270
|
+
print()
|
|
271
|
+
return 130
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
if __name__ == "__main__":
|
|
275
|
+
sys.exit(main())
|
|
276
|
+
|
scootcli/clipboard.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Copy text to the system clipboard using stdlib only (PLAN §3 / M21).
|
|
2
|
+
|
|
3
|
+
Strategy (first that works wins):
|
|
4
|
+
1. A platform clipboard binary via ``subprocess`` — ``pbcopy`` (macOS), ``wl-copy`` / ``xclip`` /
|
|
5
|
+
``xsel`` (Linux), ``clip.exe`` (WSL/Windows).
|
|
6
|
+
2. An **OSC 52** terminal escape written to the TTY (``\\033]52;c;<base64>\\a``) — works over SSH with
|
|
7
|
+
no helper binary, when the terminal supports it.
|
|
8
|
+
|
|
9
|
+
Everything degrades gracefully: :func:`copy_to_clipboard` returns ``False`` when no path works, so the
|
|
10
|
+
caller can show a friendly note instead of raising.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
# (binary name, extra args) in priority order.
|
|
21
|
+
_BINARIES = (
|
|
22
|
+
("pbcopy", ()), # macOS
|
|
23
|
+
("wl-copy", ()), # Wayland
|
|
24
|
+
("xclip", ("-selection", "clipboard")), # X11
|
|
25
|
+
("xsel", ("--clipboard", "--input")), # X11
|
|
26
|
+
("clip.exe", ()), # WSL / Windows
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# Many terminals cap the OSC 52 payload; bail on very large content rather than corrupt the clipboard.
|
|
30
|
+
_OSC52_MAX_B64 = 100_000
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _via_binary(text: str) -> bool:
|
|
34
|
+
"""Pipe ``text`` into the first available clipboard binary on ``PATH``."""
|
|
35
|
+
for name, args in _BINARIES:
|
|
36
|
+
path = shutil.which(name)
|
|
37
|
+
if not path:
|
|
38
|
+
continue
|
|
39
|
+
try:
|
|
40
|
+
subprocess.run(
|
|
41
|
+
[path, *args],
|
|
42
|
+
input=text.encode("utf-8"),
|
|
43
|
+
check=True,
|
|
44
|
+
stdout=subprocess.DEVNULL,
|
|
45
|
+
stderr=subprocess.DEVNULL,
|
|
46
|
+
)
|
|
47
|
+
return True
|
|
48
|
+
except Exception:
|
|
49
|
+
continue # try the next backend
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _via_osc52(text: str) -> bool:
|
|
54
|
+
"""Set the clipboard via an OSC 52 escape (SSH-friendly; needs terminal support)."""
|
|
55
|
+
if not sys.stdout.isatty():
|
|
56
|
+
return False
|
|
57
|
+
try:
|
|
58
|
+
b64 = base64.b64encode(text.encode("utf-8")).decode("ascii")
|
|
59
|
+
except Exception:
|
|
60
|
+
return False
|
|
61
|
+
if len(b64) > _OSC52_MAX_B64:
|
|
62
|
+
return False
|
|
63
|
+
try:
|
|
64
|
+
sys.stdout.write(f"\033]52;c;{b64}\a")
|
|
65
|
+
sys.stdout.flush()
|
|
66
|
+
except Exception:
|
|
67
|
+
return False
|
|
68
|
+
return True
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def copy_to_clipboard(text: str) -> bool:
|
|
72
|
+
"""Copy ``text`` to the clipboard. Returns ``True`` on success, ``False`` if no method worked."""
|
|
73
|
+
if not text:
|
|
74
|
+
return False
|
|
75
|
+
return _via_binary(text) or _via_osc52(text)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def copy_session_output(session) -> "tuple[bool, str]":
|
|
79
|
+
"""Copy the session's last assistant answer. Returns ``(ok, human_message)``.
|
|
80
|
+
|
|
81
|
+
Shared by the ``/c`` slash-command and the in-editor Ctrl-S hotkey so both behave identically.
|
|
82
|
+
"""
|
|
83
|
+
text = (getattr(session, "last_output", "") or "").strip()
|
|
84
|
+
if not text:
|
|
85
|
+
return False, "nothing to copy yet — ask me something first."
|
|
86
|
+
if copy_to_clipboard(text):
|
|
87
|
+
return True, f"copied last answer ({len(text)} chars) to the clipboard."
|
|
88
|
+
return False, "couldn't reach a clipboard (no pbcopy/xclip/OSC-52 support)."
|
|
89
|
+
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Slash-command registry. Drop a new module in this package and call :func:`register` to add one."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Dict, Optional
|
|
6
|
+
|
|
7
|
+
from .base import QUIT, SlashCommand
|
|
8
|
+
|
|
9
|
+
__all__ = ["QUIT", "SlashCommand", "register", "get", "all_commands", "load_builtins"]
|
|
10
|
+
|
|
11
|
+
_REGISTRY: Dict[str, SlashCommand] = {}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def register(command: SlashCommand) -> None:
|
|
15
|
+
_REGISTRY[command.name] = command
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get(name: str) -> Optional[SlashCommand]:
|
|
19
|
+
return _REGISTRY.get(name)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def all_commands() -> Dict[str, SlashCommand]:
|
|
23
|
+
return dict(sorted(_REGISTRY.items()))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_loaded = False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_builtins() -> None:
|
|
30
|
+
"""Import the built-in command modules so they self-register (idempotent)."""
|
|
31
|
+
global _loaded
|
|
32
|
+
if _loaded:
|
|
33
|
+
return
|
|
34
|
+
# Importing each module triggers its register() call.
|
|
35
|
+
from . import help as _help # noqa: F401
|
|
36
|
+
from . import exit as _exit # noqa: F401
|
|
37
|
+
from . import reset as _reset # noqa: F401
|
|
38
|
+
from . import save as _save # noqa: F401
|
|
39
|
+
from . import status as _status # noqa: F401
|
|
40
|
+
from . import model as _model # noqa: F401
|
|
41
|
+
from . import init as _init # noqa: F401
|
|
42
|
+
from . import compact as _compact # noqa: F401
|
|
43
|
+
from . import approve as _approve # noqa: F401
|
|
44
|
+
from . import yolo as _yolo # noqa: F401
|
|
45
|
+
from . import worktree as _worktree # noqa: F401
|
|
46
|
+
from . import auth as _auth # noqa: F401
|
|
47
|
+
from . import sessions as _sessions # noqa: F401
|
|
48
|
+
from . import resume as _resume # noqa: F401
|
|
49
|
+
from . import forget as _forget # noqa: F401
|
|
50
|
+
from . import panel as _panel # noqa: F401
|
|
51
|
+
from . import verbosity as _verbosity # noqa: F401
|
|
52
|
+
from . import copy as _copy # noqa: F401
|
|
53
|
+
from . import logo as _logo # noqa: F401
|
|
54
|
+
|
|
55
|
+
_loaded = True
|
|
56
|
+
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""/approve — view or set the session approval mode (always | auto-read | auto-edits | yolo)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..approvals import MODES
|
|
6
|
+
from ..rendering import color
|
|
7
|
+
from . import register
|
|
8
|
+
from .base import SlashCommand
|
|
9
|
+
|
|
10
|
+
_DESCRIPTIONS = {
|
|
11
|
+
"always": "prompt for every tool call (safest)",
|
|
12
|
+
"auto-read": "auto-approve read-only tools; prompt writes & shell",
|
|
13
|
+
"auto-edits": "auto-approve reads & file edits; prompt shell only",
|
|
14
|
+
"yolo": "auto-approve everything (denylisted shell commands still confirmed)",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _run(session, args: str):
|
|
19
|
+
arg = args.strip().lower()
|
|
20
|
+
if not arg:
|
|
21
|
+
print(color(f"approval mode: {session.approval_mode}", "bold"))
|
|
22
|
+
for mode in MODES:
|
|
23
|
+
mark = color(" ← current", "green") if mode == session.approval_mode else ""
|
|
24
|
+
print(f" {color(mode, 'cyan')}{mark} {color('— ' + _DESCRIPTIONS[mode], 'gray')}")
|
|
25
|
+
print(color(" use: /approve <mode> (or /yolo)", "gray"))
|
|
26
|
+
return
|
|
27
|
+
if arg not in MODES:
|
|
28
|
+
print(color(f"unknown mode '{arg}'. choose: {', '.join(MODES)}", "yellow"))
|
|
29
|
+
return
|
|
30
|
+
session.approval_mode = arg
|
|
31
|
+
print(color(f"approval mode set to {arg}.", "gray"))
|
|
32
|
+
if arg == "yolo":
|
|
33
|
+
print(color(" ⚠ yolo: tool calls run without asking (destructive shell commands still confirmed).", "yellow"))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
register(SlashCommand("approve", "view/set approval mode", _run,
|
|
37
|
+
usage="[always|auto-read|auto-edits|yolo]"))
|
|
38
|
+
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""/auth — show which providers have an API key, or save / forget one.
|
|
2
|
+
|
|
3
|
+
/auth table of providers: key source, default marker, base URL
|
|
4
|
+
/auth set <provider> paste a key (hidden input), validate it against the provider, save it
|
|
5
|
+
/auth clear <provider> forget the saved key (an environment key is not ours to remove)
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import getpass
|
|
11
|
+
|
|
12
|
+
from ..activity import activity
|
|
13
|
+
from ..auth import key_source, missing_key_hint, status_rows
|
|
14
|
+
from ..credentials import credentials_path, delete_key, save_key
|
|
15
|
+
from ..errors import ScootError
|
|
16
|
+
from ..providers import registry
|
|
17
|
+
from ..rendering import color, eprint, redact
|
|
18
|
+
from . import register
|
|
19
|
+
from .base import SlashCommand
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _show(session) -> None:
|
|
23
|
+
rows = status_rows(session.config)
|
|
24
|
+
print(color("providers:", "bold"))
|
|
25
|
+
for row in rows:
|
|
26
|
+
name = row["name"] + (" (default)" if row["default"] else "")
|
|
27
|
+
if not row["required"]:
|
|
28
|
+
state = color("no key needed", "gray")
|
|
29
|
+
elif row["source"]:
|
|
30
|
+
state = color(f"key from {row['source']}", "green")
|
|
31
|
+
else:
|
|
32
|
+
state = color("no key", "yellow")
|
|
33
|
+
print(f" {color(name, 'cyan'):<32} {state} {color(row['base_url'], 'gray')}")
|
|
34
|
+
print(color(" usage: /auth set <provider> · /auth clear <provider>", "gray"))
|
|
35
|
+
print(color(f" saved keys live in {credentials_path()} (owner-only)", "gray"))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _set(session, name: str) -> None:
|
|
39
|
+
spec = registry.get(name)
|
|
40
|
+
if spec is None:
|
|
41
|
+
eprint(color(f"unknown provider '{name}'. known: {', '.join(registry.names())}", "yellow"))
|
|
42
|
+
return
|
|
43
|
+
if not spec.key_required:
|
|
44
|
+
print(color(f"{name} needs no API key.", "gray"))
|
|
45
|
+
return
|
|
46
|
+
print(color(f"paste the {name} API key; it is validated before being saved and never echoed.", "gray"))
|
|
47
|
+
try:
|
|
48
|
+
key = getpass.getpass(f"{name} API key (hidden, blank to cancel): ").strip()
|
|
49
|
+
except (EOFError, KeyboardInterrupt):
|
|
50
|
+
print()
|
|
51
|
+
return
|
|
52
|
+
if not key:
|
|
53
|
+
print(color("no key entered; nothing changed.", "gray"))
|
|
54
|
+
return
|
|
55
|
+
probe = registry.make_provider(name, session.config)
|
|
56
|
+
probe._api_key = key
|
|
57
|
+
try:
|
|
58
|
+
with activity(f"checking {name} key…") as cancel:
|
|
59
|
+
probe.list_models(cancel_event=cancel)
|
|
60
|
+
except ScootError as exc:
|
|
61
|
+
eprint(color(f"✗ key rejected: {redact(str(exc))}", "red"))
|
|
62
|
+
if getattr(exc, "hint", ""):
|
|
63
|
+
print(color(f" {exc.hint}", "gray"))
|
|
64
|
+
return
|
|
65
|
+
path = save_key(name, key)
|
|
66
|
+
pool = getattr(session, "provider", None)
|
|
67
|
+
if pool is not None and hasattr(pool, "_providers"):
|
|
68
|
+
pool._providers.pop(name, None) # rebuild with the new key on next use
|
|
69
|
+
print(color(f"✔ {name} key saved to {path} (owner-only).", "green"))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _clear(session, name: str) -> None:
|
|
73
|
+
spec = registry.get(name)
|
|
74
|
+
if spec is None:
|
|
75
|
+
eprint(color(f"unknown provider '{name}'. known: {', '.join(registry.names())}", "yellow"))
|
|
76
|
+
return
|
|
77
|
+
if delete_key(name):
|
|
78
|
+
print(color(f"forgot the saved {name} key.", "green"))
|
|
79
|
+
else:
|
|
80
|
+
print(color(f"no saved {name} key.", "gray"))
|
|
81
|
+
pool = getattr(session, "provider", None)
|
|
82
|
+
if pool is not None and hasattr(pool, "_providers"):
|
|
83
|
+
pool._providers.pop(name, None)
|
|
84
|
+
src = key_source(spec)
|
|
85
|
+
if src:
|
|
86
|
+
print(color(f" note: {name} still has a key from {src} (not managed by scoot).", "gray"))
|
|
87
|
+
elif spec.key_required:
|
|
88
|
+
print(color(f" {missing_key_hint(spec)}", "gray"))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _run(session, args: str):
|
|
92
|
+
words = (args or "").split()
|
|
93
|
+
if not words:
|
|
94
|
+
_show(session)
|
|
95
|
+
return
|
|
96
|
+
action, name = words[0].lower(), (words[1].lower() if len(words) > 1 else "")
|
|
97
|
+
if action in ("set", "add", "login") and name:
|
|
98
|
+
_set(session, name)
|
|
99
|
+
elif action in ("clear", "remove", "forget", "logout") and name:
|
|
100
|
+
_clear(session, name)
|
|
101
|
+
elif action == "status":
|
|
102
|
+
_show(session)
|
|
103
|
+
else:
|
|
104
|
+
eprint(color("usage: /auth [set <provider> | clear <provider>]", "yellow"))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
register(SlashCommand("auth", "show provider keys, or set/clear one", _run, usage="[set|clear <provider>]"))
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""SlashCommand interface for the drop-in command registry (PLAN §6, §2 design principles).
|
|
2
|
+
|
|
3
|
+
Adding a new slash-command = drop a module in this package that builds a :class:`SlashCommand` and
|
|
4
|
+
calls :func:`scootcli.commands.register`. No core edits required.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import TYPE_CHECKING, Callable, Optional
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING: # avoid a circular import at runtime
|
|
13
|
+
from ..repl import ReplSession
|
|
14
|
+
|
|
15
|
+
# Control-flow signal a command may return to the REPL.
|
|
16
|
+
QUIT = "quit"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class SlashCommand:
|
|
21
|
+
"""A REPL slash-command.
|
|
22
|
+
|
|
23
|
+
``handler(session, args)`` runs the command. It may return :data:`QUIT` to end the session,
|
|
24
|
+
otherwise ``None``. ``args`` is the raw text after the command name.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
name: str
|
|
28
|
+
summary: str
|
|
29
|
+
handler: "Callable[[ReplSession, str], Optional[str]]"
|
|
30
|
+
usage: str = ""
|
|
31
|
+
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""/compact — summarize the conversation, clear it, and continue from the summary."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..activity import activity
|
|
6
|
+
from ..context import compact, estimate_context_tokens, estimate_messages_tokens
|
|
7
|
+
from ..errors import ScootError, Interrupted
|
|
8
|
+
from ..rendering import color, eprint
|
|
9
|
+
from . import register
|
|
10
|
+
from .base import SlashCommand
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _run(session, args: str):
|
|
14
|
+
if not session.messages:
|
|
15
|
+
print(color("nothing to compact.", "gray"))
|
|
16
|
+
return
|
|
17
|
+
before = estimate_context_tokens(session)
|
|
18
|
+
try:
|
|
19
|
+
with activity("compacting context…") as cancel:
|
|
20
|
+
summary = compact(session, cancel)
|
|
21
|
+
except Interrupted:
|
|
22
|
+
print(color("⏹ interrupted", "yellow"))
|
|
23
|
+
return
|
|
24
|
+
except ScootError as exc:
|
|
25
|
+
eprint(color(f"compact failed: {exc}", "red"))
|
|
26
|
+
return
|
|
27
|
+
if not summary:
|
|
28
|
+
print(color("nothing to compact.", "gray"))
|
|
29
|
+
return
|
|
30
|
+
after = estimate_messages_tokens(session.messages)
|
|
31
|
+
note = f"context compacted: ~{before} → ~{after} prompt tokens. Continuing from summary."
|
|
32
|
+
redraw = getattr(session, "redraw_home", None)
|
|
33
|
+
if callable(redraw):
|
|
34
|
+
redraw(note)
|
|
35
|
+
else:
|
|
36
|
+
print(color(note, "gray"))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
register(SlashCommand("compact", "summarize & shrink the conversation", _run))
|
|
40
|
+
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""/c — copy the most recent assistant answer to the system clipboard (PLAN §3 / M21).
|
|
2
|
+
|
|
3
|
+
The same action is available in the input dock via Ctrl-S. ⌘C can't be used: terminal emulators keep
|
|
4
|
+
the ⌘ (Super) modifier for themselves and never forward it to the program.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from ..clipboard import copy_session_output
|
|
10
|
+
from ..rendering import color, eprint
|
|
11
|
+
from . import register
|
|
12
|
+
from .base import SlashCommand
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _run(session, args: str):
|
|
16
|
+
ok, message = copy_session_output(session)
|
|
17
|
+
(print if ok else eprint)(color(message, "gray" if ok else "yellow"))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
register(SlashCommand("c", "copy the last answer to the clipboard (also Ctrl-S)", _run))
|
|
21
|
+
# Discoverable alias for people who reach for the long form.
|
|
22
|
+
register(SlashCommand("copy", "copy the last answer to the clipboard (alias of /c)", _run))
|
|
23
|
+
|