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.
- {substrate_setup-0.4.0 → substrate_setup-0.5.0}/CHANGELOG.md +24 -0
- {substrate_setup-0.4.0 → substrate_setup-0.5.0}/PKG-INFO +26 -1
- {substrate_setup-0.4.0 → substrate_setup-0.5.0}/README.md +25 -0
- {substrate_setup-0.4.0 → substrate_setup-0.5.0}/pyproject.toml +1 -1
- substrate_setup-0.5.0/substrate_setup/__init__.py +14 -0
- {substrate_setup-0.4.0 → substrate_setup-0.5.0}/substrate_setup/agents/base.py +3 -0
- {substrate_setup-0.4.0 → substrate_setup-0.5.0}/substrate_setup/agents/claude_code.py +39 -5
- {substrate_setup-0.4.0 → substrate_setup-0.5.0}/substrate_setup/agents/codex.py +23 -1
- {substrate_setup-0.4.0 → substrate_setup-0.5.0}/substrate_setup/cli.py +7 -0
- substrate_setup-0.5.0/substrate_setup/switchers.py +73 -0
- {substrate_setup-0.4.0 → substrate_setup-0.5.0}/tests/agents/test_claude_code.py +2 -1
- {substrate_setup-0.4.0 → substrate_setup-0.5.0}/tests/agents/test_codex.py +2 -1
- substrate_setup-0.5.0/tests/test_switchers.py +197 -0
- {substrate_setup-0.4.0 → substrate_setup-0.5.0}/uv.lock +1 -1
- substrate_setup-0.4.0/substrate_setup/__init__.py +0 -6
- {substrate_setup-0.4.0 → substrate_setup-0.5.0}/.gitignore +0 -0
- {substrate_setup-0.4.0 → substrate_setup-0.5.0}/scripts/lint_no_app_import.sh +0 -0
- {substrate_setup-0.4.0 → substrate_setup-0.5.0}/scripts/regenerate_fallback_catalog.py +0 -0
- {substrate_setup-0.4.0 → substrate_setup-0.5.0}/substrate_setup/__main__.py +0 -0
- {substrate_setup-0.4.0 → substrate_setup-0.5.0}/substrate_setup/agents/__init__.py +0 -0
- {substrate_setup-0.4.0 → substrate_setup-0.5.0}/substrate_setup/agents/aider.py +0 -0
- {substrate_setup-0.4.0 → substrate_setup-0.5.0}/substrate_setup/agents/continue_dev.py +0 -0
- {substrate_setup-0.4.0 → substrate_setup-0.5.0}/substrate_setup/agents/cursor.py +0 -0
- {substrate_setup-0.4.0 → substrate_setup-0.5.0}/substrate_setup/agents/hermes.py +0 -0
- {substrate_setup-0.4.0 → substrate_setup-0.5.0}/substrate_setup/backup.py +0 -0
- {substrate_setup-0.4.0 → substrate_setup-0.5.0}/substrate_setup/catalog.py +0 -0
- {substrate_setup-0.4.0 → substrate_setup-0.5.0}/substrate_setup/credentials.py +0 -0
- {substrate_setup-0.4.0 → substrate_setup-0.5.0}/substrate_setup/data/fallback_catalog.json +0 -0
- {substrate_setup-0.4.0 → substrate_setup-0.5.0}/substrate_setup/env_persist.py +0 -0
- {substrate_setup-0.4.0 → substrate_setup-0.5.0}/substrate_setup/markers.py +0 -0
- {substrate_setup-0.4.0 → substrate_setup-0.5.0}/tests/__init__.py +0 -0
- {substrate_setup-0.4.0 → substrate_setup-0.5.0}/tests/agents/__init__.py +0 -0
- {substrate_setup-0.4.0 → substrate_setup-0.5.0}/tests/agents/test_aider.py +0 -0
- {substrate_setup-0.4.0 → substrate_setup-0.5.0}/tests/agents/test_continue_dev.py +0 -0
- {substrate_setup-0.4.0 → substrate_setup-0.5.0}/tests/agents/test_cursor.py +0 -0
- {substrate_setup-0.4.0 → substrate_setup-0.5.0}/tests/agents/test_hermes.py +0 -0
- {substrate_setup-0.4.0 → substrate_setup-0.5.0}/tests/conftest.py +0 -0
- {substrate_setup-0.4.0 → substrate_setup-0.5.0}/tests/test_backup.py +0 -0
- {substrate_setup-0.4.0 → substrate_setup-0.5.0}/tests/test_catalog.py +0 -0
- {substrate_setup-0.4.0 → substrate_setup-0.5.0}/tests/test_cli.py +0 -0
- {substrate_setup-0.4.0 → substrate_setup-0.5.0}/tests/test_credentials.py +0 -0
- {substrate_setup-0.4.0 → substrate_setup-0.5.0}/tests/test_e2e.py +0 -0
- {substrate_setup-0.4.0 → substrate_setup-0.5.0}/tests/test_env_persist.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
-
|
|
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
|
|
193
|
-
"
|
|
194
|
-
"
|
|
195
|
-
"
|
|
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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|