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.
Files changed (39) hide show
  1. {substrate_setup-0.2.2 → substrate_setup-0.3.0}/PKG-INFO +14 -2
  2. {substrate_setup-0.2.2 → substrate_setup-0.3.0}/README.md +12 -1
  3. {substrate_setup-0.2.2 → substrate_setup-0.3.0}/pyproject.toml +2 -1
  4. {substrate_setup-0.2.2 → substrate_setup-0.3.0}/substrate_setup/__init__.py +1 -1
  5. {substrate_setup-0.2.2 → substrate_setup-0.3.0}/substrate_setup/agents/__init__.py +4 -0
  6. {substrate_setup-0.2.2 → substrate_setup-0.3.0}/substrate_setup/agents/aider.py +114 -37
  7. substrate_setup-0.3.0/substrate_setup/agents/claude_code.py +339 -0
  8. substrate_setup-0.3.0/substrate_setup/agents/codex.py +345 -0
  9. {substrate_setup-0.2.2 → substrate_setup-0.3.0}/substrate_setup/agents/continue_dev.py +5 -1
  10. {substrate_setup-0.2.2 → substrate_setup-0.3.0}/substrate_setup/agents/cursor.py +5 -1
  11. {substrate_setup-0.2.2 → substrate_setup-0.3.0}/substrate_setup/agents/hermes.py +126 -10
  12. {substrate_setup-0.2.2 → substrate_setup-0.3.0}/substrate_setup/catalog.py +6 -0
  13. {substrate_setup-0.2.2 → substrate_setup-0.3.0}/tests/agents/test_aider.py +97 -11
  14. substrate_setup-0.3.0/tests/agents/test_claude_code.py +262 -0
  15. substrate_setup-0.3.0/tests/agents/test_codex.py +278 -0
  16. {substrate_setup-0.2.2 → substrate_setup-0.3.0}/tests/agents/test_continue_dev.py +27 -0
  17. {substrate_setup-0.2.2 → substrate_setup-0.3.0}/tests/agents/test_cursor.py +24 -0
  18. {substrate_setup-0.2.2 → substrate_setup-0.3.0}/tests/agents/test_hermes.py +109 -1
  19. substrate_setup-0.3.0/tests/conftest.py +19 -0
  20. {substrate_setup-0.2.2 → substrate_setup-0.3.0}/tests/test_cli.py +34 -54
  21. {substrate_setup-0.2.2 → substrate_setup-0.3.0}/tests/test_e2e.py +43 -2
  22. substrate_setup-0.2.2/tests/conftest.py +0 -1
  23. substrate_setup-0.2.2/uv.lock +0 -341
  24. {substrate_setup-0.2.2 → substrate_setup-0.3.0}/.gitignore +0 -0
  25. {substrate_setup-0.2.2 → substrate_setup-0.3.0}/scripts/lint_no_app_import.sh +0 -0
  26. {substrate_setup-0.2.2 → substrate_setup-0.3.0}/scripts/regenerate_fallback_catalog.py +0 -0
  27. {substrate_setup-0.2.2 → substrate_setup-0.3.0}/substrate_setup/__main__.py +0 -0
  28. {substrate_setup-0.2.2 → substrate_setup-0.3.0}/substrate_setup/agents/base.py +0 -0
  29. {substrate_setup-0.2.2 → substrate_setup-0.3.0}/substrate_setup/backup.py +0 -0
  30. {substrate_setup-0.2.2 → substrate_setup-0.3.0}/substrate_setup/cli.py +0 -0
  31. {substrate_setup-0.2.2 → substrate_setup-0.3.0}/substrate_setup/credentials.py +0 -0
  32. {substrate_setup-0.2.2 → substrate_setup-0.3.0}/substrate_setup/data/fallback_catalog.json +0 -0
  33. {substrate_setup-0.2.2 → substrate_setup-0.3.0}/substrate_setup/markers.py +0 -0
  34. {substrate_setup-0.2.2 → substrate_setup-0.3.0}/tests/__init__.py +0 -0
  35. {substrate_setup-0.2.2 → substrate_setup-0.3.0}/tests/agents/__init__.py +0 -0
  36. {substrate_setup-0.2.2 → substrate_setup-0.3.0}/tests/test_backup.py +0 -0
  37. {substrate_setup-0.2.2 → substrate_setup-0.3.0}/tests/test_catalog.py +0 -0
  38. {substrate_setup-0.2.2 → substrate_setup-0.3.0}/tests/test_credentials.py +0 -0
  39. {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.2.2
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.2.2"
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]
@@ -3,4 +3,4 @@
3
3
  See https://github.com/FrankXiaA/substrate-solutions for the gateway it
4
4
  configures against.
5
5
  """
6
- __version__ = "0.2.2"
6
+ __version__ = "0.3.0"
@@ -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.2.0 — single conf.yml, no metadata file).
1
+ """Aider integration (0.3.0 — conf.yml + model-metadata sidecar).
2
2
 
3
3
  Files:
4
- ~/.aider.conf.yml — openai-api-base, openai-api-key, model (default)
5
-
6
- Why no `.aider.model.metadata.json` (was written by 0.1)
7
- -------------------------------------------------------
8
- Aider routes everything through one OpenAI-compatible endpoint set via
9
- ``openai-api-base``. Per-model LiteLLM metadata (context length, cost
10
- per token) lives upstream on the gateway, not in Aider; the metadata
11
- file was over-engineered. 0.2.0 drops it. A user who wants a specific
12
- model passes ``--model <id>`` on the Aider CLI; otherwise the ``model:``
13
- key in ``~/.aider.conf.yml`` is used.
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 drops the env write.
21
- ``remove()`` still cleans up a legacy ``~/.aider.model.metadata.json``
22
- and a substrate-shaped ``OPENAI_API_KEY=`` line in ``~/.env`` if they
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
- Since the keys we own (``openai-api-base``, ``openai-api-key``,
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
- _substrate_setup_managed_keys:
32
- - "openai-api-base"
33
- - "openai-api-key"
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
- If the marker is absent AND any of those three keys are user-set,
37
- configure()/remove() refuses to touch them.
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 _legacy_meta_path() -> Path:
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
- """Delete 0.1 artifacts: ~/.aider.model.metadata.json and substrate
278
- entries in ~/.env. Returns True if anything was cleaned."""
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
+ )