substrate-setup 0.1.0__py3-none-any.whl
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/__init__.py +6 -0
- substrate_setup/__main__.py +5 -0
- substrate_setup/agents/__init__.py +20 -0
- substrate_setup/agents/aider.py +250 -0
- substrate_setup/agents/base.py +56 -0
- substrate_setup/agents/continue_dev.py +194 -0
- substrate_setup/agents/cursor.py +90 -0
- substrate_setup/agents/hermes.py +259 -0
- substrate_setup/backup.py +32 -0
- substrate_setup/catalog.py +109 -0
- substrate_setup/cli.py +211 -0
- substrate_setup/credentials.py +82 -0
- substrate_setup/data/fallback_catalog.json +301 -0
- substrate_setup/markers.py +27 -0
- substrate_setup-0.1.0.dist-info/METADATA +27 -0
- substrate_setup-0.1.0.dist-info/RECORD +18 -0
- substrate_setup-0.1.0.dist-info/WHEEL +4 -0
- substrate_setup-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Agent registry. Imports each agent module; exposes the list.
|
|
2
|
+
|
|
3
|
+
Order of this list is the order they print in the summary.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from substrate_setup.agents.aider import AiderAgent
|
|
8
|
+
from substrate_setup.agents.base import Agent
|
|
9
|
+
from substrate_setup.agents.continue_dev import ContinueAgent
|
|
10
|
+
from substrate_setup.agents.cursor import CursorAgent
|
|
11
|
+
from substrate_setup.agents.hermes import HermesAgent
|
|
12
|
+
|
|
13
|
+
ALL_AGENTS: list[Agent] = [
|
|
14
|
+
HermesAgent(),
|
|
15
|
+
CursorAgent(),
|
|
16
|
+
AiderAgent(),
|
|
17
|
+
ContinueAgent(),
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
AGENT_NAMES: list[str] = [a.name for a in ALL_AGENTS]
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Aider integration.
|
|
2
|
+
|
|
3
|
+
Files:
|
|
4
|
+
~/.aider.conf.yml — openai-api-base + model-metadata-file pointer
|
|
5
|
+
~/.aider.model.metadata.json — LiteLLM-shaped per-model metadata
|
|
6
|
+
~/.env — OPENAI_API_KEY
|
|
7
|
+
|
|
8
|
+
NEVER touches project-local .aider.conf.yml, .aider.model.metadata.json,
|
|
9
|
+
or .env files.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import io
|
|
14
|
+
import json
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from ruamel.yaml import YAML
|
|
19
|
+
|
|
20
|
+
from substrate_setup.agents.base import (
|
|
21
|
+
Agent,
|
|
22
|
+
AgentResult,
|
|
23
|
+
ConfigureContext,
|
|
24
|
+
ResultStatus,
|
|
25
|
+
)
|
|
26
|
+
from substrate_setup.backup import backup_once
|
|
27
|
+
from substrate_setup.markers import MARKER_KEY, MARKER_VALUE, is_substrate_managed
|
|
28
|
+
|
|
29
|
+
OPENAI_KEY_VAR = "OPENAI_API_KEY"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _conf_path() -> Path:
|
|
33
|
+
return Path.home() / ".aider.conf.yml"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _meta_path() -> Path:
|
|
37
|
+
return Path.home() / ".aider.model.metadata.json"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _env_path() -> Path:
|
|
41
|
+
return Path.home() / ".env"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _yaml() -> YAML:
|
|
45
|
+
y = YAML()
|
|
46
|
+
y.preserve_quotes = True
|
|
47
|
+
y.indent(mapping=2, sequence=4, offset=2)
|
|
48
|
+
return y
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _load_yaml(path: Path) -> dict[str, Any]:
|
|
52
|
+
if not path.exists():
|
|
53
|
+
return {}
|
|
54
|
+
result: dict[str, Any] = _yaml().load(path.read_text(encoding="utf-8")) or {}
|
|
55
|
+
return result
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _dump_yaml(path: Path, data: dict[str, Any]) -> None:
|
|
59
|
+
buf = io.StringIO()
|
|
60
|
+
_yaml().dump(data, buf)
|
|
61
|
+
path.write_text(buf.getvalue(), encoding="utf-8")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _load_meta(path: Path) -> dict[str, Any]:
|
|
65
|
+
if not path.exists():
|
|
66
|
+
return {}
|
|
67
|
+
result: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
|
|
68
|
+
return result
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _dump_meta(path: Path, data: dict[str, Any]) -> None:
|
|
72
|
+
path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _model_id_for_aider(catalog_id: str) -> str:
|
|
76
|
+
"""LiteLLM needs `openai/` prefix to route through OPENAI_API_BASE."""
|
|
77
|
+
return f"openai/{catalog_id}"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _metadata_for_entry() -> dict[str, Any]:
|
|
81
|
+
return {
|
|
82
|
+
"max_input_tokens": 200000,
|
|
83
|
+
"max_output_tokens": 8192,
|
|
84
|
+
"input_cost_per_token": 0.0, # Cost tracking lives in the gateway, not in Aider.
|
|
85
|
+
"output_cost_per_token": 0.0,
|
|
86
|
+
"litellm_provider": "openai",
|
|
87
|
+
"mode": "chat",
|
|
88
|
+
MARKER_KEY: MARKER_VALUE,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _read_env_lines(path: Path) -> list[str]:
|
|
93
|
+
if not path.exists():
|
|
94
|
+
return []
|
|
95
|
+
return path.read_text(encoding="utf-8").splitlines()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _write_env_lines(path: Path, lines: list[str]) -> None:
|
|
99
|
+
path.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class AiderAgent(Agent):
|
|
103
|
+
name = "aider"
|
|
104
|
+
pretty_name = "Aider"
|
|
105
|
+
|
|
106
|
+
def detect(self) -> Path | None:
|
|
107
|
+
conf = _conf_path()
|
|
108
|
+
return conf if conf.exists() else None
|
|
109
|
+
|
|
110
|
+
def configure(self, ctx: ConfigureContext) -> AgentResult:
|
|
111
|
+
backups: list[Path] = []
|
|
112
|
+
notes: list[str] = []
|
|
113
|
+
|
|
114
|
+
# ── ~/.aider.conf.yml ────────────────────────────────────────
|
|
115
|
+
conf = _conf_path()
|
|
116
|
+
if conf.exists():
|
|
117
|
+
backup = backup_once(conf)
|
|
118
|
+
if backup is not None:
|
|
119
|
+
backups.append(backup)
|
|
120
|
+
conf_data = _load_yaml(conf)
|
|
121
|
+
conf_data["openai-api-base"] = ctx.base_url
|
|
122
|
+
conf_data["model-metadata-file"] = str(_meta_path())
|
|
123
|
+
if not ctx.dry_run:
|
|
124
|
+
_dump_yaml(conf, conf_data)
|
|
125
|
+
|
|
126
|
+
# ── ~/.aider.model.metadata.json ─────────────────────────────
|
|
127
|
+
meta = _meta_path()
|
|
128
|
+
if meta.exists():
|
|
129
|
+
backup = backup_once(meta)
|
|
130
|
+
if backup is not None:
|
|
131
|
+
backups.append(backup)
|
|
132
|
+
meta_data = _load_meta(meta)
|
|
133
|
+
catalog_keys = {_model_id_for_aider(e.id) for e in ctx.catalog}
|
|
134
|
+
|
|
135
|
+
# Drop substrate-managed entries that are no longer in the live catalog.
|
|
136
|
+
if ctx.catalog_source == "live":
|
|
137
|
+
meta_data = {
|
|
138
|
+
k: v for k, v in meta_data.items()
|
|
139
|
+
if not is_substrate_managed(v) or k in catalog_keys
|
|
140
|
+
}
|
|
141
|
+
# Add / update from current catalog.
|
|
142
|
+
for entry in ctx.catalog:
|
|
143
|
+
meta_data[_model_id_for_aider(entry.id)] = _metadata_for_entry()
|
|
144
|
+
|
|
145
|
+
if not ctx.dry_run:
|
|
146
|
+
_dump_meta(meta, meta_data)
|
|
147
|
+
|
|
148
|
+
# ── ~/.env (credential) ──────────────────────────────────────
|
|
149
|
+
env = _env_path()
|
|
150
|
+
lines = _read_env_lines(env)
|
|
151
|
+
existing_value = None
|
|
152
|
+
for line in lines:
|
|
153
|
+
if line.startswith(f"{OPENAI_KEY_VAR}="):
|
|
154
|
+
existing_value = line.split("=", 1)[1]
|
|
155
|
+
break
|
|
156
|
+
should_write_env = False
|
|
157
|
+
new_lines = list(lines)
|
|
158
|
+
if existing_value is None:
|
|
159
|
+
new_lines.append(f"{OPENAI_KEY_VAR}={ctx.api_key}")
|
|
160
|
+
should_write_env = True
|
|
161
|
+
elif existing_value.startswith("sk-substrate-"):
|
|
162
|
+
if existing_value != ctx.api_key:
|
|
163
|
+
new_lines = [
|
|
164
|
+
f"{OPENAI_KEY_VAR}={ctx.api_key}"
|
|
165
|
+
if line.startswith(f"{OPENAI_KEY_VAR}=")
|
|
166
|
+
else line
|
|
167
|
+
for line in lines
|
|
168
|
+
]
|
|
169
|
+
should_write_env = True
|
|
170
|
+
else:
|
|
171
|
+
notes.append(
|
|
172
|
+
"Aider's ~/.env already has OPENAI_API_KEY set to a non-Substrate-shaped "
|
|
173
|
+
"value; please update manually."
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
if should_write_env:
|
|
177
|
+
if env.exists():
|
|
178
|
+
env_backup = backup_once(env)
|
|
179
|
+
if env_backup is not None:
|
|
180
|
+
backups.append(env_backup)
|
|
181
|
+
if not ctx.dry_run:
|
|
182
|
+
_write_env_lines(env, new_lines)
|
|
183
|
+
|
|
184
|
+
msg = (
|
|
185
|
+
f"{len(ctx.catalog)} models written. Conf: ~/.aider.conf.yml, "
|
|
186
|
+
"metadata: ~/.aider.model.metadata.json, key in ~/.env."
|
|
187
|
+
)
|
|
188
|
+
if notes:
|
|
189
|
+
msg += "\n" + "\n".join(notes)
|
|
190
|
+
return AgentResult(ResultStatus.SUCCESS, msg, tuple(backups))
|
|
191
|
+
|
|
192
|
+
def verify(self, ctx: ConfigureContext) -> AgentResult:
|
|
193
|
+
meta = _meta_path()
|
|
194
|
+
if not meta.exists():
|
|
195
|
+
return AgentResult(ResultStatus.ERROR, "Aider metadata file does not exist")
|
|
196
|
+
meta_data = _load_meta(meta)
|
|
197
|
+
catalog_keys = {_model_id_for_aider(e.id) for e in ctx.catalog}
|
|
198
|
+
substrate_keys = {
|
|
199
|
+
k for k, v in meta_data.items() if is_substrate_managed(v)
|
|
200
|
+
}
|
|
201
|
+
missing = catalog_keys - substrate_keys
|
|
202
|
+
stale = substrate_keys - catalog_keys
|
|
203
|
+
if missing or stale:
|
|
204
|
+
parts = []
|
|
205
|
+
if missing:
|
|
206
|
+
parts.append(f"missing: {sorted(missing)}")
|
|
207
|
+
if stale:
|
|
208
|
+
parts.append(f"stale: {sorted(stale)}")
|
|
209
|
+
return AgentResult(ResultStatus.ERROR, "; ".join(parts))
|
|
210
|
+
return AgentResult(ResultStatus.SUCCESS, "in sync")
|
|
211
|
+
|
|
212
|
+
def remove(self, ctx: ConfigureContext) -> AgentResult:
|
|
213
|
+
meta = _meta_path()
|
|
214
|
+
backups: list[Path] = []
|
|
215
|
+
if meta.exists():
|
|
216
|
+
backup = backup_once(meta)
|
|
217
|
+
if backup is not None:
|
|
218
|
+
backups.append(backup)
|
|
219
|
+
meta_data = _load_meta(meta)
|
|
220
|
+
meta_data = {
|
|
221
|
+
k: v for k, v in meta_data.items() if not is_substrate_managed(v)
|
|
222
|
+
}
|
|
223
|
+
if not ctx.dry_run:
|
|
224
|
+
_dump_meta(meta, meta_data)
|
|
225
|
+
|
|
226
|
+
# Strip OPENAI_API_KEY from ~/.env only if its value is currently a Substrate key.
|
|
227
|
+
env = _env_path()
|
|
228
|
+
if env.exists():
|
|
229
|
+
lines = _read_env_lines(env)
|
|
230
|
+
kept = []
|
|
231
|
+
stripped = False
|
|
232
|
+
for line in lines:
|
|
233
|
+
if line.startswith(f"{OPENAI_KEY_VAR}="):
|
|
234
|
+
value = line.split("=", 1)[1]
|
|
235
|
+
if value.startswith("sk-substrate-"):
|
|
236
|
+
stripped = True
|
|
237
|
+
continue
|
|
238
|
+
kept.append(line)
|
|
239
|
+
if stripped:
|
|
240
|
+
env_backup = backup_once(env)
|
|
241
|
+
if env_backup is not None:
|
|
242
|
+
backups.append(env_backup)
|
|
243
|
+
if not ctx.dry_run:
|
|
244
|
+
_write_env_lines(env, kept)
|
|
245
|
+
|
|
246
|
+
return AgentResult(
|
|
247
|
+
ResultStatus.SUCCESS,
|
|
248
|
+
"Removed substrate metadata entries from Aider",
|
|
249
|
+
tuple(backups),
|
|
250
|
+
)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Abstract base class + result type for agent handlers.
|
|
2
|
+
|
|
3
|
+
All agent handlers (hermes, cursor, aider, continue_dev) implement this
|
|
4
|
+
interface. The orchestrator in cli.py treats them polymorphically.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from enum import StrEnum
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from substrate_setup.catalog import CatalogEntry
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ResultStatus(StrEnum):
|
|
17
|
+
SUCCESS = "success"
|
|
18
|
+
ERROR = "error"
|
|
19
|
+
SKIPPED = "skipped" # agent not installed
|
|
20
|
+
PRINT_ONLY = "print_only" # for Cursor — handled, but no file write
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class AgentResult:
|
|
25
|
+
status: ResultStatus
|
|
26
|
+
message: str
|
|
27
|
+
backup_paths: tuple[Path, ...] = field(default_factory=tuple)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class ConfigureContext:
|
|
32
|
+
base_url: str
|
|
33
|
+
api_key: str
|
|
34
|
+
catalog: list[CatalogEntry]
|
|
35
|
+
catalog_source: str # "live" or "fallback"
|
|
36
|
+
dry_run: bool
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Agent(ABC):
|
|
40
|
+
"""Each coding-agent integration implements this."""
|
|
41
|
+
|
|
42
|
+
name: str # short id, e.g. "cursor"
|
|
43
|
+
pretty_name: str # display name, e.g. "Cursor"
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def detect(self) -> Path | None:
|
|
47
|
+
"""Return the primary path the agent would touch, or None if not installed."""
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def configure(self, ctx: ConfigureContext) -> AgentResult: ...
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def verify(self, ctx: ConfigureContext) -> AgentResult: ...
|
|
54
|
+
|
|
55
|
+
@abstractmethod
|
|
56
|
+
def remove(self, ctx: ConfigureContext) -> AgentResult: ...
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Continue (VS Code / JetBrains extension) integration.
|
|
2
|
+
|
|
3
|
+
Files (current default is YAML, JSON is deprecated):
|
|
4
|
+
~/.continue/config.yaml — preferred
|
|
5
|
+
~/.continue/config.json — legacy fallback; ignored by Continue if YAML present
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import io
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from ruamel.yaml import YAML
|
|
15
|
+
|
|
16
|
+
from substrate_setup.agents.base import (
|
|
17
|
+
Agent,
|
|
18
|
+
AgentResult,
|
|
19
|
+
ConfigureContext,
|
|
20
|
+
ResultStatus,
|
|
21
|
+
)
|
|
22
|
+
from substrate_setup.backup import backup_once
|
|
23
|
+
from substrate_setup.catalog import CatalogEntry
|
|
24
|
+
from substrate_setup.markers import MARKER_KEY, MARKER_VALUE, is_substrate_managed
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _dir() -> Path:
|
|
28
|
+
return Path.home() / ".continue"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _yaml_path() -> Path:
|
|
32
|
+
return _dir() / "config.yaml"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _json_path() -> Path:
|
|
36
|
+
return _dir() / "config.json"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _yaml() -> YAML:
|
|
40
|
+
y = YAML()
|
|
41
|
+
y.preserve_quotes = True
|
|
42
|
+
y.indent(mapping=2, sequence=4, offset=2)
|
|
43
|
+
return y
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _model_entry(ctx: ConfigureContext, catalog_id: str, display: str) -> dict[str, Any]:
|
|
47
|
+
return {
|
|
48
|
+
"name": display,
|
|
49
|
+
"provider": "openai", # documented form for custom OpenAI-compat gateways
|
|
50
|
+
"model": catalog_id,
|
|
51
|
+
"apiBase": ctx.base_url,
|
|
52
|
+
"apiKey": ctx.api_key,
|
|
53
|
+
"roles": ["chat", "edit", "apply"],
|
|
54
|
+
MARKER_KEY: MARKER_VALUE,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _choose_path() -> tuple[Path, str]:
|
|
59
|
+
"""Return (path, format-tag) where format-tag is 'yaml' or 'json'."""
|
|
60
|
+
if _yaml_path().exists():
|
|
61
|
+
return _yaml_path(), "yaml"
|
|
62
|
+
if _json_path().exists():
|
|
63
|
+
return _json_path(), "json"
|
|
64
|
+
return _yaml_path(), "yaml"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _load(path: Path, fmt: str) -> dict[str, Any]:
|
|
68
|
+
if not path.exists():
|
|
69
|
+
return {}
|
|
70
|
+
text = path.read_text(encoding="utf-8")
|
|
71
|
+
if fmt == "yaml":
|
|
72
|
+
result: dict[str, Any] = _yaml().load(text) or {}
|
|
73
|
+
return result
|
|
74
|
+
result = json.loads(text)
|
|
75
|
+
return result
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _dump(path: Path, fmt: str, data: dict[str, Any]) -> None:
|
|
79
|
+
if fmt == "yaml":
|
|
80
|
+
buf = io.StringIO()
|
|
81
|
+
_yaml().dump(data, buf)
|
|
82
|
+
path.write_text(buf.getvalue(), encoding="utf-8")
|
|
83
|
+
else:
|
|
84
|
+
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _merge_models(
|
|
88
|
+
existing: list[dict[str, Any]],
|
|
89
|
+
catalog: list[CatalogEntry],
|
|
90
|
+
ctx: ConfigureContext,
|
|
91
|
+
) -> list[dict[str, Any]]:
|
|
92
|
+
"""Merge with marker-respecting semantics.
|
|
93
|
+
|
|
94
|
+
- Preserve user-authored entries (no marker).
|
|
95
|
+
- Replace substrate-managed entries to match the current catalog (live path).
|
|
96
|
+
- On fallback path, keep existing substrate-managed entries even if not in catalog.
|
|
97
|
+
"""
|
|
98
|
+
user_entries = [e for e in existing if not is_substrate_managed(e)]
|
|
99
|
+
existing_substrate_ids = {
|
|
100
|
+
e.get("model") for e in existing if is_substrate_managed(e)
|
|
101
|
+
}
|
|
102
|
+
catalog_ids = {entry.id for entry in catalog}
|
|
103
|
+
|
|
104
|
+
fresh_entries = [
|
|
105
|
+
_model_entry(ctx, entry.id, entry.display_name) for entry in catalog
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
if ctx.catalog_source == "fallback":
|
|
109
|
+
kept_ids = existing_substrate_ids - catalog_ids
|
|
110
|
+
for old in existing:
|
|
111
|
+
if is_substrate_managed(old) and old.get("model") in kept_ids:
|
|
112
|
+
fresh_entries.append(old)
|
|
113
|
+
|
|
114
|
+
return user_entries + fresh_entries
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class ContinueAgent(Agent):
|
|
118
|
+
name = "continue"
|
|
119
|
+
pretty_name = "Continue"
|
|
120
|
+
|
|
121
|
+
def detect(self) -> Path | None:
|
|
122
|
+
if _yaml_path().exists():
|
|
123
|
+
return _yaml_path()
|
|
124
|
+
if _json_path().exists():
|
|
125
|
+
return _json_path()
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
def configure(self, ctx: ConfigureContext) -> AgentResult:
|
|
129
|
+
_dir().mkdir(parents=True, exist_ok=True)
|
|
130
|
+
path, fmt = _choose_path()
|
|
131
|
+
backups: list[Path] = []
|
|
132
|
+
|
|
133
|
+
if path.exists():
|
|
134
|
+
backup = backup_once(path)
|
|
135
|
+
if backup is not None:
|
|
136
|
+
backups.append(backup)
|
|
137
|
+
|
|
138
|
+
payload = _load(path, fmt)
|
|
139
|
+
existing_models = payload.get("models") or []
|
|
140
|
+
merged = _merge_models(existing_models, ctx.catalog, ctx)
|
|
141
|
+
payload["models"] = merged
|
|
142
|
+
|
|
143
|
+
notes: list[str] = []
|
|
144
|
+
if fmt == "json":
|
|
145
|
+
notes.append(
|
|
146
|
+
"Continue: editing legacy config.json (consider migrating to config.yaml)."
|
|
147
|
+
)
|
|
148
|
+
print(notes[-1])
|
|
149
|
+
|
|
150
|
+
if not ctx.dry_run:
|
|
151
|
+
_dump(path, fmt, payload)
|
|
152
|
+
|
|
153
|
+
msg = f"{len(ctx.catalog)} models written, API base + key set in {path}."
|
|
154
|
+
return AgentResult(ResultStatus.SUCCESS, msg, tuple(backups))
|
|
155
|
+
|
|
156
|
+
def verify(self, ctx: ConfigureContext) -> AgentResult:
|
|
157
|
+
path = self.detect()
|
|
158
|
+
if path is None:
|
|
159
|
+
return AgentResult(ResultStatus.ERROR, "Continue config not present")
|
|
160
|
+
fmt = "yaml" if path.suffix == ".yaml" else "json"
|
|
161
|
+
payload = _load(path, fmt)
|
|
162
|
+
substrate_ids = {
|
|
163
|
+
m.get("model") for m in (payload.get("models") or [])
|
|
164
|
+
if is_substrate_managed(m)
|
|
165
|
+
}
|
|
166
|
+
catalog_ids = {e.id for e in ctx.catalog}
|
|
167
|
+
missing = catalog_ids - substrate_ids
|
|
168
|
+
stale = substrate_ids - catalog_ids
|
|
169
|
+
if missing or stale:
|
|
170
|
+
parts = []
|
|
171
|
+
if missing:
|
|
172
|
+
parts.append(f"missing: {sorted(missing)}")
|
|
173
|
+
if stale:
|
|
174
|
+
parts.append(f"stale: {sorted(stale)}")
|
|
175
|
+
return AgentResult(ResultStatus.ERROR, "; ".join(parts))
|
|
176
|
+
return AgentResult(ResultStatus.SUCCESS, "in sync")
|
|
177
|
+
|
|
178
|
+
def remove(self, ctx: ConfigureContext) -> AgentResult:
|
|
179
|
+
path = self.detect()
|
|
180
|
+
if path is None:
|
|
181
|
+
return AgentResult(ResultStatus.SKIPPED, "Continue not installed")
|
|
182
|
+
fmt = "yaml" if path.suffix == ".yaml" else "json"
|
|
183
|
+
backup = backup_once(path)
|
|
184
|
+
payload = _load(path, fmt)
|
|
185
|
+
payload["models"] = [
|
|
186
|
+
m for m in (payload.get("models") or []) if not is_substrate_managed(m)
|
|
187
|
+
]
|
|
188
|
+
if not ctx.dry_run:
|
|
189
|
+
_dump(path, fmt, payload)
|
|
190
|
+
return AgentResult(
|
|
191
|
+
ResultStatus.SUCCESS,
|
|
192
|
+
"Removed substrate model entries from Continue",
|
|
193
|
+
(backup,) if backup else (),
|
|
194
|
+
)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Cursor handler — print-only.
|
|
2
|
+
|
|
3
|
+
Cursor stores its custom OpenAI base URL + key + model list in
|
|
4
|
+
state.vscdb (SQLite) + OS secure storage. It does NOT read these from
|
|
5
|
+
settings.json and does NOT read OPENAI_API_KEY from env. So this
|
|
6
|
+
handler can only print a walkthrough.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from substrate_setup.agents.base import (
|
|
15
|
+
Agent,
|
|
16
|
+
AgentResult,
|
|
17
|
+
ConfigureContext,
|
|
18
|
+
ResultStatus,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _user_data_candidates() -> list[Path]:
|
|
23
|
+
home = Path.home()
|
|
24
|
+
candidates = [
|
|
25
|
+
home / "Library/Application Support/Cursor",
|
|
26
|
+
home / ".config/Cursor",
|
|
27
|
+
]
|
|
28
|
+
if sys.platform == "win32":
|
|
29
|
+
appdata = os.environ.get("APPDATA")
|
|
30
|
+
if appdata:
|
|
31
|
+
candidates.append(Path(appdata) / "Cursor")
|
|
32
|
+
return candidates
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CursorAgent(Agent):
|
|
36
|
+
name = "cursor"
|
|
37
|
+
pretty_name = "Cursor"
|
|
38
|
+
|
|
39
|
+
def detect(self) -> Path | None:
|
|
40
|
+
for path in _user_data_candidates():
|
|
41
|
+
if path.exists():
|
|
42
|
+
return path
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
def configure(self, ctx: ConfigureContext) -> AgentResult:
|
|
46
|
+
bullets = "\n".join(f" - {e.id}" for e in ctx.catalog)
|
|
47
|
+
print(
|
|
48
|
+
"Cursor detected. Cursor stores its API config in secure OS storage,\n"
|
|
49
|
+
"so substrate-setup cannot write it for you. Open Cursor and:\n"
|
|
50
|
+
"\n"
|
|
51
|
+
" 1. Settings (⌘/Ctrl-,) → Models\n"
|
|
52
|
+
" 2. Toggle \"Override OpenAI Base URL\" ON\n"
|
|
53
|
+
f" 3. Paste this Base URL: {ctx.base_url}\n"
|
|
54
|
+
f" 4. Paste this API Key: {ctx.api_key}\n"
|
|
55
|
+
" 5. Click \"Verify\"\n"
|
|
56
|
+
" 6. Add these models (Settings → Models → \"Add Model\"), one at a time:\n"
|
|
57
|
+
f"{bullets}\n"
|
|
58
|
+
"\n"
|
|
59
|
+
"WARNING: enabling \"Override OpenAI Base URL\" is global — it will also\n"
|
|
60
|
+
"route Cursor's first-party Composer / Tab / Apply features through\n"
|
|
61
|
+
"Substrate, which may not work as expected. This is a Cursor limitation,\n"
|
|
62
|
+
"not a substrate-setup issue."
|
|
63
|
+
)
|
|
64
|
+
return AgentResult(
|
|
65
|
+
ResultStatus.PRINT_ONLY,
|
|
66
|
+
"Printed paste-into-Cursor walkthrough",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def verify(self, ctx: ConfigureContext) -> AgentResult:
|
|
70
|
+
print(
|
|
71
|
+
"Cursor: cannot verify automatically — config lives in Cursor's secure storage."
|
|
72
|
+
)
|
|
73
|
+
return AgentResult(
|
|
74
|
+
ResultStatus.PRINT_ONLY,
|
|
75
|
+
"Cursor verify is print-only",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def remove(self, ctx: ConfigureContext) -> AgentResult:
|
|
79
|
+
print(
|
|
80
|
+
"Cursor: substrate-setup cannot remove your config (it lives in secure\n"
|
|
81
|
+
"OS storage). To clean up, open Cursor and:\n"
|
|
82
|
+
" 1. Settings (⌘/Ctrl-,) → Models\n"
|
|
83
|
+
" 2. Toggle \"Override OpenAI Base URL\" OFF\n"
|
|
84
|
+
" 3. Clear the OpenAI API Key field\n"
|
|
85
|
+
" 4. Remove the Substrate-added models from the model list."
|
|
86
|
+
)
|
|
87
|
+
return AgentResult(
|
|
88
|
+
ResultStatus.PRINT_ONLY,
|
|
89
|
+
"Printed remove-from-Cursor walkthrough",
|
|
90
|
+
)
|