substrate-setup 0.3.1__tar.gz → 0.4.0__tar.gz

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 (43) hide show
  1. {substrate_setup-0.3.1 → substrate_setup-0.4.0}/CHANGELOG.md +38 -22
  2. {substrate_setup-0.3.1 → substrate_setup-0.4.0}/PKG-INFO +17 -8
  3. {substrate_setup-0.3.1 → substrate_setup-0.4.0}/README.md +8 -4
  4. {substrate_setup-0.3.1 → substrate_setup-0.4.0}/pyproject.toml +22 -5
  5. {substrate_setup-0.3.1 → substrate_setup-0.4.0}/substrate_setup/agents/aider.py +14 -2
  6. {substrate_setup-0.3.1 → substrate_setup-0.4.0}/substrate_setup/agents/base.py +14 -1
  7. {substrate_setup-0.3.1 → substrate_setup-0.4.0}/substrate_setup/agents/claude_code.py +14 -2
  8. {substrate_setup-0.3.1 → substrate_setup-0.4.0}/substrate_setup/agents/codex.py +17 -5
  9. {substrate_setup-0.3.1 → substrate_setup-0.4.0}/substrate_setup/agents/continue_dev.py +14 -2
  10. {substrate_setup-0.3.1 → substrate_setup-0.4.0}/substrate_setup/agents/cursor.py +14 -2
  11. {substrate_setup-0.3.1 → substrate_setup-0.4.0}/substrate_setup/agents/hermes.py +14 -2
  12. {substrate_setup-0.3.1 → substrate_setup-0.4.0}/substrate_setup/credentials.py +3 -3
  13. {substrate_setup-0.3.1 → substrate_setup-0.4.0}/substrate_setup/data/fallback_catalog.json +5 -5
  14. {substrate_setup-0.3.1 → substrate_setup-0.4.0}/substrate_setup/env_persist.py +37 -21
  15. {substrate_setup-0.3.1 → substrate_setup-0.4.0}/tests/agents/test_claude_code.py +4 -0
  16. {substrate_setup-0.3.1 → substrate_setup-0.4.0}/tests/agents/test_codex.py +4 -0
  17. {substrate_setup-0.3.1 → substrate_setup-0.4.0}/tests/agents/test_cursor.py +13 -1
  18. substrate_setup-0.4.0/tests/conftest.py +37 -0
  19. {substrate_setup-0.3.1 → substrate_setup-0.4.0}/tests/test_cli.py +61 -0
  20. {substrate_setup-0.3.1 → substrate_setup-0.4.0}/tests/test_env_persist.py +30 -0
  21. substrate_setup-0.4.0/uv.lock +866 -0
  22. substrate_setup-0.3.1/tests/conftest.py +0 -19
  23. substrate_setup-0.3.1/uv.lock +0 -352
  24. {substrate_setup-0.3.1 → substrate_setup-0.4.0}/.gitignore +0 -0
  25. {substrate_setup-0.3.1 → substrate_setup-0.4.0}/scripts/lint_no_app_import.sh +0 -0
  26. {substrate_setup-0.3.1 → substrate_setup-0.4.0}/scripts/regenerate_fallback_catalog.py +0 -0
  27. {substrate_setup-0.3.1 → substrate_setup-0.4.0}/substrate_setup/__init__.py +0 -0
  28. {substrate_setup-0.3.1 → substrate_setup-0.4.0}/substrate_setup/__main__.py +0 -0
  29. {substrate_setup-0.3.1 → substrate_setup-0.4.0}/substrate_setup/agents/__init__.py +0 -0
  30. {substrate_setup-0.3.1 → substrate_setup-0.4.0}/substrate_setup/backup.py +0 -0
  31. {substrate_setup-0.3.1 → substrate_setup-0.4.0}/substrate_setup/catalog.py +0 -0
  32. {substrate_setup-0.3.1 → substrate_setup-0.4.0}/substrate_setup/cli.py +0 -0
  33. {substrate_setup-0.3.1 → substrate_setup-0.4.0}/substrate_setup/markers.py +0 -0
  34. {substrate_setup-0.3.1 → substrate_setup-0.4.0}/tests/__init__.py +0 -0
  35. {substrate_setup-0.3.1 → substrate_setup-0.4.0}/tests/agents/__init__.py +0 -0
  36. {substrate_setup-0.3.1 → substrate_setup-0.4.0}/tests/agents/test_aider.py +0 -0
  37. {substrate_setup-0.3.1 → substrate_setup-0.4.0}/tests/agents/test_continue_dev.py +0 -0
  38. {substrate_setup-0.3.1 → substrate_setup-0.4.0}/tests/agents/test_hermes.py +0 -0
  39. {substrate_setup-0.3.1 → substrate_setup-0.4.0}/tests/test_backup.py +0 -0
  40. {substrate_setup-0.3.1 → substrate_setup-0.4.0}/tests/test_catalog.py +0 -0
  41. {substrate_setup-0.3.1 → substrate_setup-0.4.0}/tests/test_credentials.py +0 -0
  42. {substrate_setup-0.3.1 → substrate_setup-0.4.0}/tests/test_e2e.py +0 -0
  43. {substrate_setup-0.3.1 → substrate_setup-0.4.0}/tests/test_markers.py +0 -0
