substrate-setup 0.1.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 (33) hide show
  1. substrate_setup-0.1.0/.gitignore +71 -0
  2. substrate_setup-0.1.0/PKG-INFO +27 -0
  3. substrate_setup-0.1.0/README.md +11 -0
  4. substrate_setup-0.1.0/pyproject.toml +45 -0
  5. substrate_setup-0.1.0/scripts/lint_no_app_import.sh +10 -0
  6. substrate_setup-0.1.0/scripts/regenerate_fallback_catalog.py +68 -0
  7. substrate_setup-0.1.0/substrate_setup/__init__.py +6 -0
  8. substrate_setup-0.1.0/substrate_setup/__main__.py +5 -0
  9. substrate_setup-0.1.0/substrate_setup/agents/__init__.py +20 -0
  10. substrate_setup-0.1.0/substrate_setup/agents/aider.py +250 -0
  11. substrate_setup-0.1.0/substrate_setup/agents/base.py +56 -0
  12. substrate_setup-0.1.0/substrate_setup/agents/continue_dev.py +194 -0
  13. substrate_setup-0.1.0/substrate_setup/agents/cursor.py +90 -0
  14. substrate_setup-0.1.0/substrate_setup/agents/hermes.py +259 -0
  15. substrate_setup-0.1.0/substrate_setup/backup.py +32 -0
  16. substrate_setup-0.1.0/substrate_setup/catalog.py +109 -0
  17. substrate_setup-0.1.0/substrate_setup/cli.py +211 -0
  18. substrate_setup-0.1.0/substrate_setup/credentials.py +82 -0
  19. substrate_setup-0.1.0/substrate_setup/data/fallback_catalog.json +301 -0
  20. substrate_setup-0.1.0/substrate_setup/markers.py +27 -0
  21. substrate_setup-0.1.0/tests/__init__.py +0 -0
  22. substrate_setup-0.1.0/tests/agents/__init__.py +0 -0
  23. substrate_setup-0.1.0/tests/agents/test_aider.py +173 -0
  24. substrate_setup-0.1.0/tests/agents/test_continue_dev.py +166 -0
  25. substrate_setup-0.1.0/tests/agents/test_cursor.py +87 -0
  26. substrate_setup-0.1.0/tests/agents/test_hermes.py +234 -0
  27. substrate_setup-0.1.0/tests/conftest.py +1 -0
  28. substrate_setup-0.1.0/tests/test_backup.py +38 -0
  29. substrate_setup-0.1.0/tests/test_catalog.py +95 -0
  30. substrate_setup-0.1.0/tests/test_cli.py +113 -0
  31. substrate_setup-0.1.0/tests/test_credentials.py +80 -0
  32. substrate_setup-0.1.0/tests/test_e2e.py +110 -0
  33. substrate_setup-0.1.0/tests/test_markers.py +45 -0
