mycode-cli 0.8.10__py3-none-any.whl → 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. mycode_cli/config.py +278 -115
  2. mycode_cli/permissions.py +1 -1
  3. mycode_cli/runtime.py +2 -2
  4. mycode_cli/server/routers/chat.py +135 -149
  5. mycode_cli/server/routers/sessions.py +8 -6
  6. mycode_cli/server/routers/settings.py +2 -174
  7. mycode_cli/server/run_manager.py +49 -1
  8. mycode_cli/server/static/assets/{EditDiff-BwTMgMSU.js → EditDiff-B0IHjo9H.js} +1 -1
  9. mycode_cli/server/static/assets/{angular-html-B6qE7Fgr.js → angular-html-1A3-6kDn.js} +1 -1
  10. mycode_cli/server/static/assets/{angular-ts-CCYXQBsQ.js → angular-ts-DHbksxnM.js} +1 -1
  11. mycode_cli/server/static/assets/{apl-BHFdPdwP.js → apl-BaBAc5eF.js} +1 -1
  12. mycode_cli/server/static/assets/{astro-CdDbCEi0.js → astro-COnAvojq.js} +1 -1
  13. mycode_cli/server/static/assets/{blade-tLtw_1xC.js → blade-TOINzxFP.js} +1 -1
  14. mycode_cli/server/static/assets/{c-gShqJt5_.js → c-Nf_dof3C.js} +1 -1
  15. mycode_cli/server/static/assets/{cobol-CKazzhoF.js → cobol-C5VZrCE3.js} +1 -1
  16. mycode_cli/server/static/assets/{coffee-DsdLebDY.js → coffee-DZrxMgkq.js} +1 -1
  17. mycode_cli/server/static/assets/{cpp-Cwf1vUUo.js → cpp-sXfSrOsh.js} +1 -1
  18. mycode_cli/server/static/assets/{crystal-D2D2rLOi.js → crystal-BjlKCmcB.js} +1 -1
  19. mycode_cli/server/static/assets/{css-DCF53qDU.js → css-DIFAVgam.js} +1 -1
  20. mycode_cli/server/static/assets/{edge-DQH05KUf.js → edge-DF8HkBtE.js} +1 -1
  21. mycode_cli/server/static/assets/{elixir-2VpsbiLV.js → elixir-qnpJ3Nvm.js} +1 -1
  22. mycode_cli/server/static/assets/{elm-_CHnbifk.js → elm-BL2EN5T8.js} +1 -1
  23. mycode_cli/server/static/assets/{engine-compile-B24DC8pd.js → engine-compile-F5yGEj2z.js} +3 -3
  24. mycode_cli/server/static/assets/{erb-Cxmz5KSP.js → erb-QTsb__7o.js} +1 -1
  25. mycode_cli/server/static/assets/{git-rebase-C04jMORK.js → git-rebase-0I7RXJtl.js} +1 -1
  26. mycode_cli/server/static/assets/{glimmer-js-D0Nvbzz0.js → glimmer-js-Bs8WRGgm.js} +1 -1
  27. mycode_cli/server/static/assets/{glimmer-ts-ix22zR5c.js → glimmer-ts-D9emdqIC.js} +1 -1
  28. mycode_cli/server/static/assets/{glsl-C4sVYnlm.js → glsl-D9ulPUIi.js} +1 -1
  29. mycode_cli/server/static/assets/{graphql-CmkcYwk_.js → graphql-DUJZ9UiP.js} +1 -1
  30. mycode_cli/server/static/assets/{hack-BDK0ocxa.js → hack-DYifHe-0.js} +1 -1
  31. mycode_cli/server/static/assets/{haml-wLZoRSkX.js → haml-Bz_OM7oT.js} +1 -1
  32. mycode_cli/server/static/assets/{handlebars-Bq6qdew-.js → handlebars-BjKJLgms.js} +1 -1
  33. mycode_cli/server/static/assets/{highlighter-Dlz8vejz.js → highlighter-w4GLV421.js} +1 -1
  34. mycode_cli/server/static/assets/{html-j61brix0.js → html-ClcZt6DK.js} +1 -1
  35. mycode_cli/server/static/assets/{html-derivative-B16zJ1uM.js → html-derivative-BaRvfEoQ.js} +1 -1
  36. mycode_cli/server/static/assets/{http-0baH_3Dk.js → http-D-6KTEZs.js} +1 -1
  37. mycode_cli/server/static/assets/{hurl-CR5A6vqw.js → hurl-DG0n-OrQ.js} +1 -1
  38. mycode_cli/server/static/assets/{index-_MPb8SvH.js → index-CRjupeqA.js} +4 -4
  39. mycode_cli/server/static/assets/{java-B_NtVZr9.js → java-tcLPA8Yi.js} +1 -1
  40. mycode_cli/server/static/assets/{javascript-BJVvvhBF.js → javascript-CNvuR0fU.js} +1 -1
  41. mycode_cli/server/static/assets/{jinja-C_N7wio_.js → jinja-BUA2of-S.js} +1 -1
  42. mycode_cli/server/static/assets/{jison-DVVdkBIk.js → jison-D-xje2pK.js} +1 -1
  43. mycode_cli/server/static/assets/{json-sM0x8hNn.js → json-BDX8WktD.js} +1 -1
  44. mycode_cli/server/static/assets/{jsx-C9BVFirF.js → jsx-B7wMhlSu.js} +1 -1
  45. mycode_cli/server/static/assets/{julia-ChaP_3F1.js → julia-pLGJrApL.js} +1 -1
  46. mycode_cli/server/static/assets/{just-CavrxuQ2.js → just-CkNBJHi-.js} +1 -1
  47. mycode_cli/server/static/assets/{latex-DzKiPX3o.js → latex-q0kn6Pcj.js} +1 -1
  48. mycode_cli/server/static/assets/{liquid-C7DJS0P7.js → liquid-B--iPi74.js} +1 -1
  49. mycode_cli/server/static/assets/{lua-BqiiO-68.js → lua-BSzNx2dN.js} +1 -1
  50. mycode_cli/server/static/assets/{marko-D6qURI0q.js → marko-CHA5nb2O.js} +1 -1
  51. mycode_cli/server/static/assets/{mdc-CcWXXq58.js → mdc-Ckuw-Vv5.js} +1 -1
  52. mycode_cli/server/static/assets/{nginx-CpKQIPTm.js → nginx-VrfEBzs7.js} +1 -1
  53. mycode_cli/server/static/assets/{nim-8_0pTVwq.js → nim-DzAmBQZE.js} +1 -1
  54. mycode_cli/server/static/assets/{perl-D3eGItpN.js → perl-BUFrzUfQ.js} +1 -1
  55. mycode_cli/server/static/assets/{php-DxTfLPo4.js → php-Bm4RKslR.js} +1 -1
  56. mycode_cli/server/static/assets/{pug-Czlo7T9J.js → pug-n_U-KK_5.js} +1 -1
  57. mycode_cli/server/static/assets/{qml-DcXsrGQw.js → qml-B9YHUmta.js} +1 -1
  58. mycode_cli/server/static/assets/{r-p_dzGn9P.js → r-Dw_ezJH6.js} +1 -1
  59. mycode_cli/server/static/assets/{razor-DYXsAW5_.js → razor-yv2hRY5M.js} +1 -1
  60. mycode_cli/server/static/assets/{regexp-T_fsBtNr.js → regexp-D4Ukx3kJ.js} +1 -1
  61. mycode_cli/server/static/assets/{rst-ORuymkQc.js → rst-C6v-SMfD.js} +1 -1
  62. mycode_cli/server/static/assets/{ruby-ClzjSQ0l.js → ruby-CYSDCpk-.js} +1 -1
  63. mycode_cli/server/static/assets/{sas-BRTK3la1.js → sas-DrkbyhMO.js} +1 -1
  64. mycode_cli/server/static/assets/{scss-DS6I3QcS.js → scss-SpHo1rm1.js} +1 -1
  65. mycode_cli/server/static/assets/{shellscript-CiTYCSib.js → shellscript-DbHlMLhv.js} +1 -1
  66. mycode_cli/server/static/assets/{shellsession-D5DT705X.js → shellsession-DWVDoPWl.js} +1 -1
  67. mycode_cli/server/static/assets/{soy-NNJOX3cw.js → soy-D1a_iX7F.js} +1 -1
  68. mycode_cli/server/static/assets/{sql-Buaa9QxE.js → sql-CefQnlGw.js} +1 -1
  69. mycode_cli/server/static/assets/{stata-ntax-hDY.js → stata-BxFGeD9k.js} +1 -1
  70. mycode_cli/server/static/assets/{surrealql-DOI7nkTm.js → surrealql-h0ka8_Su.js} +1 -1
  71. mycode_cli/server/static/assets/{svelte-BtWRbgsA.js → svelte-I0t2JQun.js} +1 -1
  72. mycode_cli/server/static/assets/{templ-BXLB83Z5.js → templ-BibZqt9i.js} +1 -1
  73. mycode_cli/server/static/assets/{tex-DkP4hQm8.js → tex-BjKBBM3h.js} +1 -1
  74. mycode_cli/server/static/assets/{ts-tags-BhFXwTms.js → ts-tags-D1PtKTiF.js} +1 -1
  75. mycode_cli/server/static/assets/{tsx-Ct4UJ72v.js → tsx-C9yQch3d.js} +1 -1
  76. mycode_cli/server/static/assets/{twig-BVS_f6WM.js → twig-CNmz35zb.js} +1 -1
  77. mycode_cli/server/static/assets/{typescript-31zaVSb0.js → typescript-CqchtJT1.js} +1 -1
  78. mycode_cli/server/static/assets/{vue-axzl95W-.js → vue-CP0lvd8t.js} +1 -1
  79. mycode_cli/server/static/assets/{vue-html-BZJQggZA.js → vue-html-CEXjbQ_2.js} +1 -1
  80. mycode_cli/server/static/assets/{vue-vine-C0SHPvOF.js → vue-vine-CZuLbSRK.js} +1 -1
  81. mycode_cli/server/static/assets/{xml-DXLyvx5e.js → xml-BVrV4sh8.js} +1 -1
  82. mycode_cli/server/static/assets/{xsl-C1DaZKBE.js → xsl-BycG2Clx.js} +1 -1
  83. mycode_cli/server/static/assets/{yaml-BSIzUixy.js → yaml-D7btoOKo.js} +1 -1
  84. mycode_cli/server/static/index.html +1 -1
  85. mycode_cli/tui/chat.py +30 -52
  86. {mycode_cli-0.8.10.dist-info → mycode_cli-0.9.0.dist-info}/METADATA +2 -2
  87. {mycode_cli-0.8.10.dist-info → mycode_cli-0.9.0.dist-info}/RECORD +90 -90
  88. {mycode_cli-0.8.10.dist-info → mycode_cli-0.9.0.dist-info}/WHEEL +1 -1
  89. {mycode_cli-0.8.10.dist-info → mycode_cli-0.9.0.dist-info}/entry_points.txt +0 -0
  90. {mycode_cli-0.8.10.dist-info → mycode_cli-0.9.0.dist-info}/licenses/LICENSE +0 -0
