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/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
+
@@ -0,0 +1,14 @@
1
+ """/exit — quit the REPL."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from . import QUIT, register
6
+ from .base import SlashCommand
7
+
8
+
9
+ def _run(session, args: str):
10
+ return QUIT
11
+
12
+
13
+ register(SlashCommand("exit", "quit scoot", _run))
14
+