substrate-setup 0.2.2__tar.gz → 0.3.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.2.2 → substrate_setup-0.3.0}/PKG-INFO +14 -2
- {substrate_setup-0.2.2 → substrate_setup-0.3.0}/README.md +12 -1
- {substrate_setup-0.2.2 → substrate_setup-0.3.0}/pyproject.toml +2 -1
- {substrate_setup-0.2.2 → substrate_setup-0.3.0}/substrate_setup/__init__.py +1 -1
- {substrate_setup-0.2.2 → substrate_setup-0.3.0}/substrate_setup/agents/__init__.py +4 -0
- {substrate_setup-0.2.2 → substrate_setup-0.3.0}/substrate_setup/agents/aider.py +114 -37
- substrate_setup-0.3.0/substrate_setup/agents/claude_code.py +339 -0
- substrate_setup-0.3.0/substrate_setup/agents/codex.py +345 -0
- {substrate_setup-0.2.2 → substrate_setup-0.3.0}/substrate_setup/agents/continue_dev.py +5 -1
- {substrate_setup-0.2.2 → substrate_setup-0.3.0}/substrate_setup/agents/cursor.py +5 -1
- {substrate_setup-0.2.2 → substrate_setup-0.3.0}/substrate_setup/agents/hermes.py +126 -10
- {substrate_setup-0.2.2 → substrate_setup-0.3.0}/substrate_setup/catalog.py +6 -0
- {substrate_setup-0.2.2 → substrate_setup-0.3.0}/tests/agents/test_aider.py +97 -11
- substrate_setup-0.3.0/tests/agents/test_claude_code.py +262 -0
- substrate_setup-0.3.0/tests/agents/test_codex.py +278 -0
- {substrate_setup-0.2.2 → substrate_setup-0.3.0}/tests/agents/test_continue_dev.py +27 -0
- {substrate_setup-0.2.2 → substrate_setup-0.3.0}/tests/agents/test_cursor.py +24 -0
- {substrate_setup-0.2.2 → substrate_setup-0.3.0}/tests/agents/test_hermes.py +109 -1
- substrate_setup-0.3.0/tests/conftest.py +19 -0
- {substrate_setup-0.2.2 → substrate_setup-0.3.0}/tests/test_cli.py +34 -54
- {substrate_setup-0.2.2 → substrate_setup-0.3.0}/tests/test_e2e.py +43 -2
- substrate_setup-0.2.2/tests/conftest.py +0 -1
- substrate_setup-0.2.2/uv.lock +0 -341
- {substrate_setup-0.2.2 → substrate_setup-0.3.0}/.gitignore +0 -0
- {substrate_setup-0.2.2 → substrate_setup-0.3.0}/scripts/lint_no_app_import.sh +0 -0
- {substrate_setup-0.2.2 → substrate_setup-0.3.0}/scripts/regenerate_fallback_catalog.py +0 -0
- {substrate_setup-0.2.2 → substrate_setup-0.3.0}/substrate_setup/__main__.py +0 -0
- {substrate_setup-0.2.2 → substrate_setup-0.3.0}/substrate_setup/agents/base.py +0 -0
- {substrate_setup-0.2.2 → substrate_setup-0.3.0}/substrate_setup/backup.py +0 -0
- {substrate_setup-0.2.2 → substrate_setup-0.3.0}/substrate_setup/cli.py +0 -0
- {substrate_setup-0.2.2 → substrate_setup-0.3.0}/substrate_setup/credentials.py +0 -0
- {substrate_setup-0.2.2 → substrate_setup-0.3.0}/substrate_setup/data/fallback_catalog.json +0 -0
- {substrate_setup-0.2.2 → substrate_setup-0.3.0}/substrate_setup/markers.py +0 -0
- {substrate_setup-0.2.2 → substrate_setup-0.3.0}/tests/__init__.py +0 -0
- {substrate_setup-0.2.2 → substrate_setup-0.3.0}/tests/agents/__init__.py +0 -0
- {substrate_setup-0.2.2 → substrate_setup-0.3.0}/tests/test_backup.py +0 -0
- {substrate_setup-0.2.2 → substrate_setup-0.3.0}/tests/test_catalog.py +0 -0
- {substrate_setup-0.2.2 → substrate_setup-0.3.0}/tests/test_credentials.py +0 -0
- {substrate_setup-0.2.2 → substrate_setup-0.3.0}/tests/test_markers.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: substrate-setup
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.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
|
|
@@ -20,6 +20,7 @@ Classifier: Topic :: Utilities
|
|
|
20
20
|
Requires-Python: >=3.12
|
|
21
21
|
Requires-Dist: httpx>=0.27
|
|
22
22
|
Requires-Dist: ruamel-yaml>=0.18
|
|
23
|
+
Requires-Dist: tomli-w>=1.2
|
|
23
24
|
Provides-Extra: dev
|
|
24
25
|
Requires-Dist: mypy<2,>=1.13; extra == 'dev'
|
|
25
26
|
Requires-Dist: pytest-httpx>=0.30; extra == 'dev'
|
|
@@ -64,6 +65,17 @@ substrate-setup remove # strip the substrate-managed entries
|
|
|
64
65
|
substrate-setup --help
|
|
65
66
|
```
|
|
66
67
|
|
|
67
|
-
Supported agents: `hermes`, `cursor`, `aider`, `continue`.
|
|
68
|
+
Supported agents: `hermes`, `cursor`, `aider`, `continue`, `claude-code`, `codex`.
|
|
68
69
|
|
|
69
70
|
Subset with `--agents-only hermes,aider`. Preview without writing: `--dry-run`. Override the gateway base URL: `--base-url https://your-gateway.example.com`.
|
|
71
|
+
|
|
72
|
+
### Per-agent catalog UX (0.3.0+)
|
|
73
|
+
|
|
74
|
+
| Agent | How it learns about Substrate's models |
|
|
75
|
+
|---|---|
|
|
76
|
+
| `hermes` | Live URL fetch via `model_catalog.providers.substrate.url`. Picker shows all chat-capable Substrate models, refreshed on Hermes' 24h TTL. |
|
|
77
|
+
| `cursor` | Walkthrough printed after configure — copy the base URL, key, and model ids into Cursor's Settings → Models. |
|
|
78
|
+
| `aider` | `~/.aider.model.metadata.json` written with one entry per chat-capable Substrate model. Use `--model openai/<id>` to switch. |
|
|
79
|
+
| `continue` | All chat-capable Substrate models written as separate `models:` entries in `~/.continue/config.yaml`. |
|
|
80
|
+
| `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. **Note:** requires Substrate's `/v1/messages` endpoint, which is gated behind a Fly feature flag — until it's flipped, requests return 404. |
|
|
81
|
+
| `codex` | `~/.codex/config.toml` `[model_providers.substrate]` block with `wire_api = "responses"`. Set `SUBSTRATE_API_KEY` in your shell rc. **Note:** requires Substrate's `/v1/responses` endpoint (gateway protocol adapters Phase 2 — not yet started). |
|
|
@@ -35,6 +35,17 @@ substrate-setup remove # strip the substrate-managed entries
|
|
|
35
35
|
substrate-setup --help
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
Supported agents: `hermes`, `cursor`, `aider`, `continue`.
|
|
38
|
+
Supported agents: `hermes`, `cursor`, `aider`, `continue`, `claude-code`, `codex`.
|
|
39
39
|
|
|
40
40
|
Subset with `--agents-only hermes,aider`. Preview without writing: `--dry-run`. Override the gateway base URL: `--base-url https://your-gateway.example.com`.
|
|
41
|
+
|
|
42
|
+
### Per-agent catalog UX (0.3.0+)
|
|
43
|
+
|
|
44
|
+
| Agent | How it learns about Substrate's models |
|
|
45
|
+
|---|---|
|
|
46
|
+
| `hermes` | Live URL fetch via `model_catalog.providers.substrate.url`. Picker shows all chat-capable Substrate models, refreshed on Hermes' 24h TTL. |
|
|
47
|
+
| `cursor` | Walkthrough printed after configure — copy the base URL, key, and model ids into Cursor's Settings → Models. |
|
|
48
|
+
| `aider` | `~/.aider.model.metadata.json` written with one entry per chat-capable Substrate model. Use `--model openai/<id>` to switch. |
|
|
49
|
+
| `continue` | All chat-capable Substrate models written as separate `models:` entries in `~/.continue/config.yaml`. |
|
|
50
|
+
| `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. **Note:** requires Substrate's `/v1/messages` endpoint, which is gated behind a Fly feature flag — until it's flipped, requests return 404. |
|
|
51
|
+
| `codex` | `~/.codex/config.toml` `[model_providers.substrate]` block with `wire_api = "responses"`. Set `SUBSTRATE_API_KEY` in your shell rc. **Note:** requires Substrate's `/v1/responses` endpoint (gateway protocol adapters Phase 2 — not yet started). |
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "substrate-setup"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.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.12"
|
|
@@ -25,6 +25,7 @@ classifiers = [
|
|
|
25
25
|
dependencies = [
|
|
26
26
|
"httpx>=0.27",
|
|
27
27
|
"ruamel.yaml>=0.18",
|
|
28
|
+
"tomli-w>=1.2",
|
|
28
29
|
]
|
|
29
30
|
|
|
30
31
|
[project.urls]
|
|
@@ -6,6 +6,8 @@ from __future__ import annotations
|
|
|
6
6
|
|
|
7
7
|
from substrate_setup.agents.aider import AiderAgent
|
|
8
8
|
from substrate_setup.agents.base import Agent
|
|
9
|
+
from substrate_setup.agents.claude_code import ClaudeCodeAgent
|
|
10
|
+
from substrate_setup.agents.codex import CodexAgent
|
|
9
11
|
from substrate_setup.agents.continue_dev import ContinueAgent
|
|
10
12
|
from substrate_setup.agents.cursor import CursorAgent
|
|
11
13
|
from substrate_setup.agents.hermes import HermesAgent
|
|
@@ -15,6 +17,8 @@ ALL_AGENTS: list[Agent] = [
|
|
|
15
17
|
CursorAgent(),
|
|
16
18
|
AiderAgent(),
|
|
17
19
|
ContinueAgent(),
|
|
20
|
+
ClaudeCodeAgent(),
|
|
21
|
+
CodexAgent(),
|
|
18
22
|
]
|
|
19
23
|
|
|
20
24
|
AGENT_NAMES: list[str] = [a.name for a in ALL_AGENTS]
|
|
@@ -1,44 +1,47 @@
|
|
|
1
|
-
"""Aider integration (0.
|
|
1
|
+
"""Aider integration (0.3.0 — conf.yml + model-metadata sidecar).
|
|
2
2
|
|
|
3
3
|
Files:
|
|
4
|
-
~/.aider.conf.yml
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
4
|
+
~/.aider.conf.yml — openai-api-base, openai-api-key, model
|
|
5
|
+
~/.aider.model.metadata.json — per-model LiteLLM hints (re-added in 0.3.0)
|
|
6
|
+
|
|
7
|
+
Why metadata.json is back (0.3.0)
|
|
8
|
+
---------------------------------
|
|
9
|
+
0.2.0 dropped the file because Aider routes everything through one
|
|
10
|
+
OpenAI-compatible endpoint and the per-model metadata duplicated info
|
|
11
|
+
the gateway already knows. The 0.3.0 multi-agent catalog reverses this:
|
|
12
|
+
the file is now used to advertise every chat-capable Substrate model
|
|
13
|
+
to Aider so ``--model openai/<id>`` works for any catalog entry without
|
|
14
|
+
each user having to memorise the prefix discipline. Keys use the
|
|
15
|
+
``openai/<gateway_id>`` form aider's OpenAI-compatible provider
|
|
16
|
+
requires. Image-gen catalog entries (``output_modality != "text"``) are
|
|
17
|
+
filtered out — Aider only routes chat completions.
|
|
14
18
|
|
|
15
19
|
Why no `~/.env` (was written by 0.1)
|
|
16
20
|
------------------------------------
|
|
17
21
|
Aider's docs say the inline ``openai-api-key`` in ``~/.aider.conf.yml``
|
|
18
22
|
is the supported way to authenticate against OpenAI-compatible
|
|
19
23
|
gateways. Writing a shared ``~/.env`` was an over-reach that risked
|
|
20
|
-
clobbering other tools' OPENAI_API_KEY. 0.2.0
|
|
21
|
-
``remove()`` still
|
|
22
|
-
|
|
23
|
-
were planted by 0.1.
|
|
24
|
+
clobbering other tools' OPENAI_API_KEY. 0.2.0 dropped the env write.
|
|
25
|
+
``remove()`` still strips a substrate-shaped ``OPENAI_API_KEY=`` line
|
|
26
|
+
from ``~/.env`` if planted by 0.1 (best-effort legacy cleanup).
|
|
24
27
|
|
|
25
28
|
Marker discipline
|
|
26
29
|
-----------------
|
|
27
|
-
|
|
28
|
-
``model``) sit at the top level of a user-shared YAML doc, we write
|
|
29
|
-
a sibling marker:
|
|
30
|
+
Two markers, one per file:
|
|
30
31
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
- "model"
|
|
32
|
+
conf.yml: top-level ``_substrate_setup_managed_keys`` list naming the
|
|
33
|
+
three keys we own (``openai-api-base``, ``openai-api-key``, ``model``).
|
|
34
|
+
Configure/remove refuse to touch the file if absent + user-set values.
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
metadata.json: top-level sentinel ``_substrate_setup_managed: true``.
|
|
37
|
+
Configure refuses to overwrite an unmarked file; remove deletes the
|
|
38
|
+
file only if the marker is present. An unmarked file is treated as
|
|
39
|
+
user-owned and left alone.
|
|
38
40
|
"""
|
|
39
41
|
from __future__ import annotations
|
|
40
42
|
|
|
41
43
|
import io
|
|
44
|
+
import json
|
|
42
45
|
import os
|
|
43
46
|
import sys
|
|
44
47
|
from pathlib import Path
|
|
@@ -57,12 +60,18 @@ from substrate_setup.backup import backup_once
|
|
|
57
60
|
MANAGED_KEYS_MARKER = "_substrate_setup_managed_keys"
|
|
58
61
|
MANAGED_KEYS: list[str] = ["openai-api-base", "openai-api-key", "model"]
|
|
59
62
|
|
|
63
|
+
# 0.3.0: ~/.aider.model.metadata.json is now substrate-managed (sidecar
|
|
64
|
+
# to ~/.aider.conf.yml). The top-level sentinel below differentiates a
|
|
65
|
+
# substrate-written file from a user-authored one.
|
|
66
|
+
META_MARKER_KEY = "_substrate_setup_managed"
|
|
67
|
+
|
|
60
68
|
|
|
61
69
|
def _conf_path() -> Path:
|
|
62
70
|
return Path.home() / ".aider.conf.yml"
|
|
63
71
|
|
|
64
72
|
|
|
65
|
-
def
|
|
73
|
+
def _meta_path() -> Path:
|
|
74
|
+
"""Path to the Aider model-metadata JSON file (peer of .aider.conf.yml)."""
|
|
66
75
|
return Path.home() / ".aider.model.metadata.json"
|
|
67
76
|
|
|
68
77
|
|
|
@@ -70,6 +79,38 @@ def _legacy_env_path() -> Path:
|
|
|
70
79
|
return Path.home() / ".env"
|
|
71
80
|
|
|
72
81
|
|
|
82
|
+
def _meta_has_marker(meta: dict[str, Any]) -> bool:
|
|
83
|
+
return meta.get(META_MARKER_KEY) is True
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _is_chat_capable(entry: Any) -> bool:
|
|
87
|
+
"""True if the catalog entry is a chat model (not image-gen).
|
|
88
|
+
|
|
89
|
+
Catalog entries without an explicit ``output_modality`` default to
|
|
90
|
+
``text`` (chat).
|
|
91
|
+
"""
|
|
92
|
+
return getattr(entry, "output_modality", "text") == "text"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _build_metadata_payload(catalog: list[Any]) -> dict[str, Any]:
|
|
96
|
+
"""Build the JSON payload aider reads.
|
|
97
|
+
|
|
98
|
+
Keys use the ``openai/<gateway_id>`` form because aider's
|
|
99
|
+
OpenAI-compatible provider always prefixes with ``openai/``. The
|
|
100
|
+
sentinel ``_substrate_setup_managed: true`` carries marker
|
|
101
|
+
discipline so remove() knows the file is ours.
|
|
102
|
+
"""
|
|
103
|
+
out: dict[str, Any] = {META_MARKER_KEY: True}
|
|
104
|
+
for entry in catalog:
|
|
105
|
+
if not _is_chat_capable(entry):
|
|
106
|
+
continue
|
|
107
|
+
out[f"openai/{entry.id}"] = {
|
|
108
|
+
"litellm_provider": "openai",
|
|
109
|
+
"mode": "chat",
|
|
110
|
+
}
|
|
111
|
+
return out
|
|
112
|
+
|
|
113
|
+
|
|
73
114
|
def _yaml() -> YAML:
|
|
74
115
|
y = YAML()
|
|
75
116
|
y.preserve_quotes = True
|
|
@@ -191,6 +232,35 @@ class AiderAgent(Agent):
|
|
|
191
232
|
if not ctx.dry_run:
|
|
192
233
|
_dump_yaml(conf, payload)
|
|
193
234
|
|
|
235
|
+
# Write the model-metadata sidecar file. If a user-owned file
|
|
236
|
+
# already exists (no marker), refuse to clobber it — same
|
|
237
|
+
# refusal pattern as the conf.yml block above.
|
|
238
|
+
meta_path = _meta_path()
|
|
239
|
+
if meta_path.exists():
|
|
240
|
+
try:
|
|
241
|
+
existing_meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
242
|
+
except json.JSONDecodeError:
|
|
243
|
+
existing_meta = {}
|
|
244
|
+
if not isinstance(existing_meta, dict):
|
|
245
|
+
existing_meta = {}
|
|
246
|
+
if not _meta_has_marker(existing_meta):
|
|
247
|
+
return AgentResult(
|
|
248
|
+
ResultStatus.ERROR,
|
|
249
|
+
f"Aider {meta_path} exists but is not substrate-managed; "
|
|
250
|
+
"refused to overwrite. Delete the file or add "
|
|
251
|
+
f"`{META_MARKER_KEY}: true` at the top to take over.",
|
|
252
|
+
tuple(backups),
|
|
253
|
+
)
|
|
254
|
+
backup = backup_once(meta_path)
|
|
255
|
+
if backup is not None:
|
|
256
|
+
backups.append(backup)
|
|
257
|
+
new_meta = _build_metadata_payload(ctx.catalog)
|
|
258
|
+
if not ctx.dry_run:
|
|
259
|
+
meta_path.write_text(
|
|
260
|
+
json.dumps(new_meta, indent=2) + "\n", encoding="utf-8"
|
|
261
|
+
)
|
|
262
|
+
_restrict_file_mode(meta_path)
|
|
263
|
+
|
|
194
264
|
return AgentResult(
|
|
195
265
|
ResultStatus.SUCCESS,
|
|
196
266
|
f"Wrote openai-api-base + openai-api-key + model into {conf}.",
|
|
@@ -261,6 +331,18 @@ class AiderAgent(Agent):
|
|
|
261
331
|
if not ctx.dry_run:
|
|
262
332
|
_dump_yaml(conf, payload)
|
|
263
333
|
|
|
334
|
+
# Drop the model-metadata sidecar if it's ours.
|
|
335
|
+
meta_path = _meta_path()
|
|
336
|
+
if meta_path.exists():
|
|
337
|
+
try:
|
|
338
|
+
existing_meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
339
|
+
except json.JSONDecodeError:
|
|
340
|
+
existing_meta = {}
|
|
341
|
+
if isinstance(existing_meta, dict) and _meta_has_marker(existing_meta):
|
|
342
|
+
if not ctx.dry_run:
|
|
343
|
+
meta_path.unlink()
|
|
344
|
+
# If no marker (user-owned), leave it alone.
|
|
345
|
+
|
|
264
346
|
# Also clean up legacy artifacts written by 0.1 (best-effort).
|
|
265
347
|
self._clean_legacy(ctx, backups=backups)
|
|
266
348
|
|
|
@@ -274,22 +356,17 @@ class AiderAgent(Agent):
|
|
|
274
356
|
def _clean_legacy(
|
|
275
357
|
ctx: ConfigureContext, *, backups: list[Path] | None = None
|
|
276
358
|
) -> bool:
|
|
277
|
-
"""
|
|
278
|
-
|
|
359
|
+
"""Strip substrate entries from ~/.env (0.1 legacy).
|
|
360
|
+
|
|
361
|
+
~/.aider.model.metadata.json is no longer treated as legacy in
|
|
362
|
+
0.3.0 — it's a substrate-managed sidecar with its own marker
|
|
363
|
+
discipline handled directly in configure()/remove(). This
|
|
364
|
+
helper now covers only the ~/.env case.
|
|
365
|
+
"""
|
|
279
366
|
if backups is None:
|
|
280
367
|
backups = []
|
|
281
368
|
did_something = False
|
|
282
369
|
|
|
283
|
-
# ~/.aider.model.metadata.json
|
|
284
|
-
legacy_meta = _legacy_meta_path()
|
|
285
|
-
if legacy_meta.exists():
|
|
286
|
-
backup = backup_once(legacy_meta)
|
|
287
|
-
if backup is not None:
|
|
288
|
-
backups.append(backup)
|
|
289
|
-
if not ctx.dry_run:
|
|
290
|
-
legacy_meta.unlink()
|
|
291
|
-
did_something = True
|
|
292
|
-
|
|
293
370
|
# ~/.env: strip the OPENAI_API_KEY line if its value starts with "sk-substrate-".
|
|
294
371
|
legacy_env = _legacy_env_path()
|
|
295
372
|
if legacy_env.exists():
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""Claude Code (Anthropic) integration.
|
|
2
|
+
|
|
3
|
+
Claude Code reads its config from ``~/.claude/settings.json``. We
|
|
4
|
+
merge three env keys into the ``env:`` block:
|
|
5
|
+
|
|
6
|
+
- ANTHROPIC_BASE_URL — Substrate gateway, WITHOUT /v1 suffix
|
|
7
|
+
(Claude Code appends /v1/messages itself)
|
|
8
|
+
- ANTHROPIC_AUTH_TOKEN — Substrate API key
|
|
9
|
+
- ANTHROPIC_MODEL — default model for chat sessions
|
|
10
|
+
|
|
11
|
+
Claude Code's /model slash-command picker is hardcoded
|
|
12
|
+
(opus/sonnet/haiku) and not externally extensible. To use other
|
|
13
|
+
Substrate models the user passes ``claude --model openai/gpt-5.5``
|
|
14
|
+
(or any chat-capable catalog id) on the command line. configure()
|
|
15
|
+
prints a walkthrough listing the available model ids.
|
|
16
|
+
|
|
17
|
+
Marker discipline:
|
|
18
|
+
Because we share settings.json with other Claude Code config
|
|
19
|
+
(skill registry, hooks, theme, etc.), we use a dedicated top-level
|
|
20
|
+
list ``_substrate_setup_managed_env_keys`` enumerating which env
|
|
21
|
+
keys we own. The settings.json itself never gets deleted — even
|
|
22
|
+
when remove() runs.
|
|
23
|
+
|
|
24
|
+
Gateway dependency:
|
|
25
|
+
Claude Code reaches Substrate via /v1/messages. The route was
|
|
26
|
+
merged in gateway protocol adapters Phase 1 (PR #57) and is
|
|
27
|
+
mounted behind the ``GATEWAY_PROTOCOL_ADAPTERS_ENABLED`` Fly
|
|
28
|
+
secret. Until that flag is flipped, requests return 404 — the
|
|
29
|
+
walkthrough below tells the user this so they're not surprised.
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import json
|
|
34
|
+
import os
|
|
35
|
+
import shutil
|
|
36
|
+
import sys
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Any
|
|
39
|
+
|
|
40
|
+
from substrate_setup.agents.base import (
|
|
41
|
+
Agent,
|
|
42
|
+
AgentResult,
|
|
43
|
+
ConfigureContext,
|
|
44
|
+
ResultStatus,
|
|
45
|
+
)
|
|
46
|
+
from substrate_setup.backup import backup_once
|
|
47
|
+
|
|
48
|
+
MANAGED_ENV_KEYS_MARKER = "_substrate_setup_managed_env_keys"
|
|
49
|
+
MANAGED_ENV_KEYS: list[str] = [
|
|
50
|
+
"ANTHROPIC_BASE_URL",
|
|
51
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
52
|
+
"ANTHROPIC_MODEL",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _claude_dir() -> Path:
|
|
57
|
+
return Path.home() / ".claude"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _settings_path() -> Path:
|
|
61
|
+
return _claude_dir() / "settings.json"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _strip_v1_suffix(base_url: str) -> str:
|
|
65
|
+
"""Claude Code appends /v1/messages itself.
|
|
66
|
+
|
|
67
|
+
The Substrate gateway base is `https://...fly.dev/v1`; Hermes/Aider/
|
|
68
|
+
Continue.dev want the `/v1` in their base URL (they hit
|
|
69
|
+
`/v1/chat/completions`); Claude Code does NOT. Strip a trailing `/v1`
|
|
70
|
+
(and any trailing slash) if present.
|
|
71
|
+
"""
|
|
72
|
+
trimmed = base_url.rstrip("/")
|
|
73
|
+
if trimmed.endswith("/v1"):
|
|
74
|
+
return trimmed[: -len("/v1")]
|
|
75
|
+
return trimmed
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _load_settings(path: Path) -> tuple[dict[str, Any] | None, AgentResult | None]:
|
|
79
|
+
if not path.exists():
|
|
80
|
+
return {}, None
|
|
81
|
+
try:
|
|
82
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
83
|
+
except json.JSONDecodeError as exc:
|
|
84
|
+
return None, AgentResult(
|
|
85
|
+
ResultStatus.ERROR,
|
|
86
|
+
f"Claude Code {path} is not valid JSON: {exc.msg} at "
|
|
87
|
+
f"line {exc.lineno}. Fix the file and re-run.",
|
|
88
|
+
)
|
|
89
|
+
if not isinstance(data, dict):
|
|
90
|
+
return None, AgentResult(
|
|
91
|
+
ResultStatus.ERROR,
|
|
92
|
+
f"Claude Code {path} root is not an object; refused to merge.",
|
|
93
|
+
)
|
|
94
|
+
return data, None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _has_managed_marker(payload: dict[str, Any]) -> bool:
|
|
98
|
+
marker = payload.get(MANAGED_ENV_KEYS_MARKER)
|
|
99
|
+
if not isinstance(marker, list):
|
|
100
|
+
return False
|
|
101
|
+
return set(MANAGED_ENV_KEYS).issubset(set(marker))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _user_owns_anthropic_env(env_block: dict[str, Any]) -> bool:
|
|
105
|
+
for key in MANAGED_ENV_KEYS:
|
|
106
|
+
v = env_block.get(key)
|
|
107
|
+
if isinstance(v, str) and v.strip():
|
|
108
|
+
return True
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _existing_matches_substrate_shape(
|
|
113
|
+
env_block: dict[str, Any], ctx: ConfigureContext
|
|
114
|
+
) -> bool:
|
|
115
|
+
"""Auto-claim heuristic for an existing config."""
|
|
116
|
+
expected_base = _strip_v1_suffix(ctx.base_url)
|
|
117
|
+
if env_block.get("ANTHROPIC_BASE_URL") != expected_base:
|
|
118
|
+
return False
|
|
119
|
+
token = env_block.get("ANTHROPIC_AUTH_TOKEN")
|
|
120
|
+
if not isinstance(token, str) or not token.startswith("sk-substrate-"):
|
|
121
|
+
return False
|
|
122
|
+
return True
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _chat_capable(catalog: list[Any]) -> list[Any]:
|
|
126
|
+
return [e for e in catalog if e.output_modality == "text"]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _restrict_file_mode(path: Path) -> None:
|
|
130
|
+
if sys.platform == "win32":
|
|
131
|
+
return
|
|
132
|
+
try:
|
|
133
|
+
os.chmod(path, 0o600)
|
|
134
|
+
except OSError:
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class ClaudeCodeAgent(Agent):
|
|
139
|
+
name = "claude-code"
|
|
140
|
+
pretty_name = "Claude Code"
|
|
141
|
+
|
|
142
|
+
def detect(self) -> Path | None:
|
|
143
|
+
"""Three layered signals:
|
|
144
|
+
1. settings.json present (strongest)
|
|
145
|
+
2. ~/.claude/ directory present (Claude Code ran at least once)
|
|
146
|
+
3. `claude` binary on PATH (pipx install, never run)
|
|
147
|
+
"""
|
|
148
|
+
settings = _settings_path()
|
|
149
|
+
if settings.exists():
|
|
150
|
+
return settings
|
|
151
|
+
claude_dir = _claude_dir()
|
|
152
|
+
if claude_dir.exists():
|
|
153
|
+
return claude_dir
|
|
154
|
+
binary = shutil.which("claude")
|
|
155
|
+
if binary:
|
|
156
|
+
return Path(binary)
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
def configure(self, ctx: ConfigureContext) -> AgentResult:
|
|
160
|
+
claude_dir = _claude_dir()
|
|
161
|
+
claude_dir.mkdir(parents=True, exist_ok=True)
|
|
162
|
+
|
|
163
|
+
settings_path = _settings_path()
|
|
164
|
+
backups: list[Path] = []
|
|
165
|
+
notes: list[str] = []
|
|
166
|
+
|
|
167
|
+
payload, err = _load_settings(settings_path)
|
|
168
|
+
if err is not None:
|
|
169
|
+
return err
|
|
170
|
+
assert payload is not None
|
|
171
|
+
|
|
172
|
+
env_block = payload.get("env") or {}
|
|
173
|
+
if not isinstance(env_block, dict):
|
|
174
|
+
env_block = {}
|
|
175
|
+
|
|
176
|
+
if not _has_managed_marker(payload):
|
|
177
|
+
if _user_owns_anthropic_env(env_block):
|
|
178
|
+
if _existing_matches_substrate_shape(env_block, ctx):
|
|
179
|
+
notes.append(
|
|
180
|
+
"Claude Code settings.json had substrate-shaped env "
|
|
181
|
+
"keys but no marker; auto-claiming ownership."
|
|
182
|
+
)
|
|
183
|
+
else:
|
|
184
|
+
return AgentResult(
|
|
185
|
+
ResultStatus.ERROR,
|
|
186
|
+
"Claude Code settings.json has user-owned ANTHROPIC_* "
|
|
187
|
+
"env keys but no substrate-setup marker; refused to "
|
|
188
|
+
"overwrite. To take over, delete those keys from "
|
|
189
|
+
"settings.json and re-run substrate-setup.",
|
|
190
|
+
tuple(backups),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
if settings_path.exists():
|
|
194
|
+
backup = backup_once(settings_path)
|
|
195
|
+
if backup is not None:
|
|
196
|
+
backups.append(backup)
|
|
197
|
+
|
|
198
|
+
chat_models = _chat_capable(ctx.catalog)
|
|
199
|
+
if not chat_models:
|
|
200
|
+
return AgentResult(
|
|
201
|
+
ResultStatus.ERROR,
|
|
202
|
+
"Cannot configure Claude Code with an empty chat-model "
|
|
203
|
+
"catalog.",
|
|
204
|
+
tuple(backups),
|
|
205
|
+
)
|
|
206
|
+
default_model = chat_models[0].id
|
|
207
|
+
|
|
208
|
+
env_block["ANTHROPIC_BASE_URL"] = _strip_v1_suffix(ctx.base_url)
|
|
209
|
+
env_block["ANTHROPIC_AUTH_TOKEN"] = ctx.api_key
|
|
210
|
+
env_block["ANTHROPIC_MODEL"] = default_model
|
|
211
|
+
payload["env"] = env_block
|
|
212
|
+
|
|
213
|
+
payload[MANAGED_ENV_KEYS_MARKER] = list(MANAGED_ENV_KEYS)
|
|
214
|
+
|
|
215
|
+
if not ctx.dry_run:
|
|
216
|
+
settings_path.write_text(
|
|
217
|
+
json.dumps(payload, indent=2) + "\n", encoding="utf-8"
|
|
218
|
+
)
|
|
219
|
+
_restrict_file_mode(settings_path)
|
|
220
|
+
|
|
221
|
+
bullets = "\n".join(f" - {m.id}" for m in chat_models)
|
|
222
|
+
print(
|
|
223
|
+
"Claude Code configured to use the Substrate gateway.\n"
|
|
224
|
+
"\n"
|
|
225
|
+
f" Default model: {default_model}\n"
|
|
226
|
+
" The /model slash command stays opus/sonnet/haiku (built-in).\n"
|
|
227
|
+
" To use any other Substrate model, pass --model on the CLI:\n"
|
|
228
|
+
"\n"
|
|
229
|
+
" claude --model openai/gpt-5.5 \"...\"\n"
|
|
230
|
+
"\n"
|
|
231
|
+
" Available chat-capable Substrate models:\n"
|
|
232
|
+
f"{bullets}\n"
|
|
233
|
+
"\n"
|
|
234
|
+
" NOTE: Claude Code reaches Substrate via /v1/messages. The route\n"
|
|
235
|
+
" was added in gateway protocol adapters Phase 1 (PR #57) and is\n"
|
|
236
|
+
" mounted behind a Fly feature flag — until that flag is flipped\n"
|
|
237
|
+
" on, requests return 404."
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
msg = f"Wrote {','.join(MANAGED_ENV_KEYS)} into {settings_path}."
|
|
241
|
+
if notes:
|
|
242
|
+
msg += "\n" + "\n".join(notes)
|
|
243
|
+
return AgentResult(ResultStatus.SUCCESS, msg, tuple(backups))
|
|
244
|
+
|
|
245
|
+
def verify(self, ctx: ConfigureContext) -> AgentResult:
|
|
246
|
+
settings_path = _settings_path()
|
|
247
|
+
if not settings_path.exists():
|
|
248
|
+
return AgentResult(
|
|
249
|
+
ResultStatus.ERROR,
|
|
250
|
+
"Claude Code settings.json does not exist",
|
|
251
|
+
)
|
|
252
|
+
payload, err = _load_settings(settings_path)
|
|
253
|
+
if err is not None:
|
|
254
|
+
return err
|
|
255
|
+
assert payload is not None
|
|
256
|
+
|
|
257
|
+
if not _has_managed_marker(payload):
|
|
258
|
+
return AgentResult(
|
|
259
|
+
ResultStatus.ERROR,
|
|
260
|
+
"Claude Code settings.json is not managed by substrate-setup "
|
|
261
|
+
f"(marker `{MANAGED_ENV_KEYS_MARKER}` missing or incomplete)",
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
env_block = payload.get("env") or {}
|
|
265
|
+
if not isinstance(env_block, dict):
|
|
266
|
+
return AgentResult(
|
|
267
|
+
ResultStatus.ERROR,
|
|
268
|
+
"Claude Code env block is malformed",
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
problems: list[str] = []
|
|
272
|
+
expected_base = _strip_v1_suffix(ctx.base_url)
|
|
273
|
+
if env_block.get("ANTHROPIC_BASE_URL") != expected_base:
|
|
274
|
+
problems.append(
|
|
275
|
+
f"ANTHROPIC_BASE_URL: expected {expected_base!r}, "
|
|
276
|
+
f"got {env_block.get('ANTHROPIC_BASE_URL')!r}"
|
|
277
|
+
)
|
|
278
|
+
token = env_block.get("ANTHROPIC_AUTH_TOKEN")
|
|
279
|
+
if not isinstance(token, str) or len(token) < 20:
|
|
280
|
+
problems.append(
|
|
281
|
+
"ANTHROPIC_AUTH_TOKEN: missing or implausibly short"
|
|
282
|
+
)
|
|
283
|
+
chat_models = _chat_capable(ctx.catalog)
|
|
284
|
+
expected_default = chat_models[0].id if chat_models else None
|
|
285
|
+
if env_block.get("ANTHROPIC_MODEL") != expected_default:
|
|
286
|
+
problems.append(
|
|
287
|
+
f"ANTHROPIC_MODEL: expected {expected_default!r}, "
|
|
288
|
+
f"got {env_block.get('ANTHROPIC_MODEL')!r}"
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
if problems:
|
|
292
|
+
return AgentResult(ResultStatus.ERROR, "; ".join(problems))
|
|
293
|
+
return AgentResult(ResultStatus.SUCCESS, "in sync")
|
|
294
|
+
|
|
295
|
+
def remove(self, ctx: ConfigureContext) -> AgentResult:
|
|
296
|
+
settings_path = _settings_path()
|
|
297
|
+
if not settings_path.exists():
|
|
298
|
+
return AgentResult(
|
|
299
|
+
ResultStatus.SKIPPED, "Claude Code not configured"
|
|
300
|
+
)
|
|
301
|
+
payload, err = _load_settings(settings_path)
|
|
302
|
+
if err is not None:
|
|
303
|
+
return err
|
|
304
|
+
assert payload is not None
|
|
305
|
+
|
|
306
|
+
if not _has_managed_marker(payload):
|
|
307
|
+
return AgentResult(
|
|
308
|
+
ResultStatus.SKIPPED,
|
|
309
|
+
"Claude Code settings.json is not managed by substrate-setup; "
|
|
310
|
+
"refusing to delete keys we don't own",
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
backups: list[Path] = []
|
|
314
|
+
backup = backup_once(settings_path)
|
|
315
|
+
if backup is not None:
|
|
316
|
+
backups.append(backup)
|
|
317
|
+
|
|
318
|
+
env_block = payload.get("env") or {}
|
|
319
|
+
if isinstance(env_block, dict):
|
|
320
|
+
for key in MANAGED_ENV_KEYS:
|
|
321
|
+
env_block.pop(key, None)
|
|
322
|
+
if env_block:
|
|
323
|
+
payload["env"] = env_block
|
|
324
|
+
else:
|
|
325
|
+
payload.pop("env", None)
|
|
326
|
+
|
|
327
|
+
payload.pop(MANAGED_ENV_KEYS_MARKER, None)
|
|
328
|
+
|
|
329
|
+
if not ctx.dry_run:
|
|
330
|
+
settings_path.write_text(
|
|
331
|
+
json.dumps(payload, indent=2) + "\n", encoding="utf-8"
|
|
332
|
+
)
|
|
333
|
+
_restrict_file_mode(settings_path)
|
|
334
|
+
|
|
335
|
+
return AgentResult(
|
|
336
|
+
ResultStatus.SUCCESS,
|
|
337
|
+
f"Removed substrate-managed env keys from {settings_path}",
|
|
338
|
+
tuple(backups),
|
|
339
|
+
)
|