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.
@@ -0,0 +1,6 @@
1
+ """substrate-setup — one-shot configurator for coding agents.
2
+
3
+ See https://github.com/FrankXiaA/substrate-solutions for the gateway it
4
+ configures against.
5
+ """
6
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """Module entry point: ``python -m substrate_setup``."""
2
+ from substrate_setup.cli import main
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
@@ -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
+ )