minimalcode 1.5.2__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 (58) hide show
  1. minimalcode/__init__.py +3 -0
  2. minimalcode/__main__.py +3 -0
  3. minimalcode/agent.py +1084 -0
  4. minimalcode/cli.py +1520 -0
  5. minimalcode/clipboard.py +212 -0
  6. minimalcode/config.py +1065 -0
  7. minimalcode/defaults.json +491 -0
  8. minimalcode/errors.py +31 -0
  9. minimalcode/highlight/__init__.py +47 -0
  10. minimalcode/highlight/code.py +261 -0
  11. minimalcode/highlight/color.py +58 -0
  12. minimalcode/highlight/markdown.py +710 -0
  13. minimalcode/highlight/palette.py +73 -0
  14. minimalcode/highlight/sanitize.py +175 -0
  15. minimalcode/highlight/thinking.py +71 -0
  16. minimalcode/httpclient.py +278 -0
  17. minimalcode/importer.py +253 -0
  18. minimalcode/prompt.py +195 -0
  19. minimalcode/provider/__init__.py +28 -0
  20. minimalcode/provider/anthropic.py +805 -0
  21. minimalcode/provider/anthropic_env.py +161 -0
  22. minimalcode/provider/chat.py +394 -0
  23. minimalcode/provider/client.py +243 -0
  24. minimalcode/provider/common.py +176 -0
  25. minimalcode/provider/deepseek_web.py +1158 -0
  26. minimalcode/provider/factory.py +63 -0
  27. minimalcode/provider/responses.py +692 -0
  28. minimalcode/provider/sha3_wasm_bg.wasm +0 -0
  29. minimalcode/provider/transport.py +241 -0
  30. minimalcode/provider/types.py +54 -0
  31. minimalcode/sandbox/__init__.py +64 -0
  32. minimalcode/sandbox/audit.py +120 -0
  33. minimalcode/sandbox/backends.py +343 -0
  34. minimalcode/sandbox/execution.py +435 -0
  35. minimalcode/sandbox/network.py +793 -0
  36. minimalcode/sandbox/policy.py +253 -0
  37. minimalcode/sandbox/runner.py +347 -0
  38. minimalcode/sandbox/sensitive.py +286 -0
  39. minimalcode/session.py +465 -0
  40. minimalcode/shell.py +333 -0
  41. minimalcode/subagent.py +120 -0
  42. minimalcode/tools/__init__.py +46 -0
  43. minimalcode/tools/files.py +1139 -0
  44. minimalcode/tools/process.py +164 -0
  45. minimalcode/tools/remote.py +74 -0
  46. minimalcode/tools/runtime.py +219 -0
  47. minimalcode/tools/schemas.py +418 -0
  48. minimalcode/tools/search.py +521 -0
  49. minimalcode/tools/search_tools.py +583 -0
  50. minimalcode/tools/validation.py +99 -0
  51. minimalcode/vision.py +231 -0
  52. minimalcode/web.py +238 -0
  53. minimalcode-1.5.2.dist-info/METADATA +646 -0
  54. minimalcode-1.5.2.dist-info/RECORD +58 -0
  55. minimalcode-1.5.2.dist-info/WHEEL +5 -0
  56. minimalcode-1.5.2.dist-info/entry_points.txt +2 -0
  57. minimalcode-1.5.2.dist-info/licenses/LICENSE +201 -0
  58. minimalcode-1.5.2.dist-info/top_level.txt +1 -0