@@ -0,0 +1,71 @@
1
+ # Secrets
2
+ .env
3
+ .env.local
4
+ .env.*.local
5
+ *.secret
6
+ secrets/
7
+ credentials.txt
8
+ credentials.*.txt
9
+
10
+ # Python
11
+ __pycache__/
12
+ *.py[cod]
13
+ *$py.class
14
+ *.so
15
+ .Python
16
+ .venv/
17
+ venv/
18
+ env/
19
+ .pytest_cache/
20
+ .mypy_cache/
21
+ .ruff_cache/
22
+ *.egg-info/
23
+ dist/
24
+ build/
25
+
26
+ # Node / Next.js
27
+ node_modules/
28
+ .next/
29
+ .vercel/
30
+ .turbo/
31
+ out/
32
+ *.tsbuildinfo
33
+ .npm/
34
+ .yarn/
35
+
36
+ # IDE
37
+ .vscode/
38
+ .idea/
39
+ *.swp
40
+ *.swo
41
+
42
+ # OS
43
+ .DS_Store
44
+ Thumbs.db
45
+ desktop.ini
46
+
47
+ # Logs
48
+ *.log
49
+ logs/
50
+
51
+ # Local databases
52
+ *.db
53
+ *.sqlite
54
+ *.sqlite3
55
+
56
+ # Editors
57
+ *~
58
+ .#*
59
+
60
+ # Coverage
61
+ .coverage
62
+ htmlcov/
63
+ coverage.xml
64
+ *.cover
65
+
66
+ # Claude Code project state (if any project-local)
67
+ .claude/
68
+
69
+ # Misc
70
+ *.bak
71
+ *.tmp
@@ -0,0 +1,27 @@
1
+ Metadata-Version: 2.4
2
+ Name: substrate-setup
3
+ Version: 0.1.0
4
+ Summary: One-shot local configurator for coding agents against a Substrate gateway
5
+ Author: Substrate Solutions
6
+ License: MIT
7
+ Requires-Python: >=3.12
8
+ Requires-Dist: httpx>=0.27
9
+ Requires-Dist: ruamel-yaml>=0.18
10
+ Provides-Extra: dev
11
+ Requires-Dist: mypy<2,>=1.13; extra == 'dev'
12
+ Requires-Dist: pytest-httpx>=0.30; extra == 'dev'
13
+ Requires-Dist: pytest>=8.0; extra == 'dev'
14
+ Requires-Dist: ruff<1,>=0.7; extra == 'dev'
15
+ Description-Content-Type: text/markdown
16
+
17
+ # substrate-setup
18
+
19
+ One-shot configurator that points local coding agents at a Substrate gateway.
20
+
21
+ ```bash
22
+ pip install substrate-setup
23
+ export SUBSTRATE_API_KEY="sk-substrate-..." # or be prompted
24
+ python -m substrate_setup
25
+ ```
26
+
27
+ Run `python -m substrate_setup --help` for subcommands (`configure`, `verify`, `remove`).
@@ -0,0 +1,11 @@
1
+ # substrate-setup
2
+
3
+ One-shot configurator that points local coding agents at a Substrate gateway.
4
+
5
+ ```bash
6
+ pip install substrate-setup
7
+ export SUBSTRATE_API_KEY="sk-substrate-..." # or be prompted
8
+ python -m substrate_setup
9
+ ```
10
+
11
+ Run `python -m substrate_setup --help` for subcommands (`configure`, `verify`, `remove`).
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "substrate-setup"
7
+ version = "0.1.0"
8
+ description = "One-shot local configurator for coding agents against a Substrate gateway"
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ authors = [{ name = "Substrate Solutions" }]
12
+ license = { text = "MIT" }
13
+ dependencies = [
14
+ "httpx>=0.27",
15
+ "ruamel.yaml>=0.18",
16
+ ]
17
+
18
+ [project.optional-dependencies]
19
+ dev = [
20
+ "pytest>=8.0",
21
+ "pytest-httpx>=0.30",
22
+ "ruff>=0.7,<1",
23
+ "mypy>=1.13,<2",
24
+ ]
25
+
26
+ [project.scripts]
27
+ substrate-setup = "substrate_setup.cli:main"
28
+
29
+ [tool.hatch.build.targets.wheel]
30
+ packages = ["substrate_setup"]
31
+
32
+ [tool.pytest.ini_options]
33
+ testpaths = ["tests"]
34
+
35
+ [tool.mypy]
36
+ python_version = "3.12"
37
+ strict = true
38
+ ignore_missing_imports = true
39
+ packages = ["substrate_setup"]
40
+
41
+ [[tool.mypy.overrides]]
42
+ module = "tests.*"
43
+ disallow_untyped_defs = false
44
+ disallow_incomplete_defs = false
45
+ check_untyped_defs = false
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env bash
2
+ # CI lint: substrate_setup/ MUST NOT import anything from app/.
3
+ # The two packages share a source tree, not a dependency graph.
4
+ set -euo pipefail
5
+ PKG_DIR="$(cd "$(dirname "$0")/.." && pwd)"
6
+ if grep -RIn --include='*.py' '^from app\b\|^import app\b' "$PKG_DIR/substrate_setup"; then
7
+ echo "ERROR: substrate_setup/ must not import from app/. See package boundary rule in spec."
8
+ exit 1
9
+ fi
10
+ echo "OK: no app/ imports in substrate_setup/"
@@ -0,0 +1,68 @@
1
+ """Regenerate the bundled fallback catalog snapshot.
2
+
3
+ Run manually before each PyPI release of substrate-setup. NOT run by CI cron —
4
+ a transient production glitch should NEVER be baked into the next release.
5
+
6
+ Usage:
7
+ SUBSTRATE_API_KEY=sk-substrate-... \
8
+ python scripts/regenerate_fallback_catalog.py [--base-url https://...]
9
+
10
+ Writes substrate_setup/data/fallback_catalog.json. Applies a sanity check
11
+ (>= 5 models, all five expected providers present) before writing.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import os
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ import httpx
22
+
23
+ EXPECTED_PROVIDERS = {"anthropic", "openai", "google", "deepseek", "moonshot"}
24
+ DEFAULT_BASE_URL = "https://substrate-solutions-api.fly.dev/v1"
25
+ TARGET = Path(__file__).resolve().parents[1] / "substrate_setup" / "data" / "fallback_catalog.json"
26
+
27
+
28
+ def main(argv: list[str] | None = None) -> int:
29
+ parser = argparse.ArgumentParser()
30
+ parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
31
+ args = parser.parse_args(argv)
32
+
33
+ api_key = os.environ.get("SUBSTRATE_API_KEY")
34
+ if not api_key:
35
+ print("ERROR: SUBSTRATE_API_KEY env var required.", file=sys.stderr)
36
+ return 1
37
+
38
+ # Tolerate --base-url with or without a trailing /v1 (same convention as
39
+ # substrate_setup.catalog.fetch_catalog).
40
+ normalized = args.base_url.rstrip("/")
41
+ if normalized.endswith("/v1"):
42
+ normalized = normalized[:-3]
43
+
44
+ resp = httpx.get(
45
+ f"{normalized}/v1/models",
46
+ headers={"Authorization": f"Bearer {api_key}"},
47
+ timeout=15.0,
48
+ )
49
+ resp.raise_for_status()
50
+ payload = resp.json()
51
+
52
+ data = payload.get("data") or []
53
+ providers = {item.get("owned_by") for item in data}
54
+ if len(data) < 5:
55
+ print(f"REFUSED: only {len(data)} models in catalog (expected >= 5).", file=sys.stderr)
56
+ return 2
57
+ if not EXPECTED_PROVIDERS.issubset(providers):
58
+ missing = EXPECTED_PROVIDERS - providers
59
+ print(f"REFUSED: missing providers {missing}.", file=sys.stderr)
60
+ return 2
61
+
62
+ TARGET.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
63
+ print(f"Wrote {len(data)} models to {TARGET}")
64
+ return 0
65
+
66
+
67
+ if __name__ == "__main__":
68
+ raise SystemExit(main())
@@ -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: ...