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/config.py
ADDED
|
@@ -0,0 +1,1065 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import json
|
|
5
|
+
import math
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import tempfile
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from importlib.resources import files
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
from urllib.parse import urlsplit
|
|
14
|
+
|
|
15
|
+
from .errors import ConfigError
|
|
16
|
+
from .sandbox import NETWORK_POLICIES, SANDBOX_BACKENDS
|
|
17
|
+
|
|
18
|
+
APP_NAME = "MinimalCode"
|
|
19
|
+
APP_DIR_NAME = ".MinimalCode"
|
|
20
|
+
HOME_ENV = "MINIMALCODE_HOME"
|
|
21
|
+
EFFORT_LEVELS = (
|
|
22
|
+
"none",
|
|
23
|
+
"minimal",
|
|
24
|
+
"low",
|
|
25
|
+
"medium",
|
|
26
|
+
"high",
|
|
27
|
+
"xhigh",
|
|
28
|
+
"ultra",
|
|
29
|
+
"max",
|
|
30
|
+
)
|
|
31
|
+
SETTINGS_FILENAME = "settings.json"
|
|
32
|
+
|
|
33
|
+
DEFAULT_SETTINGS: dict[str, Any] = json.loads(
|
|
34
|
+
files("minimalcode").joinpath("defaults.json").read_text(encoding="utf-8")
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
_ENV_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
|
38
|
+
_TRUE_STRINGS = frozenset({"1", "true", "yes", "on"})
|
|
39
|
+
_FALSE_STRINGS = frozenset({"0", "false", "no", "off"})
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def home_dir(path: str | Path | None = None) -> Path:
|
|
43
|
+
candidate: Path | None = None
|
|
44
|
+
try:
|
|
45
|
+
if path is not None:
|
|
46
|
+
candidate = Path(path)
|
|
47
|
+
else:
|
|
48
|
+
configured = os.environ.get(HOME_ENV)
|
|
49
|
+
candidate = Path(configured) if configured else Path.home() / APP_DIR_NAME
|
|
50
|
+
return candidate.expanduser().resolve()
|
|
51
|
+
except (OSError, RuntimeError, TypeError, ValueError) as exc:
|
|
52
|
+
detail = candidate if candidate is not None else path or "default home"
|
|
53
|
+
raise ConfigError(f"invalid home directory: {detail}") from exc
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def settings_path(home: str | Path | None = None) -> Path:
|
|
57
|
+
return home_dir(home) / SETTINGS_FILENAME
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
|
61
|
+
result = copy.deepcopy(base)
|
|
62
|
+
for key, value in override.items():
|
|
63
|
+
if isinstance(value, dict) and isinstance(result.get(key), dict):
|
|
64
|
+
result[key] = _deep_merge(result[key], value)
|
|
65
|
+
else:
|
|
66
|
+
result[key] = copy.deepcopy(value)
|
|
67
|
+
return result
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def expand_env(value: Any) -> Any:
|
|
71
|
+
if isinstance(value, str):
|
|
72
|
+
return _ENV_PATTERN.sub(
|
|
73
|
+
lambda match: os.environ.get(match.group(1), match.group(0)),
|
|
74
|
+
value,
|
|
75
|
+
)
|
|
76
|
+
if isinstance(value, list):
|
|
77
|
+
return [expand_env(item) for item in value]
|
|
78
|
+
if isinstance(value, dict):
|
|
79
|
+
return {key: expand_env(item) for key, item in value.items()}
|
|
80
|
+
return value
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _unresolved_env(value: Any) -> str | None:
|
|
84
|
+
if isinstance(value, str):
|
|
85
|
+
match = _ENV_PATTERN.search(value)
|
|
86
|
+
return match.group(0) if match else None
|
|
87
|
+
if isinstance(value, list):
|
|
88
|
+
for item in value:
|
|
89
|
+
unresolved = _unresolved_env(item)
|
|
90
|
+
if unresolved is not None:
|
|
91
|
+
return unresolved
|
|
92
|
+
if isinstance(value, dict):
|
|
93
|
+
for item in value.values():
|
|
94
|
+
unresolved = _unresolved_env(item)
|
|
95
|
+
if unresolved is not None:
|
|
96
|
+
return unresolved
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _coerce_bool(value: Any, label: str) -> bool:
|
|
101
|
+
"""Convert an expanded string setting to a real boolean."""
|
|
102
|
+
if isinstance(value, bool):
|
|
103
|
+
return value
|
|
104
|
+
if isinstance(value, str):
|
|
105
|
+
lowered = value.strip().lower()
|
|
106
|
+
if lowered in _TRUE_STRINGS:
|
|
107
|
+
return True
|
|
108
|
+
if lowered in _FALSE_STRINGS:
|
|
109
|
+
return False
|
|
110
|
+
raise ConfigError(f"{label} must be a boolean (got {value!r})")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _coerce_int(value: Any, label: str) -> int:
|
|
114
|
+
"""Convert an expanded string setting to an integer."""
|
|
115
|
+
if isinstance(value, bool):
|
|
116
|
+
raise ConfigError(f"{label} must be an integer")
|
|
117
|
+
try:
|
|
118
|
+
return int(value)
|
|
119
|
+
except (TypeError, ValueError, OverflowError) as exc:
|
|
120
|
+
raise ConfigError(f"{label} must be an integer (got {value!r})") from exc
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _coerce_float(value: Any, label: str) -> float:
|
|
124
|
+
"""Convert an expanded string setting to a finite float."""
|
|
125
|
+
if isinstance(value, bool):
|
|
126
|
+
raise ConfigError(f"{label} must be a number")
|
|
127
|
+
try:
|
|
128
|
+
number = float(value)
|
|
129
|
+
except (TypeError, ValueError, OverflowError) as exc:
|
|
130
|
+
raise ConfigError(f"{label} must be a number (got {value!r})") from exc
|
|
131
|
+
if not math.isfinite(number):
|
|
132
|
+
raise ConfigError(f"{label} must be a finite number")
|
|
133
|
+
return number
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _coerce_provider_values(name: str, provider: dict[str, Any]) -> dict[str, Any]:
|
|
137
|
+
"""Re-type values that came from ``${ENV}`` placeholders.
|
|
138
|
+
|
|
139
|
+
Validation runs before expansion and therefore only checks placeholders.
|
|
140
|
+
Once expanded, ``"false"`` is still a string unless it is converted here;
|
|
141
|
+
``bool("false")`` is ``True``, and ``int("128000")`` is the only reason
|
|
142
|
+
numeric placeholders work at all.
|
|
143
|
+
"""
|
|
144
|
+
result = provider
|
|
145
|
+
for key in (
|
|
146
|
+
"stream",
|
|
147
|
+
"include_usage",
|
|
148
|
+
"send_reasoning_effort",
|
|
149
|
+
"require_api_key",
|
|
150
|
+
"prompt_cache",
|
|
151
|
+
"web_search",
|
|
152
|
+
):
|
|
153
|
+
if key in result:
|
|
154
|
+
result[key] = _coerce_bool(result[key], f"provider {name!r} {key}")
|
|
155
|
+
if "trust_env" in result:
|
|
156
|
+
result["trust_env"] = _coerce_bool(
|
|
157
|
+
result["trust_env"], f"provider {name!r} trust_env"
|
|
158
|
+
)
|
|
159
|
+
for key in ("context_window", "max_tokens"):
|
|
160
|
+
if key in result:
|
|
161
|
+
result[key] = _coerce_int(result[key], f"provider {name!r} {key}")
|
|
162
|
+
if "timeout_seconds" in result:
|
|
163
|
+
result["timeout_seconds"] = _coerce_float(
|
|
164
|
+
result["timeout_seconds"], f"provider {name!r} timeout_seconds"
|
|
165
|
+
)
|
|
166
|
+
for block_name in ("web_tools", "vision"):
|
|
167
|
+
block = result.get(block_name)
|
|
168
|
+
if not isinstance(block, dict):
|
|
169
|
+
continue
|
|
170
|
+
for key in ("enabled", "trust_env"):
|
|
171
|
+
if key in block:
|
|
172
|
+
block[key] = _coerce_bool(
|
|
173
|
+
block[key], f"provider {name!r} {block_name}.{key}"
|
|
174
|
+
)
|
|
175
|
+
for key in ("timeout_seconds",):
|
|
176
|
+
if key in block:
|
|
177
|
+
block[key] = _coerce_float(
|
|
178
|
+
block[key], f"provider {name!r} {block_name}.{key}"
|
|
179
|
+
)
|
|
180
|
+
for key in (
|
|
181
|
+
"default_count",
|
|
182
|
+
"max_count",
|
|
183
|
+
"max_content_chars",
|
|
184
|
+
"max_image_bytes",
|
|
185
|
+
"max_chars",
|
|
186
|
+
):
|
|
187
|
+
if key in block:
|
|
188
|
+
block[key] = _coerce_int(
|
|
189
|
+
block[key], f"provider {name!r} {block_name}.{key}"
|
|
190
|
+
)
|
|
191
|
+
return result
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _placeholder_paths(
|
|
195
|
+
value: Any, prefix: tuple[str, ...] = ()
|
|
196
|
+
) -> set[tuple[str, ...]]:
|
|
197
|
+
"""Return the dotted paths whose string value contained ``${VAR}``."""
|
|
198
|
+
paths: set[tuple[str, ...]] = set()
|
|
199
|
+
if isinstance(value, str):
|
|
200
|
+
if _ENV_PATTERN.search(value):
|
|
201
|
+
paths.add(prefix)
|
|
202
|
+
elif isinstance(value, dict):
|
|
203
|
+
for key, item in value.items():
|
|
204
|
+
if isinstance(key, str):
|
|
205
|
+
paths |= _placeholder_paths(item, (*prefix, key))
|
|
206
|
+
elif isinstance(value, list):
|
|
207
|
+
for item in value:
|
|
208
|
+
paths |= _placeholder_paths(item, prefix)
|
|
209
|
+
return paths
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _coerce_keys(
|
|
213
|
+
section: dict[str, Any],
|
|
214
|
+
label: str,
|
|
215
|
+
paths: set[tuple[str, ...]],
|
|
216
|
+
*,
|
|
217
|
+
bools: tuple[str, ...] = (),
|
|
218
|
+
ints: tuple[str, ...] = (),
|
|
219
|
+
floats: tuple[str, ...] = (),
|
|
220
|
+
) -> None:
|
|
221
|
+
"""Re-type only the values that came from an environment placeholder."""
|
|
222
|
+
prefix = tuple(label.split("."))
|
|
223
|
+
for key in bools:
|
|
224
|
+
if key in section and (*prefix, key) in paths:
|
|
225
|
+
section[key] = _coerce_bool(section[key], f"{label}.{key}")
|
|
226
|
+
for key in ints:
|
|
227
|
+
if key in section and (*prefix, key) in paths:
|
|
228
|
+
section[key] = _coerce_int(section[key], f"{label}.{key}")
|
|
229
|
+
for key in floats:
|
|
230
|
+
if key in section and (*prefix, key) in paths:
|
|
231
|
+
section[key] = _coerce_float(section[key], f"{label}.{key}")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _coerce_root_values(data: dict[str, Any], paths: set[tuple[str, ...]]) -> None:
|
|
235
|
+
"""Re-type non-provider values that came from ``${ENV}`` placeholders.
|
|
236
|
+
|
|
237
|
+
Provider blocks are expanded lazily by ``provider_config`` because a
|
|
238
|
+
placeholder in an unselected provider must not fail startup, so only the
|
|
239
|
+
root settings are coerced here. A plain string such as ``"false"`` stays a
|
|
240
|
+
string and is still rejected by validation; only an expanded placeholder
|
|
241
|
+
is re-typed.
|
|
242
|
+
"""
|
|
243
|
+
if "max_turns" in data and ("max_turns",) in paths:
|
|
244
|
+
data["max_turns"] = _coerce_int(data["max_turns"], "max_turns")
|
|
245
|
+
shell = data.get("shell")
|
|
246
|
+
if isinstance(shell, dict):
|
|
247
|
+
_coerce_keys(
|
|
248
|
+
shell,
|
|
249
|
+
"shell",
|
|
250
|
+
paths,
|
|
251
|
+
ints=("max_output_chars",),
|
|
252
|
+
floats=("timeout_seconds",),
|
|
253
|
+
)
|
|
254
|
+
tools = data.get("tools")
|
|
255
|
+
if isinstance(tools, dict):
|
|
256
|
+
_coerce_keys(
|
|
257
|
+
tools,
|
|
258
|
+
"tools",
|
|
259
|
+
paths,
|
|
260
|
+
bools=("allow_outside_workspace",),
|
|
261
|
+
ints=(
|
|
262
|
+
"max_parallel",
|
|
263
|
+
"max_file_chars",
|
|
264
|
+
"max_file_bytes",
|
|
265
|
+
"max_results",
|
|
266
|
+
),
|
|
267
|
+
)
|
|
268
|
+
subagent = data.get("subagent")
|
|
269
|
+
if isinstance(subagent, dict):
|
|
270
|
+
_coerce_keys(
|
|
271
|
+
subagent,
|
|
272
|
+
"subagent",
|
|
273
|
+
paths,
|
|
274
|
+
bools=("enabled",),
|
|
275
|
+
ints=("max_depth", "max_turns", "max_parallel"),
|
|
276
|
+
floats=("timeout_seconds",),
|
|
277
|
+
)
|
|
278
|
+
context = data.get("context")
|
|
279
|
+
if isinstance(context, dict):
|
|
280
|
+
_coerce_keys(
|
|
281
|
+
context,
|
|
282
|
+
"context",
|
|
283
|
+
paths,
|
|
284
|
+
ints=("keep_messages", "max_instruction_chars"),
|
|
285
|
+
floats=("compact_at",),
|
|
286
|
+
)
|
|
287
|
+
session = data.get("session")
|
|
288
|
+
if isinstance(session, dict):
|
|
289
|
+
_coerce_keys(session, "session", paths, bools=("save",))
|
|
290
|
+
output = data.get("output")
|
|
291
|
+
if isinstance(output, dict):
|
|
292
|
+
_coerce_keys(output, "output", paths, bools=("markdown_highlight",))
|
|
293
|
+
sandbox = data.get("sandbox")
|
|
294
|
+
if isinstance(sandbox, dict):
|
|
295
|
+
_coerce_keys(
|
|
296
|
+
sandbox,
|
|
297
|
+
"sandbox",
|
|
298
|
+
paths,
|
|
299
|
+
bools=("enabled", "allow_sensitive_files"),
|
|
300
|
+
ints=(
|
|
301
|
+
"max_output_chars",
|
|
302
|
+
"max_memory_mb",
|
|
303
|
+
"max_processes",
|
|
304
|
+
),
|
|
305
|
+
floats=("timeout_seconds",),
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _expand_root_settings(data: dict[str, Any]) -> dict[str, Any]:
|
|
310
|
+
"""Expand ``${VAR}`` placeholders outside the provider table.
|
|
311
|
+
|
|
312
|
+
Provider values are expanded by ``provider_config`` when that provider is
|
|
313
|
+
actually used, so selecting another provider never fails on its missing
|
|
314
|
+
environment variable.
|
|
315
|
+
"""
|
|
316
|
+
expanded = dict(data)
|
|
317
|
+
for key, value in data.items():
|
|
318
|
+
if key != "providers":
|
|
319
|
+
expanded[key] = expand_env(value)
|
|
320
|
+
return expanded
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _validate(data: dict[str, Any], path: Path) -> None:
|
|
324
|
+
provider = data.get("provider")
|
|
325
|
+
model = data.get("model")
|
|
326
|
+
providers = data.get("providers")
|
|
327
|
+
|
|
328
|
+
if not isinstance(provider, str) or not provider.strip():
|
|
329
|
+
raise ConfigError(f"{path}: provider must be a non-empty string")
|
|
330
|
+
if not isinstance(model, str) or not model.strip():
|
|
331
|
+
raise ConfigError(f"{path}: model must be a non-empty string")
|
|
332
|
+
if not isinstance(providers, dict) or provider not in providers:
|
|
333
|
+
raise ConfigError(f"{path}: provider {provider!r} is not defined in providers")
|
|
334
|
+
for name, provider_config in providers.items():
|
|
335
|
+
if not isinstance(name, str) or not name:
|
|
336
|
+
raise ConfigError(f"{path}: provider names must be non-empty strings")
|
|
337
|
+
if not isinstance(provider_config, dict):
|
|
338
|
+
raise ConfigError(f"{path}: provider {name!r} must be an object")
|
|
339
|
+
_validate_provider(name, provider_config, path)
|
|
340
|
+
|
|
341
|
+
max_turns = data.get("max_turns")
|
|
342
|
+
if (
|
|
343
|
+
not isinstance(max_turns, int)
|
|
344
|
+
or isinstance(max_turns, bool)
|
|
345
|
+
or not 1 <= max_turns <= 10_000
|
|
346
|
+
):
|
|
347
|
+
raise ConfigError(f"{path}: max_turns must be an integer from 1 to 10000")
|
|
348
|
+
|
|
349
|
+
for key in (
|
|
350
|
+
"shell",
|
|
351
|
+
"tools",
|
|
352
|
+
"subagent",
|
|
353
|
+
"context",
|
|
354
|
+
"session",
|
|
355
|
+
"output",
|
|
356
|
+
"sandbox",
|
|
357
|
+
):
|
|
358
|
+
if not isinstance(data.get(key), dict):
|
|
359
|
+
raise ConfigError(f"{path}: {key} must be an object")
|
|
360
|
+
|
|
361
|
+
shell = data["shell"]
|
|
362
|
+
shell_path = shell.get("path")
|
|
363
|
+
if shell_path is not None and (
|
|
364
|
+
not isinstance(shell_path, str) or not shell_path.strip()
|
|
365
|
+
):
|
|
366
|
+
raise ConfigError(f"{path}: shell.path must be null or a non-empty string")
|
|
367
|
+
_validate_number(
|
|
368
|
+
shell.get("timeout_seconds"),
|
|
369
|
+
path,
|
|
370
|
+
"shell.timeout_seconds",
|
|
371
|
+
minimum=1,
|
|
372
|
+
maximum=3600,
|
|
373
|
+
)
|
|
374
|
+
_validate_integer(
|
|
375
|
+
shell.get("max_output_chars"),
|
|
376
|
+
path,
|
|
377
|
+
"shell.max_output_chars",
|
|
378
|
+
minimum=1,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
tools = data["tools"]
|
|
382
|
+
if not isinstance(tools.get("allow_outside_workspace"), bool):
|
|
383
|
+
raise ConfigError(f"{path}: tools.allow_outside_workspace must be a boolean")
|
|
384
|
+
allow = tools.get("allow")
|
|
385
|
+
if allow is not None:
|
|
386
|
+
_validate_string_list(allow, path, "tools.allow")
|
|
387
|
+
if not allow:
|
|
388
|
+
# An empty allowlist would leave an agent with no tools at all;
|
|
389
|
+
# the runtime rejects it too, but a config error names the file.
|
|
390
|
+
raise ConfigError(f"{path}: tools.allow must not be empty")
|
|
391
|
+
_validate_integer(
|
|
392
|
+
tools.get("max_parallel"),
|
|
393
|
+
path,
|
|
394
|
+
"tools.max_parallel",
|
|
395
|
+
minimum=1,
|
|
396
|
+
maximum=32,
|
|
397
|
+
)
|
|
398
|
+
_validate_integer(
|
|
399
|
+
tools.get("max_file_chars"),
|
|
400
|
+
path,
|
|
401
|
+
"tools.max_file_chars",
|
|
402
|
+
minimum=1,
|
|
403
|
+
)
|
|
404
|
+
_validate_integer(
|
|
405
|
+
tools.get("max_file_bytes"),
|
|
406
|
+
path,
|
|
407
|
+
"tools.max_file_bytes",
|
|
408
|
+
minimum=1,
|
|
409
|
+
)
|
|
410
|
+
_validate_integer(
|
|
411
|
+
tools.get("max_results"),
|
|
412
|
+
path,
|
|
413
|
+
"tools.max_results",
|
|
414
|
+
minimum=1,
|
|
415
|
+
maximum=5000,
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
subagent = data["subagent"]
|
|
419
|
+
if not isinstance(subagent.get("enabled"), bool):
|
|
420
|
+
raise ConfigError(f"{path}: subagent.enabled must be a boolean")
|
|
421
|
+
_validate_integer(
|
|
422
|
+
subagent.get("max_depth"),
|
|
423
|
+
path,
|
|
424
|
+
"subagent.max_depth",
|
|
425
|
+
minimum=0,
|
|
426
|
+
maximum=10,
|
|
427
|
+
)
|
|
428
|
+
_validate_integer(
|
|
429
|
+
subagent.get("max_turns"),
|
|
430
|
+
path,
|
|
431
|
+
"subagent.max_turns",
|
|
432
|
+
minimum=1,
|
|
433
|
+
maximum=10_000,
|
|
434
|
+
)
|
|
435
|
+
_validate_integer(
|
|
436
|
+
subagent.get("max_parallel"),
|
|
437
|
+
path,
|
|
438
|
+
"subagent.max_parallel",
|
|
439
|
+
minimum=1,
|
|
440
|
+
maximum=32,
|
|
441
|
+
)
|
|
442
|
+
_validate_number(
|
|
443
|
+
subagent.get("timeout_seconds"),
|
|
444
|
+
path,
|
|
445
|
+
"subagent.timeout_seconds",
|
|
446
|
+
minimum=1,
|
|
447
|
+
maximum=3600,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
context = data["context"]
|
|
451
|
+
_validate_number(
|
|
452
|
+
context.get("compact_at"),
|
|
453
|
+
path,
|
|
454
|
+
"context.compact_at",
|
|
455
|
+
minimum=0,
|
|
456
|
+
maximum=1,
|
|
457
|
+
inclusive_minimum=False,
|
|
458
|
+
)
|
|
459
|
+
_validate_integer(
|
|
460
|
+
context.get("keep_messages"),
|
|
461
|
+
path,
|
|
462
|
+
"context.keep_messages",
|
|
463
|
+
minimum=4,
|
|
464
|
+
)
|
|
465
|
+
_validate_integer(
|
|
466
|
+
context.get("max_instruction_chars"),
|
|
467
|
+
path,
|
|
468
|
+
"context.max_instruction_chars",
|
|
469
|
+
minimum=1,
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
if not isinstance(data["session"].get("save"), bool):
|
|
473
|
+
raise ConfigError(f"{path}: session.save must be a boolean")
|
|
474
|
+
|
|
475
|
+
_validate_sandbox(data["sandbox"], path)
|
|
476
|
+
|
|
477
|
+
highlight = data["output"].get("markdown_highlight")
|
|
478
|
+
if not isinstance(highlight, bool):
|
|
479
|
+
raise ConfigError(f"{path}: output.markdown_highlight must be a boolean")
|
|
480
|
+
theme = data["output"].get("theme")
|
|
481
|
+
if not isinstance(theme, str) or theme not in {"dark", "light"}:
|
|
482
|
+
raise ConfigError(f"{path}: output.theme must be 'dark' or 'light'")
|
|
483
|
+
|
|
484
|
+
if "thinking" in data:
|
|
485
|
+
thinking = data["thinking"]
|
|
486
|
+
# `--thinking High` is accepted (argparse lowercases it), so a settings
|
|
487
|
+
# file with the same spelling must not be rejected as invalid.
|
|
488
|
+
if isinstance(thinking, str):
|
|
489
|
+
thinking = thinking.strip().lower()
|
|
490
|
+
data["thinking"] = thinking
|
|
491
|
+
if not isinstance(thinking, str) or thinking not in EFFORT_LEVELS:
|
|
492
|
+
raise ConfigError(
|
|
493
|
+
f"{path}: thinking must be one of {', '.join(EFFORT_LEVELS)}"
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _has_env_placeholder(value: Any) -> bool:
|
|
498
|
+
return isinstance(value, str) and _ENV_PATTERN.search(value) is not None
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _validate_provider(name: str, provider: dict[str, Any], path: Path) -> None:
|
|
502
|
+
protocol = provider.get("protocol", "openai_chat")
|
|
503
|
+
if not _has_env_placeholder(protocol) and protocol not in {
|
|
504
|
+
"openai_chat",
|
|
505
|
+
"openai_responses",
|
|
506
|
+
"anthropic_messages",
|
|
507
|
+
"deepseek_web",
|
|
508
|
+
}:
|
|
509
|
+
raise ConfigError(
|
|
510
|
+
f"{path}: provider {name!r} uses unsupported protocol {protocol!r}"
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
base_url = provider.get("base_url")
|
|
514
|
+
if not isinstance(base_url, str) or not base_url:
|
|
515
|
+
raise ConfigError(f"{path}: provider {name!r} requires base_url")
|
|
516
|
+
if not _has_env_placeholder(base_url):
|
|
517
|
+
try:
|
|
518
|
+
parsed = urlsplit(base_url)
|
|
519
|
+
_ = parsed.port
|
|
520
|
+
hostname = parsed.hostname
|
|
521
|
+
except ValueError as exc:
|
|
522
|
+
raise ConfigError(
|
|
523
|
+
f"{path}: provider {name!r} has an invalid base_url"
|
|
524
|
+
) from exc
|
|
525
|
+
if parsed.scheme not in {"http", "https"} or not hostname:
|
|
526
|
+
raise ConfigError(f"{path}: provider {name!r} has an invalid base_url")
|
|
527
|
+
|
|
528
|
+
model = provider.get("model")
|
|
529
|
+
if (
|
|
530
|
+
model is not None
|
|
531
|
+
and not _has_env_placeholder(model)
|
|
532
|
+
and (not isinstance(model, str) or not model.strip())
|
|
533
|
+
):
|
|
534
|
+
raise ConfigError(f"{path}: provider {name!r} model must be a string")
|
|
535
|
+
|
|
536
|
+
for key in (
|
|
537
|
+
"stream",
|
|
538
|
+
"include_usage",
|
|
539
|
+
"send_reasoning_effort",
|
|
540
|
+
"require_api_key",
|
|
541
|
+
"prompt_cache",
|
|
542
|
+
"web_search",
|
|
543
|
+
):
|
|
544
|
+
if (
|
|
545
|
+
key in provider
|
|
546
|
+
and not _has_env_placeholder(provider[key])
|
|
547
|
+
and not isinstance(provider[key], bool)
|
|
548
|
+
):
|
|
549
|
+
raise ConfigError(f"{path}: provider {name!r} {key} must be a boolean")
|
|
550
|
+
|
|
551
|
+
for key in ("context_window", "max_tokens"):
|
|
552
|
+
if key in provider and not _has_env_placeholder(provider[key]):
|
|
553
|
+
_validate_integer(
|
|
554
|
+
provider[key],
|
|
555
|
+
path,
|
|
556
|
+
f"providers.{name}.{key}",
|
|
557
|
+
minimum=1,
|
|
558
|
+
)
|
|
559
|
+
if "timeout_seconds" in provider and not _has_env_placeholder(
|
|
560
|
+
provider["timeout_seconds"]
|
|
561
|
+
):
|
|
562
|
+
_validate_number(
|
|
563
|
+
provider["timeout_seconds"],
|
|
564
|
+
path,
|
|
565
|
+
f"providers.{name}.timeout_seconds",
|
|
566
|
+
minimum=0,
|
|
567
|
+
inclusive_minimum=False,
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
for key in ("headers", "extra_body"):
|
|
571
|
+
if key in provider and not isinstance(provider[key], dict):
|
|
572
|
+
raise ConfigError(f"{path}: provider {name!r} {key} must be an object")
|
|
573
|
+
for key in ("api_key", "api_key_env", "cookie", "cookie_env"):
|
|
574
|
+
value = provider.get(key)
|
|
575
|
+
if value is not None and not isinstance(value, str):
|
|
576
|
+
raise ConfigError(f"{path}: provider {name!r} {key} must be a string")
|
|
577
|
+
for key in ("web_tools", "vision"):
|
|
578
|
+
if key in provider and not isinstance(provider[key], dict):
|
|
579
|
+
raise ConfigError(f"{path}: provider {name!r} {key} must be an object")
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _validate_string_list(value: Any, path: Path, key: str) -> None:
|
|
583
|
+
if not isinstance(value, list):
|
|
584
|
+
raise ConfigError(f"{path}: {key} must be a list of strings")
|
|
585
|
+
for item in value:
|
|
586
|
+
if not isinstance(item, str) or not item.strip():
|
|
587
|
+
raise ConfigError(f"{path}: {key} must contain non-empty strings")
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _validate_sandbox(sandbox: dict[str, Any], path: Path) -> None:
|
|
591
|
+
if not isinstance(sandbox.get("enabled"), bool):
|
|
592
|
+
raise ConfigError(f"{path}: sandbox.enabled must be a boolean")
|
|
593
|
+
backend = sandbox.get("backend")
|
|
594
|
+
if not isinstance(backend, str) or backend not in SANDBOX_BACKENDS:
|
|
595
|
+
raise ConfigError(
|
|
596
|
+
f"{path}: sandbox.backend must be one of {', '.join(SANDBOX_BACKENDS)}"
|
|
597
|
+
)
|
|
598
|
+
_validate_string_list(sandbox.get("allowed_roots"), path, "sandbox.allowed_roots")
|
|
599
|
+
network_policy = sandbox.get("network_policy")
|
|
600
|
+
if network_policy not in NETWORK_POLICIES:
|
|
601
|
+
raise ConfigError(
|
|
602
|
+
f"{path}: sandbox.network_policy must be one of "
|
|
603
|
+
f"{', '.join(NETWORK_POLICIES)}"
|
|
604
|
+
)
|
|
605
|
+
_validate_string_list(sandbox.get("allowed_hosts"), path, "sandbox.allowed_hosts")
|
|
606
|
+
if network_policy == "allowlist" and not sandbox.get("allowed_hosts"):
|
|
607
|
+
raise ConfigError(
|
|
608
|
+
f"{path}: sandbox.network_policy 'allowlist' requires sandbox.allowed_hosts"
|
|
609
|
+
)
|
|
610
|
+
_validate_number(
|
|
611
|
+
sandbox.get("timeout_seconds"),
|
|
612
|
+
path,
|
|
613
|
+
"sandbox.timeout_seconds",
|
|
614
|
+
minimum=1,
|
|
615
|
+
maximum=3600,
|
|
616
|
+
)
|
|
617
|
+
_validate_integer(
|
|
618
|
+
sandbox.get("max_output_chars"),
|
|
619
|
+
path,
|
|
620
|
+
"sandbox.max_output_chars",
|
|
621
|
+
minimum=1,
|
|
622
|
+
)
|
|
623
|
+
if not isinstance(sandbox.get("allow_sensitive_files"), bool):
|
|
624
|
+
raise ConfigError(f"{path}: sandbox.allow_sensitive_files must be a boolean")
|
|
625
|
+
_validate_integer(
|
|
626
|
+
sandbox.get("max_memory_mb"),
|
|
627
|
+
path,
|
|
628
|
+
"sandbox.max_memory_mb",
|
|
629
|
+
minimum=0,
|
|
630
|
+
maximum=1_048_576,
|
|
631
|
+
)
|
|
632
|
+
_validate_integer(
|
|
633
|
+
sandbox.get("max_processes"),
|
|
634
|
+
path,
|
|
635
|
+
"sandbox.max_processes",
|
|
636
|
+
minimum=0,
|
|
637
|
+
maximum=65_536,
|
|
638
|
+
)
|
|
639
|
+
docker_image = sandbox.get("docker_image")
|
|
640
|
+
if not isinstance(docker_image, str) or not docker_image.strip():
|
|
641
|
+
raise ConfigError(f"{path}: sandbox.docker_image must be a non-empty string")
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _validate_number(
|
|
645
|
+
value: Any,
|
|
646
|
+
path: Path,
|
|
647
|
+
key: str,
|
|
648
|
+
*,
|
|
649
|
+
minimum: float,
|
|
650
|
+
maximum: float | None = None,
|
|
651
|
+
inclusive_minimum: bool = True,
|
|
652
|
+
) -> None:
|
|
653
|
+
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
|
654
|
+
raise ConfigError(f"{path}: {key} must be a number")
|
|
655
|
+
try:
|
|
656
|
+
numeric = float(value)
|
|
657
|
+
except (OverflowError, ValueError) as exc:
|
|
658
|
+
raise ConfigError(f"{path}: {key} must be a finite number") from exc
|
|
659
|
+
if not math.isfinite(numeric):
|
|
660
|
+
raise ConfigError(f"{path}: {key} must be a finite number")
|
|
661
|
+
minimum_ok = value >= minimum if inclusive_minimum else value > minimum
|
|
662
|
+
if not minimum_ok or (maximum is not None and value > maximum):
|
|
663
|
+
bounds = f">{minimum}" if not inclusive_minimum else f">={minimum}"
|
|
664
|
+
if maximum is not None:
|
|
665
|
+
bounds += f" and <={maximum}"
|
|
666
|
+
raise ConfigError(f"{path}: {key} must be {bounds}")
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _validate_integer(
|
|
670
|
+
value: Any,
|
|
671
|
+
path: Path,
|
|
672
|
+
key: str,
|
|
673
|
+
*,
|
|
674
|
+
minimum: int,
|
|
675
|
+
maximum: int | None = None,
|
|
676
|
+
) -> None:
|
|
677
|
+
if not isinstance(value, int) or isinstance(value, bool):
|
|
678
|
+
raise ConfigError(f"{path}: {key} must be an integer")
|
|
679
|
+
if value < minimum or (maximum is not None and value > maximum):
|
|
680
|
+
bounds = f">={minimum}"
|
|
681
|
+
if maximum is not None:
|
|
682
|
+
bounds += f" and <={maximum}"
|
|
683
|
+
raise ConfigError(f"{path}: {key} must be {bounds}")
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
@dataclass(slots=True)
|
|
687
|
+
class Settings:
|
|
688
|
+
data: dict[str, Any]
|
|
689
|
+
home: Path
|
|
690
|
+
source: Path
|
|
691
|
+
# The settings file as written by the user (not merged with defaults and
|
|
692
|
+
# not env-expanded). Config writes start from this, so `config set-model`
|
|
693
|
+
# neither freezes every preset nor replaces `${VAR}` with its value.
|
|
694
|
+
raw: dict[str, Any] | None = None
|
|
695
|
+
|
|
696
|
+
def get(self, path: str, default: Any = None) -> Any:
|
|
697
|
+
value: Any = self.data
|
|
698
|
+
for part in path.split("."):
|
|
699
|
+
if not isinstance(value, dict) or part not in value:
|
|
700
|
+
return default
|
|
701
|
+
value = value[part]
|
|
702
|
+
return value
|
|
703
|
+
|
|
704
|
+
def provider_config(self, name: str | None = None) -> tuple[str, dict[str, Any]]:
|
|
705
|
+
provider_name = name or str(self.data["provider"])
|
|
706
|
+
providers = self.data["providers"]
|
|
707
|
+
provider = providers.get(provider_name)
|
|
708
|
+
if not isinstance(provider, dict):
|
|
709
|
+
raise ConfigError(f"provider {provider_name!r} is not defined")
|
|
710
|
+
expanded = expand_env(copy.deepcopy(provider))
|
|
711
|
+
unresolved = _unresolved_env(expanded)
|
|
712
|
+
if unresolved is not None:
|
|
713
|
+
raise ConfigError(
|
|
714
|
+
f"provider {provider_name!r} has an unset environment variable: "
|
|
715
|
+
f"{unresolved}"
|
|
716
|
+
)
|
|
717
|
+
expanded = _coerce_provider_values(provider_name, expanded)
|
|
718
|
+
_validate_provider(provider_name, expanded, self.source)
|
|
719
|
+
return provider_name, expanded
|
|
720
|
+
|
|
721
|
+
def effective_provider(self, override: str | None = None) -> str:
|
|
722
|
+
return override or str(self.data["provider"])
|
|
723
|
+
|
|
724
|
+
def effective_model(self, override: str | None = None) -> str:
|
|
725
|
+
return override or str(self.data["model"])
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def load_settings(
|
|
729
|
+
home: str | Path | None = None,
|
|
730
|
+
*,
|
|
731
|
+
require_file: bool = False,
|
|
732
|
+
) -> Settings:
|
|
733
|
+
root = home_dir(home)
|
|
734
|
+
source = root / SETTINGS_FILENAME
|
|
735
|
+
try:
|
|
736
|
+
text = source.read_text(encoding="utf-8-sig")
|
|
737
|
+
except FileNotFoundError:
|
|
738
|
+
if require_file:
|
|
739
|
+
raise ConfigError(f"settings file not found: {source}") from None
|
|
740
|
+
data = copy.deepcopy(DEFAULT_SETTINGS)
|
|
741
|
+
_coerce_root_values(data, set())
|
|
742
|
+
_validate(data, source)
|
|
743
|
+
return Settings(data=data, home=root, source=source, raw={})
|
|
744
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
745
|
+
# An existing but unreadable file must never silently fall back to
|
|
746
|
+
# defaults: the user would be talking to a different provider than
|
|
747
|
+
# the one their settings file describes.
|
|
748
|
+
raise ConfigError(f"cannot read {source}: {exc}") from exc
|
|
749
|
+
|
|
750
|
+
try:
|
|
751
|
+
raw = json.loads(text)
|
|
752
|
+
except json.JSONDecodeError as exc:
|
|
753
|
+
raise ConfigError(f"cannot read {source}: {exc}") from exc
|
|
754
|
+
if not isinstance(raw, dict):
|
|
755
|
+
raise ConfigError(f"{source}: top-level value must be an object")
|
|
756
|
+
|
|
757
|
+
data = _deep_merge(DEFAULT_SETTINGS, raw)
|
|
758
|
+
unexpanded = {key: value for key, value in data.items() if key != "providers"}
|
|
759
|
+
placeholders = _placeholder_paths(unexpanded)
|
|
760
|
+
data = _expand_root_settings(data)
|
|
761
|
+
unresolved = _unresolved_env(
|
|
762
|
+
{key: value for key, value in data.items() if key != "providers"}
|
|
763
|
+
)
|
|
764
|
+
if unresolved is not None:
|
|
765
|
+
raise ConfigError(f"{source}: unset environment variable: {unresolved}")
|
|
766
|
+
_coerce_root_values(data, placeholders)
|
|
767
|
+
_validate(data, source)
|
|
768
|
+
return Settings(data=data, home=root, source=source, raw=raw)
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def _atomic_write(path: Path, text: str) -> None:
|
|
772
|
+
# Write through a symlinked settings file instead of replacing the link
|
|
773
|
+
# with a regular file (dotfile repositories are the common setup).
|
|
774
|
+
try:
|
|
775
|
+
path = Path(os.path.realpath(path))
|
|
776
|
+
except (OSError, RuntimeError, ValueError):
|
|
777
|
+
pass
|
|
778
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
779
|
+
fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
780
|
+
temp_path = Path(temp_name)
|
|
781
|
+
try:
|
|
782
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
|
|
783
|
+
handle.write(text)
|
|
784
|
+
handle.flush()
|
|
785
|
+
os.fsync(handle.fileno())
|
|
786
|
+
try:
|
|
787
|
+
os.chmod(temp_path, 0o600)
|
|
788
|
+
except OSError:
|
|
789
|
+
pass
|
|
790
|
+
os.replace(temp_path, path)
|
|
791
|
+
try:
|
|
792
|
+
directory_fd = os.open(path.parent, os.O_RDONLY)
|
|
793
|
+
except OSError:
|
|
794
|
+
directory_fd = -1
|
|
795
|
+
if directory_fd >= 0:
|
|
796
|
+
try:
|
|
797
|
+
os.fsync(directory_fd)
|
|
798
|
+
except OSError:
|
|
799
|
+
pass
|
|
800
|
+
finally:
|
|
801
|
+
os.close(directory_fd)
|
|
802
|
+
finally:
|
|
803
|
+
if temp_path.exists():
|
|
804
|
+
temp_path.unlink(missing_ok=True)
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def _ensure_home_layout(root: Path) -> Path:
|
|
808
|
+
"""Create the config home and session directory with private permissions."""
|
|
809
|
+
path = root / SETTINGS_FILENAME
|
|
810
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
811
|
+
sessions = root / "sessions"
|
|
812
|
+
sessions.mkdir(parents=True, exist_ok=True)
|
|
813
|
+
try:
|
|
814
|
+
os.chmod(root, 0o700)
|
|
815
|
+
os.chmod(sessions, 0o700)
|
|
816
|
+
except OSError:
|
|
817
|
+
pass
|
|
818
|
+
return path
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
_OMIT = object()
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def _prune_defaults(value: Any, default: Any) -> Any:
|
|
825
|
+
"""Drop values that the bundled defaults already provide.
|
|
826
|
+
|
|
827
|
+
Persisting the merged settings froze every preset on disk: a later release
|
|
828
|
+
could no longer correct an existing key. Writing only the user's
|
|
829
|
+
differences keeps the defaults live. A missing key in the file simply
|
|
830
|
+
means "use the default", which is the same effective value.
|
|
831
|
+
"""
|
|
832
|
+
if isinstance(value, dict) and isinstance(default, dict):
|
|
833
|
+
result: dict[str, Any] = {}
|
|
834
|
+
for key, item in value.items():
|
|
835
|
+
if key not in default:
|
|
836
|
+
result[key] = item
|
|
837
|
+
continue
|
|
838
|
+
pruned = _prune_defaults(item, default[key])
|
|
839
|
+
if pruned is not _OMIT:
|
|
840
|
+
result[key] = pruned
|
|
841
|
+
return result if result else _OMIT
|
|
842
|
+
if value == default:
|
|
843
|
+
return _OMIT
|
|
844
|
+
return value
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def file_settings(settings: Settings) -> dict[str, Any]:
|
|
848
|
+
"""Return the settings document a user would edit.
|
|
849
|
+
|
|
850
|
+
Bundled defaults are merged in so the output is complete, and ``raw``
|
|
851
|
+
values are returned unexpanded so ``${VAR}`` placeholders stay visible.
|
|
852
|
+
"""
|
|
853
|
+
return _deep_merge(DEFAULT_SETTINGS, copy.deepcopy(settings.raw or {}))
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def write_settings(data: dict[str, Any], home: str | Path | None = None) -> Path:
|
|
857
|
+
"""Write a complete effective settings mapping.
|
|
858
|
+
|
|
859
|
+
Keys equal to the bundled defaults are pruned before writing, so the file
|
|
860
|
+
stays a delta and a later release can still correct a preset.
|
|
861
|
+
"""
|
|
862
|
+
root = home_dir(home)
|
|
863
|
+
path = _ensure_home_layout(root)
|
|
864
|
+
_validate(data, path)
|
|
865
|
+
pruned = _prune_defaults(data, DEFAULT_SETTINGS)
|
|
866
|
+
payload = {} if pruned is _OMIT else pruned
|
|
867
|
+
_atomic_write(path, json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
|
|
868
|
+
return path
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def write_settings_document(
|
|
872
|
+
document: dict[str, Any],
|
|
873
|
+
home: str | Path | None = None,
|
|
874
|
+
) -> Path:
|
|
875
|
+
"""Validate and persist a complete user settings document.
|
|
876
|
+
|
|
877
|
+
The document is the raw file the user would edit: defaults are merged only
|
|
878
|
+
for validation, and values matching a bundled default are pruned so a
|
|
879
|
+
later release can still correct them.
|
|
880
|
+
"""
|
|
881
|
+
root = home_dir(home)
|
|
882
|
+
path = _ensure_home_layout(root)
|
|
883
|
+
unexpanded = _deep_merge(DEFAULT_SETTINGS, copy.deepcopy(document))
|
|
884
|
+
placeholders = _placeholder_paths(
|
|
885
|
+
{key: value for key, value in unexpanded.items() if key != "providers"}
|
|
886
|
+
)
|
|
887
|
+
effective = _expand_root_settings(unexpanded)
|
|
888
|
+
_coerce_root_values(effective, placeholders)
|
|
889
|
+
_validate(effective, path)
|
|
890
|
+
pruned = _prune_defaults(document, DEFAULT_SETTINGS)
|
|
891
|
+
payload = {} if pruned is _OMIT else pruned
|
|
892
|
+
try:
|
|
893
|
+
text = json.dumps(payload, indent=2, ensure_ascii=False) + "\n"
|
|
894
|
+
except (TypeError, ValueError) as exc:
|
|
895
|
+
raise ConfigError(f"cannot serialize settings: {exc}") from exc
|
|
896
|
+
_atomic_write(path, text)
|
|
897
|
+
return path
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def save_settings(
|
|
901
|
+
settings: Settings,
|
|
902
|
+
updates: dict[str, Any],
|
|
903
|
+
home: str | Path | None = None,
|
|
904
|
+
) -> Path:
|
|
905
|
+
"""Merge ``updates`` into the user's file and persist only that delta.
|
|
906
|
+
|
|
907
|
+
``settings.data`` is the effective view (defaults merged, environment
|
|
908
|
+
expanded); writing it back would freeze every preset and replace
|
|
909
|
+
placeholders with secrets. The raw file plus the requested keys is what a
|
|
910
|
+
`config set-*` command actually changes.
|
|
911
|
+
"""
|
|
912
|
+
document = _deep_merge(copy.deepcopy(settings.raw or {}), updates)
|
|
913
|
+
# Fall back to the home the settings were loaded from: defaulting to
|
|
914
|
+
# `home_dir(None)` elsewhere would write a different file than the one
|
|
915
|
+
# these settings came from.
|
|
916
|
+
return write_settings_document(document, home or settings.home)
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def ensure_config(
|
|
920
|
+
home: str | Path | None = None,
|
|
921
|
+
*,
|
|
922
|
+
overwrite: bool = False,
|
|
923
|
+
minimal: bool = False,
|
|
924
|
+
) -> Path:
|
|
925
|
+
"""Create the settings file.
|
|
926
|
+
|
|
927
|
+
``minimal`` is used for the automatic first-run creation: pinning only the
|
|
928
|
+
provider/model keeps every other value live under the bundled defaults, so
|
|
929
|
+
a later release can still correct a preset. ``minimalcode init`` keeps
|
|
930
|
+
writing the full template for users who want a file to edit.
|
|
931
|
+
"""
|
|
932
|
+
root = home_dir(home)
|
|
933
|
+
path = _ensure_home_layout(root)
|
|
934
|
+
if overwrite or not path.exists():
|
|
935
|
+
payload = (
|
|
936
|
+
{
|
|
937
|
+
"provider": DEFAULT_SETTINGS["provider"],
|
|
938
|
+
"model": DEFAULT_SETTINGS["model"],
|
|
939
|
+
}
|
|
940
|
+
if minimal
|
|
941
|
+
else DEFAULT_SETTINGS
|
|
942
|
+
)
|
|
943
|
+
_atomic_write(path, json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
|
|
944
|
+
return path
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
_SENSITIVE_EXACT_KEYS = {
|
|
948
|
+
"api_key",
|
|
949
|
+
"apikey",
|
|
950
|
+
"authorization",
|
|
951
|
+
"cookie",
|
|
952
|
+
"x-api-key",
|
|
953
|
+
}
|
|
954
|
+
# Token budgets and counters describe costs, not credentials, and environment
|
|
955
|
+
# variable names are safe to print. Redacting them made `config show` hide
|
|
956
|
+
# ordinary settings such as `max_tokens` or `prompt_tokens`.
|
|
957
|
+
_SAFE_COMPACT_KEYS = {
|
|
958
|
+
"maxtokens",
|
|
959
|
+
"maxoutputtokens",
|
|
960
|
+
"maxinputtokens",
|
|
961
|
+
"maxcompletiontokens",
|
|
962
|
+
"prompttokens",
|
|
963
|
+
"completiontokens",
|
|
964
|
+
"totaltokens",
|
|
965
|
+
"inputtokens",
|
|
966
|
+
"outputtokens",
|
|
967
|
+
"cachedtokens",
|
|
968
|
+
"reasoningtokens",
|
|
969
|
+
"tokenizer",
|
|
970
|
+
"tokencount",
|
|
971
|
+
"tokenlimit",
|
|
972
|
+
"tokenbudget",
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
_ENV_REFERENCE_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
977
|
+
_SAFE_HEADER_NAMES = {
|
|
978
|
+
"accept",
|
|
979
|
+
"accept-language",
|
|
980
|
+
"anthropic-beta",
|
|
981
|
+
"anthropic-version",
|
|
982
|
+
"content-type",
|
|
983
|
+
"user-agent",
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
def _is_sensitive_key(key: object) -> bool:
|
|
988
|
+
if not isinstance(key, str):
|
|
989
|
+
return False
|
|
990
|
+
lowered = key.lower()
|
|
991
|
+
if lowered in _SENSITIVE_EXACT_KEYS:
|
|
992
|
+
return True
|
|
993
|
+
compact = re.sub(r"[^a-z0-9]", "", lowered)
|
|
994
|
+
if compact in _SAFE_COMPACT_KEYS:
|
|
995
|
+
return False
|
|
996
|
+
return (
|
|
997
|
+
"apikey" in compact
|
|
998
|
+
or "secret" in compact
|
|
999
|
+
or "password" in compact
|
|
1000
|
+
or "credential" in compact
|
|
1001
|
+
or compact.endswith(("token", "tokens"))
|
|
1002
|
+
)
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
def redact_config(value: Any) -> Any:
|
|
1006
|
+
if isinstance(value, dict):
|
|
1007
|
+
result: dict[str, Any] = {}
|
|
1008
|
+
for key, item in value.items():
|
|
1009
|
+
if (
|
|
1010
|
+
isinstance(key, str)
|
|
1011
|
+
and key.lower() == "headers"
|
|
1012
|
+
and isinstance(item, dict)
|
|
1013
|
+
):
|
|
1014
|
+
# Custom headers carry credentials too, and an arbitrary name
|
|
1015
|
+
# such as `X-Auth` does not look sensitive. Only the standard
|
|
1016
|
+
# protocol headers are safe to print.
|
|
1017
|
+
result[key] = {
|
|
1018
|
+
header: (
|
|
1019
|
+
"<redacted>"
|
|
1020
|
+
if str(header).lower() not in _SAFE_HEADER_NAMES
|
|
1021
|
+
else header_value
|
|
1022
|
+
)
|
|
1023
|
+
for header, header_value in item.items()
|
|
1024
|
+
}
|
|
1025
|
+
elif (
|
|
1026
|
+
isinstance(key, str)
|
|
1027
|
+
and key.lower().endswith("env")
|
|
1028
|
+
and not (isinstance(item, str) and _ENV_REFERENCE_RE.fullmatch(item))
|
|
1029
|
+
):
|
|
1030
|
+
# An `*_env` key is only safe when its value is a variable
|
|
1031
|
+
# name; a literal secret under that name must not print. A
|
|
1032
|
+
# non-string value is a setting (`trust_env`), not a name.
|
|
1033
|
+
result[key] = (
|
|
1034
|
+
"<redacted>" if isinstance(item, str) else redact_config(item)
|
|
1035
|
+
)
|
|
1036
|
+
elif _is_sensitive_key(key):
|
|
1037
|
+
# An `*_env` entry holds the *name* of a variable, which is
|
|
1038
|
+
# safe and useful to print; any other shape is a value.
|
|
1039
|
+
if (
|
|
1040
|
+
isinstance(key, str)
|
|
1041
|
+
and key.lower().endswith("env")
|
|
1042
|
+
and isinstance(item, str)
|
|
1043
|
+
and _ENV_REFERENCE_RE.fullmatch(item)
|
|
1044
|
+
):
|
|
1045
|
+
result[key] = item
|
|
1046
|
+
else:
|
|
1047
|
+
result[key] = "<redacted>" if item else item
|
|
1048
|
+
else:
|
|
1049
|
+
result[key] = redact_config(item)
|
|
1050
|
+
return result
|
|
1051
|
+
if isinstance(value, list):
|
|
1052
|
+
return [redact_config(item) for item in value]
|
|
1053
|
+
return value
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
def provider_api_key(provider: dict[str, Any]) -> str | None:
|
|
1057
|
+
direct = provider.get("api_key")
|
|
1058
|
+
if isinstance(direct, str) and direct:
|
|
1059
|
+
return direct
|
|
1060
|
+
env_name = provider.get("api_key_env")
|
|
1061
|
+
if isinstance(env_name, str) and env_name:
|
|
1062
|
+
key = os.environ.get(env_name)
|
|
1063
|
+
if key:
|
|
1064
|
+
return key
|
|
1065
|
+
return None
|