substrate-setup 0.1.0__tar.gz → 0.2.1__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.1.0 → substrate_setup-0.2.1}/.gitignore +3 -0
- {substrate_setup-0.1.0 → substrate_setup-0.2.1}/PKG-INFO +14 -1
- {substrate_setup-0.1.0 → substrate_setup-0.2.1}/pyproject.toml +18 -1
- {substrate_setup-0.1.0 → substrate_setup-0.2.1}/substrate_setup/__init__.py +1 -1
- substrate_setup-0.2.1/substrate_setup/agents/aider.py +316 -0
- substrate_setup-0.2.1/substrate_setup/agents/continue_dev.py +278 -0
- {substrate_setup-0.1.0 → substrate_setup-0.2.1}/substrate_setup/agents/cursor.py +5 -5
- substrate_setup-0.2.1/substrate_setup/agents/hermes.py +388 -0
- {substrate_setup-0.1.0 → substrate_setup-0.2.1}/substrate_setup/cli.py +19 -5
- {substrate_setup-0.1.0 → substrate_setup-0.2.1}/substrate_setup/credentials.py +1 -1
- {substrate_setup-0.1.0 → substrate_setup-0.2.1}/substrate_setup/data/fallback_catalog.json +42 -0
- substrate_setup-0.2.1/tests/agents/test_aider.py +278 -0
- {substrate_setup-0.1.0 → substrate_setup-0.2.1}/tests/agents/test_continue_dev.py +230 -166
- substrate_setup-0.2.1/tests/agents/test_hermes.py +498 -0
- {substrate_setup-0.1.0 → substrate_setup-0.2.1}/tests/test_cli.py +67 -5
- {substrate_setup-0.1.0 → substrate_setup-0.2.1}/tests/test_e2e.py +3 -2
- substrate_setup-0.2.1/uv.lock +341 -0
- substrate_setup-0.1.0/substrate_setup/agents/aider.py +0 -250
- substrate_setup-0.1.0/substrate_setup/agents/continue_dev.py +0 -194
- substrate_setup-0.1.0/substrate_setup/agents/hermes.py +0 -259
- substrate_setup-0.1.0/tests/agents/test_aider.py +0 -173
- substrate_setup-0.1.0/tests/agents/test_hermes.py +0 -234
- {substrate_setup-0.1.0 → substrate_setup-0.2.1}/README.md +0 -0
- {substrate_setup-0.1.0 → substrate_setup-0.2.1}/scripts/lint_no_app_import.sh +0 -0
- {substrate_setup-0.1.0 → substrate_setup-0.2.1}/scripts/regenerate_fallback_catalog.py +0 -0
- {substrate_setup-0.1.0 → substrate_setup-0.2.1}/substrate_setup/__main__.py +0 -0
- {substrate_setup-0.1.0 → substrate_setup-0.2.1}/substrate_setup/agents/__init__.py +0 -0
- {substrate_setup-0.1.0 → substrate_setup-0.2.1}/substrate_setup/agents/base.py +0 -0
- {substrate_setup-0.1.0 → substrate_setup-0.2.1}/substrate_setup/backup.py +0 -0
- {substrate_setup-0.1.0 → substrate_setup-0.2.1}/substrate_setup/catalog.py +0 -0
- {substrate_setup-0.1.0 → substrate_setup-0.2.1}/substrate_setup/markers.py +0 -0
- {substrate_setup-0.1.0 → substrate_setup-0.2.1}/tests/__init__.py +0 -0
- {substrate_setup-0.1.0 → substrate_setup-0.2.1}/tests/agents/__init__.py +0 -0
- {substrate_setup-0.1.0 → substrate_setup-0.2.1}/tests/agents/test_cursor.py +0 -0
- {substrate_setup-0.1.0 → substrate_setup-0.2.1}/tests/conftest.py +0 -0
- {substrate_setup-0.1.0 → substrate_setup-0.2.1}/tests/test_backup.py +0 -0
- {substrate_setup-0.1.0 → substrate_setup-0.2.1}/tests/test_catalog.py +0 -0
- {substrate_setup-0.1.0 → substrate_setup-0.2.1}/tests/test_credentials.py +0 -0
- {substrate_setup-0.1.0 → substrate_setup-0.2.1}/tests/test_markers.py +0 -0
|
@@ -1,9 +1,22 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: substrate-setup
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.2.1
|
|
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
|
|
7
|
+
version = "0.2.1"
|
|
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",
|
|
@@ -0,0 +1,316 @@
|
|
|
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 _existing_matches_substrate_shape(
|
|
124
|
+
payload: dict[str, Any], ctx: ConfigureContext
|
|
125
|
+
) -> bool:
|
|
126
|
+
"""True if existing top-level values look substrate-shaped already.
|
|
127
|
+
|
|
128
|
+
Specifically:
|
|
129
|
+
- openai-api-base matches ctx.base_url
|
|
130
|
+
- openai-api-key starts with "sk-substrate-" (the user may have
|
|
131
|
+
rotated keys since, so we don't require an exact match)
|
|
132
|
+
|
|
133
|
+
Lets us silently take ownership of configs that were either manually
|
|
134
|
+
configured to match our schema or written by an earlier substrate-setup
|
|
135
|
+
version that didn't drop the marker. Without this auto-claim, every
|
|
136
|
+
user upgrading from 0.1 (or who manually fixed their config) sees a
|
|
137
|
+
refusal ERROR on first 0.2.x run.
|
|
138
|
+
"""
|
|
139
|
+
if payload.get("openai-api-base") != ctx.base_url:
|
|
140
|
+
return False
|
|
141
|
+
api_key = payload.get("openai-api-key")
|
|
142
|
+
if not isinstance(api_key, str) or not api_key.startswith("sk-substrate-"):
|
|
143
|
+
return False
|
|
144
|
+
return True
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _pick_default_model(catalog: list[Any]) -> str:
|
|
148
|
+
if not catalog:
|
|
149
|
+
raise ValueError("Cannot pick default model from empty catalog")
|
|
150
|
+
return str(catalog[0].id)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class AiderAgent(Agent):
|
|
154
|
+
name = "aider"
|
|
155
|
+
pretty_name = "Aider"
|
|
156
|
+
|
|
157
|
+
def detect(self) -> Path | None:
|
|
158
|
+
conf = _conf_path()
|
|
159
|
+
return conf if conf.exists() else None
|
|
160
|
+
|
|
161
|
+
def configure(self, ctx: ConfigureContext) -> AgentResult:
|
|
162
|
+
conf = _conf_path()
|
|
163
|
+
backups: list[Path] = []
|
|
164
|
+
payload = _load_yaml(conf)
|
|
165
|
+
|
|
166
|
+
if not _has_substrate_marker(payload) and _user_has_meaningful_keys(payload):
|
|
167
|
+
if _existing_matches_substrate_shape(payload, ctx):
|
|
168
|
+
# Shape already matches us -- silently take ownership.
|
|
169
|
+
# Fall through to the normal write below, which adds the
|
|
170
|
+
# marker and refreshes our 3 keys.
|
|
171
|
+
pass
|
|
172
|
+
else:
|
|
173
|
+
return AgentResult(
|
|
174
|
+
ResultStatus.ERROR,
|
|
175
|
+
"Aider ~/.aider.conf.yml has user-owned openai-api-base / "
|
|
176
|
+
"openai-api-key / model keys but no substrate-setup marker; "
|
|
177
|
+
"refused to overwrite. To take over, remove those keys (or "
|
|
178
|
+
"the whole file) and re-run substrate-setup.",
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if conf.exists():
|
|
182
|
+
backup = backup_once(conf)
|
|
183
|
+
if backup is not None:
|
|
184
|
+
backups.append(backup)
|
|
185
|
+
|
|
186
|
+
payload["openai-api-base"] = ctx.base_url
|
|
187
|
+
payload["openai-api-key"] = ctx.api_key
|
|
188
|
+
payload["model"] = _pick_default_model(ctx.catalog)
|
|
189
|
+
payload[MANAGED_KEYS_MARKER] = list(MANAGED_KEYS)
|
|
190
|
+
|
|
191
|
+
if not ctx.dry_run:
|
|
192
|
+
_dump_yaml(conf, payload)
|
|
193
|
+
|
|
194
|
+
return AgentResult(
|
|
195
|
+
ResultStatus.SUCCESS,
|
|
196
|
+
f"Wrote openai-api-base + openai-api-key + model into {conf}.",
|
|
197
|
+
tuple(backups),
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
def verify(self, ctx: ConfigureContext) -> AgentResult:
|
|
201
|
+
conf = _conf_path()
|
|
202
|
+
if not conf.exists():
|
|
203
|
+
return AgentResult(ResultStatus.ERROR, "Aider conf.yml does not exist")
|
|
204
|
+
payload = _load_yaml(conf)
|
|
205
|
+
if not _has_substrate_marker(payload):
|
|
206
|
+
return AgentResult(
|
|
207
|
+
ResultStatus.ERROR,
|
|
208
|
+
"Aider ~/.aider.conf.yml is not managed by substrate-setup "
|
|
209
|
+
"(marker missing)",
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
problems: list[str] = []
|
|
213
|
+
if payload.get("openai-api-base") != ctx.base_url:
|
|
214
|
+
problems.append(
|
|
215
|
+
f"openai-api-base: expected {ctx.base_url!r}, "
|
|
216
|
+
f"got {payload.get('openai-api-base')!r}"
|
|
217
|
+
)
|
|
218
|
+
expected_model = _pick_default_model(ctx.catalog)
|
|
219
|
+
if payload.get("model") != expected_model:
|
|
220
|
+
problems.append(
|
|
221
|
+
f"model: expected {expected_model!r}, got {payload.get('model')!r}"
|
|
222
|
+
)
|
|
223
|
+
api_key = payload.get("openai-api-key")
|
|
224
|
+
if not isinstance(api_key, str) or len(api_key) < 20:
|
|
225
|
+
problems.append("openai-api-key: missing or implausibly short")
|
|
226
|
+
|
|
227
|
+
if problems:
|
|
228
|
+
return AgentResult(ResultStatus.ERROR, "; ".join(problems))
|
|
229
|
+
return AgentResult(ResultStatus.SUCCESS, "in sync")
|
|
230
|
+
|
|
231
|
+
def remove(self, ctx: ConfigureContext) -> AgentResult:
|
|
232
|
+
conf = _conf_path()
|
|
233
|
+
if not conf.exists():
|
|
234
|
+
# Still try to clean up legacy artifacts even if conf.yml is gone.
|
|
235
|
+
cleaned_legacy = self._clean_legacy(ctx)
|
|
236
|
+
if cleaned_legacy:
|
|
237
|
+
return AgentResult(
|
|
238
|
+
ResultStatus.SUCCESS,
|
|
239
|
+
"Removed legacy Aider artifacts (~/.aider.model.metadata.json / ~/.env)",
|
|
240
|
+
(),
|
|
241
|
+
)
|
|
242
|
+
return AgentResult(ResultStatus.SKIPPED, "Aider not configured")
|
|
243
|
+
|
|
244
|
+
payload = _load_yaml(conf)
|
|
245
|
+
if not _has_substrate_marker(payload):
|
|
246
|
+
return AgentResult(
|
|
247
|
+
ResultStatus.SKIPPED,
|
|
248
|
+
"Aider ~/.aider.conf.yml is not managed by substrate-setup; "
|
|
249
|
+
"refusing to delete keys we don't own",
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
backups: list[Path] = []
|
|
253
|
+
backup = backup_once(conf)
|
|
254
|
+
if backup is not None:
|
|
255
|
+
backups.append(backup)
|
|
256
|
+
|
|
257
|
+
for k in MANAGED_KEYS:
|
|
258
|
+
payload.pop(k, None)
|
|
259
|
+
payload.pop(MANAGED_KEYS_MARKER, None)
|
|
260
|
+
|
|
261
|
+
if not ctx.dry_run:
|
|
262
|
+
_dump_yaml(conf, payload)
|
|
263
|
+
|
|
264
|
+
# Also clean up legacy artifacts written by 0.1 (best-effort).
|
|
265
|
+
self._clean_legacy(ctx, backups=backups)
|
|
266
|
+
|
|
267
|
+
return AgentResult(
|
|
268
|
+
ResultStatus.SUCCESS,
|
|
269
|
+
f"Removed substrate-managed keys from {conf}",
|
|
270
|
+
tuple(backups),
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
@staticmethod
|
|
274
|
+
def _clean_legacy(
|
|
275
|
+
ctx: ConfigureContext, *, backups: list[Path] | None = None
|
|
276
|
+
) -> bool:
|
|
277
|
+
"""Delete 0.1 artifacts: ~/.aider.model.metadata.json and substrate
|
|
278
|
+
entries in ~/.env. Returns True if anything was cleaned."""
|
|
279
|
+
if backups is None:
|
|
280
|
+
backups = []
|
|
281
|
+
did_something = False
|
|
282
|
+
|
|
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
|
+
# ~/.env: strip the OPENAI_API_KEY line if its value starts with "sk-substrate-".
|
|
294
|
+
legacy_env = _legacy_env_path()
|
|
295
|
+
if legacy_env.exists():
|
|
296
|
+
lines = legacy_env.read_text(encoding="utf-8").splitlines()
|
|
297
|
+
kept: list[str] = []
|
|
298
|
+
stripped = False
|
|
299
|
+
for line in lines:
|
|
300
|
+
if line.startswith("OPENAI_API_KEY="):
|
|
301
|
+
value = line.split("=", 1)[1]
|
|
302
|
+
if value.startswith("sk-substrate-"):
|
|
303
|
+
stripped = True
|
|
304
|
+
continue
|
|
305
|
+
kept.append(line)
|
|
306
|
+
if stripped:
|
|
307
|
+
backup = backup_once(legacy_env)
|
|
308
|
+
if backup is not None:
|
|
309
|
+
backups.append(backup)
|
|
310
|
+
if not ctx.dry_run:
|
|
311
|
+
legacy_env.write_text(
|
|
312
|
+
"\n".join(kept) + ("\n" if kept else ""), encoding="utf-8"
|
|
313
|
+
)
|
|
314
|
+
did_something = True
|
|
315
|
+
|
|
316
|
+
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
|
+
)
|