@@ -1,22 +1,38 @@
1
- # Changelog
2
-
3
- ## 0.3.1 — 2026-05-24
4
-
5
- - Persist `SUBSTRATE_API_KEY` automatically during `configure`:
6
- on Windows, writes `HKCU\Environment\SUBSTRATE_API_KEY` via `winreg`
7
- + `WM_SETTINGCHANGE` broadcast visible to GUI agents (Codex Desktop)
8
- on next launch without the user editing rc files or restarting their
9
- session. On macOS / Linux, appends a marker-fenced `export` block (or
10
- fish-syntax `set -gx` for fish users) to the user's shell rc. Re-runs
11
- collapse stale blocks idempotently the rc never grows on repeated
12
- configures.
13
- - Codex adapter now prints a one-line Windows Defender exclusion command
14
- to eliminate SmartScreen prompts on per-action helper binary spawns.
15
- Informational only substrate-setup never elevates itself.
16
- - Adapters surface env-persistence failures with a clear `WARNING:`
17
- prefix + manual fallback hint so users notice when the convenience
18
- step fails but the config file itself was written successfully.
19
- - Codex adapter drops the stale "/v1/responses NOT YET STARTED" warning
20
- — Phase 2 shipped on 2026-05-24.
21
- - No config schema changes from 0.3.0. Upgrade cleanly via
22
- `uv tool install --force substrate-setup` (or pipx).
1
+ # Changelog
2
+
3
+ ## 0.4.0 — 2026-05-30
4
+
5
+ - Lower the supported Python floor from **3.12 to 3.8**. Nothing in the tool
6
+ actually required 3.12 the gate was conservative. `pip install
7
+ substrate-setup` now works on Anaconda's default 3.9 and other older
8
+ interpreters, no `uv`/`pipx` workaround needed.
9
+ - `enum.StrEnum` (3.11+) imports natively on 3.11+ with a minimal
10
+ `(str, Enum)` backport on older interpreters.
11
+ - The `tomllib` `tomli` fallback is now `sys.version_info`-gated, and
12
+ `tomli` is a declared dependency (`python_version < '3.11'`) — previously
13
+ the fallback path had no dependency backing it and would have failed.
14
+ - `tomli-w` lower bound relaxed `1.2 1.0` so 3.8 resolves.
15
+ - No CLI or config-schema changes pure compatibility widening. The
16
+ test/type tooling still requires 3.9+ (`pytest-httpx`/`mypy` dropped 3.8);
17
+ end users on 3.8 are unaffected.
18
+
19
+ ## 0.3.1 2026-05-24
20
+
21
+ - Persist `SUBSTRATE_API_KEY` automatically during `configure`:
22
+ on Windows, writes `HKCU\Environment\SUBSTRATE_API_KEY` via `winreg`
23
+ + `WM_SETTINGCHANGE` broadcast — visible to GUI agents (Codex Desktop)
24
+ on next launch without the user editing rc files or restarting their
25
+ session. On macOS / Linux, appends a marker-fenced `export` block (or
26
+ fish-syntax `set -gx` for fish users) to the user's shell rc. Re-runs
27
+ collapse stale blocks idempotently — the rc never grows on repeated
28
+ configures.
29
+ - Codex adapter now prints a one-line Windows Defender exclusion command
30
+ to eliminate SmartScreen prompts on per-action helper binary spawns.
31
+ Informational only — substrate-setup never elevates itself.
32
+ - Adapters surface env-persistence failures with a clear `WARNING:`
33
+ prefix + manual fallback hint so users notice when the convenience
34
+ step fails but the config file itself was written successfully.
35
+ - Codex adapter drops the stale "/v1/responses NOT YET STARTED" warning
36
+ — Phase 2 shipped on 2026-05-24.
37
+ - No config schema changes from 0.3.0. Upgrade cleanly via
38
+ `uv tool install --force substrate-setup` (or pipx).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: substrate-setup
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
4
  Summary: One-shot local configurator for coding agents against a Substrate gateway
