minimalcode 1.5.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. minimalcode/__init__.py +3 -0
  2. minimalcode/__main__.py +3 -0
  3. minimalcode/agent.py +1084 -0
  4. minimalcode/cli.py +1520 -0
  5. minimalcode/clipboard.py +212 -0
  6. minimalcode/config.py +1065 -0
  7. minimalcode/defaults.json +491 -0
  8. minimalcode/errors.py +31 -0
  9. minimalcode/highlight/__init__.py +47 -0
  10. minimalcode/highlight/code.py +261 -0
  11. minimalcode/highlight/color.py +58 -0
  12. minimalcode/highlight/markdown.py +710 -0
  13. minimalcode/highlight/palette.py +73 -0
  14. minimalcode/highlight/sanitize.py +175 -0
  15. minimalcode/highlight/thinking.py +71 -0
  16. minimalcode/httpclient.py +278 -0
  17. minimalcode/importer.py +253 -0
  18. minimalcode/prompt.py +195 -0
  19. minimalcode/provider/__init__.py +28 -0
  20. minimalcode/provider/anthropic.py +805 -0
  21. minimalcode/provider/anthropic_env.py +161 -0
  22. minimalcode/provider/chat.py +394 -0
  23. minimalcode/provider/client.py +243 -0
  24. minimalcode/provider/common.py +176 -0
  25. minimalcode/provider/deepseek_web.py +1158 -0
  26. minimalcode/provider/factory.py +63 -0
  27. minimalcode/provider/responses.py +692 -0
  28. minimalcode/provider/sha3_wasm_bg.wasm +0 -0
  29. minimalcode/provider/transport.py +241 -0
  30. minimalcode/provider/types.py +54 -0
  31. minimalcode/sandbox/__init__.py +64 -0
  32. minimalcode/sandbox/audit.py +120 -0
  33. minimalcode/sandbox/backends.py +343 -0
  34. minimalcode/sandbox/execution.py +435 -0
  35. minimalcode/sandbox/network.py +793 -0
  36. minimalcode/sandbox/policy.py +253 -0
  37. minimalcode/sandbox/runner.py +347 -0
  38. minimalcode/sandbox/sensitive.py +286 -0
  39. minimalcode/session.py +465 -0
  40. minimalcode/shell.py +333 -0
  41. minimalcode/subagent.py +120 -0
  42. minimalcode/tools/__init__.py +46 -0
  43. minimalcode/tools/files.py +1139 -0
  44. minimalcode/tools/process.py +164 -0
  45. minimalcode/tools/remote.py +74 -0
  46. minimalcode/tools/runtime.py +219 -0
  47. minimalcode/tools/schemas.py +418 -0
  48. minimalcode/tools/search.py +521 -0
  49. minimalcode/tools/search_tools.py +583 -0
  50. minimalcode/tools/validation.py +99 -0
  51. minimalcode/vision.py +231 -0
  52. minimalcode/web.py +238 -0
  53. minimalcode-1.5.2.dist-info/METADATA +646 -0
  54. minimalcode-1.5.2.dist-info/RECORD +58 -0
  55. minimalcode-1.5.2.dist-info/WHEEL +5 -0
  56. minimalcode-1.5.2.dist-info/entry_points.txt +2 -0
  57. minimalcode-1.5.2.dist-info/licenses/LICENSE +201 -0
  58. minimalcode-1.5.2.dist-info/top_level.txt +1 -0
minimalcode/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