substrate-setup 0.4.0__tar.gz → 0.5.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 (44) hide show
  1. {substrate_setup-0.4.0 → substrate_setup-0.5.0}/CHANGELOG.md +24 -0
  2. {substrate_setup-0.4.0 → substrate_setup-0.5.0}/PKG-INFO +26 -1
  3. {substrate_setup-0.4.0 → substrate_setup-0.5.0}/README.md +25 -0
  4. {substrate_setup-0.4.0 → substrate_setup-0.5.0}/pyproject.toml +1 -1
  5. substrate_setup-0.5.0/substrate_setup/__init__.py +14 -0
  6. {substrate_setup-0.4.0 → substrate_setup-0.5.0}/substrate_setup/agents/base.py +3 -0
  7. {substrate_setup-0.4.0 → substrate_setup-0.5.0}/substrate_setup/agents/claude_code.py +39 -5
  8. {substrate_setup-0.4.0 → substrate_setup-0.5.0}/substrate_setup/agents/codex.py +23 -1
  9. {substrate_setup-0.4.0 → substrate_setup-0.5.0}/substrate_setup/cli.py +7 -0
  10. substrate_setup-0.5.0/substrate_setup/switchers.py +73 -0
  11. {substrate_setup-0.4.0 → substrate_setup-0.5.0}/tests/agents/test_claude_code.py +2 -1
  12. {substrate_setup-0.4.0 → substrate_setup-0.5.0}/tests/agents/test_codex.py +2 -1
  13. substrate_setup-0.5.0/tests/test_switchers.py +197 -0
  14. {substrate_setup-0.4.0 → substrate_setup-0.5.0}/uv.lock +1 -1
  15. substrate_setup-0.4.0/substrate_setup/__init__.py +0 -6
  16. {substrate_setup-0.4.0 → substrate_setup-0.5.0}/.gitignore +0 -0
  17. {substrate_setup-0.4.0 → substrate_setup-0.5.0}/scripts/lint_no_app_import.sh +0 -0
  18. {substrate_setup-0.4.0 → substrate_setup-0.5.0}/scripts/regenerate_fallback_catalog.py +0 -0
  19. {substrate_setup-0.4.0 → substrate_setup-0.5.0}/substrate_setup/__main__.py +0 -0
  20. {substrate_setup-0.4.0 → substrate_setup-0.5.0}/substrate_setup/agents/__init__.py +0 -0
  21. {substrate_setup-0.4.0 → substrate_setup-0.5.0}/substrate_setup/agents/aider.py +0 -0
  22. {substrate_setup-0.4.0 → substrate_setup-0.5.0}/substrate_setup/agents/continue_dev.py +0 -0
  23. {substrate_setup-0.4.0 → substrate_setup-0.5.0}/substrate_setup/agents/cursor.py +0 -0
  24. {substrate_setup-0.4.0 → substrate_setup-0.5.0}/substrate_setup/agents/hermes.py +0 -0
  25. {substrate_setup-0.4.0 → substrate_setup-0.5.0}/substrate_setup/backup.py +0 -0
  26. {substrate_setup-0.4.0 → substrate_setup-0.5.0}/substrate_setup/catalog.py +0 -0
  27. {substrate_setup-0.4.0 → substrate_setup-0.5.0}/substrate_setup/credentials.py +0 -0
  28. {substrate_setup-0.4.0 → substrate_setup-0.5.0}/substrate_setup/data/fallback_catalog.json +0 -0
  29. {substrate_setup-0.4.0 → substrate_setup-0.5.0}/substrate_setup/env_persist.py +0 -0
  30. {substrate_setup-0.4.0 → substrate_setup-0.5.0}/substrate_setup/markers.py +0 -0
  31. {substrate_setup-0.4.0 → substrate_setup-0.5.0}/tests/__init__.py +0 -0
  32. {substrate_setup-0.4.0 → substrate_setup-0.5.0}/tests/agents/__init__.py +0 -0
  33. {substrate_setup-0.4.0 → substrate_setup-0.5.0}/tests/agents/test_aider.py +0 -0
  34. {substrate_setup-0.4.0 → substrate_setup-0.5.0}/tests/agents/test_continue_dev.py +0 -0
  35. {substrate_setup-0.4.0 → substrate_setup-0.5.0}/tests/agents/test_cursor.py +0 -0
  36. {substrate_setup-0.4.0 → substrate_setup-0.5.0}/tests/agents/test_hermes.py +0 -0
  37. {substrate_setup-0.4.0 → substrate_setup-0.5.0}/tests/conftest.py +0 -0
  38. {substrate_setup-0.4.0 → substrate_setup-0.5.0}/tests/test_backup.py +0 -0
  39. {substrate_setup-0.4.0 → substrate_setup-0.5.0}/tests/test_catalog.py +0 -0
  40. {substrate_setup-0.4.0 → substrate_setup-0.5.0}/tests/test_cli.py +0 -0
  41. {substrate_setup-0.4.0 → substrate_setup-0.5.0}/tests/test_credentials.py +0 -0
  42. {substrate_setup-0.4.0 → substrate_setup-0.5.0}/tests/test_e2e.py +0 -0
  43. {substrate_setup-0.4.0 → substrate_setup-0.5.0}/tests/test_env_persist.py +0 -0
  44. {substrate_setup-0.4.0 → substrate_setup-0.5.0}/tests/test_markers.py +0 -0
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0 — 2026-05-30
4
+
5
+ - **Coexist with account switchers (cc-switch, cockpit).** `configure` now
6
+ detects a running account-switcher (`~/.cc-switch/`, `~/.antigravity_cockpit/`)
7
+ and, for the `claude-code` and `codex` agents, leaves the shared config file
8
+ (`~/.claude/settings.json`, `~/.codex/config.toml`) untouched rather than
9
+ fighting the switcher over the same keys. It prints guidance instead:
10
+ add Substrate as a provider *inside* the switcher (recommended), or use a
11
+ one-off shell override.
12
+ - New `--force` flag takes over the file anyway (still backs it up first).
13
+ - The "refused to overwrite" message for foreign `ANTHROPIC_*` keys now names
14
+ the likely cause and points at the shell-override and `--force` paths,
15
+ instead of only "delete the keys".
16
+ - Agents already managed by substrate-setup (marker present) keep updating
17
+ normally — the guard only applies to first-time takeovers.
18
+
19
+ ## 0.4.1 — 2026-05-30
20
+
21
+ - Fix `substrate-setup --version` reporting a stale number. `__version__` is
22
+ now derived from the installed package metadata
23
+ (`importlib.metadata.version`) instead of a hand-maintained string, so it
24
+ can never drift from `pyproject.toml` again. (0.4.0 shipped with a leftover
25
+ `__version__ = "0.3.1"`; the compatibility fix itself was unaffected.)
26
+
3
27
  ## 0.4.0 — 2026-05-30
