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.
- substrate_setup-0.1.0/.gitignore +71 -0
- substrate_setup-0.1.0/PKG-INFO +27 -0
- substrate_setup-0.1.0/README.md +11 -0
- substrate_setup-0.1.0/pyproject.toml +45 -0
- substrate_setup-0.1.0/scripts/lint_no_app_import.sh +10 -0
- substrate_setup-0.1.0/scripts/regenerate_fallback_catalog.py +68 -0
- substrate_setup-0.1.0/substrate_setup/__init__.py +6 -0
- substrate_setup-0.1.0/substrate_setup/__main__.py +5 -0
- substrate_setup-0.1.0/substrate_setup/agents/__init__.py +20 -0
- substrate_setup-0.1.0/substrate_setup/agents/aider.py +250 -0
- substrate_setup-0.1.0/substrate_setup/agents/base.py +56 -0
- substrate_setup-0.1.0/substrate_setup/agents/continue_dev.py +194 -0
- substrate_setup-0.1.0/substrate_setup/agents/cursor.py +90 -0
- substrate_setup-0.1.0/substrate_setup/agents/hermes.py +259 -0
- substrate_setup-0.1.0/substrate_setup/backup.py +32 -0
- substrate_setup-0.1.0/substrate_setup/catalog.py +109 -0
- substrate_setup-0.1.0/substrate_setup/cli.py +211 -0
- substrate_setup-0.1.0/substrate_setup/credentials.py +82 -0
- substrate_setup-0.1.0/substrate_setup/data/fallback_catalog.json +301 -0
- substrate_setup-0.1.0/substrate_setup/markers.py +27 -0
- substrate_setup-0.1.0/tests/__init__.py +0 -0
- substrate_setup-0.1.0/tests/agents/__init__.py +0 -0
- substrate_setup-0.1.0/tests/agents/test_aider.py +173 -0
- substrate_setup-0.1.0/tests/agents/test_continue_dev.py +166 -0
- substrate_setup-0.1.0/tests/agents/test_cursor.py +87 -0
- substrate_setup-0.1.0/tests/agents/test_hermes.py +234 -0
- substrate_setup-0.1.0/tests/conftest.py +1 -0
- substrate_setup-0.1.0/tests/test_backup.py +38 -0
- substrate_setup-0.1.0/tests/test_catalog.py +95 -0
- substrate_setup-0.1.0/tests/test_cli.py +113 -0
- substrate_setup-0.1.0/tests/test_credentials.py +80 -0
- substrate_setup-0.1.0/tests/test_e2e.py +110 -0
- 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,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: ...
|