5
5
  Project-URL: Homepage, https://github.com/FrankXiaA/substrate-solutions
6
6
  Project-URL: Source, https://github.com/FrankXiaA/substrate-solutions/tree/main/substrate-api/substrate_setup
@@ -14,16 +14,21 @@ Classifier: Intended Audience :: Developers
14
14
  Classifier: License :: OSI Approved :: MIT License
15
15
  Classifier: Operating System :: OS Independent
16
16
  Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.8
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
17
21
  Classifier: Programming Language :: Python :: 3.12
18
22
  Classifier: Topic :: Software Development :: Code Generators
19
23
  Classifier: Topic :: Utilities
20
- Requires-Python: >=3.12
24
+ Requires-Python: >=3.8
21
25
  Requires-Dist: httpx>=0.27
22
26
  Requires-Dist: ruamel-yaml>=0.18
23
- Requires-Dist: tomli-w>=1.2
27
+ Requires-Dist: tomli-w>=1.0
28
+ Requires-Dist: tomli>=2.0; python_version < '3.11'
24
29
  Provides-Extra: dev
25
30
  Requires-Dist: mypy<2,>=1.13; extra == 'dev'
26
- Requires-Dist: pytest-httpx>=0.30; extra == 'dev'
31
+ Requires-Dist: pytest-httpx>=0.30; (python_version >= '3.9') and extra == 'dev'
27
32
  Requires-Dist: pytest>=8.0; extra == 'dev'
28
33
  Requires-Dist: ruff<1,>=0.7; extra == 'dev'
29
34
  Description-Content-Type: text/markdown
@@ -34,10 +39,16 @@ One-shot configurator that points local coding agents at a Substrate gateway.
34
39
 
35
40
  ## Install
36
41
 
37
- `substrate-setup` requires Python 3.12+. The installers below pick the right interpreter automatically you don't need a Python 3.12 already on your machine:
42
+ `substrate-setup` runs on **Python 3.8 or newer** including Anaconda's default 3.9 and the system Python on older Linux/macOS boxes. Plain `pip` works:
38
43
 
39
44
  ```bash
40
- # Option A — uv (recommended; downloads Python 3.12 on demand if missing)
45
+ pip install substrate-setup
46
+ ```
47
+
48
+ If you'd rather keep the tool in its own isolated environment (recommended, so it can't clash with a project's dependencies), use `uv` or `pipx`:
49
+
50
+ ```bash
51
+ # Option A — uv (also downloads a Python for the tool if needed)
41
52
  uv tool install substrate-setup
42
53
 
43
54
  # Option B — pipx
@@ -52,8 +63,6 @@ curl -LsSf https://astral.sh/uv/install.sh | sh # macOS / Linux
52
63
  powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
53
64
  ```
54
65
 
55
- > **Why not `pip install substrate-setup`?** It works only if the `pip` on your PATH is bound to a Python 3.12+ interpreter. Anaconda's default `pip` (Python 3.9) is the common pitfall — PyPI hides every release from it with `Requires-Python >=3.12` and the error message is unhelpful. `uv` and `pipx` both create an isolated 3.12 venv for the tool, so they sidestep the issue entirely.
56
-
57
66
  ## Use
58
67
 
59
68
  ```bash
@@ -4,10 +4,16 @@ One-shot configurator that points local coding agents at a Substrate gateway.
4
4
 
5
5
  ## Install
6
6
 
7
- `substrate-setup` requires Python 3.12+. The installers below pick the right interpreter automatically you don't need a Python 3.12 already on your machine:
7
+ `substrate-setup` runs on **Python 3.8 or newer** including Anaconda's default 3.9 and the system Python on older Linux/macOS boxes. Plain `pip` works:
8
8
 
9
9
  ```bash
10
- # Option A — uv (recommended; downloads Python 3.12 on demand if missing)
10
+ pip install substrate-setup
11
+ ```
12
+
13
+ If you'd rather keep the tool in its own isolated environment (recommended, so it can't clash with a project's dependencies), use `uv` or `pipx`:
14
+
15
+ ```bash
16
+ # Option A — uv (also downloads a Python for the tool if needed)
11
17
  uv tool install substrate-setup
12
18
 
13
19
  # Option B — pipx
@@ -22,8 +28,6 @@ curl -LsSf https://astral.sh/uv/install.sh | sh # macOS / Linux
22
28
  powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
