substrate-setup 0.1.0__tar.gz → 0.2.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.1.0 → substrate_setup-0.2.0}/.gitignore +3 -0
  2. {substrate_setup-0.1.0 → substrate_setup-0.2.0}/PKG-INFO +14 -1
  3. {substrate_setup-0.1.0 → substrate_setup-0.2.0}/pyproject.toml +18 -1
  4. {substrate_setup-0.1.0 → substrate_setup-0.2.0}/substrate_setup/__init__.py +1 -1
  5. substrate_setup-0.2.0/substrate_setup/agents/aider.py +286 -0
  6. substrate_setup-0.2.0/substrate_setup/agents/continue_dev.py +278 -0
  7. substrate_setup-0.2.0/substrate_setup/agents/hermes.py +351 -0
  8. {substrate_setup-0.1.0 → substrate_setup-0.2.0}/substrate_setup/data/fallback_catalog.json +42 -0
  9. substrate_setup-0.2.0/tests/agents/test_aider.py +224 -0
  10. {substrate_setup-0.1.0 → substrate_setup-0.2.0}/tests/agents/test_continue_dev.py +230 -166
  11. substrate_setup-0.2.0/tests/agents/test_hermes.py +429 -0
  12. {substrate_setup-0.1.0 → substrate_setup-0.2.0}/tests/test_cli.py +10 -5
  13. {substrate_setup-0.1.0 → substrate_setup-0.2.0}/tests/test_e2e.py +3 -2
  14. substrate_setup-0.2.0/uv.lock +341 -0
  15. substrate_setup-0.1.0/substrate_setup/agents/aider.py +0 -250
  16. substrate_setup-0.1.0/substrate_setup/agents/continue_dev.py +0 -194
  17. substrate_setup-0.1.0/substrate_setup/agents/hermes.py +0 -259
  18. substrate_setup-0.1.0/tests/agents/test_aider.py +0 -173
  19. substrate_setup-0.1.0/tests/agents/test_hermes.py +0 -234
  20. {substrate_setup-0.1.0 → substrate_setup-0.2.0}/README.md +0 -0
  21. {substrate_setup-0.1.0 → substrate_setup-0.2.0}/scripts/lint_no_app_import.sh +0 -0
  22. {substrate_setup-0.1.0 → substrate_setup-0.2.0}/scripts/regenerate_fallback_catalog.py +0 -0
  23. {substrate_setup-0.1.0 → substrate_setup-0.2.0}/substrate_setup/__main__.py +0 -0
  24. {substrate_setup-0.1.0 → substrate_setup-0.2.0}/substrate_setup/agents/__init__.py +0 -0
  25. {substrate_setup-0.1.0 → substrate_setup-0.2.0}/substrate_setup/agents/base.py +0 -0
  26. {substrate_setup-0.1.0 → substrate_setup-0.2.0}/substrate_setup/agents/cursor.py +0 -0
  27. {substrate_setup-0.1.0 → substrate_setup-0.2.0}/substrate_setup/backup.py +0 -0
  28. {substrate_setup-0.1.0 → substrate_setup-0.2.0}/substrate_setup/catalog.py +0 -0
  29. {substrate_setup-0.1.0 → substrate_setup-0.2.0}/substrate_setup/cli.py +0 -0
  30. {substrate_setup-0.1.0 → substrate_setup-0.2.0}/substrate_setup/credentials.py +0 -0
  31. {substrate_setup-0.1.0 → substrate_setup-0.2.0}/substrate_setup/markers.py +0 -0
  32. {substrate_setup-0.1.0 → substrate_setup-0.2.0}/tests/__init__.py +0 -0
  33. {substrate_setup-0.1.0 → substrate_setup-0.2.0}/tests/agents/__init__.py +0 -0
  34. {substrate_setup-0.1.0 → substrate_setup-0.2.0}/tests/agents/test_cursor.py +0 -0
  35. {substrate_setup-0.1.0 → substrate_setup-0.2.0}/tests/conftest.py +0 -0
  36. {substrate_setup-0.1.0 → substrate_setup-0.2.0}/tests/test_backup.py +0 -0
  37. {substrate_setup-0.1.0 → substrate_setup-0.2.0}/tests/test_catalog.py +0 -0
  38. {substrate_setup-0.1.0 → substrate_setup-0.2.0}/tests/test_credentials.py +0 -0
  39. {substrate_setup-0.1.0 → substrate_setup-0.2.0}/tests/test_markers.py +0 -0