minimalcode/cli.py ADDED
@@ -0,0 +1,1520 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import errno
5
+ import json
6
+ import math
7
+ import os
8
+ import stat
9
+ import sys
10
+ import time
11
+ from collections.abc import Iterator, Sequence
12
+ from contextlib import contextmanager
13
+ from pathlib import Path
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ from . import __version__
17
+ from .config import (
18
+ APP_NAME,
19
+ EFFORT_LEVELS,
20
+ Settings,
21
+ _unresolved_env,
22
+ ensure_config,
23
+ expand_env,
24
+ file_settings,
25
+ home_dir,
26
+ load_settings,
27
+ redact_config,
28
+ save_settings,
29
+ settings_path,
30
+ )
31
+ from .errors import ConfigError, MinimalCodeError
32
+ from .highlight import sanitize_terminal_text, style_activity_tag
33
+ from .sandbox import NETWORK_POLICIES, SANDBOX_BACKENDS
34
+
35
+ if TYPE_CHECKING:
36
+ from .agent import Agent, AgentResult
37
+ from .provider.anthropic_env import AnthropicEnvConfig
38
+ from .session import Session, SessionStore
39
+
40
+
41
+ def _parser() -> argparse.ArgumentParser:
42
+ parser = argparse.ArgumentParser(
43
+ prog="minimalcode",
44
+ description="Fast, minimal terminal coding agent.",
45
+ )
46
+ parser.add_argument("prompt", nargs="*", help="Initial message")
47
+ parser.add_argument("-p", "--print", dest="print_mode", action="store_true")
48
+ parser.add_argument("--json", dest="json_output", action="store_true")
49
+ parser.add_argument(
50
+ "-c", "--continue", dest="continue_session", action="store_true"
51
+ )
52
+ parser.add_argument("--session", help="Session ID, partial ID, or JSONL path")
53
+ parser.add_argument("--no-session", action="store_true")
54
+ parser.add_argument("--provider", help="Provider name from settings")
55
+ parser.add_argument(
56
+ "--anthropic-env",
57
+ action="store_true",
58
+ help=(
59
+ "Use the Anthropic Messages protocol configured by Claude Code style "
60
+ "ANTHROPIC_* / CLAUDE_CODE_* environment variables without editing "
61
+ "settings.json"
62
+ ),
63
+ )
64
+ parser.add_argument("-m", "--model", help="Provider model ID")
65
+ parser.add_argument(
66
+ "--thinking",
67
+ type=str.lower,
68
+ choices=EFFORT_LEVELS,
69
+ metavar="EFFORT",
70
+ help=(
71
+ f"Provider reasoning effort ({', '.join(EFFORT_LEVELS)}); "
72
+ "'none' disables thinking"
73
+ ),
74
+ )
75
+ parser.add_argument("--max-turns", type=int)
76
+ parser.add_argument(
77
+ "--read-only",
78
+ action="store_true",
79
+ help=(
80
+ "Keep the read-only tools (read, grep, find, read_image, web_search, "
81
+ "web_fetch) and refuse every tool that can change the workspace"
82
+ ),
83
+ )
84
+ parser.add_argument("--cwd", help="Workspace directory")
85
+ parser.add_argument("--home", help="Config directory (default ~/.MinimalCode)")
86
+ parser.add_argument(
87
+ "--sandbox",
88
+ dest="sandbox_enabled",
89
+ action=argparse.BooleanOptionalAction,
90
+ default=None,
91
+ help=(
92
+ "Enable the sandbox execution layer (off by default). The workspace "
93
+ "and sensitive-file boundary applies either way; any --sandbox-* "
94
+ "option also enables it"
95
+ ),
96
+ )
97
+ parser.add_argument(
98
+ "--sandbox-backend",
99
+ choices=SANDBOX_BACKENDS,
100
+ help="Sandbox backend: auto, local, bwrap, firejail, sandbox-exec, docker",
101
+ )
102
+ parser.add_argument(
103
+ "--sandbox-root",
104
+ action="append",
105
+ metavar="PATH",
106
+ help="Extra writable/readable root (repeatable)",
107
+ )
108
+ parser.add_argument(
109
+ "--sandbox-network",
110
+ choices=NETWORK_POLICIES,
111
+ help="Network policy: deny (default), allowlist, or allow",
112
+ )
113
+ parser.add_argument(
114
+ "--sandbox-allow-host",
115
+ action="append",
116
+ metavar="HOST",
117
+ help="Allowed host for --sandbox-network allowlist (repeatable)",
118
+ )
119
+ parser.add_argument(
120
+ "--sandbox-timeout",
121
+ type=float,
122
+ metavar="SECONDS",
123
+ help="Maximum runtime for one sandboxed command",
124
+ )
125
+ parser.add_argument(
126
+ "--sandbox-max-output",
127
+ type=int,
128
+ metavar="CHARS",
129
+ help="Maximum stdout/stderr returned for a sandboxed command",
130
+ )
131
+ parser.add_argument(
132
+ "--sandbox-allow-sensitive",
133
+ dest="sandbox_allow_sensitive",
134
+ action=argparse.BooleanOptionalAction,
135
+ default=None,
136
+ help="Allow sandboxed tools to read and write credential-like files",
137
+ )
138
+ parser.add_argument(
139
+ "--quiet",
140
+ action="store_true",
141
+ help="Hide tool activity and the token usage summary",
142
+ )
143
+ parser.add_argument(
144
+ "--no-highlight",
145
+ action="store_true",
146
+ help="Show plain Markdown without terminal highlighting",
147
+ )
148
+ parser.add_argument(
149
+ "--version", action="version", version=f"%(prog)s {__version__}"
150
+ )
151
+ return parser
152
+
153
+
154
+ _MANAGEMENT_COMMANDS = (
155
+ "config",
156
+ "import-pi",
157
+ "init",
158
+ "sandbox",
159
+ "sessions",
160
+ "shell",
161
+ )
162
+
163
+
164
+ def _management_argv(argv: Sequence[str]) -> list[str] | None:
165
+ """Reorder argv so a management command comes first, or return None.
166
+
167
+ ``minimalcode --home DIR sessions`` means the same thing as
168
+ ``minimalcode sessions --home DIR``. Reading the command word only from the
169
+ first position sent the word "sessions" to the model instead: a paid turn
170
+ that also ran tools, for what the user meant as a status read. Only
171
+ ``--home`` is lifted because every management subcommand accepts it; any
172
+ other leading option keeps the old behaviour of treating the words as a
173
+ prompt.
174
+ """
175
+ prefix: list[str] = []
176
+ index = 0
177
+ while index < len(argv):
178
+ token = argv[index]
179
+ if token == "--home" and index + 1 < len(argv):
180
+ prefix.extend(argv[index : index + 2])
181
+ index += 2
182
+ continue
183
+ if token.startswith("--home="):
184
+ prefix.append(token)
185
+ index += 1
186
+ continue
187
+ break
188
+ if index >= len(argv) or argv[index] not in _MANAGEMENT_COMMANDS:
189
+ return None
190
+ return [argv[index], *prefix, *argv[index + 1 :]]
191
+
192
+
193
+ def _management_command(argv: Sequence[str]) -> int | None:
194
+ reordered = _management_argv(argv)
195
+ if reordered is None:
196
+ return None
197
+ argv = reordered
198
+ command = argv[0]
199
+ if command == "init":
200
+ parser = argparse.ArgumentParser(prog="minimalcode init")
201
+ parser.add_argument("--force", action="store_true")
202
+ parser.add_argument("--home")
203
+ args = parser.parse_args(argv[1:])
204
+ path = ensure_config(args.home, overwrite=args.force)
205
+ print(sanitize_terminal_text(str(path)))
206
+ return 0
207
+
208
+ if command == "config":
209
+ parser = argparse.ArgumentParser(prog="minimalcode config")
210
+ parser.add_argument(
211
+ "action",
212
+ nargs="?",
213
+ choices=("show", "path", "init", "set-model", "set-provider"),
214
+ default="show",
215
+ )
216
+ parser.add_argument("value", nargs="?")
217
+ parser.add_argument("--home")
218
+ args = parser.parse_args(argv[1:])
219
+ if args.action in {"show", "path", "init"} and args.value is not None:
220
+ parser.error(f"unexpected argument: {args.value}")
221
+ if args.action == "init":
222
+ path = ensure_config(args.home)
223
+ print(sanitize_terminal_text(str(path)))
224
+ return 0
225
+ if args.action == "path":
226
+ print(sanitize_terminal_text(str(settings_path(args.home))))
227
+ return 0
228
+ settings = load_settings(args.home)
229
+ if args.action == "set-model":
230
+ if not args.value or not args.value.strip():
231
+ raise ConfigError("model ID must not be empty")
232
+ model = args.value.strip()
233
+ settings.data["model"] = model
234
+ updates: dict[str, Any] = {"model": model}
235
+ providers = settings.data.get("providers")
236
+ active_name = str(settings.data.get("provider"))
237
+ active = providers.get(active_name) if isinstance(providers, dict) else None
238
+ if isinstance(active, dict) and "model" in active:
239
+ # A provider-level model wins over the global setting; update
240
+ # it too or this command would report success and change
241
+ # nothing about the request that is actually sent.
242
+ active["model"] = model
243
+ updates["providers"] = {active_name: {"model": model}}
244
+ path = save_settings(settings, updates, args.home)
245
+ print(sanitize_terminal_text(f"{path}: model={model}"))
246
+ return 0
247
+ if args.action == "set-provider":
248
+ if not args.value:
249
+ raise ConfigError("provider name must not be empty")
250
+ provider = args.value.strip()
251
+ providers = settings.data["providers"]
252
+ config = providers.get(provider)
253
+ if not isinstance(config, dict):
254
+ raise ConfigError(f"provider {provider!r} is not defined")
255
+ settings.data["provider"] = provider
256
+ updates = {"provider": provider}
257
+ provider_model = config.get("model")
258
+ if isinstance(provider_model, str) and provider_model.strip():
259
+ settings.data["model"] = provider_model.strip()
260
+ updates["model"] = provider_model.strip()
261
+ else:
262
+ print(
263
+ sanitize_terminal_text(
264
+ f"warning: provider {provider!r} has no default model; "
265
+ f"the current model {settings.data.get('model')!r} will "
266
+ "be used until you run `minimalcode config set-model`"
267
+ ),
268
+ file=sys.stderr,
269
+ )
270
+ path = save_settings(settings, updates, args.home)
271
+ print(sanitize_terminal_text(f"{path}: provider={provider}"))
272
+ return 0
273
+ print(
274
+ json.dumps(
275
+ # Show the file the user would edit, with defaults merged but
276
+ # `${VAR}` placeholders intact, instead of the runtime view.
277
+ redact_config(file_settings(settings)),
278
+ indent=2,
279
+ ensure_ascii=False,
280
+ )
281
+ )
282
+ return 0
283
+
284
+ if command == "sessions":
285
+ from .session import SessionStore
286
+
287
+ parser = argparse.ArgumentParser(prog="minimalcode sessions")
288
+ parser.add_argument("--home")
289
+ args = parser.parse_args(argv[1:])
290
+ settings = load_settings(args.home)
291
+ rows = SessionStore(settings).list()
292
+ for row in rows:
293
+ line = (
294
+ f"{row['created_at']} {row['session_id'][:10]} "
295
+ f"{row['provider']}/{row['model']} {row['workspace']}"
296
+ )
297
+ print(sanitize_terminal_text(line))
298
+ return 0
299
+
300
+ if command == "shell":
301
+ from .shell import detect_shell
302
+
303
+ parser = argparse.ArgumentParser(prog="minimalcode shell")
304
+ parser.add_argument("--home")
305
+ args = parser.parse_args(argv[1:])
306
+ settings = load_settings(args.home)
307
+ shell = detect_shell(settings.get("shell.path"))
308
+ print(sanitize_terminal_text(f"{shell.label}\t{shell.executable}"))
309
+ return 0
310
+
311
+ if command == "sandbox":
312
+ from .sandbox import SandboxRunner, available_backends
313
+
314
+ parser = argparse.ArgumentParser(prog="minimalcode sandbox")
315
+ parser.add_argument("--home")
316
+ parser.add_argument("--cwd", help="Workspace directory")
317
+ args = parser.parse_args(argv[1:])
318
+ settings = load_settings(args.home)
319
+ workspace = _resolve_cwd(args.cwd)
320
+ runner = SandboxRunner.from_settings(settings, workspace)
321
+ payload = runner.summary()
322
+ payload["available_backends"] = available_backends()
323
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
324
+ return 0
325
+
326
+ if command == "import-pi":
327
+ from .importer import import_pi_settings
328
+
329
+ parser = argparse.ArgumentParser(prog="minimalcode import-pi")
330
+ parser.add_argument("--home", help="MinimalCode config directory")
331
+ parser.add_argument("--pi-home", help="Pi agent config directory")
332
+ # Copying credentials is opt-in: a migration command should not write a
333
+ # second plaintext copy of an API key unless it is asked to.
334
+ secrets = parser.add_mutually_exclusive_group()
335
+ secrets.add_argument(
336
+ "--secrets",
337
+ dest="copy_secrets",
338
+ action="store_true",
339
+ help="Copy API keys from Pi into settings.json",
340
+ )
341
+ secrets.add_argument(
342
+ "--no-secrets",
343
+ dest="copy_secrets",
344
+ action="store_false",
345
+ help="Skip API keys and reference environment variables (default)",
346
+ )
347
+ parser.set_defaults(copy_secrets=False)
348
+ parser.add_argument("--overwrite", action="store_true")
349
+ args = parser.parse_args(argv[1:])
350
+ result = import_pi_settings(
351
+ destination_home=args.home or home_dir(),
352
+ pi_home=args.pi_home,
353
+ include_secrets=args.copy_secrets,
354
+ overwrite=args.overwrite,
355
+ )
356
+ print(
357
+ sanitize_terminal_text(
358
+ f"imported {result.provider}/{result.model} into {result.settings_path}"
359
+ )
360
+ )
361
+ if result.skipped:
362
+ print(sanitize_terminal_text(f"skipped: {', '.join(result.skipped)}"))
363
+ return 0
364
+ return None
365
+
366
+
367
+ def _resolve_cwd(raw: str | None) -> Path:
368
+ try:
369
+ path = Path(raw).expanduser() if raw else Path.cwd()
370
+ resolved = path.resolve()
371
+ except (OSError, RuntimeError, TypeError, ValueError) as exc:
372
+ raise ConfigError(f"invalid workspace: {raw}") from exc
373
+ if not resolved.is_dir():
374
+ raise ConfigError(f"workspace is not a directory: {resolved}")
375
+ return resolved
376
+
377
+
378
+ def _sandbox_tuning_requested(args: argparse.Namespace) -> bool:
379
+ """Return True when the CLI asked for sandbox behaviour, not just its state."""
380
+ if args.sandbox_backend or args.sandbox_root or args.sandbox_network:
381
+ return True
382
+ if args.sandbox_allow_host:
383
+ return True
384
+ if args.sandbox_timeout is not None or args.sandbox_max_output is not None:
385
+ return True
386
+ return args.sandbox_allow_sensitive is not None
387
+
388
+
389
+ def _apply_sandbox_overrides(settings: Settings, args: argparse.Namespace) -> None:
390
+ """Apply CLI sandbox flags on top of the loaded settings (in memory)."""
391
+ sandbox = settings.data.setdefault("sandbox", {})
392
+ if args.sandbox_enabled is not None:
393
+ sandbox["enabled"] = args.sandbox_enabled
394
+ elif _sandbox_tuning_requested(args):
395
+ # The sandbox is off by default, so a flag that only tunes it would
396
+ # otherwise be a silent no-op.
397
+ sandbox["enabled"] = True
398
+ if args.sandbox_backend is not None:
399
+ sandbox["backend"] = args.sandbox_backend
400
+ if args.sandbox_root:
401
+ roots = list(sandbox.get("allowed_roots") or [])
402
+ for root in args.sandbox_root:
403
+ if root not in roots:
404
+ roots.append(root)
405
+ sandbox["allowed_roots"] = roots
406
+ if args.sandbox_network is not None:
407
+ sandbox["network_policy"] = args.sandbox_network
408
+ if args.sandbox_allow_host:
409
+ hosts = list(sandbox.get("allowed_hosts") or [])
410
+ for host in args.sandbox_allow_host:
411
+ cleaned = host.strip().lower()
412
+ if cleaned and cleaned not in hosts:
413
+ hosts.append(cleaned)
414
+ sandbox["allowed_hosts"] = hosts
415
+ if args.sandbox_timeout is not None:
416
+ sandbox["timeout_seconds"] = args.sandbox_timeout
417
+ if args.sandbox_max_output is not None:
418
+ sandbox["max_output_chars"] = args.sandbox_max_output
419
+ if args.sandbox_allow_sensitive is not None:
420
+ sandbox["allow_sensitive_files"] = args.sandbox_allow_sensitive
421
+ if sandbox.get("network_policy") == "allowlist" and not sandbox.get(
422
+ "allowed_hosts"
423
+ ):
424
+ raise ConfigError(
425
+ "--sandbox-network allowlist requires at least one "
426
+ "--sandbox-allow-host (or sandbox.allowed_hosts)"
427
+ )
428
+
429
+
430
+ def _apply_anthropic_env(
431
+ settings: Settings, args: argparse.Namespace
432
+ ) -> AnthropicEnvConfig:
433
+ """Install the environment-derived provider in memory and return it.
434
+
435
+ The settings file stays untouched: only this process's ``settings.data``
436
+ gains the provider entry, so the next ordinary run behaves as before.
437
+ """
438
+ from .provider.anthropic_env import from_anthropic_env
439
+
440
+ env_config = from_anthropic_env(model=args.model, thinking=args.thinking)
441
+ settings.data.setdefault("providers", {})[env_config.provider] = env_config.config
442
+ settings.data["provider"] = env_config.provider
443
+ if env_config.compact_at is not None:
444
+ settings.data.setdefault("context", {})["compact_at"] = env_config.compact_at
445
+ return env_config
446
+
447
+
448
+ def _load_or_create_session(
449
+ store: SessionStore,
450
+ args: argparse.Namespace,
451
+ settings: Settings,
452
+ cwd: Path,
453
+ provider: str,
454
+ model: str,
455
+ ) -> tuple[Session, Path]:
456
+ if args.continue_session and args.session:
457
+ raise ConfigError("--continue and --session cannot be used together")
458
+ if args.no_session and (args.continue_session or args.session):
459
+ raise ConfigError(
460
+ "--no-session cannot be combined with --continue or --session"
461
+ )
462
+ if args.continue_session:
463
+ session = store.latest(cwd)
464
+ if session is None:
465
+ raise FileNotFoundError(f"no saved session for {cwd}")
466
+ if args.cwd:
467
+ session.cwd = cwd
468
+ return session, session.cwd
469
+ if args.session:
470
+ identifier = args.session.strip()
471
+ if not identifier:
472
+ raise ConfigError("--session must not be empty")
473
+ session = store.resolve(identifier, cwd)
474
+ if args.cwd:
475
+ session.cwd = cwd
476
+ return session, session.cwd
477
+ save = bool(settings.get("session.save", True)) and not args.no_session
478
+ return store.create(cwd, provider=provider, model=model, save=save), cwd
479
+
480
+
481
+ def _paste_shortcut() -> str:
482
+ """Name the terminal's paste shortcut without assuming it is Ctrl+V."""
483
+ if os.name == "nt":
484
+ return "Ctrl+V"
485
+ if sys.platform == "darwin":
486
+ return "Cmd+V"
487
+ return "Ctrl+Shift+V"
488
+
489
+
490
+ def _print_banner(
491
+ provider: str,
492
+ model: str,
493
+ shell_label: str,
494
+ effort: str,
495
+ cwd: Path,
496
+ session: Session,
497
+ *,
498
+ vision_enabled: bool = False,
499
+ read_only: bool = False,
500
+ ) -> None:
501
+ print(
502
+ sanitize_terminal_text(
503
+ f"{APP_NAME} {__version__} | {provider}/{model} | {shell_label}"
504
+ f" | effort {effort}"
505
+ )
506
+ )
507
+ print(f"cwd: {sanitize_terminal_text(str(cwd))}")
508
+ if session.path:
509
+ print(f"session: {sanitize_terminal_text(str(session.path))}")
510
+ if read_only:
511
+ print("read-only: write, edit, shell, and subagent are disabled")
512
+ print("Type /help for commands. End a line with \\ to continue, or paste text.")
513
+ if vision_enabled:
514
+ print(f"Paste an image with {_paste_shortcut()}, then press Enter.")
515
+
516
+
517
+ def _ensure_newline(result: AgentResult) -> None:
518
+ if result.text and not result.text.endswith("\n"):
519
+ print()
520
+
521
+
522
+ def _format_usage(usage: dict[str, int], *, estimated: bool = False) -> str:
523
+ prompt = int(usage.get("prompt_tokens", 0) or 0)
524
+ completion = int(usage.get("completion_tokens", 0) or 0)
525
+ total = int(usage.get("total_tokens", 0) or 0) or prompt + completion
526
+ if not (prompt or completion or total):
527
+ return ""
528
+ parts = [
529
+ f"{prompt:,} prompt",
530
+ f"{completion:,} completion",
531
+ f"{total:,} total",
532
+ ]
533
+ cached = int(usage.get("cached_tokens", 0) or 0)
534
+ reasoning = int(usage.get("reasoning_tokens", 0) or 0)
535
+
536
+ if cached:
537
+ hit_rate = cached / prompt if prompt else 0.0
538
+ parts.append(f"{cached:,} cached")
539
+ parts.append(f"{hit_rate:.2%} hit rate")
540
+
541
+ if reasoning:
542
+ parts.append(f"{reasoning:,} reasoning")
543
+
544
+ line = "[usage] " + " | ".join(parts)
545
+ if estimated:
546
+ # Say so instead of presenting a character-count guess as billed usage.
547
+ line += " (estimated)"
548
+ return line
549
+
550
+
551
+ def _print_usage(result: AgentResult) -> None:
552
+ # The summary goes to stderr so piped stdout still contains only the answer.
553
+ usage = getattr(result, "run_usage", None)
554
+ if usage is None:
555
+ usage = getattr(result, "usage", None)
556
+ if not usage:
557
+ return
558
+ line = _format_usage(
559
+ usage, estimated=bool(getattr(result, "usage_estimated", False))
560
+ )
561
+ if line:
562
+ print(style_activity_tag(line, sys.stderr), file=sys.stderr)
563
+ sys.stderr.flush()
564
+
565
+
566
+ def _handle_effort(agent: Agent, text: str) -> None:
567
+ parts = text.split()
568
+ if len(parts) > 2:
569
+ print(f"usage: /effort [{'|'.join(EFFORT_LEVELS)}]", file=sys.stderr)
570
+ return
571
+ if len(parts) == 1:
572
+ print(
573
+ f"effort: {agent.provider.thinking} (available: {', '.join(EFFORT_LEVELS)})"
574
+ )
575
+ return
576
+ requested = parts[1].lower()
577
+ if requested not in EFFORT_LEVELS:
578
+ print(
579
+ f"unknown effort: {sanitize_terminal_text(parts[1])} "
580
+ f"(choose from {', '.join(EFFORT_LEVELS)})",
581
+ file=sys.stderr,
582
+ )
583
+ return
584
+ agent.provider.thinking = requested
585
+ tools = getattr(agent, "tools", None)
586
+ if tools is not None and hasattr(tools, "thinking"):
587
+ tools.thinking = requested
588
+ print(f"effort: {requested}")
589
+
590
+
591
+ def _provider_credential(config: dict[str, object]) -> tuple[str, bool]:
592
+ """Describe a provider key without printing its value and say if it is usable."""
593
+ expanded = expand_env(config)
594
+ unresolved = _unresolved_env(expanded)
595
+ if unresolved is not None:
596
+ return f"unresolved {unresolved}", False
597
+ if expanded.get("api_key"):
598
+ return "inline key (set)", True
599
+ headers = expanded.get("headers")
600
+ if isinstance(headers, dict):
601
+ names = {str(name).lower() for name in headers}
602
+ if "authorization" in names or "x-api-key" in names:
603
+ return "header credential (set)", True
604
+ env_name = expanded.get("api_key_env")
605
+ require_key = expanded.get("require_api_key", True)
606
+ if isinstance(require_key, str):
607
+ require_key = require_key.strip().lower() not in {"0", "false", "no", "off"}
608
+ if isinstance(env_name, str) and env_name:
609
+ if os.environ.get(env_name):
610
+ return f"{env_name} (set)", True
611
+ if require_key is False:
612
+ return f"{env_name} (not required)", True
613
+ return f"{env_name} (missing)", False
614
+ if require_key is False:
615
+ return "no key required", True
616
+ return "no key", False
617
+
618
+
619
+ def _print_models(agent: Agent, settings: Settings) -> None:
620
+ print(
621
+ "model: "
622
+ f"{sanitize_terminal_text(str(agent.provider.name))}/"
623
+ f"{sanitize_terminal_text(str(agent.provider.model))}"
624
+ )
625
+ providers = settings.data.get("providers", {})
626
+ detected: list[tuple[str, str, str]] = []
627
+ for name in sorted(providers):
628
+ config = providers[name]
629
+ if not isinstance(config, dict):
630
+ continue
631
+ credential, available = _provider_credential(config)
632
+ if not available:
633
+ continue
634
+ default_model = config.get("model")
635
+ model_text = (
636
+ str(default_model)
637
+ if isinstance(default_model, str) and default_model.strip()
638
+ else "-"
639
+ )
640
+ detected.append((str(name), model_text, credential))
641
+ if detected:
642
+ print(f"providers with detected credentials ({len(detected)}):")
643
+ for name, model_text, credential in detected:
644
+ print(
645
+ " "
646
+ f"{sanitize_terminal_text(name)} "
647
+ f"{sanitize_terminal_text(model_text)} "
648
+ f"{sanitize_terminal_text(credential)}"
649
+ )
650
+ else:
651
+ print("no provider credentials detected")
652
+ print("use /model provider/model to switch")
653
+
654
+
655
+ def _switch_runtime(
656
+ agent: Agent,
657
+ settings: Settings,
658
+ provider_name: str,
659
+ model: str,
660
+ ) -> tuple[str, str]:
661
+ """Build and install a new provider without losing the session history."""
662
+ from .provider import create_provider
663
+ from .tools import ToolRuntime
664
+ from .vision import ProviderVisionClient
665
+ from .web import ProviderWebClient
666
+
667
+ provider, name, resolved_model = create_provider(
668
+ settings,
669
+ provider_name=provider_name,
670
+ model=model,
671
+ thinking=agent.provider.thinking,
672
+ )
673
+ web_client: ProviderWebClient | None = None
674
+ vision_client: ProviderVisionClient | None = None
675
+ try:
676
+ web_client = ProviderWebClient(name, provider.config)
677
+ vision_client = ProviderVisionClient(
678
+ name, provider.config, model=resolved_model
679
+ )
680
+ tools = ToolRuntime(
681
+ settings,
682
+ agent.session.cwd,
683
+ agent.shell,
684
+ web_client=web_client,
685
+ vision_client=vision_client,
686
+ provider_name=name,
687
+ model=resolved_model,
688
+ thinking=provider.thinking,
689
+ read_only=getattr(agent.tools, "read_only", False),
690
+ )
691
+ old_provider = agent.provider
692
+ old_tools = agent.tools
693
+ agent.switch_runtime(provider, tools)
694
+ except BaseException:
695
+ if web_client is not None:
696
+ web_client.close()
697
+ if vision_client is not None:
698
+ vision_client.close()
699
+ provider.close()
700
+ raise
701
+ old_provider.close()
702
+ for client in (old_tools.web_client, old_tools.vision_client):
703
+ if client is not None:
704
+ client.close()
705
+ return name, resolved_model
706
+
707
+
708
+ def _handle_model(agent: Agent, settings: Settings, text: str) -> bool:
709
+ parts = text.split()
710
+ if len(parts) == 1:
711
+ _print_models(agent, settings)
712
+ return False
713
+ if len(parts) != 2:
714
+ print("usage: /model [provider/model]", file=sys.stderr)
715
+ return False
716
+
717
+ provider_name, separator, model = parts[1].partition("/")
718
+ provider_name = provider_name.strip()
719
+ model = model.strip()
720
+ if not provider_name or (separator and not model):
721
+ print("usage: /model [provider/model]", file=sys.stderr)
722
+ return False
723
+
724
+ providers = settings.data.get("providers")
725
+ config = providers.get(provider_name) if isinstance(providers, dict) else None
726
+ if not isinstance(config, dict):
727
+ available = ", ".join(sorted(providers)) if isinstance(providers, dict) else ""
728
+ message = f"unknown provider: {sanitize_terminal_text(provider_name)}"
729
+ if available:
730
+ message += f" (available: {sanitize_terminal_text(available)})"
731
+ print(message, file=sys.stderr)
732
+ return False
733
+ if not separator:
734
+ try:
735
+ _, resolved_config = settings.provider_config(provider_name)
736
+ except ConfigError as exc:
737
+ print(
738
+ f"model switch failed: {sanitize_terminal_text(str(exc))}",
739
+ file=sys.stderr,
740
+ )
741
+ return False
742
+ default_model = resolved_config.get("model")
743
+ if not isinstance(default_model, str) or not default_model.strip():
744
+ print(
745
+ f"provider {sanitize_terminal_text(provider_name)} has no default "
746
+ f"model; use /model {sanitize_terminal_text(provider_name)}/<model>",
747
+ file=sys.stderr,
748
+ )
749
+ return False
750
+ model = default_model.strip()
751
+
752
+ try:
753
+ name, resolved_model = _switch_runtime(agent, settings, provider_name, model)
754
+ except (MinimalCodeError, OSError, TypeError, ValueError) as exc:
755
+ print(
756
+ f"model switch failed: {sanitize_terminal_text(str(exc))}",
757
+ file=sys.stderr,
758
+ )
759
+ return False
760
+ print(
761
+ "model: "
762
+ f"{sanitize_terminal_text(name)}/{sanitize_terminal_text(resolved_model)}"
763
+ )
764
+ return True
765
+
766
+
767
+ def _run_once(agent: Agent, args: argparse.Namespace, prompt: str) -> int:
768
+ result = agent.run(prompt)
769
+ if args.json_output:
770
+ payload = {
771
+ "text": result.text,
772
+ "completed": result.completed,
773
+ "turns": result.turns,
774
+ # ``usage`` is this run; ``session_usage`` is the whole session, so a
775
+ # resumed run cannot silently report the cumulative total as its own.
776
+ "usage": result.run_usage,
777
+ "session_usage": result.usage,
778
+ "usage_estimated": result.usage_estimated,
779
+ "session": str(agent.session.path) if agent.session.path else None,
780
+ }
781
+ print(json.dumps(payload, ensure_ascii=False, separators=(",", ":")))
782
+ else:
783
+ _ensure_newline(result)
784
+ if not args.quiet:
785
+ _print_usage(result)
786
+ return 0 if result.completed else 1
787
+
788
+
789
+ _PASTE_START = "\x1b[200~"
790
+ _PASTE_END = "\x1b[201~"
791
+ _PASTE_SETTLE_SECONDS = 0.02
792
+ _PASTE_MAX_CHARS = 1_000_000
793
+ # Lines already buffered inside TextIOWrapper when select() only sees the raw
794
+ # file descriptor. They are drained here so a POSIX multi-line paste does not
795
+ # submit only its first line.
796
+ _INPUT_LINES: list[str] = []
797
+ # A non-blocking read can return the beginning of a line that has not arrived
798
+ # completely. Keep it here instead of treating the fragment as a message.
799
+ _INPUT_FRAGMENT = ""
800
+
801
+
802
+ def _terminal_can_paste() -> bool:
803
+ """Return True when bracketed paste can be enabled safely."""
804
+ if os.environ.get("TERM", "").lower() == "dumb":
805
+ return False
806
+ try:
807
+ return sys.stdin.isatty() and sys.stdout.isatty()
808
+ except (AttributeError, OSError, ValueError):
809
+ return False
810
+
811
+
812
+ def _write_terminal(sequence: str) -> None:
813
+ try:
814
+ sys.stdout.write(sequence)
815
+ sys.stdout.flush()
816
+ except (OSError, ValueError):
817
+ pass
818
+
819
+
820
+ @contextmanager
821
+ def _bracketed_paste() -> Iterator[None]:
822
+ """Let the terminal mark pasted text so its newlines do not submit."""
823
+ enabled = _terminal_can_paste()
824
+ if enabled:
825
+ from .highlight.color import _enable_windows_ansi
826
+
827
+ # On Windows the enable sequence only becomes a control code once
828
+ # virtual-terminal output is on; otherwise it prints as garbage.
829
+ enabled = _enable_windows_ansi(sys.stdout)
830
+ if enabled:
831
+ _write_terminal("\x1b[?2004h")
832
+ try:
833
+ yield
834
+ finally:
835
+ if enabled:
836
+ _write_terminal("\x1b[?2004l")
837
+
838
+
839
+ def _read_paste(line: str) -> str:
840
+ """Collect a bracketed paste that continues after its first line.
841
+
842
+ Reading stops exactly at the closing marker, so a newline inside the pasted
843
+ block never submits the message and text typed after the block is left for
844
+ the next prompt.
845
+ """
846
+ before, _, pasted = line.partition(_PASTE_START)
847
+ while _PASTE_END not in pasted and len(pasted) < _PASTE_MAX_CHARS:
848
+ try:
849
+ char = sys.stdin.read(1)
850
+ except (AttributeError, OSError, ValueError):
851
+ break
852
+ if not char:
853
+ break
854
+ pasted += char
855
+ end = pasted.find(_PASTE_END)
856
+ if end < 0:
857
+ return before + pasted
858
+ # Text typed after the closing marker belongs to this message too; the
859
+ # previous implementation silently dropped it.
860
+ return before + pasted[:end] + pasted[end + len(_PASTE_END) :]
861
+
862
+
863
+ def _read_buffered_line() -> str | None:
864
+ """Try to read a complete line already buffered inside ``sys.stdin``.
865
+
866
+ ``select`` only observes the file descriptor; ``input()`` may have read a
867
+ whole paste into TextIOWrapper's decoded buffer. A non-blocking ``readline``
868
+ returns that line without waiting for the terminal. A line without its
869
+ newline is a fragment, not a submitted message, so it is retained and
870
+ prepended to the next read.
871
+ """
872
+ global _INPUT_FRAGMENT
873
+ try:
874
+ fd = sys.stdin.fileno()
875
+ except (AttributeError, OSError, ValueError):
876
+ return None
877
+ set_blocking = getattr(os, "set_blocking", None)
878
+ if set_blocking is None:
879
+ # ``os.set_blocking`` only reached Windows in Python 3.12, and the
880
+ # console branch of ``_input_pending`` probes with ``msvcrt.kbhit``
881
+ # instead, so an older interpreter has nothing to peek at here.
882
+ return None
883
+ try:
884
+ set_blocking(fd, False)
885
+ try:
886
+ line = sys.stdin.readline()
887
+ except (BlockingIOError, OSError, ValueError):
888
+ return None
889
+ finally:
890
+ set_blocking(fd, True)
891
+ except (OSError, ValueError):
892
+ return None
893
+ if not line:
894
+ return None
895
+ line = _INPUT_FRAGMENT + line
896
+ _INPUT_FRAGMENT = ""
897
+ if not line.endswith("\n"):
898
+ _INPUT_FRAGMENT = line
899
+ return None
900
+ return line
901
+
902
+
903
+ def _input_pending(timeout: float = _PASTE_SETTLE_SECONDS) -> bool:
904
+ """Return True when stdin already holds more input (a multi-line paste).
905
+
906
+ A terminal without bracketed-paste support hands a pasted block over one
907
+ line at a time, so the lines are drained with a short settle window before
908
+ the first line is submitted as a message.
909
+ """
910
+ try:
911
+ if not sys.stdin.isatty():
912
+ return False
913
+ except (AttributeError, OSError, ValueError):
914
+ return False
915
+ if _INPUT_LINES:
916
+ return True
917
+ if os.name == "nt":
918
+ try:
919
+ import ctypes
920
+ import msvcrt
921
+
922
+ handle = msvcrt.get_osfhandle(sys.stdin.fileno())
923
+ mode = ctypes.c_uint32()
924
+ if not ctypes.windll.kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
925
+ # NUL and redirected character devices report isatty() but are
926
+ # not consoles; kbhit() on them would claim input forever.
927
+ return False
928
+ except (AttributeError, ImportError, OSError, ValueError):
929
+ return False
930
+ deadline = time.monotonic() + timeout
931
+ while True:
932
+ if msvcrt.kbhit():
933
+ return True
934
+ if time.monotonic() >= deadline:
935
+ return False
936
+ time.sleep(0.002)
937
+ try:
938
+ import select
939
+ except ImportError:
940
+ return False
941
+ try:
942
+ readable, _, _ = select.select([sys.stdin], [], [], timeout)
943
+ except (OSError, ValueError):
944
+ return False
945
+ if readable:
946
+ return True
947
+ line = _read_buffered_line()
948
+ if line is not None:
949
+ _INPUT_LINES.append(line)
950
+ return True
951
+ return False
952
+
953
+
954
+ def _read_input_line(prompt: str = "") -> str:
955
+ """Return a buffered line when one exists, else read from the terminal."""
956
+ global _INPUT_FRAGMENT
957
+ if _INPUT_LINES:
958
+ line = _INPUT_FRAGMENT + _INPUT_LINES.pop(0)
959
+ _INPUT_FRAGMENT = ""
960
+ else:
961
+ line = input(prompt)
962
+ if _INPUT_FRAGMENT:
963
+ line = _INPUT_FRAGMENT + line
964
+ _INPUT_FRAGMENT = ""
965
+ return line.removesuffix("\n").removesuffix("\r")
966
+
967
+
968
+ def _read_message(prompt: str = "> ") -> str:
969
+ """Read one message, keeping pasted lines and manual continuations.
970
+
971
+ A trailing backslash continues the message on the next line; ``\\\\`` at the
972
+ end of the last line means a literal backslash instead.
973
+ """
974
+ line = _read_input_line(prompt)
975
+ if _PASTE_START in line:
976
+ return _read_paste(line)
977
+ lines = [line]
978
+ while _input_pending():
979
+ lines.append(_read_input_line(""))
980
+ if len(lines) > 1:
981
+ # Pasted lines are literal text: no continuation markers to interpret.
982
+ return "\n".join(lines)
983
+ while lines[-1].endswith("\\") and not lines[-1].endswith("\\\\"):
984
+ lines[-1] = lines[-1][:-1]
985
+ lines.append(_read_input_line("... "))
986
+ if lines[-1].endswith("\\\\"):
987
+ lines[-1] = lines[-1][:-1]
988
+ return "\n".join(lines)
989
+
990
+
991
+ def _vision_enabled(agent: Agent) -> bool:
992
+ client = getattr(agent.tools, "vision_client", None)
993
+ return bool(getattr(client, "enabled", False))
994
+
995
+
996
+ def _read_clipboard_image(agent: Agent) -> tuple[str, bytes] | None:
997
+ """Return the clipboard image when vision can describe it, else None."""
998
+ if not _vision_enabled(agent):
999
+ return None
1000
+ from .clipboard import clipboard_image
1001
+
1002
+ try:
1003
+ return clipboard_image()
1004
+ except KeyboardInterrupt:
1005
+ # Cancelling the paste should return to the prompt, not end the REPL.
1006
+ print("\nclipboard image cancelled", file=sys.stderr)
1007
+ return None
1008
+ except (MinimalCodeError, OSError) as exc:
1009
+ print(
1010
+ f"clipboard image unavailable: {sanitize_terminal_text(str(exc))}",
1011
+ file=sys.stderr,
1012
+ )
1013
+ return None
1014
+
1015
+
1016
+ def _image_message(
1017
+ agent: Agent,
1018
+ image: tuple[str, bytes],
1019
+ text: str,
1020
+ ) -> str | None:
1021
+ """Merge a clipboard image into the message via the vision model.
1022
+
1023
+ Returns the text to send, or None when there is nothing left to send.
1024
+ """
1025
+ client = getattr(agent.tools, "vision_client", None)
1026
+ if client is None or not getattr(client, "enabled", False):
1027
+ print(
1028
+ "clipboard image needs provider vision; enable vision.enabled",
1029
+ file=sys.stderr,
1030
+ )
1031
+ return text or None
1032
+ media_type, data = image
1033
+ limit = int(getattr(client, "max_image_bytes", 0) or 0)
1034
+ if limit and len(data) > limit:
1035
+ print(
1036
+ f"clipboard image is too large: {len(data)} bytes exceeds "
1037
+ f"vision.max_image_bytes ({limit})",
1038
+ file=sys.stderr,
1039
+ )
1040
+ return text or None
1041
+ try:
1042
+ description = client.describe(media_type, data)
1043
+ except KeyboardInterrupt:
1044
+ print("\nimage paste cancelled", file=sys.stderr)
1045
+ return text or None
1046
+ except (MinimalCodeError, OSError) as exc:
1047
+ print(
1048
+ f"image paste failed: {sanitize_terminal_text(str(exc))}",
1049
+ file=sys.stderr,
1050
+ )
1051
+ return text or None
1052
+ note = (
1053
+ f"[pasted image from clipboard: {media_type}, {len(data)} bytes]\n"
1054
+ f"The vision model described it as follows:\n{description}"
1055
+ )
1056
+ return f"{text}\n\n{note}" if text else note
1057
+
1058
+
1059
+ def _interactive(
1060
+ agent: Agent,
1061
+ args: argparse.Namespace,
1062
+ settings: Settings,
1063
+ store: SessionStore,
1064
+ provider_name: str,
1065
+ model: str,
1066
+ ) -> int:
1067
+ _print_banner(
1068
+ provider_name,
1069
+ model,
1070
+ agent.shell.label,
1071
+ agent.provider.thinking,
1072
+ agent.session.cwd,
1073
+ agent.session,
1074
+ vision_enabled=_vision_enabled(agent),
1075
+ read_only=getattr(agent.tools, "read_only", False),
1076
+ )
1077
+ with _bracketed_paste():
1078
+ return _chat_loop(agent, args, settings, store)
1079
+
1080
+
1081
+ def _chat_loop(
1082
+ agent: Agent,
1083
+ args: argparse.Namespace,
1084
+ settings: Settings,
1085
+ store: SessionStore,
1086
+ ) -> int:
1087
+ from .tools import ToolRuntime
1088
+
1089
+ while True:
1090
+ try:
1091
+ text = _read_message("> ")
1092
+ except EOFError:
1093
+ print()
1094
+ return 0
1095
+ except KeyboardInterrupt:
1096
+ # Ctrl-C cancels the current input or an unterminated paste; it
1097
+ # must not end the session with a success status.
1098
+ _INPUT_LINES.clear()
1099
+ _INPUT_FRAGMENT = ""
1100
+ print("\ncancelled", file=sys.stderr)
1101
+ continue
1102
+ image: tuple[str, bytes] | None = None
1103
+ if not text.strip():
1104
+ # A terminal cannot carry an image through its paste shortcut, so
1105
+ # the gesture is paste followed by Enter: the empty line asks for
1106
+ # the clipboard image.
1107
+ image = _read_clipboard_image(agent)
1108
+ if image is None:
1109
+ continue
1110
+ print(
1111
+ "clipboard image attached; type a message to send with it, "
1112
+ "or press Enter to describe it",
1113
+ file=sys.stderr,
1114
+ )
1115
+ try:
1116
+ text = _read_message("> ")
1117
+ except EOFError:
1118
+ print()
1119
+ return 0
1120
+ except KeyboardInterrupt:
1121
+ _INPUT_LINES.clear()
1122
+ _INPUT_FRAGMENT = ""
1123
+ print("\ncancelled", file=sys.stderr)
1124
+ continue
1125
+ text = text.strip()
1126
+ if not text and image is None:
1127
+ continue
1128
+ command = text.lower()
1129
+ head = command.split(maxsplit=1)[0] if command else ""
1130
+ if command in {"/exit", "/quit"}:
1131
+ return 0
1132
+ if command in {"/new", "/clear"}:
1133
+ save = bool(settings.get("session.save", True)) and not args.no_session
1134
+ try:
1135
+ session = store.create(
1136
+ agent.session.cwd,
1137
+ provider=agent.provider.name,
1138
+ model=agent.provider.model,
1139
+ save=save,
1140
+ )
1141
+ agent.new_session(
1142
+ session,
1143
+ ToolRuntime(
1144
+ settings,
1145
+ session.cwd,
1146
+ agent.shell,
1147
+ web_client=agent.tools.web_client,
1148
+ vision_client=agent.tools.vision_client,
1149
+ provider_name=agent.provider.name,
1150
+ model=agent.provider.model,
1151
+ thinking=agent.provider.thinking,
1152
+ read_only=getattr(agent.tools, "read_only", False),
1153
+ ),
1154
+ )
1155
+ except (MinimalCodeError, OSError, ValueError) as exc:
1156
+ print(
1157
+ f"new session failed: {sanitize_terminal_text(str(exc))}",
1158
+ file=sys.stderr,
1159
+ )
1160
+ continue
1161
+ print("new session")
1162
+ continue
1163
+ if command == "/status":
1164
+ print(json.dumps(agent.status(), indent=2, ensure_ascii=False))
1165
+ continue
1166
+ if command == "/compact":
1167
+ try:
1168
+ changed = agent.compact()
1169
+ except (RuntimeError, OSError, ValueError) as exc:
1170
+ print(
1171
+ f"compact failed: {sanitize_terminal_text(str(exc))}",
1172
+ file=sys.stderr,
1173
+ )
1174
+ continue
1175
+ print("compacted" if changed else "nothing to compact")
1176
+ continue
1177
+ if command == "/summary":
1178
+ try:
1179
+ summary_path = agent.write_summary()
1180
+ except (MinimalCodeError, OSError) as exc:
1181
+ print(
1182
+ f"summary failed: {sanitize_terminal_text(str(exc))}",
1183
+ file=sys.stderr,
1184
+ )
1185
+ continue
1186
+ print(f"summary written to {sanitize_terminal_text(str(summary_path))}")
1187
+ continue
1188
+ if command == "/session":
1189
+ print(
1190
+ sanitize_terminal_text(
1191
+ str(agent.session.path) if agent.session.path else "<memory>"
1192
+ )
1193
+ )
1194
+ continue
1195
+ if head == "/effort":
1196
+ _handle_effort(agent, text)
1197
+ continue
1198
+ if head == "/model":
1199
+ _handle_model(agent, settings, text)
1200
+ continue
1201
+ if command == "/help":
1202
+ print(
1203
+ "/new /clear /compact /summary /session /status "
1204
+ "/model /effort /exit"
1205
+ )
1206
+ print("multiline: end a line with \\ to continue, or paste a block")
1207
+ print(
1208
+ f"image: paste with {_paste_shortcut()}, then Enter "
1209
+ "(needs provider vision)"
1210
+ )
1211
+ continue
1212
+ if text.startswith("/") and "\n" not in text:
1213
+ # A pasted block that happens to start with `/` is a prompt, not a
1214
+ # mistyped command; only a single line is treated as one.
1215
+ print(f"unknown command: {sanitize_terminal_text(text)}", file=sys.stderr)
1216
+ continue
1217
+ if image is not None:
1218
+ composed = _image_message(agent, image, text)
1219
+ if composed is None:
1220
+ continue
1221
+ text = composed
1222
+ try:
1223
+ result = agent.run(text)
1224
+ _ensure_newline(result)
1225
+ if not args.quiet:
1226
+ _print_usage(result)
1227
+ except KeyboardInterrupt:
1228
+ print("\ncancelled", file=sys.stderr)
1229
+ except (MinimalCodeError, OSError) as exc:
1230
+ # A failed turn must not end the session: report and keep reading.
1231
+ print(f"error: {sanitize_terminal_text(str(exc))}", file=sys.stderr)
1232
+
1233
+
1234
+ def run(argv: Sequence[str] | None = None) -> int:
1235
+ raw_args = list(sys.argv[1:] if argv is None else argv)
1236
+ # `minimalcode -- init` is the prompt "init", not the subcommand: a bare
1237
+ # management word is otherwise consumed before argparse ever sees it.
1238
+ if "--" not in raw_args:
1239
+ managed = _management_command(raw_args)
1240
+ if managed is not None:
1241
+ return managed
1242
+
1243
+ parser = _parser()
1244
+ args = parser.parse_args(raw_args)
1245
+ if args.max_turns is not None and not 1 <= args.max_turns <= 10_000:
1246
+ parser.error("--max-turns must be from 1 to 10000")
1247
+ if args.sandbox_timeout is not None and (
1248
+ not math.isfinite(args.sandbox_timeout) or not 1 <= args.sandbox_timeout <= 3600
1249
+ ):
1250
+ parser.error("--sandbox-timeout must be from 1 to 3600")
1251
+ if args.sandbox_max_output is not None and args.sandbox_max_output < 1:
1252
+ parser.error("--sandbox-max-output must be at least 1")
1253
+ if args.json_output:
1254
+ args.print_mode = True
1255
+ if args.anthropic_env and args.provider:
1256
+ parser.error("--anthropic-env cannot be combined with --provider")
1257
+
1258
+ prompt = " ".join(args.prompt).strip()
1259
+ if not prompt and not sys.stdin.isatty():
1260
+ prompt = sys.stdin.read().strip()
1261
+ if not prompt and args.print_mode:
1262
+ parser.error("--print requires a prompt or piped stdin")
1263
+
1264
+ from .agent import Agent
1265
+ from .provider import create_provider
1266
+ from .session import SessionStore
1267
+ from .shell import detect_shell
1268
+ from .tools import ToolRuntime
1269
+ from .vision import ProviderVisionClient
1270
+ from .web import ProviderWebClient
1271
+
1272
+ cwd = _resolve_cwd(args.cwd)
1273
+ settings = load_settings(args.home)
1274
+ if not settings.source.exists():
1275
+ ensure_config(args.home, minimal=True)
1276
+ settings = load_settings(args.home, require_file=True)
1277
+ _apply_sandbox_overrides(settings, args)
1278
+ env_config = _apply_anthropic_env(settings, args) if args.anthropic_env else None
1279
+ shell = detect_shell(settings.get("shell.path"))
1280
+ provider = None
1281
+ web_client = None
1282
+ vision_client = None
1283
+ agent = None
1284
+ try:
1285
+ store = SessionStore(settings)
1286
+ resumed = None
1287
+ effective_cwd = cwd
1288
+ if args.continue_session or args.session:
1289
+ # Load first so a resumed session can restore the provider and
1290
+ # model it was recorded with instead of sending the history to a
1291
+ # different wire protocol.
1292
+ resumed, effective_cwd = _load_or_create_session(
1293
+ store, args, settings, cwd, "", ""
1294
+ )
1295
+ providers = settings.data.get("providers")
1296
+ recorded_provider = resumed.provider if resumed is not None else ""
1297
+ if (
1298
+ resumed is not None
1299
+ and recorded_provider
1300
+ and not args.provider
1301
+ and not args.anthropic_env
1302
+ and isinstance(providers, dict)
1303
+ and recorded_provider not in providers
1304
+ ):
1305
+ # The recorded history uses a wire protocol that this settings file
1306
+ # cannot reconstruct. Falling back to the default provider would
1307
+ # send it to a different protocol (and usually a different model).
1308
+ raise ConfigError(
1309
+ f"session was recorded with provider {recorded_provider!r}, which "
1310
+ "is not configured; pass --provider, --anthropic-env, or a "
1311
+ "different --session"
1312
+ )
1313
+ use_recorded = (
1314
+ resumed is not None
1315
+ and not args.provider
1316
+ and not args.anthropic_env
1317
+ and isinstance(providers, dict)
1318
+ and recorded_provider in providers
1319
+ )
1320
+ provider_override = args.provider or (
1321
+ recorded_provider if use_recorded else None
1322
+ )
1323
+ model_override = args.model
1324
+ if (
1325
+ model_override is None
1326
+ and use_recorded
1327
+ and provider_override == recorded_provider
1328
+ and resumed is not None
1329
+ and resumed.model
1330
+ ):
1331
+ model_override = resumed.model
1332
+ provider, provider_name, model = create_provider(
1333
+ settings,
1334
+ provider_name=provider_override,
1335
+ model=model_override,
1336
+ thinking=args.thinking or (env_config.thinking if env_config else None),
1337
+ )
1338
+ if resumed is None:
1339
+ session, effective_cwd = _load_or_create_session(
1340
+ store,
1341
+ args,
1342
+ settings,
1343
+ cwd,
1344
+ provider_name,
1345
+ model,
1346
+ )
1347
+ else:
1348
+ session = resumed
1349
+ if not effective_cwd.is_dir():
1350
+ raise ConfigError(f"session workspace no longer exists: {effective_cwd}")
1351
+ web_client = ProviderWebClient(provider_name, provider.config)
1352
+ vision_client = ProviderVisionClient(
1353
+ provider_name, provider.config, model=model
1354
+ )
1355
+ tools = ToolRuntime(
1356
+ settings,
1357
+ effective_cwd,
1358
+ shell,
1359
+ web_client=web_client,
1360
+ vision_client=vision_client,
1361
+ provider_name=provider_name,
1362
+ model=model,
1363
+ thinking=provider.thinking,
1364
+ read_only=args.read_only,
1365
+ )
1366
+ agent = Agent(
1367
+ settings=settings,
1368
+ provider=provider,
1369
+ session=session,
1370
+ tool_runtime=tools,
1371
+ shell=shell,
1372
+ max_turns=args.max_turns,
1373
+ quiet=args.quiet or args.json_output,
1374
+ stream_output=not args.json_output,
1375
+ markdown_highlight=False if args.no_highlight else None,
1376
+ )
1377
+
1378
+ if prompt:
1379
+ return _run_once(agent, args, prompt)
1380
+ return _interactive(
1381
+ agent,
1382
+ args,
1383
+ settings,
1384
+ store,
1385
+ provider_name,
1386
+ model,
1387
+ )
1388
+ finally:
1389
+ # `/model` swaps the provider and its optional clients. Close whatever is
1390
+ # active now instead of the objects created before the interactive loop.
1391
+ if agent is not None:
1392
+ web_client = agent.tools.web_client
1393
+ vision_client = agent.tools.vision_client
1394
+ provider = agent.provider
1395
+ if web_client is not None:
1396
+ web_client.close()
1397
+ if vision_client is not None:
1398
+ vision_client.close()
1399
+ if provider is not None:
1400
+ provider.close()
1401
+
1402
+
1403
+ def _standard_stream_is_pipe(stream: object) -> bool:
1404
+ """Return True when ``stream`` is backed by a pipe."""
1405
+ try:
1406
+ return stat.S_ISFIFO(os.fstat(stream.fileno()).st_mode)
1407
+ except (AttributeError, OSError, ValueError):
1408
+ return False
1409
+
1410
+
1411
+ def _reader_closed(exc: OSError) -> bool:
1412
+ """Return True when ``exc`` is a write to a pipe whose reader has gone."""
1413
+ if exc.filename is not None:
1414
+ # A filesystem call failed; only stream writes carry no file name.
1415
+ return False
1416
+ if exc.errno == errno.EPIPE:
1417
+ return True
1418
+ if os.name != "nt" or exc.errno != errno.EINVAL:
1419
+ return False
1420
+ # Windows reports a pipe closed mid-write as ERROR_INVALID_PARAMETER
1421
+ # (EINVAL) instead of ERROR_BROKEN_PIPE, so the errno alone is ambiguous.
1422
+ # Quietly exiting is confined to a standard stream that really is a pipe;
1423
+ # every other EINVAL keeps its error report.
1424
+ return any(_standard_stream_is_pipe(stream) for stream in (sys.stdout, sys.stderr))
1425
+
1426
+
1427
+ def _discard_standard_streams() -> None:
1428
+ """Point stdout and stderr at the null device after a pipe broke.
1429
+
1430
+ Surviving ``main`` is not enough: CPython flushes the text streams during
1431
+ shutdown, that flush fails on the same closed pipe, and the process exits
1432
+ with status 120 instead of the code ``main`` returned.
1433
+ """
1434
+ try:
1435
+ devnull = os.open(os.devnull, os.O_WRONLY)
1436
+ except OSError:
1437
+ return
1438
+ try:
1439
+ for stream in (sys.stdout, sys.stderr):
1440
+ try:
1441
+ os.dup2(devnull, stream.fileno())
1442
+ except (AttributeError, OSError, ValueError):
1443
+ continue
1444
+ finally:
1445
+ try:
1446
+ os.close(devnull)
1447
+ except OSError:
1448
+ pass
1449
+
1450
+
1451
+ def _flush_standard_streams() -> None:
1452
+ """Flush stdout and stderr, discarding them when the reader has closed."""
1453
+ for stream in (sys.stdout, sys.stderr):
1454
+ try:
1455
+ stream.flush()
1456
+ except (OSError, ValueError):
1457
+ _discard_standard_streams()
1458
+ return
1459
+
1460
+
1461
+ def _write_error(message: str) -> None:
1462
+ """Write one error line, tolerating a closed or broken stderr."""
1463
+ try:
1464
+ print(f"error: {sanitize_terminal_text(message)}", file=sys.stderr)
1465
+ except (OSError, ValueError):
1466
+ _discard_standard_streams()
1467
+
1468
+
1469
+ def _configure_stdio() -> None:
1470
+ """Pin stdio to UTF-8 so a pipe cannot decode CJK or emit a surrogate.
1471
+
1472
+ `errors="replace"` alone kept the platform default encoding, which on a
1473
+ Windows pipe is the ANSI code page: a task piped into `-p` and the answer
1474
+ piped out were silently mangled for every non-ASCII character. The console
1475
+ itself is unaffected — Python already talks UTF-16 to it.
1476
+ """
1477
+ for stream in (sys.stdin, sys.stdout, sys.stderr):
1478
+ reconfigure = getattr(stream, "reconfigure", None)
1479
+ if reconfigure is None:
1480
+ continue
1481
+ try:
1482
+ reconfigure(encoding="utf-8", errors="replace")
1483
+ except (ValueError, OSError):
1484
+ pass
1485
+
1486
+
1487
+ def main(argv: Sequence[str] | None = None) -> int:
1488
+ try:
1489
+ _configure_stdio()
1490
+ return _exit_code(argv)
1491
+ finally:
1492
+ # Buffered output may only fail here, when the reader is already gone.
1493
+ # Flushing (and discarding on failure) keeps the interpreter's shutdown
1494
+ # flush from replacing this status with 120. A `finally` also covers
1495
+ # argparse's SystemExit for --help, --version, and usage errors.
1496
+ _flush_standard_streams()
1497
+
1498
+
1499
+ def _exit_code(argv: Sequence[str] | None) -> int:
1500
+ try:
1501
+ return run(argv)
1502
+ except KeyboardInterrupt:
1503
+ try:
1504
+ print("\ncancelled", file=sys.stderr)
1505
+ except (OSError, ValueError):
1506
+ _discard_standard_streams()
1507
+ return 130
1508
+ except OSError as exc:
1509
+ # The reader of a pipe (often `head`) closed it; this is not an error.
1510
+ if _reader_closed(exc):
1511
+ return 0
1512
+ _write_error(str(exc))
1513
+ return 2
1514
+ except (MinimalCodeError, ValueError) as exc:
1515
+ _write_error(str(exc))
1516
+ return 2
1517
+
1518
+
1519
+ if __name__ == "__main__":
1520
+ raise SystemExit(main())