23
29
  ```
24
30
 
25
- > **Why not `pip install substrate-setup`?** It works only if the `pip` on your PATH is bound to a Python 3.12+ interpreter. Anaconda's default `pip` (Python 3.9) is the common pitfall — PyPI hides every release from it with `Requires-Python >=3.12` and the error message is unhelpful. `uv` and `pipx` both create an isolated 3.12 venv for the tool, so they sidestep the issue entirely.
26
-
27
31
  ## Use
28
32
 
29
33
  ```bash
@@ -4,10 +4,10 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "substrate-setup"
7
- version = "0.3.1"
7
+ version = "0.4.0"
8
8
  description = "One-shot local configurator for coding agents against a Substrate gateway"
9
9
  readme = "README.md"
10
- requires-python = ">=3.12"
10
+ requires-python = ">=3.8"
11
11
  authors = [{ name = "Substrate Solutions" }]
12
12
  license = { text = "MIT" }
13
13
  keywords = ["substrate", "llm", "gateway", "aider", "continue", "hermes", "openai-compatible"]
@@ -18,6 +18,10 @@ classifiers = [
18
18
  "License :: OSI Approved :: MIT License",
19
19
  "Operating System :: OS Independent",
20
20
  "Programming Language :: Python :: 3 :: Only",
21
+ "Programming Language :: Python :: 3.8",
22
+ "Programming Language :: Python :: 3.9",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.11",
21
25
  "Programming Language :: Python :: 3.12",
22
26
  "Topic :: Software Development :: Code Generators",
23
27
  "Topic :: Utilities",
@@ -25,7 +29,11 @@ classifiers = [
25
29
  dependencies = [
26
30
  "httpx>=0.27",
27
31
  "ruamel.yaml>=0.18",
28
- "tomli-w>=1.2",
32
+ "tomli-w>=1.0",
33
+ # Reading TOML (codex config, credentials probe) uses the stdlib
34
+ # ``tomllib`` on 3.11+; older interpreters fall back to the ``tomli``
35
+ # backport. See ``credentials.py`` / ``agents/codex.py`` import guards.
36
+ "tomli>=2.0; python_version < '3.11'",
29
37
  ]
30
38
 
31
39
  [project.urls]
@@ -34,9 +42,12 @@ Source = "https://github.com/FrankXiaA/substrate-solutions/tree/main/substrate-a
34
42
  Issues = "https://github.com/FrankXiaA/substrate-solutions/issues"
35
43
 
36
44
  [project.optional-dependencies]
45
+ # Runtime floor is Python 3.8, but the test/type tooling needs 3.9+
46
+ # (pytest-httpx>=0.30 and modern mypy both dropped 3.8). Developing/testing
47
+ # the package therefore requires 3.9+; end users on 3.8 are unaffected.
37
48
  dev = [
38
49
  "pytest>=8.0",
39
- "pytest-httpx>=0.30",
50
+ "pytest-httpx>=0.30; python_version >= '3.9'",
40
51
  "ruff>=0.7,<1",
41
52
  "mypy>=1.13,<2",
42
53
  ]
@@ -50,8 +61,14 @@ packages = ["substrate_setup"]
50
61
  [tool.pytest.ini_options]
51
62
  testpaths = ["tests"]
52
63
 
64
+ [tool.ruff]
65
+ target-version = "py38"
66
+
53
67
  [tool.mypy]
54
- python_version = "3.12"
68
+ # Lowest version mypy itself supports as an analysis target; the package's
69
+ # runtime floor is 3.8 (see requires-python). 3.8-only breakage is caught by
70
+ # the test suite + import smoke, not the type checker.
71
+ python_version = "3.9"
55
72
  strict = true
56
73
  ignore_missing_imports = true
57
74
  packages = ["substrate_setup"]
@@ -56,7 +56,12 @@ from substrate_setup.agents.base import (
56
56
  ResultStatus,
57
57
  )
58
58
  from substrate_setup.backup import backup_once
59
- from substrate_setup.env_persist import format_persist_message_for_user, persist_substrate_api_key
59
+ from substrate_setup.env_persist import (
60
+ PersistResult,
61
+ PersistStatus,
62
+ format_persist_message_for_user,
63
+ persist_substrate_api_key,
64
+ )
60
65
 
61
66
  MANAGED_KEYS_MARKER = "_substrate_setup_managed_keys"
62
67
  MANAGED_KEYS: list[str] = ["openai-api-base", "openai-api-key", "model"]
@@ -262,7 +267,14 @@ class AiderAgent(Agent):
262
267
  )
263
268
  _restrict_file_mode(meta_path)
264
269
 
265
- persist_result = persist_substrate_api_key(ctx.api_key)
270
+ if not ctx.dry_run:
271
+ persist_result = persist_substrate_api_key(ctx.api_key)
272
+ else:
273
+ persist_result = PersistResult(
274
+ status=PersistStatus.SKIPPED,
275
+ message="(dry-run) would persist SUBSTRATE_API_KEY",
276
+ path=None,
277
+ )
266
278
  print(format_persist_message_for_user(persist_result))
267
279
 
268
280
  return AgentResult(
@@ -5,11 +5,24 @@ interface. The orchestrator in cli.py treats them polymorphically.
5
5
  """
6
6
  from __future__ import annotations
7
7
 
8
+ import sys
8
9
  from abc import ABC, abstractmethod
9
10
  from dataclasses import dataclass, field
10
- from enum import StrEnum
11
11
  from pathlib import Path
12
12
 
13
+ if sys.version_info >= (3, 11):
14
+ from enum import StrEnum
15
+ else: # pragma: no cover - Python <3.11 backport
16
+ # ``enum.StrEnum`` landed in 3.11. On older interpreters a plain
17
+ # ``(str, Enum)`` mixin gives the same behaviour: members are real
18
+ # ``str`` instances, so ``ResultStatus.SUCCESS == "success"`` holds.
19
+ # ``__str__ = str.__str__`` matches 3.11's StrEnum, where ``str(member)``
20
+ # returns the bare value rather than ``"ResultStatus.SUCCESS"``.
21
+ from enum import Enum
22
+
23
+ class StrEnum(str, Enum):
24
+ __str__ = str.__str__
25
+
13
26
  from substrate_setup.catalog import CatalogEntry
14
27
 
15
28
 
@@ -44,7 +44,12 @@ from substrate_setup.agents.base import (
44
44
  ResultStatus,
45
45
  )
46
46
  from substrate_setup.backup import backup_once
47
- from substrate_setup.env_persist import format_persist_message_for_user, persist_substrate_api_key
47
+ from substrate_setup.env_persist import (
48
+ PersistResult,
49
+ PersistStatus,
50
+ format_persist_message_for_user,
51
+ persist_substrate_api_key,
52
+ )
48
53
 
49
54
  MANAGED_ENV_KEYS_MARKER = "_substrate_setup_managed_env_keys"
50
55
  MANAGED_ENV_KEYS: list[str] = [
@@ -219,7 +224,14 @@ class ClaudeCodeAgent(Agent):
219
224
  )
220
225
  _restrict_file_mode(settings_path)
221
226
 
222
- persist_result = persist_substrate_api_key(ctx.api_key)
227
+ if not ctx.dry_run:
228
+ persist_result = persist_substrate_api_key(ctx.api_key)
229
+ else:
230
+ persist_result = PersistResult(
231
+ status=PersistStatus.SKIPPED,
232
+ message="(dry-run) would persist SUBSTRATE_API_KEY",
233
+ path=None,
234
+ )
223
235
  bullets = "\n".join(f" - {m.id}" for m in chat_models)
224
236
  print(
225
237
  "Claude Code configured to use the Substrate gateway.\n"
@@ -42,10 +42,10 @@ from typing import Any
42
42
 
43
43
  import tomli_w
44
44
 
45
- try:
45
+ if sys.version_info >= (3, 11):
46
46
  import tomllib
47
- except ImportError: # pragma: no cover — Python <3.11 fallback
48
- import tomli as tomllib # type: ignore[no-redef]
47
+ else: # pragma: no cover — Python <3.11 uses the tomli backport
48
+ import tomli as tomllib
49
49
 
50
50
  from substrate_setup.agents.base import (
51
51
  Agent,
@@ -54,7 +54,12 @@ from substrate_setup.agents.base import (
54
54
  ResultStatus,
55
55
  )
56
56
  from substrate_setup.backup import backup_once
57
- from substrate_setup.env_persist import format_persist_message_for_user, persist_substrate_api_key
57
+ from substrate_setup.env_persist import (
58
+ PersistResult,
59
+ PersistStatus,
60
+ format_persist_message_for_user,
61
+ persist_substrate_api_key,
62
+ )
58
63
 
59
64
  PROVIDER_KEY = "substrate"
60
65
  ENV_KEY_NAME = "SUBSTRATE_API_KEY"
@@ -217,7 +222,14 @@ class CodexAgent(Agent):
217
222
  cfg_path.write_text(f"{MARKER_COMMENT}\n{body}", encoding="utf-8")
218
223
  _restrict_file_mode(cfg_path)
219
224
 
220
- persist_result = persist_substrate_api_key(ctx.api_key)
225
+ if not ctx.dry_run:
226
+ persist_result = persist_substrate_api_key(ctx.api_key)
227
+ else:
228
+ persist_result = PersistResult(
229
+ status=PersistStatus.SKIPPED,
230
+ message="(dry-run) would persist SUBSTRATE_API_KEY",
231
+ path=None,
232
+ )
221
233
  bullets = "\n".join(f" - {m.id}" for m in chat_models)
222
234
  print(
223
235
  "Codex configured to use the Substrate gateway.\n"
@@ -46,7 +46,12 @@ from substrate_setup.agents.base import (
46
46
  ResultStatus,
47
47
  )
48
48
  from substrate_setup.backup import backup_once
49
- from substrate_setup.env_persist import format_persist_message_for_user, persist_substrate_api_key
49
+ from substrate_setup.env_persist import (
50
+ PersistResult,
51
+ PersistStatus,
52
+ format_persist_message_for_user,
53
+ persist_substrate_api_key,
54
+ )
50
55
  from substrate_setup.catalog import CatalogEntry
51
56
  from substrate_setup.markers import MARKER_KEY, MARKER_VALUE, is_substrate_managed
52
57
 
@@ -241,7 +246,14 @@ class ContinueAgent(Agent):
241
246
  if not ctx.dry_run:
242
247
  _dump_yaml(yaml_path, payload)
243
248
 
244
- persist_result = persist_substrate_api_key(ctx.api_key)
249
+ if not ctx.dry_run:
250
+ persist_result = persist_substrate_api_key(ctx.api_key)
251
+ else:
252
+ persist_result = PersistResult(
253
+ status=PersistStatus.SKIPPED,
254
+ message="(dry-run) would persist SUBSTRATE_API_KEY",
255
+ path=None,
256
+ )
245
257
  print(format_persist_message_for_user(persist_result))
246
258
 
247
259
  msg = f"{len(ctx.catalog)} models written, API base + key set in {yaml_path}."
@@ -17,7 +17,12 @@ from substrate_setup.agents.base import (
17
17
  ConfigureContext,
18
18
  ResultStatus,
19
19
  )
20
- from substrate_setup.env_persist import format_persist_message_for_user, persist_substrate_api_key
20
+ from substrate_setup.env_persist import (
21
+ PersistResult,
22
+ PersistStatus,
23
+ format_persist_message_for_user,
24
+ persist_substrate_api_key,
25
+ )
21
26
 
22
27
 
23
28
  def _user_data_candidates() -> list[Path]:
@@ -44,7 +49,14 @@ class CursorAgent(Agent):
44
49
  return None
45
50
 
46
51
  def configure(self, ctx: ConfigureContext) -> AgentResult:
47
- persist_result = persist_substrate_api_key(ctx.api_key)
52
+ if not ctx.dry_run:
53
+ persist_result = persist_substrate_api_key(ctx.api_key)
54
+ else:
55
+ persist_result = PersistResult(
56
+ status=PersistStatus.SKIPPED,
57
+ message="(dry-run) would persist SUBSTRATE_API_KEY",
58
+ path=None,
59
+ )
48
60
  chat_models = [
49
61
  e for e in ctx.catalog
50
62
  if e.output_modality == "text"
@@ -62,7 +62,12 @@ from substrate_setup.agents.base import (
62
62
  ResultStatus,
63
63
  )
64
64
  from substrate_setup.backup import backup_once
65
- from substrate_setup.env_persist import format_persist_message_for_user, persist_substrate_api_key
65
+ from substrate_setup.env_persist import (
66
+ PersistResult,
67
+ PersistStatus,
68
+ format_persist_message_for_user,
69
+ persist_substrate_api_key,
70
+ )
66
71
 
67
72
  # Top-level marker key listing the model.* keys substrate-setup owns.
68
73
  MANAGED_KEYS_MARKER = "_substrate_setup_managed_keys"
@@ -387,7 +392,14 @@ class HermesAgent(Agent):
387
392
  if not ctx.dry_run:
388
393
  _dump_yaml(cfg_path, payload)
389
394
 
390
- persist_result = persist_substrate_api_key(ctx.api_key)
395
+ if not ctx.dry_run:
396
+ persist_result = persist_substrate_api_key(ctx.api_key)
397
+ else:
398
+ persist_result = PersistResult(
399
+ status=PersistStatus.SKIPPED,
400
+ message="(dry-run) would persist SUBSTRATE_API_KEY",
401
+ path=None,
402
+ )
391
403
  print(format_persist_message_for_user(persist_result))
392
404
 
393
405
  msg = (
@@ -8,10 +8,10 @@ from collections.abc import Callable
8
8
  from getpass import getpass
9
9
  from pathlib import Path
10
10
 
11
- try:
11
+ if sys.version_info >= (3, 11):
12
12
  import tomllib
13
- except ImportError: # pragma: no cover - Python <3.11 fallback
14
- import tomli as tomllib # type: ignore[no-redef]
13
+ else: # pragma: no cover - Python <3.11 uses the tomli backport
14
+ import tomli as tomllib
15
15
 
16
16
  KEY_REGEX = re.compile(r"^sk-substrate-[A-Za-z0-9_-]{20,}$")
17
17
  ENV_VAR = "SUBSTRATE_API_KEY"
@@ -2,13 +2,13 @@
2
2
  "object": "list",
3
3
  "data": [
4
4
  {
5
- "id": "claude-opus-4-7",
5
+ "id": "claude-opus-4-8",
6
6
  "object": "model",
7
- "created": 1776297600,
7
+ "created": 1779926400,
8
8
  "owned_by": "anthropic",
9
- "display_name": "Claude Opus 4.7",
9
+ "display_name": "Claude Opus 4.8",
10
10
  "description": "Anthropic flagship (most capable, agentic coding)",
11
- "context_length": 200000,
11
+ "context_length": 1000000,
12
12
  "max_completion_tokens": 32000,
13
13
  "pricing": {
14
14
  "input_per_million_usd": 5.0,
@@ -29,7 +29,7 @@
29
29
  "owned_by": "anthropic",
30
30
  "display_name": "Claude Sonnet 4.6",
31
31
  "description": "Balanced quality and speed",
32
- "context_length": 200000,
32
+ "context_length": 1000000,
33
33
  "max_completion_tokens": 32000,
34
34
  "pricing": {
35
35
  "input_per_million_usd": 3.0,
@@ -25,6 +25,7 @@ import sys
25
25
  from dataclasses import dataclass
26
26
  from enum import Enum
27
27
  from pathlib import Path
28
+ from typing import Any, cast
28
29
 
29
30
  _ENV_VAR = "SUBSTRATE_API_KEY"
30
31
  _MARKER_OPEN = "# >>> substrate-setup managed >>>"
@@ -89,14 +90,15 @@ def _persist_windows(api_key: str) -> PersistResult:
89
90
  path=None,
90
91
  )
91
92
 
93
+ wr = cast(Any, winreg) # cross-platform mypy: winreg attrs absent from Linux stubs
92
94
  try:
93
- with winreg.OpenKey(
94
- winreg.HKEY_CURRENT_USER,
95
+ with wr.OpenKey(
96
+ wr.HKEY_CURRENT_USER,
95
97
  "Environment",
96
98
  0,
97
- winreg.KEY_SET_VALUE,
99
+ wr.KEY_SET_VALUE,
98
100
  ) as h:
99
- winreg.SetValueEx(h, _ENV_VAR, 0, winreg.REG_EXPAND_SZ, api_key)
101
+ wr.SetValueEx(h, _ENV_VAR, 0, wr.REG_EXPAND_SZ, api_key)
100
102
  except OSError as exc:
101
103
  return PersistResult(
102
104
  status=PersistStatus.ERROR,
@@ -130,8 +132,10 @@ def _broadcast_setting_change_windows() -> None:
130
132
  HWND_BROADCAST = 0xFFFF
131
133
  WM_SETTINGCHANGE = 0x001A
132
134
  SMTO_ABORTIFHUNG = 0x0002
133
- user32 = ctypes.WinDLL("user32", use_last_error=True)
134
- result = wintypes.DWORD()
135
+ ct = cast(Any, ctypes) # cross-platform mypy: WinDLL absent from Linux stubs
136
+ wt = cast(Any, wintypes) # cross-platform mypy: wintypes attrs absent from Linux stubs
137
+ user32 = ct.WinDLL("user32", use_last_error=True)
138
+ result = wt.DWORD()
135
139
  try:
136
140
  user32.SendMessageTimeoutW(
137
141
  HWND_BROADCAST,
@@ -140,7 +144,7 @@ def _broadcast_setting_change_windows() -> None:
140
144
  "Environment",
141
145
  SMTO_ABORTIFHUNG,
142
146
  5000,
143
- ctypes.byref(result),
147
+ ct.byref(result),
144
148
  )
145
149
  except OSError:
146
150
  pass # broadcast is best-effort
@@ -170,20 +174,32 @@ def _persist_posix(api_key: str) -> PersistResult:
170
174
  export_line = f'export {_ENV_VAR}="{api_key}"'
171
175
  block = f"{_MARKER_OPEN}\n{export_line}\n{_MARKER_CLOSE}\n"
172
176
 
173
- # Ensure parent dir exists fish users on a fresh install won't
174
- # have ~/.config/fish/ yet.
175
- rc.parent.mkdir(parents=True, exist_ok=True)
176
- existing = rc.read_text(encoding="utf-8") if rc.exists() else ""
177
-
178
- # Strip ALL existing marker blocks (defensive: a buggy earlier run
179
- # or hand-editing could leave duplicates) and append exactly one new
180
- # block. Invariant: exactly one marker block after any call.
181
- stripped = _MARKER_BLOCK_RE.sub("", existing)
182
- if stripped and not stripped.endswith("\n"):
183
- stripped += "\n"
184
- new_body = stripped + block
185
-
186
- rc.write_text(new_body, encoding="utf-8")
177
+ # Wrap the rc read/write in try/except mirror the Windows path's
178
+ # OSError handling. Without this, an unreadable file (binary or bad
179
+ # encoding) or an unwritable mount (read-only filesystem, permissions
180
+ # error) crashes configure with an unhandled exception instead of
181
+ # returning a clean ERROR result the orchestrator can surface.
182
+ try:
183
+ # Ensure parent dir exists fish users on a fresh install won't
184
+ # have ~/.config/fish/ yet.
185
+ rc.parent.mkdir(parents=True, exist_ok=True)
186
+ existing = rc.read_text(encoding="utf-8") if rc.exists() else ""
187
+
188
+ # Strip ALL existing marker blocks (defensive: a buggy earlier run
189
+ # or hand-editing could leave duplicates) and append exactly one new
190
+ # block. Invariant: exactly one marker block after any call.
191
+ stripped = _MARKER_BLOCK_RE.sub("", existing)
192
+ if stripped and not stripped.endswith("\n"):
193
+ stripped += "\n"
194
+ new_body = stripped + block
195
+
196
+ rc.write_text(new_body, encoding="utf-8")
197
+ except (OSError, UnicodeDecodeError) as exc:
198
+ return PersistResult(
199
+ status=PersistStatus.ERROR,
200
+ message=f"Failed to write {rc}: {exc}",
201
+ path=rc,
202
+ )
187
203
 
188
204
  return PersistResult(
189
205
  status=PersistStatus.PERSISTED,
@@ -169,6 +169,10 @@ def test_configure_handles_malformed_json(home_dir):
169
169
  assert "valid JSON" in result.message
170
170
 
171
171
 
172
+ @pytest.mark.skipif(
173
+ sys.platform == "win32",
174
+ reason="POSIX file modes don't apply on Windows (NTFS reports 0o666)",
175
+ )
172
176
  def test_configure_chmods_0600_on_posix(home_dir, monkeypatch):
173
177
  monkeypatch.setattr(sys, "platform", "linux")
174
178
  ClaudeCodeAgent().configure(_ctx())
@@ -181,6 +181,10 @@ def test_configure_returns_clean_error_on_malformed_toml(home_dir):
181
181
  assert "valid TOML" in result.message
182
182
 
183
183
 
184
+ @pytest.mark.skipif(
185
+ sys.platform == "win32",
186
+ reason="POSIX file modes don't apply on Windows (NTFS reports 0o666)",
187
+ )
184
188
  def test_configure_chmods_0600_on_posix(home_dir, monkeypatch):
185
189
  monkeypatch.setattr(sys, "platform", "linux")
186
190
  CodexAgent().configure(_ctx())
@@ -125,7 +125,19 @@ def test_cursor_configure_calls_env_persist(monkeypatch, tmp_path):
125
125
  assert captured == [VALID_KEY]
126
126
 
127
127
 
128
- def test_walkthrough_excludes_image_gen_models(capsys):
128
+ def test_walkthrough_excludes_image_gen_models(monkeypatch, capsys):
129
+ # Patch persist at the call site — without this, configure() on a
130
+ # Windows test host writes SUBSTRATE_API_KEY to the real
131
+ # HKCU\Environment hive (no $HOME redirection in scope here).
132
+ monkeypatch.setattr(
133
+ "substrate_setup.agents.cursor.persist_substrate_api_key",
134
+ lambda key: PersistResult(
135
+ status=PersistStatus.PERSISTED,
136
+ message="patched in test",
137
+ path=None,
138
+ ),
139
+ )
140
+
129
141
  chat = CatalogEntry(
130
142
  id="openai/gpt-5.5", provider="openai",
131
143
  display_name="GPT-5.5", description="...",