@@ -28,6 +28,9 @@ node_modules/
28
28
  .next/
29
29
  .vercel/
30
30
  .turbo/
31
+
32
+ # CodeGraph (local-only code knowledge graph index)
33
+ .codegraph/
31
34
  out/
32
35
  *.tsbuildinfo
33
36
  .npm/
@@ -1,9 +1,22 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: substrate-setup
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: One-shot local configurator for coding agents against a Substrate gateway
5
+ Project-URL: Homepage, https://github.com/FrankXiaA/substrate-solutions
6
+ Project-URL: Source, https://github.com/FrankXiaA/substrate-solutions/tree/main/substrate-api/substrate_setup
7
+ Project-URL: Issues, https://github.com/FrankXiaA/substrate-solutions/issues
5
8
  Author: Substrate Solutions
6
9
  License: MIT
10
+ Keywords: aider,continue,gateway,hermes,llm,openai-compatible,substrate
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Software Development :: Code Generators
19
+ Classifier: Topic :: Utilities
7
20
  Requires-Python: >=3.12
8
21
  Requires-Dist: httpx>=0.27
9
22
  Requires-Dist: ruamel-yaml>=0.18
@@ -4,17 +4,34 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "substrate-setup"
7
- version = "0.1.0"
7
+ version = "0.2.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"
11
11
  authors = [{ name = "Substrate Solutions" }]
12
12
  license = { text = "MIT" }
13
+ keywords = ["substrate", "llm", "gateway", "aider", "continue", "hermes", "openai-compatible"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3 :: Only",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Topic :: Software Development :: Code Generators",
23
+ "Topic :: Utilities",
24
+ ]
13
25
  dependencies = [
14
26
  "httpx>=0.27",
15
27
  "ruamel.yaml>=0.18",
16
28
  ]
17
29
 
30
+ [project.urls]
31
+ Homepage = "https://github.com/FrankXiaA/substrate-solutions"
32
+ Source = "https://github.com/FrankXiaA/substrate-solutions/tree/main/substrate-api/substrate_setup"
33
+ Issues = "https://github.com/FrankXiaA/substrate-solutions/issues"
34
+
18
35
  [project.optional-dependencies]
