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.
- minimalcode/__init__.py +3 -0
- minimalcode/__main__.py +3 -0
- minimalcode/agent.py +1084 -0
- minimalcode/cli.py +1520 -0
- minimalcode/clipboard.py +212 -0
- minimalcode/config.py +1065 -0
- minimalcode/defaults.json +491 -0
- minimalcode/errors.py +31 -0
- minimalcode/highlight/__init__.py +47 -0
- minimalcode/highlight/code.py +261 -0
- minimalcode/highlight/color.py +58 -0
- minimalcode/highlight/markdown.py +710 -0
- minimalcode/highlight/palette.py +73 -0
- minimalcode/highlight/sanitize.py +175 -0
- minimalcode/highlight/thinking.py +71 -0
- minimalcode/httpclient.py +278 -0
- minimalcode/importer.py +253 -0
- minimalcode/prompt.py +195 -0
- minimalcode/provider/__init__.py +28 -0
- minimalcode/provider/anthropic.py +805 -0
- minimalcode/provider/anthropic_env.py +161 -0
- minimalcode/provider/chat.py +394 -0
- minimalcode/provider/client.py +243 -0
- minimalcode/provider/common.py +176 -0
- minimalcode/provider/deepseek_web.py +1158 -0
- minimalcode/provider/factory.py +63 -0
- minimalcode/provider/responses.py +692 -0
- minimalcode/provider/sha3_wasm_bg.wasm +0 -0
- minimalcode/provider/transport.py +241 -0
- minimalcode/provider/types.py +54 -0
- minimalcode/sandbox/__init__.py +64 -0
- minimalcode/sandbox/audit.py +120 -0
- minimalcode/sandbox/backends.py +343 -0
- minimalcode/sandbox/execution.py +435 -0
- minimalcode/sandbox/network.py +793 -0
- minimalcode/sandbox/policy.py +253 -0
- minimalcode/sandbox/runner.py +347 -0
- minimalcode/sandbox/sensitive.py +286 -0
- minimalcode/session.py +465 -0
- minimalcode/shell.py +333 -0
- minimalcode/subagent.py +120 -0
- minimalcode/tools/__init__.py +46 -0
- minimalcode/tools/files.py +1139 -0
- minimalcode/tools/process.py +164 -0
- minimalcode/tools/remote.py +74 -0
- minimalcode/tools/runtime.py +219 -0
- minimalcode/tools/schemas.py +418 -0
- minimalcode/tools/search.py +521 -0
- minimalcode/tools/search_tools.py +583 -0
- minimalcode/tools/validation.py +99 -0
- minimalcode/vision.py +231 -0
- minimalcode/web.py +238 -0
- minimalcode-1.5.2.dist-info/METADATA +646 -0
- minimalcode-1.5.2.dist-info/RECORD +58 -0
- minimalcode-1.5.2.dist-info/WHEEL +5 -0
- minimalcode-1.5.2.dist-info/entry_points.txt +2 -0
- minimalcode-1.5.2.dist-info/licenses/LICENSE +201 -0
- 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())
|