4
28
 
5
29
  - Lower the supported Python floor from **3.12 to 3.8**. Nothing in the tool
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: substrate-setup
3
- Version: 0.4.0
3
+ Version: 0.5.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
@@ -96,6 +96,31 @@ Subset with `--agents-only hermes,aider`. Preview without writing: `--dry-run`.
96
96
  | `claude-code` | `~/.claude/settings.json` env block (`ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`). The `/model` picker stays opus/sonnet/haiku — use `claude --model openai/<id>` for any other Substrate model. Requires Substrate's `/v1/messages` endpoint — shipped in gateway Phase 1. |
97
97
  | `codex` | `~/.codex/config.toml` `[model_providers.substrate]` block with `wire_api = "responses"`. API key is auto-persisted (see above). Requires Substrate's `/v1/responses` endpoint — shipped in gateway Phase 2. |
98
98
 
99
+ ### Coexisting with account switchers (cc-switch, cockpit) — 0.5.0+
100
+
101
+ If you use an account-switcher like [cc-switch](https://github.com/farion1231/cc-switch)
102
+ or [cockpit](https://github.com/jlcodes99/cockpit-tools), it already owns
103
+ `~/.claude/settings.json` and/or `~/.codex/config.toml` — it rewrites the
104
+ `ANTHROPIC_*` env block / `[model_providers.*]` on every switch. substrate-setup
105
+ and the switcher can't both own the same keys, so `configure` **detects the
106
+ switcher and leaves those files untouched**, printing guidance instead of
107
+ fighting it. (`hermes`, `cursor`, `aider`, `continue` are unaffected — they use
108
+ their own files.)
109
+
110
+ **Recommended: add Substrate as a provider inside your switcher**, then switch to
111
+ it when you want the gateway:
112
+
113
+ - **Claude Code** — `ANTHROPIC_BASE_URL = https://substrate-solutions-api.fly.dev`
114
+ (no `/v1`), `ANTHROPIC_AUTH_TOKEN = <your sk-substrate-… key>`
115
+ - **Codex** — `base_url = https://substrate-solutions-api.fly.dev/v1`,
116
+ `wire_api = "responses"`, key = your `sk-substrate-…`
117
+
118
+ Alternatives:
119
+ - **One-off Claude Code session:** `export ANTHROPIC_BASE_URL=… ANTHROPIC_AUTH_TOKEN=…`
120
+ before `claude` — shell env overrides the switcher's settings file, touches nothing.
121
+ - **Let substrate-setup own the file anyway:** re-run with `--force` (it backs up
122
+ the file first). Note your switcher will then overwrite it on its next switch.
123
+
99
124
  ### Heads-up: tool calling on Gemini 3.1 Pro Preview
100
125
 
101
126
  If your CLI agent (Hermes, Aider, etc.) uses tool calling against
@@ -61,6 +61,31 @@ Subset with `--agents-only hermes,aider`. Preview without writing: `--dry-run`.
61
61
  | `claude-code` | `~/.claude/settings.json` env block (`ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`). The `/model` picker stays opus/sonnet/haiku — use `claude --model openai/<id>` for any other Substrate model. Requires Substrate's `/v1/messages` endpoint — shipped in gateway Phase 1. |
62
62
  | `codex` | `~/.codex/config.toml` `[model_providers.substrate]` block with `wire_api = "responses"`. API key is auto-persisted (see above). Requires Substrate's `/v1/responses` endpoint — shipped in gateway Phase 2. |
63
63
 
64
+ ### Coexisting with account switchers (cc-switch, cockpit) — 0.5.0+
65
+
66
+ If you use an account-switcher like [cc-switch](https://github.com/farion1231/cc-switch)
67
+ or [cockpit](https://github.com/jlcodes99/cockpit-tools), it already owns
68
+ `~/.claude/settings.json` and/or `~/.codex/config.toml` — it rewrites the
69
+ `ANTHROPIC_*` env block / `[model_providers.*]` on every switch. substrate-setup
70
+ and the switcher can't both own the same keys, so `configure` **detects the
71
+ switcher and leaves those files untouched**, printing guidance instead of
72
+ fighting it. (`hermes`, `cursor`, `aider`, `continue` are unaffected — they use
73
+ their own files.)
74
+
75
+ **Recommended: add Substrate as a provider inside your switcher**, then switch to
76
+ it when you want the gateway:
77
+
78
+ - **Claude Code** — `ANTHROPIC_BASE_URL = https://substrate-solutions-api.fly.dev`
79
+ (no `/v1`), `ANTHROPIC_AUTH_TOKEN = <your sk-substrate-… key>`
80
+ - **Codex** — `base_url = https://substrate-solutions-api.fly.dev/v1`,
81
+ `wire_api = "responses"`, key = your `sk-substrate-…`
82
+
83
+ Alternatives:
84
+ - **One-off Claude Code session:** `export ANTHROPIC_BASE_URL=… ANTHROPIC_AUTH_TOKEN=…`
85
+ before `claude` — shell env overrides the switcher's settings file, touches nothing.
86
+ - **Let substrate-setup own the file anyway:** re-run with `--force` (it backs up
87
+ the file first). Note your switcher will then overwrite it on its next switch.
88
+
64
89
  ### Heads-up: tool calling on Gemini 3.1 Pro Preview
65
90
 
66
91
  If your CLI agent (Hermes, Aider, etc.) uses tool calling against
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "substrate-setup"
7
- version = "0.4.0"
7
+ version = "0.5.0"
8
8
  description = "One-shot local configurator for coding agents against a Substrate gateway"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -0,0 +1,14 @@
1
+ """substrate-setup — one-shot configurator for coding agents.
2
+
3
+ See https://github.com/FrankXiaA/substrate-solutions for the gateway it
4
+ configures against.
5
+ """
6
+ from importlib.metadata import PackageNotFoundError, version
7
+
8
+ try:
9
+ # Single source of truth: read the version from the installed package
10
+ # metadata (driven by pyproject.toml) so it can never drift from the
11
+ # published release. ``importlib.metadata`` is stdlib since Python 3.8.
12
+ __version__ = version("substrate-setup")
13
+ except PackageNotFoundError: # pragma: no cover - running from a source tree
14
+ __version__ = "0.0.0+unknown"
@@ -47,6 +47,9 @@ class ConfigureContext:
47
47
  catalog: list[CatalogEntry]
48
48
  catalog_source: str # "live" or "fallback"
49
49
  dry_run: bool
50
+ # Override the account-switcher coexistence guard (cc-switch / cockpit):
51
+ # write claude-code / codex config even when a switcher owns the file.
52
+ force: bool = False
50
53
 
51
54
 
52
55
  class Agent(ABC):
@@ -50,6 +50,7 @@ from substrate_setup.env_persist import (
50
50
  format_persist_message_for_user,
51
51
  persist_substrate_api_key,
52
52
  )
53
+ from substrate_setup.switchers import coexistence_message, detect_switchers
53
54
 
54
55
  MANAGED_ENV_KEYS_MARKER = "_substrate_setup_managed_env_keys"
55
56
  MANAGED_ENV_KEYS: list[str] = [
@@ -141,6 +142,23 @@ def _restrict_file_mode(path: Path) -> None:
141
142
  pass
142
143
 
143
144
 
145
+ def _coexistence_guidance(ctx: ConfigureContext, switchers: list[str]) -> str:
146
+ base = _strip_v1_suffix(ctx.base_url)
147
+ return coexistence_message(
148
+ switchers,
149
+ pretty_name="Claude Code",
150
+ config_label="~/.claude/settings.json",
151
+ provider_lines=[
152
+ f"ANTHROPIC_BASE_URL = {base}",
153
+ "ANTHROPIC_AUTH_TOKEN = <your sk-substrate-... key>",
154
+ ],
155
+ shell_lines=[
156
+ f"export ANTHROPIC_BASE_URL={base}",
157
+ "export ANTHROPIC_AUTH_TOKEN=<your sk-substrate-... key>",
158
+ ],
159
+ )
160
+
161
+
144
162
  class ClaudeCodeAgent(Agent):
145
163
  name = "claude-code"
146
164
  pretty_name = "Claude Code"
@@ -180,7 +198,15 @@ class ClaudeCodeAgent(Agent):
180
198
  env_block = {}
181
199
 
182
200
  if not _has_managed_marker(payload):
183
- if _user_owns_anthropic_env(env_block):
201
+ switchers = detect_switchers()
202
+ if switchers and not ctx.force:
203
+ print(_coexistence_guidance(ctx, switchers))
204
+ return AgentResult(
205
+ ResultStatus.PRINT_ONLY,
206
+ f"left {settings_path} to {' / '.join(switchers)} "
207
+ "(re-run with --force to manage it anyway)",
208
+ )
209
+ if not ctx.force and _user_owns_anthropic_env(env_block):
184
210
  if _existing_matches_substrate_shape(env_block, ctx):
185
211
  notes.append(
186
212
  "Claude Code settings.json had substrate-shaped env "
@@ -189,10 +215,18 @@ class ClaudeCodeAgent(Agent):
189
215
  else:
190
216
  return AgentResult(
191
217
  ResultStatus.ERROR,
192
- "Claude Code settings.json has user-owned ANTHROPIC_* "
193
- "env keys but no substrate-setup marker; refused to "
194
- "overwrite. To take over, delete those keys from "
195
- "settings.json and re-run substrate-setup.",
218
+ "Claude Code settings.json has ANTHROPIC_* env keys "
219
+ "that substrate-setup doesn't manage (no marker). "
220
+ "Another tool (e.g. an account switcher like cc-switch) "
221
+ "may own this file, so substrate-setup won't overwrite "
222
+ "it.\n"
223
+ " - To use Substrate without disturbing it: export "
224
+ "ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN in your "
225
+ "shell before running `claude` (shell env wins over "
226
+ "settings.json).\n"
227
+ " - To let substrate-setup take over: re-run with "
228
+ "--force (it backs up settings.json first), or delete "
229
+ "those keys and re-run.",
196
230
  tuple(backups),
197
231
  )
198
232
 
@@ -60,6 +60,7 @@ from substrate_setup.env_persist import (
60
60
  format_persist_message_for_user,
61
61
  persist_substrate_api_key,
62
62
  )
63
+ from substrate_setup.switchers import coexistence_message, detect_switchers
63
64
 
64
65
  PROVIDER_KEY = "substrate"
65
66
  ENV_KEY_NAME = "SUBSTRATE_API_KEY"
@@ -136,6 +137,19 @@ def _restrict_file_mode(path: Path) -> None:
136
137
  pass
137
138
 
138
139
 
140
+ def _coexistence_guidance(ctx: ConfigureContext, switchers: list[str]) -> str:
141
+ return coexistence_message(
142
+ switchers,
143
+ pretty_name="Codex",
144
+ config_label="~/.codex/config.toml",
145
+ provider_lines=[
146
+ f'base_url = "{ctx.base_url}"',
147
+ f'wire_api = "{WIRE_API}"',
148
+ f'env_key = "{ENV_KEY_NAME}" (set it to your sk-substrate-... key)',
149
+ ],
150
+ )
151
+
152
+
139
153
  class CodexAgent(Agent):
140
154
  name = "codex"
141
155
  pretty_name = "Codex"
@@ -168,13 +182,21 @@ class CodexAgent(Agent):
168
182
  cfg_path.read_text(encoding="utf-8") if cfg_path.exists() else ""
169
183
  )
170
184
  if not _has_marker_comment(existing_text):
185
+ switchers = detect_switchers()
186
+ if switchers and not ctx.force:
187
+ print(_coexistence_guidance(ctx, switchers))
188
+ return AgentResult(
189
+ ResultStatus.PRINT_ONLY,
190
+ f"left {cfg_path} to {' / '.join(switchers)} "
191
+ "(re-run with --force to manage it anyway)",
192
+ )
171
193
  providers = data.get("model_providers")
172
194
  existing_substrate = (
173
195
  providers.get(PROVIDER_KEY)
174
196
  if isinstance(providers, dict)
175
197
  else None
176
198
  )
177
- if isinstance(existing_substrate, dict):
199
+ if isinstance(existing_substrate, dict) and not ctx.force:
178
200
  if _existing_substrate_block_matches(data, ctx):
179
201
  notes.append(
180
202
  "Codex config.toml had a substrate-shaped block but "
@@ -49,6 +49,12 @@ def _common_flags_parser() -> argparse.ArgumentParser:
49
49
  )
50
50
  p.add_argument("--dry-run", action="store_true", help="Mutate nothing")
51
51
  p.add_argument("--quiet", action="store_true", help="Summary only")
52
+ p.add_argument(
53
+ "--force",
54
+ action="store_true",
55
+ help="Manage claude-code/codex config even if an account switcher "
56
+ "(cc-switch, cockpit) owns it",
57
+ )
52
58
  return p
53
59
 
54
60
 
@@ -178,6 +184,7 @@ def _build_ctx(
178
184
  catalog=catalog,
179
185
  catalog_source=source,
180
186
  dry_run=args.dry_run,
187
+ force=args.force,
181
188
  )
182
189
 
183
190
 
@@ -0,0 +1,73 @@
1
+ """Detect third-party account-switcher tools that co-manage our config files.
2
+
3
+ The ``claude-code`` and ``codex`` adapters write into ``~/.claude/settings.json``
4
+ and ``~/.codex/config.toml`` — the same files tools like **cc-switch** and
5
+ **cockpit** rewrite on every account switch. When such a tool is present,
6
+ substrate-setup must not silently fight it: ``configure()`` leaves the file
7
+ untouched and prints coexistence guidance, unless the user passes ``--force``.
8
+
9
+ Detection is a best-effort heuristic — presence of the tool's data directory:
10
+
11
+ - cc-switch (https://github.com/farion1231/cc-switch) → ``~/.cc-switch/``
12
+ (holds ``cc-switch.db``)
13
+ - cockpit (https://github.com/jlcodes99/cockpit-tools) → ``~/.antigravity_cockpit/``
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from pathlib import Path
18
+
19
+ # Display name -> home-relative marker whose existence signals the tool is
20
+ # installed and managing local CLI configs.
21
+ _SWITCHER_MARKERS: dict[str, str] = {
22
+ "cc-switch": ".cc-switch",
23
+ "cockpit": ".antigravity_cockpit",
24
+ }
25
+
26
+
27
+ def detect_switchers() -> list[str]:
28
+ """Return display names of detected account-switcher tools, sorted."""
29
+ home = Path.home()
30
+ return sorted(
31
+ name for name, rel in _SWITCHER_MARKERS.items() if (home / rel).exists()
32
+ )
33
+
34
+
35
+ def coexistence_message(
36
+ switchers: list[str],
37
+ *,
38
+ pretty_name: str,
39
+ config_label: str,
40
+ provider_lines: list[str],
41
+ shell_lines: list[str] | None = None,
42
+ ) -> str:
43
+ """Build the guidance printed when a switcher owns an agent's config file.
44
+
45
+ ``provider_lines`` are the fields to enter when adding Substrate as a
46
+ provider inside the switcher (the recommended path). ``shell_lines``, when
47
+ given, show a one-off shell override (shell env wins over the switcher's
48
+ config file for Claude Code; Codex has no equivalent, so it omits this).
49
+ """
50
+ names = " / ".join(switchers)
51
+ provider = "\n".join(f" {ln}" for ln in provider_lines)
52
+ parts = [
53
+ f"{pretty_name}: detected {names}, which manages {config_label}.",
54
+ " substrate-setup left it untouched so it can't clobber your account "
55
+ "switching.",
56
+ "",
57
+ f" To use Substrate, add it as a provider in {names} (recommended):",
58
+ provider,
59
+ ]
60
+ if shell_lines:
61
+ shell = "\n".join(f" {ln}" for ln in shell_lines)
62
+ parts += [
63
+ "",
64
+ " Or, for a one-off session, set these in your shell (shell env",
65
+ " takes precedence over the switcher's config file):",
66
+ shell,
67
+ ]
68
+ parts += [
69
+ "",
70
+ f" To let substrate-setup manage {config_label} anyway, "
71
+ "re-run with --force.",
72
+ ]
73
+ return "\n".join(parts)
@@ -30,13 +30,14 @@ SAMPLE_CATALOG = [
30
30
  ]
31
31
 
32
32
 
33
- def _ctx(*, dry_run: bool = False) -> ConfigureContext:
33
+ def _ctx(*, dry_run: bool = False, force: bool = False) -> ConfigureContext:
34
34
  return ConfigureContext(
35
35
  base_url="https://gw.example/v1",
36
36
  api_key=VALID_KEY,
37
37
  catalog=SAMPLE_CATALOG,
38
38
  catalog_source="live",
39
39
  dry_run=dry_run,
40
+ force=force,
40
41
  )
41
42
 
42
43
 
@@ -37,13 +37,14 @@ SAMPLE_CATALOG = [
37
37
  ]
38
38
 
39
39
 
40
- def _ctx(*, dry_run: bool = False) -> ConfigureContext:
40
+ def _ctx(*, dry_run: bool = False, force: bool = False) -> ConfigureContext:
41
41
  return ConfigureContext(
42
42
  base_url="https://gw.example/v1",
43
43
  api_key=VALID_KEY,
44
44
  catalog=SAMPLE_CATALOG,
45
45
  catalog_source="live",
46
46
  dry_run=dry_run,
47
+ force=force,
47
48
  )
48
49
 
49
50
 
@@ -0,0 +1,197 @@
1
+ """Tests for account-switcher coexistence (cc-switch / cockpit).
2
+
3
+ substrate-setup's claude-code and codex adapters share config files with
4
+ account-switcher tools. When such a tool is detected, configure() must leave
5
+ the file untouched and print guidance, unless --force is passed. Files already
6
+ managed by substrate-setup (marker present) keep updating normally.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sys
12
+
13
+ import pytest
14
+
15
+ try:
16
+ import tomllib
17
+ except ImportError: # pragma: no cover
18
+ import tomli as tomllib # type: ignore[no-redef]
19
+
20
+ from substrate_setup.agents.base import ConfigureContext, ResultStatus
21
+ from substrate_setup.agents.claude_code import MANAGED_ENV_KEYS, ClaudeCodeAgent
22
+ from substrate_setup.agents.codex import MARKER_COMMENT_PATTERN, CodexAgent
23
+ from substrate_setup.catalog import CatalogEntry
24
+ from substrate_setup.switchers import coexistence_message, detect_switchers
25
+
26
+ VALID_KEY = "sk-substrate-abcdefghijklmnopqrstuv"
27
+ SAMPLE_CATALOG = [
28
+ CatalogEntry(
29
+ id="anthropic/claude-sonnet-4-6", provider="anthropic",
30
+ display_name="Claude Sonnet 4.6", description="...",
31
+ ),
32
+ ]
33
+
34
+
35
+ def _ctx(*, force: bool = False) -> ConfigureContext:
36
+ return ConfigureContext(
37
+ base_url="https://gw.example/v1",
38
+ api_key=VALID_KEY,
39
+ catalog=SAMPLE_CATALOG,
40
+ catalog_source="live",
41
+ dry_run=False,
42
+ force=force,
43
+ )
44
+
45
+
46
+ @pytest.fixture
47
+ def home_dir(tmp_path, monkeypatch):
48
+ monkeypatch.setattr(sys, "platform", "linux")
49
+ monkeypatch.setenv("HOME", str(tmp_path))
50
+ monkeypatch.setenv("USERPROFILE", str(tmp_path))
51
+ monkeypatch.setenv("PATH", "")
52
+ return tmp_path
53
+
54
+
55
+ # ─── detect_switchers ────────────────────────────────────────────────────────
56
+
57
+
58
+ def test_no_switchers_detected(home_dir):
59
+ assert detect_switchers() == []
60
+
61
+
62
+ def test_detects_cc_switch(home_dir):
63
+ (home_dir / ".cc-switch").mkdir()
64
+ assert detect_switchers() == ["cc-switch"]
65
+
66
+
67
+ def test_detects_cockpit(home_dir):
68
+ (home_dir / ".antigravity_cockpit").mkdir()
69
+ assert detect_switchers() == ["cockpit"]
70
+
71
+
72
+ def test_detects_both_sorted(home_dir):
73
+ (home_dir / ".cc-switch").mkdir()
74
+ (home_dir / ".antigravity_cockpit").mkdir()
75
+ assert detect_switchers() == ["cc-switch", "cockpit"]
76
+
77
+
78
+ # ─── coexistence_message ─────────────────────────────────────────────────────
79
+
80
+
81
+ def test_message_includes_names_provider_and_force():
82
+ msg = coexistence_message(
83
+ ["cc-switch"],
84
+ pretty_name="Claude Code",
85
+ config_label="~/.claude/settings.json",
86
+ provider_lines=["ANTHROPIC_BASE_URL = https://x"],
87
+ shell_lines=["export ANTHROPIC_BASE_URL=https://x"],
88
+ )
89
+ assert "cc-switch" in msg
90
+ assert "~/.claude/settings.json" in msg
91
+ assert "ANTHROPIC_BASE_URL = https://x" in msg
92
+ assert "--force" in msg
93
+ assert "shell" in msg.lower()
94
+
95
+
96
+ def test_message_omits_shell_section_when_not_given():
97
+ msg = coexistence_message(
98
+ ["cockpit"],
99
+ pretty_name="Codex",
100
+ config_label="~/.codex/config.toml",
101
+ provider_lines=['base_url = "https://x/v1"'],
102
+ )
103
+ assert "one-off session" not in msg
104
+ assert "--force" in msg
105
+
106
+
107
+ # ─── claude-code coexistence ─────────────────────────────────────────────────
108
+
109
+
110
+ def test_claude_defers_to_switcher(home_dir, capsys):
111
+ (home_dir / ".cc-switch").mkdir()
112
+ (home_dir / ".claude").mkdir()
113
+ (home_dir / ".claude" / "settings.json").write_text(
114
+ '{"env": {}}', encoding="utf-8"
115
+ )
116
+ res = ClaudeCodeAgent().configure(_ctx())
117
+ assert res.status == ResultStatus.PRINT_ONLY
118
+ data = json.loads((home_dir / ".claude" / "settings.json").read_text())
119
+ assert "_substrate_setup_managed_env_keys" not in data
120
+ out = capsys.readouterr().out
121
+ assert "cc-switch" in out and "--force" in out
122
+
123
+
124
+ def test_claude_force_overrides_switcher(home_dir):
125
+ (home_dir / ".cc-switch").mkdir()
126
+ (home_dir / ".claude").mkdir()
127
+ (home_dir / ".claude" / "settings.json").write_text(
128
+ '{"env": {"ANTHROPIC_BASE_URL": "https://other"}}', encoding="utf-8"
129
+ )
130
+ res = ClaudeCodeAgent().configure(_ctx(force=True))
131
+ assert res.status == ResultStatus.SUCCESS
132
+ data = json.loads((home_dir / ".claude" / "settings.json").read_text())
133
+ assert data["_substrate_setup_managed_env_keys"] == MANAGED_ENV_KEYS
134
+ assert data["env"]["ANTHROPIC_BASE_URL"] == "https://gw.example"
135
+
136
+
137
+ def test_claude_already_managed_updates_despite_switcher(home_dir):
138
+ (home_dir / ".cc-switch").mkdir()
139
+ (home_dir / ".claude").mkdir()
140
+ payload = {
141
+ "env": {
142
+ "ANTHROPIC_BASE_URL": "https://gw.example",
143
+ "ANTHROPIC_AUTH_TOKEN": VALID_KEY,
144
+ "ANTHROPIC_MODEL": "anthropic/claude-sonnet-4-6",
145
+ },
146
+ "_substrate_setup_managed_env_keys": MANAGED_ENV_KEYS,
147
+ }
148
+ (home_dir / ".claude" / "settings.json").write_text(
149
+ json.dumps(payload), encoding="utf-8"
150
+ )
151
+ res = ClaudeCodeAgent().configure(_ctx())
152
+ assert res.status == ResultStatus.SUCCESS
153
+
154
+
155
+ def test_claude_refusal_message_mentions_shell_and_force(home_dir):
156
+ # foreign ANTHROPIC_* with NO known switcher present → improved refusal
157
+ (home_dir / ".claude").mkdir()
158
+ (home_dir / ".claude" / "settings.json").write_text(
159
+ '{"env": {"ANTHROPIC_BASE_URL": "https://other", '
160
+ '"ANTHROPIC_AUTH_TOKEN": "sk-ext-xxxxxxxxxxxxxxxxxxxx"}}',
161
+ encoding="utf-8",
162
+ )
163
+ res = ClaudeCodeAgent().configure(_ctx())
164
+ assert res.status == ResultStatus.ERROR
165
+ assert "--force" in res.message
166
+ assert "shell" in res.message.lower()
167
+
168
+
169
+ # ─── codex coexistence ───────────────────────────────────────────────────────
170
+
171
+
172
+ def test_codex_defers_to_switcher(home_dir, capsys):
173
+ (home_dir / ".antigravity_cockpit").mkdir()
174
+ (home_dir / ".codex").mkdir()
175
+ (home_dir / ".codex" / "config.toml").write_text(
176
+ 'model = "gpt-5.5"\n', encoding="utf-8"
177
+ )
178
+ res = CodexAgent().configure(_ctx())
179
+ assert res.status == ResultStatus.PRINT_ONLY
180
+ text = (home_dir / ".codex" / "config.toml").read_text()
181
+ assert not text.startswith("# substrate-setup-managed")
182
+ out = capsys.readouterr().out
183
+ assert "cockpit" in out and "--force" in out
184
+
185
+
186
+ def test_codex_force_overrides_switcher(home_dir):
187
+ (home_dir / ".antigravity_cockpit").mkdir()
188
+ (home_dir / ".codex").mkdir()
189
+ (home_dir / ".codex" / "config.toml").write_text(
190
+ 'model_provider = "openai"\n', encoding="utf-8"
191
+ )
192
+ res = CodexAgent().configure(_ctx(force=True))
193
+ assert res.status == ResultStatus.SUCCESS
194
+ text = (home_dir / ".codex" / "config.toml").read_text()
195
+ assert MARKER_COMMENT_PATTERN.match(text.splitlines()[0]) is not None
196
+ data = tomllib.loads(text)
197
+ assert data["model_provider"] == "substrate"
@@ -722,7 +722,7 @@ wheels = [
722
722
 
723
723
  [[package]]
724
724
  name = "substrate-setup"
725
- version = "0.4.0"
725
+ version = "0.5.0"
726
726
  source = { editable = "." }
727
727
  dependencies = [
728
728
  { name = "httpx" },
@@ -1,6 +0,0 @@
1
- """substrate-setup — one-shot configurator for coding agents.
2
-
3
- See https://github.com/FrankXiaA/substrate-solutions for the gateway it
4
- configures against.
5
- """
6
- __version__ = "0.3.1"