@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.
- package/CHANGELOG.md +56 -0
- package/README.md +8 -8
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/hooks/_hook-io.sh +14 -0
- package/app/hooks/commit-quality.sh +1 -0
- package/app/hooks/governance-capture.sh +1 -0
- package/app/hooks/guard-config.sh +1 -0
- package/app/hooks/guard-destructive.sh +1 -0
- package/app/hooks/revert-guard.sh +1 -0
- package/app/hooks/search-tracker.sh +5 -1
- package/app/hooks/session-start.sh +4 -0
- package/app/hooks/stop-search-check.sh +3 -1
- package/app/hooks/test-cohesion.sh +1 -0
- package/app/hooks/track-usage.sh +1 -0
- package/app/hooks/user-prompt-submit.sh +2 -1
- package/bin/ai-toolkit.js +43 -0
- package/kb/reference/extension-api.md +77 -11
- package/kb/reference/hooks-catalog.md +3 -3
- package/kb/reference/mcp-templates.md +6 -4
- package/llms-full.txt +86 -18
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/inject_mcp_cli.py +514 -0
- package/scripts/install.py +2 -1
- package/scripts/install_steps/markers.py +40 -0
- package/scripts/mcp_sources.py +162 -0
- package/scripts/paths.py +2 -0
- package/scripts/test_cohesion.py +1 -1
|
@@ -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"
|
package/scripts/test_cohesion.py
CHANGED