@softspark/ai-toolkit 4.2.5 → 4.3.1

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,162 @@
1
+ #!/usr/bin/env python3
2
+ """URL source registry for externally-injected MCP templates.
3
+
4
+ Tracks which MCP templates were registered from a URL or local file so that
5
+ `ai-toolkit update` can re-fetch the latest version and re-inject. Mirrors
6
+ the design of hook_sources.py for parity between inject-hook and inject-mcp.
7
+
8
+ Metadata stored in ~/.softspark/ai-toolkit/mcp-templates/external/sources.json.
9
+
10
+ Stdlib-only -- no external dependencies.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ import os
17
+ import re
18
+ import sys
19
+ import tempfile
20
+ from datetime import datetime, timezone
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
25
+ from paths import EXTERNAL_MCP_DIR
26
+
27
+ _SOURCES_FILENAME = "sources.json"
28
+
29
+
30
+ def _sources_path(mcp_dir: Path | None = None) -> Path:
31
+ return (mcp_dir or EXTERNAL_MCP_DIR) / _SOURCES_FILENAME
32
+
33
+
34
+ def load_sources(mcp_dir: Path | None = None) -> dict[str, dict[str, Any]]:
35
+ """Load sources.json. Returns {} if missing or corrupt."""
36
+ path = _sources_path(mcp_dir)
37
+ if not path.is_file():
38
+ return {}
39
+ try:
40
+ with open(path, encoding="utf-8") as f:
41
+ data = json.load(f)
42
+ if isinstance(data, dict):
43
+ return data.get("templates", {})
44
+ return {}
45
+ except (json.JSONDecodeError, OSError):
46
+ return {}
47
+
48
+
49
+ def save_sources(mcp_dir: Path | None = None,
50
+ sources: dict[str, dict[str, Any]] | None = None) -> None:
51
+ """Write sources.json atomically."""
52
+ mcp_dir = mcp_dir or EXTERNAL_MCP_DIR
53
+ path = _sources_path(mcp_dir)
54
+ path.parent.mkdir(parents=True, exist_ok=True)
55
+
56
+ payload = json.dumps(
57
+ {"schema_version": 1, "templates": sources or {}}, indent=2
58
+ )
59
+
60
+ fd, tmp_path = tempfile.mkstemp(
61
+ dir=str(path.parent), prefix=".sources_", suffix=".tmp"
62
+ )
63
+ try:
64
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
65
+ f.write(payload)
66
+ f.write("\n")
67
+ f.flush()
68
+ os.fsync(f.fileno())
69
+ os.rename(tmp_path, str(path))
70
+ except BaseException:
71
+ try:
72
+ os.unlink(tmp_path)
73
+ except OSError:
74
+ pass
75
+ raise
76
+
77
+
78
+ def register_url_source(
79
+ mcp_dir: Path | None,
80
+ template_name: str,
81
+ url: str,
82
+ content: bytes | None = None,
83
+ ) -> None:
84
+ """Add or update a URL source entry for an MCP template.
85
+
86
+ When ``content`` is supplied, its sha256 is persisted. If a previous
87
+ sha256 exists and differs from the new one, a warning is printed
88
+ (and the process fails with exit 2 when ``AI_TOOLKIT_STRICT_PIN=1``).
89
+ """
90
+ if not template_name or not re.fullmatch(r"[a-zA-Z0-9_-]+", template_name):
91
+ raise ValueError(f"Invalid MCP template name: {template_name!r}")
92
+ mcp_dir = mcp_dir or EXTERNAL_MCP_DIR
93
+ sources = load_sources(mcp_dir)
94
+ entry: dict[str, Any] = {
95
+ "url": url,
96
+ "fetched_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
97
+ }
98
+ if content is not None:
99
+ new_hash = hashlib.sha256(content).hexdigest()
100
+ prev = sources.get(template_name) or {}
101
+ prev_hash = prev.get("sha256")
102
+ if prev_hash and prev_hash != new_hash:
103
+ msg = (
104
+ f" CHECKSUM CHANGED: mcp '{template_name}' sha256 "
105
+ f"{prev_hash[:12]}... -> {new_hash[:12]}..."
106
+ )
107
+ print(msg)
108
+ if os.environ.get("AI_TOOLKIT_STRICT_PIN") == "1":
109
+ raise SystemExit(
110
+ f"Refusing to update '{template_name}' under AI_TOOLKIT_STRICT_PIN=1."
111
+ )
112
+ entry["sha256"] = new_hash
113
+ sources[template_name] = entry
114
+ save_sources(mcp_dir, sources)
115
+
116
+
117
+ def register_path_source(
118
+ mcp_dir: Path | None,
119
+ template_name: str,
120
+ path: Path,
121
+ content: bytes | None = None,
122
+ ) -> None:
123
+ """Add or update a local-file source entry for an MCP template.
124
+
125
+ Stores the absolute origin path so subsequent ``ai-toolkit update`` runs
126
+ can detect drift, plus a sha256 of the injected content.
127
+ """
128
+ if not template_name or not re.fullmatch(r"[a-zA-Z0-9_-]+", template_name):
129
+ raise ValueError(f"Invalid MCP template name: {template_name!r}")
130
+ mcp_dir = mcp_dir or EXTERNAL_MCP_DIR
131
+ sources = load_sources(mcp_dir)
132
+ existing = sources.get(template_name) or {}
133
+ # Never demote a URL-tracked entry to a local-path entry. update() flows
134
+ # call inject() with the cached file path after URL fetch, which would
135
+ # otherwise overwrite the URL.
136
+ if "url" in existing:
137
+ return
138
+ entry: dict[str, Any] = {
139
+ "path": str(Path(path).resolve()),
140
+ "fetched_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
141
+ }
142
+ if content is not None:
143
+ entry["sha256"] = hashlib.sha256(content).hexdigest()
144
+ sources[template_name] = entry
145
+ save_sources(mcp_dir, sources)
146
+
147
+
148
+ def unregister_source(mcp_dir: Path | None, template_name: str) -> bool:
149
+ """Remove a source entry. Returns True if found and removed."""
150
+ mcp_dir = mcp_dir or EXTERNAL_MCP_DIR
151
+ sources = load_sources(mcp_dir)
152
+ if template_name in sources:
153
+ del sources[template_name]
154
+ save_sources(mcp_dir, sources)
155
+ return True
156
+ return False
157
+
158
+
159
+ def get_url_templates(mcp_dir: Path | None = None) -> dict[str, str]:
160
+ """Return {template_name: url} for all URL-sourced MCP templates."""
161
+ sources = load_sources(mcp_dir)
162
+ return {name: entry["url"] for name, entry in sources.items() if "url" in entry}
package/scripts/paths.py CHANGED
@@ -26,6 +26,8 @@ LEGACY_DATA_DIR = Path.home() / ".ai-toolkit"
26
26
  # Sub-directories under TOOLKIT_DATA_DIR
27
27
  HOOKS_DIR = TOOLKIT_DATA_DIR / "hooks"
28
28
  EXTERNAL_HOOKS_DIR = HOOKS_DIR / "external"
29
+ MCP_TEMPLATES_DIR = TOOLKIT_DATA_DIR / "mcp-templates"
30
+ EXTERNAL_MCP_DIR = MCP_TEMPLATES_DIR / "external"
29
31
  RULES_DIR = TOOLKIT_DATA_DIR / "rules"
30
32
  SESSIONS_DIR = TOOLKIT_DATA_DIR / "sessions"
31
33
  COMPACTIONS_DIR = TOOLKIT_DATA_DIR / "compactions"
@@ -33,7 +33,7 @@ import sys
33
33
  from pathlib import Path
34
34
 
35
35
  DEFAULT_RUNNERS: dict[str, str] = {
36
- "bats": "bats --no-parallelize-within-files",
36
+ "bats": "bats",
37
37
  "pytest": "pytest -x",
38
38
  "vitest": "npx vitest run",
39
39
  "jest": "npx jest --bail",