mycode_cli/config.py CHANGED
@@ -1,4 +1,4 @@
1
- """Config loading, provider resolution, and CLI-owned filesystem paths."""
1
+ """Configuration document validation, settings loading, and provider resolution."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -22,27 +22,20 @@ from mycode.providers import (
22
22
  from mycode.utils import as_bool, as_int
23
23
 
24
24
  _DEFAULT_MYCODE_HOME = "~/.mycode"
25
-
26
-
27
- def resolve_mycode_home() -> Path:
28
- """Resolve the mycode home directory (``$MYCODE_HOME`` or ``~/.mycode``)."""
29
-
30
- raw = os.environ.get("MYCODE_HOME", _DEFAULT_MYCODE_HOME)
31
- return Path(raw).expanduser().resolve(strict=False)
32
-
33
-
34
- def resolve_sessions_dir() -> Path:
35
- """Resolve the directory used for persisted sessions."""
36
-
37
- return resolve_mycode_home() / "sessions"
38
-
39
-
40
25
  _API_KEY_ENV_REF_RE = re.compile(r"^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$")
41
- # Full set of user-facing choices. "auto" means "do not send an explicit effort
42
- # to the provider" and is represented internally as `None` after normalization.
26
+
27
+ # "auto" means "do not send an explicit effort to the provider"; after
28
+ # normalization it is represented as None.
43
29
  REASONING_EFFORT_OPTIONS = ("auto", "none", "low", "medium", "high", "xhigh")
44
30
  PERMISSION_LEVEL_OPTIONS = ("readonly", "safe", "standard", "yolo")
45
31
  PERMISSION_MODE_OPTIONS = ("ask", "deny")
32
+ MODEL_OVERRIDE_KEYS = (
33
+ "context_window",
34
+ "max_output_tokens",
35
+ "supports_reasoning",
36
+ "supports_image_input",
37
+ "supports_pdf_input",
38
+ )
46
39
  _EFFORT_AUTO_ALIASES = frozenset({"", "auto", "default"})
47
40
  _EFFORT_OFF_ALIASES = frozenset({"off", "disabled"})
48
41
 
@@ -103,14 +96,17 @@ class ResolvedProvider:
103
96
  model_config: ModelConfig | None = None
104
97
 
105
98
 
106
- def _load_json(path: Path) -> dict[str, Any] | None:
107
- if not path.is_file():
108
- return None
109
- try:
110
- data = json.loads(path.read_text(encoding="utf-8"))
111
- except Exception:
112
- return None
113
- return data if isinstance(data, dict) else None
99
+ def resolve_mycode_home() -> Path:
100
+ """Resolve the mycode home directory (``$MYCODE_HOME`` or ``~/.mycode``)."""
101
+
102
+ raw = os.environ.get("MYCODE_HOME", _DEFAULT_MYCODE_HOME)
103
+ return Path(raw).expanduser().resolve(strict=False)
104
+
105
+
106
+ def resolve_sessions_dir() -> Path:
107
+ """Resolve the directory used for persisted sessions."""
108
+
109
+ return resolve_mycode_home() / "sessions"
114
110
 
115
111
 
116
112
  def resolve_project(cwd: str) -> Path:
@@ -127,7 +123,10 @@ def project_dirs(cwd: str, project: str | Path | None = None) -> list[Path]:
127
123
  """Return directories from project to cwd, inclusive."""
128
124
 
129
125
  cwd_path = Path(cwd).expanduser().resolve(strict=False)
130
- project_path = Path(project).expanduser().resolve(strict=False) if project is not None else resolve_project(cwd)
126
+ if project is None:
127
+ project_path = resolve_project(cwd)
128
+ else:
129
+ project_path = Path(project).expanduser().resolve(strict=False)
131
130
 
132
131
  dirs = [cwd_path]
133
132
  while dirs[-1] != project_path:
@@ -139,51 +138,23 @@ def project_dirs(cwd: str, project: str | Path | None = None) -> list[Path]:
139
138
  return list(reversed(dirs))
140
139
 
141
140
 
142
- def _normalize_models(value: Any) -> dict[str, ModelConfig]:
143
- if not isinstance(value, dict):
144
- return {}
145
-
146
- models: dict[str, ModelConfig] = {}
147
- for model, raw in value.items():
148
- if not isinstance(model, str):
149
- continue
150
- model_id = model.strip()
151
- if not model_id:
152
- continue
153
- if isinstance(raw, ModelConfig):
154
- models[model_id] = raw
155
- continue
156
- raw_config = raw if isinstance(raw, dict) else {}
157
- models[model_id] = ModelConfig(
158
- context_window=as_int(raw_config.get("context_window")),
159
- max_output_tokens=as_int(raw_config.get("max_output_tokens")),
160
- supports_reasoning=as_bool(raw_config.get("supports_reasoning")),
161
- supports_image_input=as_bool(raw_config.get("supports_image_input")),
162
- supports_pdf_input=as_bool(raw_config.get("supports_pdf_input")),
163
- )
164
- return models
141
+ def _candidate_config_paths(cwd: str, project: str | Path) -> list[Path]:
142
+ """Return config files in override order: global, then project to cwd."""
165
143
 
144
+ paths = [resolve_mycode_home() / "config.json"]
145
+ paths.extend(path / ".mycode" / "config.json" for path in project_dirs(cwd, project))
146
+ return paths
166
147
 
167
- def _parse_config_api_key(value: Any) -> tuple[str | None, str | None]:
168
- if not isinstance(value, str):
169
- return None, None
170
148
 
171
- cleaned = value.strip()
172
- if not cleaned:
173
- return None, None
149
+ def is_api_key_env_ref(value: str) -> str | None:
150
+ """Return the env var name when ``value`` is a ``${NAME}`` reference."""
174
151
 
175
- match = _API_KEY_ENV_REF_RE.fullmatch(cleaned)
176
- if match:
177
- return None, match.group(1)
178
- return cleaned, None
152
+ match = _API_KEY_ENV_REF_RE.fullmatch(value.strip())
153
+ return match.group(1) if match else None
179
154
 
180
155
 
181
156
  def parse_compact_threshold(value: Any) -> float | None:
182
- """Parse compact_threshold from config.
183
-
184
- Returns ``None`` when the key should keep the current/default value, ``0.0``
185
- when compaction is explicitly disabled, or a valid float in ``[0, 1]``.
186
- """
157
+ """Parse compact_threshold from config."""
187
158
 
188
159
  if value is None:
189
160
  return None
@@ -201,13 +172,7 @@ def parse_compact_threshold(value: Any) -> float | None:
201
172
 
202
173
 
203
174
  def normalize_reasoning_effort(value: Any) -> str | None:
204
- """Normalize a reasoning effort setting.
205
-
206
- Returns `None` for "auto"/"default"/empty — the sentinel for "do not send an
207
- explicit effort to the provider". Returns "none" for "off"/"disabled" and
208
- a canonical tier string for recognized levels. Raises `ValueError` for any
209
- other string (or non-string, non-None value).
210
- """
175
+ """Normalize a reasoning effort setting."""
211
176
 
212
177
  if value is None:
213
178
  return None
@@ -258,61 +223,151 @@ def parse_permission(value: Any, current: PermissionConfig | None = None) -> Per
258
223
  raise ValueError(f"permission must be a string or object, got {type(value).__name__}")
259
224
 
260
225
 
261
- def _config_api_key_from_env_var(provider: ProviderConfig, *, require: bool = False) -> str | None:
262
- env_name = provider.api_key_env_var
263
- if not env_name:
264
- return None
226
+ def validate_global_config(data: Any) -> dict[str, Any]:
227
+ """Validate and clean a global config document before writing it to disk."""
265
228
 
266
- value = (os.environ.get(env_name) or "").strip()
267
- if value:
268
- return value
229
+ if data is None:
230
+ return {}
231
+ if not isinstance(data, dict):
232
+ raise ValueError("config must be an object")
269
233
 
270
- if require:
271
- raise ValueError(f"missing API key env var {env_name!r} referenced by provider {provider.name!r}")
272
- return None
234
+ out: dict[str, Any] = {}
273
235
 
236
+ if data.get("default") is not None:
237
+ default = _validate_default_config(data["default"])
238
+ if default:
239
+ out["default"] = default
274
240
 
275
- def provider_has_api_key(provider: ProviderConfig) -> bool:
276
- """Return whether a configured provider can authenticate right now."""
241
+ if data.get("permission") is not None:
242
+ out["permission"] = _validate_permission_config(data["permission"])
277
243
 
278
- if provider.api_key_env_var:
279
- return bool(_config_api_key_from_env_var(provider))
280
- return bool(provider.api_key or provider_api_key_from_env(provider.type))
244
+ raw_providers = data.get("providers")
245
+ if raw_providers is not None:
246
+ if not isinstance(raw_providers, dict):
247
+ raise ValueError("providers must be an object")
248
+ providers: dict[str, dict[str, Any]] = {}
249
+ for name, raw in raw_providers.items():
250
+ if not isinstance(name, str) or not name.strip():
251
+ raise ValueError("provider name must be a non-empty string")
252
+ provider_name = name.strip()
253
+ providers[provider_name] = _validate_provider_config(provider_name, raw)
254
+ if providers:
255
+ out["providers"] = providers
281
256
 
257
+ return out
282
258
 
283
- def _candidate_config_paths(cwd: str, project: str | Path) -> list[Path]:
284
- paths = [resolve_mycode_home() / "config.json"]
285
- paths.extend(path / ".mycode" / "config.json" for path in project_dirs(cwd, project))
286
- return paths
287
259
 
260
+ def _optional_config_string(raw: dict[str, Any], key: str, label: str) -> str | None:
261
+ if key not in raw:
262
+ return None
263
+ value = raw[key]
264
+ if value is None or value == "":
265
+ return None
266
+ if not isinstance(value, str):
267
+ raise ValueError(f"{label} must be a string")
268
+ return value.strip() or None
269
+
270
+
271
+ def _validate_default_config(raw: Any) -> dict[str, Any]:
272
+ if not isinstance(raw, dict):
273
+ raise ValueError("default must be an object")
274
+
275
+ out: dict[str, Any] = {}
276
+ for key in ("provider", "model"):
277
+ value = _optional_config_string(raw, key, f"default.{key}")
278
+ if value:
279
+ out[key] = value
280
+
281
+ effort = raw.get("reasoning_effort")
282
+ if effort not in (None, ""):
283
+ normalize_reasoning_effort(effort)
284
+ out["reasoning_effort"] = effort
285
+
286
+ compact_threshold = raw.get("compact_threshold")
287
+ if compact_threshold is False:
288
+ out["compact_threshold"] = False
289
+ elif compact_threshold is not None:
290
+ if isinstance(compact_threshold, bool):
291
+ raise ValueError("default.compact_threshold must be a number in [0, 1] or false")
292
+ parsed = parse_compact_threshold(compact_threshold)
293
+ if parsed is None:
294
+ raise ValueError("default.compact_threshold must be a number in [0, 1] or false")
295
+ out["compact_threshold"] = parsed
296
+
297
+ return out
298
+
299
+
300
+ def _validate_permission_config(raw: Any) -> PermissionLevel | dict[str, Any]:
301
+ if isinstance(raw, str):
302
+ return normalize_permission_level(raw)
303
+ if not isinstance(raw, dict):
304
+ raise ValueError("permission must be a string or object")
305
+
306
+ out: dict[str, Any] = {}
307
+ if "level" in raw:
308
+ out["level"] = normalize_permission_level(raw.get("level"))
309
+ if "mode" in raw:
310
+ out["mode"] = normalize_permission_mode(raw.get("mode"))
311
+ return out
312
+
313
+
314
+ def _validate_provider_config(name: str, raw: Any) -> dict[str, Any]:
315
+ if not isinstance(raw, dict):
316
+ raise ValueError(f"provider {name!r} must be an object")
317
+
318
+ out: dict[str, Any] = {}
319
+ raw_type = raw.get("type")
320
+ if raw_type in (None, ""):
321
+ if not is_supported_provider(name):
322
+ raise ValueError(f"provider {name!r} must set 'type'")
323
+ elif not isinstance(raw_type, str):
324
+ raise ValueError(f"provider {name!r}: type must be a string")
325
+ elif not is_supported_provider(raw_type):
326
+ supported = ", ".join(list_supported_providers())
327
+ raise ValueError(f"provider {name!r}: unsupported type {raw_type!r}; supported: {supported}")
328
+ else:
329
+ out["type"] = raw_type
288
330
 
289
- def _build_providers(raw_providers: dict[str, dict[str, Any]]) -> dict[str, ProviderConfig]:
290
- providers: dict[str, ProviderConfig] = {}
331
+ for key in ("api_key", "base_url"):
332
+ value = _optional_config_string(raw, key, f"provider {name!r}: {key}")
333
+ if value:
334
+ out[key] = value
291
335
 
292
- for name, raw in raw_providers.items():
293
- raw_type = raw.get("type")
294
- if raw_type:
295
- provider_type = str(raw_type)
296
- elif is_supported_provider(name):
297
- # Built-in providers can be overridden by name without repeating type.
298
- provider_type = name
299
- else:
300
- raise ValueError(f"provider {name!r} must set 'type'")
336
+ effort = raw.get("reasoning_effort")
337
+ if effort not in (None, ""):
338
+ normalize_reasoning_effort(effort)
339
+ out["reasoning_effort"] = effort
301
340
 
302
- models = _normalize_models(raw.get("models"))
303
- if not models:
304
- models = {model: ModelConfig() for model in provider_default_models(provider_type)}
305
- providers[name] = ProviderConfig(
306
- name=name,
307
- type=provider_type,
308
- models=models,
309
- api_key=raw.get("api_key") or None,
310
- api_key_env_var=raw.get("api_key_env_var") or None,
311
- base_url=raw.get("base_url") or None,
312
- reasoning_effort=normalize_reasoning_effort(raw.get("reasoning_effort")),
313
- )
341
+ raw_models = raw.get("models")
342
+ if raw_models is not None:
343
+ models = _validate_model_config_entries(name, raw_models)
344
+ if models:
345
+ out["models"] = models
346
+
347
+ return out
314
348
 
315
- return providers
349
+
350
+ def _validate_model_config_entries(name: str, raw: Any) -> dict[str, dict[str, Any]]:
351
+ # The settings UI sends a model list, while stored config uses a model map.
352
+ if isinstance(raw, list):
353
+ items: list[tuple[Any, Any]] = [(model, None) for model in raw]
354
+ elif isinstance(raw, dict):
355
+ items = list(raw.items())
356
+ else:
357
+ raise ValueError(f"provider {name!r}: models must be a list or object")
358
+
359
+ out: dict[str, dict[str, Any]] = {}
360
+ for model_id, overrides in items:
361
+ if not isinstance(model_id, str) or not model_id.strip():
362
+ raise ValueError(f"provider {name!r}: model id must be a non-empty string")
363
+ key = model_id.strip()
364
+ if overrides is None:
365
+ out[key] = {}
366
+ elif isinstance(overrides, dict):
367
+ out[key] = {k: v for k, v in overrides.items() if k in MODEL_OVERRIDE_KEYS and v is not None}
368
+ else:
369
+ raise ValueError(f"provider {name!r}: model {key!r} config must be an object")
370
+ return out
316
371
 
317
372
 
318
373
  def get_settings(cwd: str | None = None) -> Settings:
@@ -392,6 +447,100 @@ def get_settings(cwd: str | None = None) -> Settings:
392
447
  )
393
448
 
394
449
 
450
+ def _load_json(path: Path) -> dict[str, Any] | None:
451
+ """Load a JSON object from a config file; ignore missing or invalid files."""
452
+
453
+ if not path.is_file():
454
+ return None
455
+ try:
456
+ data = json.loads(path.read_text(encoding="utf-8"))
457
+ except Exception:
458
+ return None
459
+ return data if isinstance(data, dict) else None
460
+
461
+
462
+ def _normalize_models(value: Any) -> dict[str, ModelConfig]:
463
+ """Convert raw model overrides into runtime model config objects."""
464
+
465
+ if not isinstance(value, dict):
466
+ return {}
467
+
468
+ models: dict[str, ModelConfig] = {}
469
+ for model, raw in value.items():
470
+ if not isinstance(model, str):
471
+ continue
472
+ model_id = model.strip()
473
+ if not model_id:
474
+ continue
475
+ if isinstance(raw, ModelConfig):
476
+ models[model_id] = raw
477
+ continue
478
+ raw_config = raw if isinstance(raw, dict) else {}
479
+ models[model_id] = ModelConfig(
480
+ context_window=as_int(raw_config.get("context_window")),
481
+ max_output_tokens=as_int(raw_config.get("max_output_tokens")),
482
+ supports_reasoning=as_bool(raw_config.get("supports_reasoning")),
483
+ supports_image_input=as_bool(raw_config.get("supports_image_input")),
484
+ supports_pdf_input=as_bool(raw_config.get("supports_pdf_input")),
485
+ )
486
+ return models
487
+
488
+
489
+ def _parse_config_api_key(value: Any) -> tuple[str | None, str | None]:
490
+ """Return a literal API key or an env var reference from config."""
491
+
492
+ if not isinstance(value, str):
493
+ return None, None
494
+
495
+ cleaned = value.strip()
496
+ if not cleaned:
497
+ return None, None
498
+
499
+ env_name = is_api_key_env_ref(cleaned)
500
+ if env_name:
501
+ return None, env_name
502
+ return cleaned, None
503
+
504
+
505
+ def _build_providers(raw_providers: dict[str, dict[str, Any]]) -> dict[str, ProviderConfig]:
506
+ """Build runtime provider configs after layered config files are merged."""
507
+
508
+ providers: dict[str, ProviderConfig] = {}
509
+
510
+ for name, raw in raw_providers.items():
511
+ raw_type = raw.get("type")
512
+ if raw_type:
513
+ provider_type = str(raw_type)
514
+ elif is_supported_provider(name):
515
+ # Built-in providers can be overridden by name without repeating type.
516
+ provider_type = name
517
+ else:
518
+ raise ValueError(f"provider {name!r} must set 'type'")
519
+
520
+ models = _normalize_models(raw.get("models"))
521
+ if not models:
522
+ models = {model: ModelConfig() for model in provider_default_models(provider_type)}
523
+ providers[name] = ProviderConfig(
524
+ name=name,
525
+ type=provider_type,
526
+ models=models,
527
+ api_key=raw.get("api_key") or None,
528
+ api_key_env_var=raw.get("api_key_env_var") or None,
529
+ base_url=raw.get("base_url") or None,
530
+ reasoning_effort=normalize_reasoning_effort(raw.get("reasoning_effort")),
531
+ )
532
+
533
+ return providers
534
+
535
+
536
+ def provider_has_api_key(provider: ProviderConfig) -> bool:
537
+ """Return whether a configured provider can authenticate right now."""
538
+
539
+ if provider.api_key_env_var:
540
+ return bool(_config_api_key_from_env_var(provider))
541
+ return bool(provider.api_key or provider_api_key_from_env(provider.type))
542
+
543
+
395
544
  def resolve_provider(
396
545
  settings: Settings,
397
546
  *,
@@ -453,6 +602,20 @@ def resolve_provider_choices(settings: Settings) -> list[ResolvedProvider]:
453
602
  return choices
454
603
 
455
604
 
605
+ def _config_api_key_from_env_var(provider: ProviderConfig, *, require: bool = False) -> str | None:
606
+ env_name = provider.api_key_env_var
607
+ if not env_name:
608
+ return None
609
+
610
+ value = (os.environ.get(env_name) or "").strip()
611
+ if value:
612
+ return value
613
+
614
+ if require:
615
+ raise ValueError(f"missing API key env var {env_name!r} referenced by provider {provider.name!r}")
616
+ return None
617
+
618
+
456
619
  def _available_provider_references(settings: Settings) -> list[tuple[str, ProviderConfig | None]]:
457
620
  """Return usable provider references with the configured default first."""
458
621
 
mycode_cli/permissions.py CHANGED
@@ -9,7 +9,7 @@ from pathlib import Path
9
9
  from typing import Literal, NamedTuple
10
10
 
11
11
  from mycode import Hooks, ToolExecutionResult, ToolHookContext
12
- from mycode.tools import resolve_path
12
+ from mycode.utils import resolve_path
13
13
  from mycode_cli.config import PermissionConfig, PermissionLevel, Settings
14
14
  from mycode_cli.system_prompt import discover_skills
15
15
 
mycode_cli/runtime.py CHANGED
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from mycode.agent import Agent
6
6
  from mycode.session import SessionStore
7
- from mycode.tools import DEFAULT_TOOL_SPECS
7
+ from mycode.tools import bash_tool, edit_tool, read_tool, write_tool
8
8
  from mycode_cli.config import ResolvedProvider, Settings
9
9
  from mycode_cli.permissions import ToolReviewCallback, build_permission_hooks
10
10
  from mycode_cli.system_prompt import build_system_prompt
@@ -46,6 +46,6 @@ def build_agent(
46
46
  compact_threshold=settings.compact_threshold,
47
47
  max_turns=max_turns,
48
48
  system=build_system_prompt(cwd, settings),
49
- tools=DEFAULT_TOOL_SPECS,
49
+ tools=[read_tool, write_tool, edit_tool, bash_tool],
50
50
  hooks=hooks,
51
51
  )