19
36
  dev = [
20
37
  "pytest>=8.0",
@@ -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.1.0"
6
+ __version__ = "0.2.0"
@@ -0,0 +1,286 @@
1
+ """Aider integration (0.2.0 — single conf.yml, no metadata file).
2
+
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.
14
+
15
+ Why no `~/.env` (was written by 0.1)
16
+ ------------------------------------
17
+ Aider's docs say the inline ``openai-api-key`` in ``~/.aider.conf.yml``
18
+ is the supported way to authenticate against OpenAI-compatible
19
+ 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
+
25
+ Marker discipline
26
+ -----------------
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
+
31
+ _substrate_setup_managed_keys:
32
+ - "openai-api-base"
33
+ - "openai-api-key"
34
+ - "model"
35
+
36
+ If the marker is absent AND any of those three keys are user-set,
37
+ configure()/remove() refuses to touch them.
38
+ """
39
+ from __future__ import annotations
40
+
41
+ import io
42
+ import os
43
+ import sys
44
+ from pathlib import Path
45
+ from typing import Any
46
+
47
+ from ruamel.yaml import YAML
48
+
49
+ from substrate_setup.agents.base import (
50
+ Agent,
51
+ AgentResult,
52
+ ConfigureContext,
53
+ ResultStatus,
54
+ )
55
+ from substrate_setup.backup import backup_once
56
+
57
+ MANAGED_KEYS_MARKER = "_substrate_setup_managed_keys"
58
+ MANAGED_KEYS: list[str] = ["openai-api-base", "openai-api-key", "model"]
59
+
60
+
61
+ def _conf_path() -> Path:
62
+ return Path.home() / ".aider.conf.yml"
63
+
64
+
65
+ def _legacy_meta_path() -> Path:
66
+ return Path.home() / ".aider.model.metadata.json"
67
+
68
+
69
+ def _legacy_env_path() -> Path:
70
+ return Path.home() / ".env"
71
+
72
+
73
+ def _yaml() -> YAML:
74
+ y = YAML()
75
+ y.preserve_quotes = True
76
+ y.indent(mapping=2, sequence=4, offset=2)
77
+ return y
78
+
79
+
80
+ def _load_yaml(path: Path) -> dict[str, Any]:
81
+ if not path.exists():
82
+ return {}
83
+ result: dict[str, Any] | None = _yaml().load(path.read_text(encoding="utf-8"))
84
+ return result or {}
85
+
86
+
87
+ def _dump_yaml(path: Path, data: dict[str, Any]) -> None:
88
+ buf = io.StringIO()
89
+ _yaml().dump(data, buf)
90
+ path.write_text(buf.getvalue(), encoding="utf-8")
91
+ _restrict_file_mode(path)
92
+
93
+
94
+ def _restrict_file_mode(path: Path) -> None:
95
+ """0600 on POSIX so the inline ``openai-api-key`` isn't world-readable.
96
+
97
+ No-op on Windows (file mode bits aren't POSIX-ish there; ACLs already
98
+ default to per-user under %USERPROFILE%).
99
+ """
100
+ if sys.platform == "win32":
101
+ return
102
+ try:
103
+ os.chmod(path, 0o600)
104
+ except OSError:
105
+ pass
106
+
107
+
108
+ def _has_substrate_marker(payload: dict[str, Any]) -> bool:
109
+ marker = payload.get(MANAGED_KEYS_MARKER)
110
+ if not isinstance(marker, list):
111
+ return False
112
+ return set(MANAGED_KEYS).issubset(set(marker))
113
+
114
+
115
+ def _user_has_meaningful_keys(payload: dict[str, Any]) -> bool:
116
+ for k in MANAGED_KEYS:
117
+ v = payload.get(k)
118
+ if isinstance(v, str) and v.strip():
119
+ return True
120
+ return False
121
+
122
+
123
+ def _pick_default_model(catalog: list[Any]) -> str:
124
+ if not catalog:
125
+ raise ValueError("Cannot pick default model from empty catalog")
126
+ return str(catalog[0].id)
127
+
128
+
129
+ class AiderAgent(Agent):
130
+ name = "aider"
131
+ pretty_name = "Aider"
132
+
133
+ def detect(self) -> Path | None:
134
+ conf = _conf_path()
135
+ return conf if conf.exists() else None
136
+
137
+ def configure(self, ctx: ConfigureContext) -> AgentResult:
138
+ conf = _conf_path()
139
+ backups: list[Path] = []
140
+ payload = _load_yaml(conf)
141
+
142
+ if not _has_substrate_marker(payload) and _user_has_meaningful_keys(payload):
143
+ return AgentResult(
144
+ ResultStatus.ERROR,
145
+ "Aider ~/.aider.conf.yml has user-owned openai-api-base / "
146
+ "openai-api-key / model keys but no substrate-setup marker; "
147
+ "refused to overwrite. To take over, remove those keys (or "
148
+ "the whole file) and re-run substrate-setup.",
149
+ )
150
+
151
+ if conf.exists():
152
+ backup = backup_once(conf)
153
+ if backup is not None:
154
+ backups.append(backup)
155
+
156
+ payload["openai-api-base"] = ctx.base_url
157
+ payload["openai-api-key"] = ctx.api_key
158
+ payload["model"] = _pick_default_model(ctx.catalog)
159
+ payload[MANAGED_KEYS_MARKER] = list(MANAGED_KEYS)
160
+
161
+ if not ctx.dry_run:
162
+ _dump_yaml(conf, payload)
163
+
164
+ return AgentResult(
165
+ ResultStatus.SUCCESS,
166
+ f"Wrote openai-api-base + openai-api-key + model into {conf}.",
167
+ tuple(backups),
168
+ )
169
+
170
+ def verify(self, ctx: ConfigureContext) -> AgentResult:
171
+ conf = _conf_path()
172
+ if not conf.exists():
173
+ return AgentResult(ResultStatus.ERROR, "Aider conf.yml does not exist")
174
+ payload = _load_yaml(conf)
175
+ if not _has_substrate_marker(payload):
176
+ return AgentResult(
177
+ ResultStatus.ERROR,
178
+ "Aider ~/.aider.conf.yml is not managed by substrate-setup "
179
+ "(marker missing)",
180
+ )
181
+
182
+ problems: list[str] = []
183
+ if payload.get("openai-api-base") != ctx.base_url:
184
+ problems.append(
185
+ f"openai-api-base: expected {ctx.base_url!r}, "
186
+ f"got {payload.get('openai-api-base')!r}"
187
+ )
188
+ expected_model = _pick_default_model(ctx.catalog)
189
+ if payload.get("model") != expected_model:
190
+ problems.append(
191
+ f"model: expected {expected_model!r}, got {payload.get('model')!r}"
192
+ )
193
+ api_key = payload.get("openai-api-key")
194
+ if not isinstance(api_key, str) or len(api_key) < 20:
195
+ problems.append("openai-api-key: missing or implausibly short")
196
+
197
+ if problems:
198
+ return AgentResult(ResultStatus.ERROR, "; ".join(problems))
199
+ return AgentResult(ResultStatus.SUCCESS, "in sync")
200
+
201
+ def remove(self, ctx: ConfigureContext) -> AgentResult:
202
+ conf = _conf_path()
203
+ if not conf.exists():
204
+ # Still try to clean up legacy artifacts even if conf.yml is gone.
205
+ cleaned_legacy = self._clean_legacy(ctx)
206
+ if cleaned_legacy:
207
+ return AgentResult(
208
+ ResultStatus.SUCCESS,
209
+ "Removed legacy Aider artifacts (~/.aider.model.metadata.json / ~/.env)",
210
+ (),
211
+ )
212
+ return AgentResult(ResultStatus.SKIPPED, "Aider not configured")
213
+
214
+ payload = _load_yaml(conf)
215
+ if not _has_substrate_marker(payload):
216
+ return AgentResult(
217
+ ResultStatus.SKIPPED,
218
+ "Aider ~/.aider.conf.yml is not managed by substrate-setup; "
219
+ "refusing to delete keys we don't own",
220
+ )
221
+
222
+ backups: list[Path] = []
223
+ backup = backup_once(conf)
224
+ if backup is not None:
225
+ backups.append(backup)
226
+
227
+ for k in MANAGED_KEYS:
228
+ payload.pop(k, None)
229
+ payload.pop(MANAGED_KEYS_MARKER, None)
230
+
231
+ if not ctx.dry_run:
232
+ _dump_yaml(conf, payload)
233
+
234
+ # Also clean up legacy artifacts written by 0.1 (best-effort).
235
+ self._clean_legacy(ctx, backups=backups)
236
+
237
+ return AgentResult(
238
+ ResultStatus.SUCCESS,
239
+ f"Removed substrate-managed keys from {conf}",
240
+ tuple(backups),
241
+ )
242
+
243
+ @staticmethod
244
+ def _clean_legacy(
245
+ ctx: ConfigureContext, *, backups: list[Path] | None = None
246
+ ) -> bool:
247
+ """Delete 0.1 artifacts: ~/.aider.model.metadata.json and substrate
248
+ entries in ~/.env. Returns True if anything was cleaned."""
249
+ if backups is None:
250
+ backups = []
251
+ did_something = False
252
+
253
+ # ~/.aider.model.metadata.json
254
+ legacy_meta = _legacy_meta_path()
255
+ if legacy_meta.exists():
256
+ backup = backup_once(legacy_meta)
257
+ if backup is not None:
258
+ backups.append(backup)
259
+ if not ctx.dry_run:
260
+ legacy_meta.unlink()
261
+ did_something = True
262
+
263
+ # ~/.env: strip the OPENAI_API_KEY line if its value starts with "sk-substrate-".
264
+ legacy_env = _legacy_env_path()
265
+ if legacy_env.exists():
266
+ lines = legacy_env.read_text(encoding="utf-8").splitlines()
267
+ kept: list[str] = []
268
+ stripped = False
269
+ for line in lines:
270
+ if line.startswith("OPENAI_API_KEY="):
271
+ value = line.split("=", 1)[1]
272
+ if value.startswith("sk-substrate-"):
273
+ stripped = True
274
+ continue
275
+ kept.append(line)
276
+ if stripped:
277
+ backup = backup_once(legacy_env)
278
+ if backup is not None:
279
+ backups.append(backup)
280
+ if not ctx.dry_run:
281
+ legacy_env.write_text(
282
+ "\n".join(kept) + ("\n" if kept else ""), encoding="utf-8"
283
+ )
284
+ did_something = True
285
+
286
+ return did_something
@@ -0,0 +1,278 @@
1
+ """Continue (VS Code / JetBrains extension) integration.
2
+
3
+ 0.2.0 writes only ``~/.continue/config.yaml``. The legacy
4
+ ``~/.continue/config.json`` is migrated: if it exists but config.yaml
5
+ doesn't, we read the JSON, copy the user's ``models`` array over, back
6
+ up the JSON to ``config.json.substrate-bak-<ts>``, and write fresh YAML.
7
+
8
+ Fresh config.yaml carries the required top-level fields the Continue
9
+ upstream config schema needs:
10
+
11
+ name: substrate-setup
12
+ version: 0.0.1
13
+ schema: v1
14
+ models: [...]
15
+
16
+ Each substrate-managed model entry:
17
+
18
+ - name: "<model_id>"
19
+ provider: openai
20
+ apiBase: "<base_url>"
21
+ apiKey: "<api_key>"
22
+ model: "<model_id>"
23
+ roles: [chat, edit, apply, summarize]
24
+ _substrate_managed: true
25
+
26
+ The ``_substrate_managed: true`` marker is per-entry (Continue's
27
+ ``models`` is a list of dicts, so each dict can carry its own marker
28
+ without colliding with user state).
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import io
33
+ import json
34
+ import os
35
+ import sys
36
+ import time
37
+ from pathlib import Path
38
+ from typing import Any
39
+
40
+ from ruamel.yaml import YAML
41
+
42
+ from substrate_setup.agents.base import (
43
+ Agent,
44
+ AgentResult,
45
+ ConfigureContext,
46
+ ResultStatus,
47
+ )
48
+ from substrate_setup.backup import backup_once
49
+ from substrate_setup.catalog import CatalogEntry
50
+ from substrate_setup.markers import MARKER_KEY, MARKER_VALUE, is_substrate_managed
51
+
52
+ CONFIG_NAME = "substrate-setup"
53
+ CONFIG_VERSION = "0.0.1"
54
+ CONFIG_SCHEMA = "v1"
55
+ DEFAULT_ROLES = ["chat", "edit", "apply", "summarize"]
56
+
57
+
58
+ def _dir() -> Path:
59
+ return Path.home() / ".continue"
60
+
61
+
62
+ def _yaml_path() -> Path:
63
+ return _dir() / "config.yaml"
64
+
65
+
66
+ def _json_path() -> Path:
67
+ return _dir() / "config.json"
68
+
69
+
70
+ def _yaml() -> YAML:
71
+ y = YAML()
72
+ y.preserve_quotes = True
73
+ y.indent(mapping=2, sequence=4, offset=2)
74
+ return y
75
+
76
+
77
+ def _load_yaml(path: Path) -> dict[str, Any]:
78
+ if not path.exists():
79
+ return {}
80
+ result: dict[str, Any] | None = _yaml().load(path.read_text(encoding="utf-8"))
81
+ return result or {}
82
+
83
+
84
+ def _dump_yaml(path: Path, data: dict[str, Any]) -> None:
85
+ buf = io.StringIO()
86
+ _yaml().dump(data, buf)
87
+ path.write_text(buf.getvalue(), encoding="utf-8")
88
+ _restrict_file_mode(path)
89
+
90
+
91
+ def _restrict_file_mode(path: Path) -> None:
92
+ """0600 on POSIX so per-model inline ``apiKey`` values aren't world-readable.
93
+
94
+ No-op on Windows (file mode bits aren't POSIX-ish there; ACLs already
95
+ default to per-user under %USERPROFILE%).
96
+ """
97
+ if sys.platform == "win32":
98
+ return
99
+ try:
100
+ os.chmod(path, 0o600)
101
+ except OSError:
102
+ pass
103
+
104
+
105
+ def _load_json(path: Path) -> dict[str, Any]:
106
+ if not path.exists():
107
+ return {}
108
+ result: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
109
+ return result
110
+
111
+
112
+ def _model_entry(ctx: ConfigureContext, catalog_id: str) -> dict[str, Any]:
113
+ return {
114
+ "name": catalog_id,
115
+ "provider": "openai", # OpenAI-compatible gateway
116
+ "model": catalog_id,
117
+ "apiBase": ctx.base_url,
118
+ "apiKey": ctx.api_key,
119
+ "roles": list(DEFAULT_ROLES),
120
+ MARKER_KEY: MARKER_VALUE,
121
+ }
122
+
123
+
124
+ def _merge_models(
125
+ existing: list[dict[str, Any]],
126
+ catalog: list[CatalogEntry],
127
+ ctx: ConfigureContext,
128
+ ) -> list[dict[str, Any]]:
129
+ """Marker-respecting merge.
130
+
131
+ - Preserve user-authored entries (no marker).
132
+ - Replace substrate-managed entries to match the current catalog (live path).
133
+ - On fallback path, keep existing substrate-managed entries even if absent
134
+ from the (potentially stale) catalog.
135
+ """
136
+ user_entries = [e for e in existing if not is_substrate_managed(e)]
137
+ existing_substrate_ids = {
138
+ e.get("model") for e in existing if is_substrate_managed(e)
139
+ }
140
+ catalog_ids = {entry.id for entry in catalog}
141
+
142
+ fresh_entries = [_model_entry(ctx, entry.id) for entry in catalog]
143
+
144
+ if ctx.catalog_source == "fallback":
145
+ kept_ids = existing_substrate_ids - catalog_ids
146
+ for old in existing:
147
+ if is_substrate_managed(old) and old.get("model") in kept_ids:
148
+ fresh_entries.append(old)
149
+
150
+ return user_entries + fresh_entries
151
+
152
+
153
+ def _ensure_required_top_level(payload: dict[str, Any]) -> dict[str, Any]:
154
+ """Make sure name/version/schema are set; populate from defaults if missing."""
155
+ payload.setdefault("name", CONFIG_NAME)
156
+ payload.setdefault("version", CONFIG_VERSION)
157
+ payload.setdefault("schema", CONFIG_SCHEMA)
158
+ return payload
159
+
160
+
161
+ def _migrate_legacy_json(json_path: Path, yaml_path: Path) -> list[Path]:
162
+ """Migrate config.json → config.yaml. Returns the list of backup paths created.
163
+
164
+ Strategy:
165
+ - Read JSON.
166
+ - Copy its ``models`` array (and any other top-level keys) onto a fresh
167
+ YAML payload with the required ``name``/``version``/``schema`` fields.
168
+ - Write YAML.
169
+ - Backup JSON to ``config.json.substrate-bak-<ts>``.
170
+ """
171
+ backups: list[Path] = []
172
+ legacy = _load_json(json_path)
173
+
174
+ # Carry over the user's data wholesale. We'll fill in required fields below.
175
+ yaml_payload: dict[str, Any] = dict(legacy)
176
+ _ensure_required_top_level(yaml_payload)
177
+ yaml_payload.setdefault("models", [])
178
+ # Leave a breadcrumb so future readers know what happened.
179
+ yaml_payload["_substrate_migration_note"] = (
180
+ "migrated from config.json by substrate-setup"
181
+ )
182
+
183
+ # Back up the legacy file. Use a stable suffix so detect/cleanup logic can
184
+ # find it if needed.
185
+ ts = int(time.time())
186
+ backup_path = json_path.parent / f"{json_path.name}.substrate-bak-{ts}"
187
+ backup_path.write_text(json_path.read_text(encoding="utf-8"), encoding="utf-8")
188
+ backups.append(backup_path)
189
+
190
+ yaml_path.parent.mkdir(parents=True, exist_ok=True)
191
+ _dump_yaml(yaml_path, yaml_payload)
192
+
193
+ return backups
194
+
195
+
196
+ class ContinueAgent(Agent):
197
+ name = "continue"
198
+ pretty_name = "Continue"
199
+
200
+ def detect(self) -> Path | None:
201
+ if _yaml_path().exists():
202
+ return _yaml_path()
203
+ if _json_path().exists():
204
+ # Continue is installed (legacy JSON); the configure() call will
205
+ # migrate it to YAML.
206
+ return _yaml_path()
207
+ return None
208
+
209
+ def configure(self, ctx: ConfigureContext) -> AgentResult:
210
+ _dir().mkdir(parents=True, exist_ok=True)
211
+ backups: list[Path] = []
212
+ notes: list[str] = []
213
+
214
+ # ── migration: only-JSON → write YAML and back up JSON ───────
215
+ if _json_path().exists() and not _yaml_path().exists():
216
+ migration_backups = _migrate_legacy_json(_json_path(), _yaml_path())
217
+ backups.extend(migration_backups)
218
+ notes.append(
219
+ "Migrated legacy ~/.continue/config.json to config.yaml "
220
+ f"(backup: {migration_backups[0].name})."
221
+ )
222
+
223
+ yaml_path = _yaml_path()
224
+ if yaml_path.exists():
225
+ backup = backup_once(yaml_path)
226
+ if backup is not None:
227
+ backups.append(backup)
228
+
229
+ payload = _load_yaml(yaml_path)
230
+ payload = _ensure_required_top_level(payload)
231
+
232
+ existing_models = payload.get("models") or []
233
+ merged = _merge_models(existing_models, ctx.catalog, ctx)
234
+ payload["models"] = merged
235
+
236
+ if not ctx.dry_run:
237
+ _dump_yaml(yaml_path, payload)
238
+
239
+ msg = f"{len(ctx.catalog)} models written, API base + key set in {yaml_path}."
240
+ if notes:
241
+ msg = "\n".join(notes) + "\n" + msg
242
+ return AgentResult(ResultStatus.SUCCESS, msg, tuple(backups))
243
+
244
+ def verify(self, ctx: ConfigureContext) -> AgentResult:
245
+ if not _yaml_path().exists():
246
+ return AgentResult(ResultStatus.ERROR, "Continue config.yaml not present")
247
+ payload = _load_yaml(_yaml_path())
248
+ substrate_ids = {
249
+ m.get("model") for m in (payload.get("models") or [])
250
+ if is_substrate_managed(m)
251
+ }
252
+ catalog_ids = {e.id for e in ctx.catalog}
253
+ missing = catalog_ids - substrate_ids
254
+ stale = substrate_ids - catalog_ids
255
+ if missing or stale:
256
+ parts = []
257
+ if missing:
258
+ parts.append(f"missing: {sorted(missing)}")
259
+ if stale:
260
+ parts.append(f"stale: {sorted(stale)}")
261
+ return AgentResult(ResultStatus.ERROR, "; ".join(parts))
262
+ return AgentResult(ResultStatus.SUCCESS, "in sync")
263
+
264
+ def remove(self, ctx: ConfigureContext) -> AgentResult:
265
+ if not _yaml_path().exists():
266
+ return AgentResult(ResultStatus.SKIPPED, "Continue not installed")
267
+ backup = backup_once(_yaml_path())
268
+ payload = _load_yaml(_yaml_path())
269
+ payload["models"] = [
270
+ m for m in (payload.get("models") or []) if not is_substrate_managed(m)
271
+ ]
272
+ if not ctx.dry_run:
273
+ _dump_yaml(_yaml_path(), payload)
274
+ return AgentResult(
275
+ ResultStatus.SUCCESS,
276
+ "Removed substrate model entries from Continue",
277
+ (backup,) if backup else (),
278
+ )