@softspark/ai-toolkit 4.2.4 → 4.3.0
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 +67 -0
- package/README.md +10 -9
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/hooks/_hook-io.sh +64 -0
- package/app/hooks/_locate-toolkit.sh +39 -0
- package/app/hooks/_search-capability.sh +46 -0
- package/app/hooks/commit-quality.sh +3 -1
- package/app/hooks/config-desync-guard.sh +95 -0
- package/app/hooks/governance-capture.sh +5 -3
- package/app/hooks/guard-config.sh +5 -3
- package/app/hooks/guard-destructive.sh +3 -1
- package/app/hooks/guard-path.sh +8 -1
- package/app/hooks/instructions-audit.sh +43 -0
- package/app/hooks/post-tool-use.sh +20 -8
- package/app/hooks/quality-gate.sh +47 -0
- package/app/hooks/revert-guard.sh +84 -0
- package/app/hooks/search-tracker.sh +18 -0
- package/app/hooks/session-start.sh +14 -2
- package/app/hooks/stop-search-check.sh +37 -0
- package/app/hooks/test-cohesion-map.json +77 -0
- package/app/hooks/test-cohesion.sh +93 -0
- package/app/hooks/track-usage.sh +3 -1
- package/app/hooks/user-prompt-submit.sh +33 -6
- package/app/hooks.json +65 -1
- package/benchmarks/ecosystem-doctor-snapshot.json +8 -8
- package/bin/ai-toolkit.js +43 -0
- package/kb/procedures/release-verification-sop.md +2 -2
- package/kb/reference/architecture-overview.md +1 -1
- package/kb/reference/extension-api.md +77 -11
- package/kb/reference/hooks-catalog.md +149 -24
- package/kb/reference/mcp-templates.md +6 -4
- package/kb/reference/unique-features.md +11 -5
- package/llms-full.txt +163 -32
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/doctor.py +13 -0
- package/scripts/generate_augment_hooks.py +5 -1
- package/scripts/generate_codex_hooks.py +2 -0
- package/scripts/generate_cursor_hooks.py +6 -0
- package/scripts/generate_gemini_hooks.py +5 -1
- package/scripts/generate_windsurf_hooks.py +6 -0
- package/scripts/inject_mcp_cli.py +514 -0
- package/scripts/install.py +2 -1
- package/scripts/install_steps/hooks.py +5 -0
- package/scripts/install_steps/markers.py +40 -0
- package/scripts/mcp_sources.py +162 -0
- package/scripts/merge-hooks.py +10 -1
- package/scripts/paths.py +2 -0
- package/scripts/plugin_schema.py +4 -0
- package/scripts/session_state.py +150 -0
- package/scripts/test_cohesion.py +133 -0
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Inject external MCP server templates into .mcp.json and all editor configs.
|
|
3
|
+
|
|
4
|
+
Allows external tools (MCP servers, plugins) to register their own MCP server
|
|
5
|
+
templates alongside ai-toolkit's built-in templates. Each injected template is
|
|
6
|
+
tagged with ``_source`` derived from the filename stem (or URL last segment)
|
|
7
|
+
so that re-running is idempotent and removal is safe.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
inject_mcp_cli.py <template-file-or-url> [target-dir] [--name <name>] [--force]
|
|
11
|
+
inject_mcp_cli.py <url> [template-name] [target-dir] [--force] # URL-only legacy form
|
|
12
|
+
inject_mcp_cli.py --remove <template-source-name> [target-dir]
|
|
13
|
+
|
|
14
|
+
Arguments:
|
|
15
|
+
template-file-or-url Path to a JSON file or HTTPS URL with
|
|
16
|
+
``{"mcpServers": {...}}`` block (toolkit template format).
|
|
17
|
+
target-dir Directory containing ``.mcp.json``
|
|
18
|
+
(default: $HOME -- writes to ~/.mcp.json and propagates
|
|
19
|
+
to ~/.claude.json, ~/.cursor/mcp.json, ~/.codex/config.toml, ...).
|
|
20
|
+
|
|
21
|
+
Flags:
|
|
22
|
+
--name <name> Explicit source name. Default: filename stem or URL last segment.
|
|
23
|
+
Works for both local files and URLs. Preferred over positional
|
|
24
|
+
template-name (which only works for URL sources for backward
|
|
25
|
+
compatibility with the inject-hook positional grammar).
|
|
26
|
+
--force Overwrite servers that already exist under a different
|
|
27
|
+
``_source`` tag. Without --force, collisions are rejected.
|
|
28
|
+
--remove Remove all server entries tagged with the given source name
|
|
29
|
+
(also unregisters URL source and removes cached template file).
|
|
30
|
+
|
|
31
|
+
The source name is derived from the filename stem (e.g.,
|
|
32
|
+
``rag-mcp-template.json`` becomes ``"rag-mcp-template"``). Every server in the
|
|
33
|
+
template is tagged with ``"_source": "<source-name>"`` inside ``.mcp.json``
|
|
34
|
+
(toolkit source-of-truth). Native editor configs receive the same servers
|
|
35
|
+
without ``_source`` (some editors reject unknown fields).
|
|
36
|
+
|
|
37
|
+
Servers tagged with ``_source: "ai-toolkit"`` are **never** modified -- those
|
|
38
|
+
are managed exclusively by the toolkit itself.
|
|
39
|
+
|
|
40
|
+
Propagation: every supported editor with a `global_path` in EDITOR_SPECS is
|
|
41
|
+
updated. Project-scoped editors are skipped (use ``ai-toolkit mcp install`` for
|
|
42
|
+
project scope).
|
|
43
|
+
|
|
44
|
+
Exit codes:
|
|
45
|
+
0 success
|
|
46
|
+
1 usage / argument error
|
|
47
|
+
2 JSON parse error
|
|
48
|
+
3 collision rejected (use --force)
|
|
49
|
+
"""
|
|
50
|
+
from __future__ import annotations
|
|
51
|
+
|
|
52
|
+
import copy
|
|
53
|
+
import json
|
|
54
|
+
import os
|
|
55
|
+
import re
|
|
56
|
+
import sys
|
|
57
|
+
import urllib.parse
|
|
58
|
+
from pathlib import Path
|
|
59
|
+
|
|
60
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
61
|
+
|
|
62
|
+
PROTECTED_SOURCE = "ai-toolkit"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def load_json(path: str) -> dict:
|
|
66
|
+
"""Load and parse a JSON file."""
|
|
67
|
+
with open(path) as f:
|
|
68
|
+
return json.load(f)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def save_json(path: str, data: dict, indent: int = 2) -> None:
|
|
72
|
+
"""Write a dictionary to a JSON file with trailing newline."""
|
|
73
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
with open(path, "w") as f:
|
|
75
|
+
json.dump(data, f, indent=indent, ensure_ascii=False)
|
|
76
|
+
f.write("\n")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _is_url(source: str) -> bool:
|
|
80
|
+
"""Check if source looks like an HTTP(S) URL."""
|
|
81
|
+
return source.startswith("https://") or source.startswith("http://")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _name_from_url(url: str) -> str:
|
|
85
|
+
"""Derive a template source name from a URL's last path segment."""
|
|
86
|
+
parsed = urllib.parse.urlparse(url)
|
|
87
|
+
filename = parsed.path.rstrip("/").split("/")[-1]
|
|
88
|
+
stem = filename.rsplit(".", 1)[0] if "." in filename else filename
|
|
89
|
+
return re.sub(r"[^a-zA-Z0-9_-]", "", stem)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _server_source(server: dict) -> str | None:
|
|
93
|
+
"""Return the ``_source`` tag of a server entry, or None if untagged."""
|
|
94
|
+
if isinstance(server, dict):
|
|
95
|
+
return server.get("_source")
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _strip_source_tag(server: dict) -> dict:
|
|
100
|
+
"""Return a copy of *server* with ``_source`` removed (for native editors)."""
|
|
101
|
+
clean = copy.deepcopy(server)
|
|
102
|
+
clean.pop("_source", None)
|
|
103
|
+
return clean
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _tag_servers(servers: dict, source: str) -> dict:
|
|
107
|
+
"""Return a copy of *servers* with every entry tagged ``_source: <source>``."""
|
|
108
|
+
result: dict = {}
|
|
109
|
+
for name, server in servers.items():
|
|
110
|
+
if not isinstance(server, dict):
|
|
111
|
+
result[name] = server
|
|
112
|
+
continue
|
|
113
|
+
tagged = copy.deepcopy(server)
|
|
114
|
+
tagged["_source"] = source
|
|
115
|
+
result[name] = tagged
|
|
116
|
+
return result
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _strip_servers_by_source(servers: dict, source: str) -> dict:
|
|
120
|
+
"""Return a copy of *servers* with entries matching ``_source`` removed."""
|
|
121
|
+
return {
|
|
122
|
+
name: server for name, server in servers.items()
|
|
123
|
+
if _server_source(server) != source
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _server_names_for_source(servers: dict, source: str) -> list[str]:
|
|
128
|
+
"""Return server names tagged with the given ``_source``."""
|
|
129
|
+
return [name for name, server in servers.items()
|
|
130
|
+
if _server_source(server) == source]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _check_collisions(
|
|
134
|
+
existing: dict, new_servers: dict, source: str, force: bool
|
|
135
|
+
) -> None:
|
|
136
|
+
"""Reject when an existing server has a different ``_source``.
|
|
137
|
+
|
|
138
|
+
Same-source overwrite is always allowed (idempotent re-inject).
|
|
139
|
+
PROTECTED_SOURCE entries are protected even with --force.
|
|
140
|
+
"""
|
|
141
|
+
collisions = []
|
|
142
|
+
for name in new_servers:
|
|
143
|
+
if name not in existing:
|
|
144
|
+
continue
|
|
145
|
+
existing_source = _server_source(existing[name])
|
|
146
|
+
if existing_source == source:
|
|
147
|
+
continue
|
|
148
|
+
if existing_source == PROTECTED_SOURCE:
|
|
149
|
+
print(
|
|
150
|
+
f"Error: server '{name}' is managed by '{PROTECTED_SOURCE}' "
|
|
151
|
+
"and cannot be overwritten.",
|
|
152
|
+
file=sys.stderr,
|
|
153
|
+
)
|
|
154
|
+
sys.exit(3)
|
|
155
|
+
collisions.append((name, existing_source))
|
|
156
|
+
|
|
157
|
+
if not collisions:
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
if not force:
|
|
161
|
+
print(
|
|
162
|
+
f"Error: server name collision(s) in .mcp.json (re-run with --force):",
|
|
163
|
+
file=sys.stderr,
|
|
164
|
+
)
|
|
165
|
+
for name, other_source in collisions:
|
|
166
|
+
src_label = other_source or "(untagged)"
|
|
167
|
+
print(f" - {name} already exists under _source={src_label}", file=sys.stderr)
|
|
168
|
+
sys.exit(3)
|
|
169
|
+
|
|
170
|
+
for name, other_source in collisions:
|
|
171
|
+
src_label = other_source or "(untagged)"
|
|
172
|
+
print(f" Overwriting '{name}' (was _source={src_label})")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _fetch_and_cache(url: str, source: str) -> str:
|
|
176
|
+
"""Fetch template JSON from URL, cache locally, register source.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
Path to the cached template JSON file.
|
|
180
|
+
"""
|
|
181
|
+
from url_fetch import fetch_url
|
|
182
|
+
from mcp_sources import register_url_source
|
|
183
|
+
from paths import EXTERNAL_MCP_DIR
|
|
184
|
+
|
|
185
|
+
EXTERNAL_MCP_DIR.mkdir(parents=True, exist_ok=True)
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
data = fetch_url(url)
|
|
189
|
+
except Exception as exc:
|
|
190
|
+
print(f"Error fetching URL: {exc}", file=sys.stderr)
|
|
191
|
+
sys.exit(1)
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
parsed = json.loads(data)
|
|
195
|
+
except json.JSONDecodeError as exc:
|
|
196
|
+
print(f"Error: URL returned invalid JSON: {exc}", file=sys.stderr)
|
|
197
|
+
sys.exit(2)
|
|
198
|
+
|
|
199
|
+
if "mcpServers" not in parsed:
|
|
200
|
+
print("Warning: no 'mcpServers' key found in URL response", file=sys.stderr)
|
|
201
|
+
|
|
202
|
+
cached_path = EXTERNAL_MCP_DIR / f"{source}.json"
|
|
203
|
+
cached_path.write_bytes(data)
|
|
204
|
+
register_url_source(None, source, url, content=data)
|
|
205
|
+
|
|
206
|
+
return str(cached_path)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _propagate_to_editors(
|
|
210
|
+
servers_clean: dict, source: str, target_dir: str, force: bool
|
|
211
|
+
) -> None:
|
|
212
|
+
"""Mirror servers to every editor that has a global_path in EDITOR_SPECS.
|
|
213
|
+
|
|
214
|
+
Servers are written without the ``_source`` tag (native editor configs do
|
|
215
|
+
not need it). Per-editor failures are non-fatal -- we report and continue.
|
|
216
|
+
"""
|
|
217
|
+
from mcp_editors import EDITOR_SPECS, install_servers
|
|
218
|
+
|
|
219
|
+
home = Path(target_dir)
|
|
220
|
+
editors_with_global = [
|
|
221
|
+
name for name, spec in EDITOR_SPECS.items()
|
|
222
|
+
if spec.get("global_path")
|
|
223
|
+
]
|
|
224
|
+
|
|
225
|
+
for editor in sorted(editors_with_global):
|
|
226
|
+
try:
|
|
227
|
+
updated = install_servers(
|
|
228
|
+
[editor], servers_clean, scope="global", home=home,
|
|
229
|
+
)
|
|
230
|
+
for path in updated:
|
|
231
|
+
print(f" Propagated to {editor}: {path}")
|
|
232
|
+
except Exception as exc:
|
|
233
|
+
print(
|
|
234
|
+
f" Warning: {editor} propagation failed: {exc}",
|
|
235
|
+
file=sys.stderr,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _remove_from_editors(server_names: list[str], target_dir: str) -> None:
|
|
240
|
+
"""Remove servers from every editor with a global_path."""
|
|
241
|
+
from mcp_editors import EDITOR_SPECS, remove_servers
|
|
242
|
+
|
|
243
|
+
home = Path(target_dir)
|
|
244
|
+
editors_with_global = [
|
|
245
|
+
name for name, spec in EDITOR_SPECS.items()
|
|
246
|
+
if spec.get("global_path")
|
|
247
|
+
]
|
|
248
|
+
|
|
249
|
+
for editor in sorted(editors_with_global):
|
|
250
|
+
try:
|
|
251
|
+
updated = remove_servers(
|
|
252
|
+
[editor], server_names, scope="global", home=home,
|
|
253
|
+
)
|
|
254
|
+
for path in updated:
|
|
255
|
+
print(f" Cleaned {editor}: {path}")
|
|
256
|
+
except Exception as exc:
|
|
257
|
+
print(
|
|
258
|
+
f" Warning: {editor} cleanup failed: {exc}",
|
|
259
|
+
file=sys.stderr,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def inject(
|
|
264
|
+
template_file: str,
|
|
265
|
+
target_dir: str,
|
|
266
|
+
source_override: str = "",
|
|
267
|
+
force: bool = False,
|
|
268
|
+
) -> None:
|
|
269
|
+
"""Inject an MCP template into .mcp.json + propagate to all editors.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
template_file: Path to the template JSON file, or an HTTPS URL.
|
|
273
|
+
target_dir: Directory used as $HOME for editor config resolution.
|
|
274
|
+
``.mcp.json`` is written to ``<target_dir>/.mcp.json``.
|
|
275
|
+
source_override: Explicit source name (overrides filename-derived).
|
|
276
|
+
force: Overwrite servers tagged with a different ``_source``.
|
|
277
|
+
"""
|
|
278
|
+
is_url = _is_url(template_file)
|
|
279
|
+
|
|
280
|
+
if is_url:
|
|
281
|
+
if template_file.startswith("http://"):
|
|
282
|
+
print(
|
|
283
|
+
"Error: only HTTPS URLs are supported. Use https:// for security.",
|
|
284
|
+
file=sys.stderr,
|
|
285
|
+
)
|
|
286
|
+
sys.exit(1)
|
|
287
|
+
|
|
288
|
+
source = source_override or _name_from_url(template_file)
|
|
289
|
+
source = re.sub(r"[^a-zA-Z0-9_-]", "", source)
|
|
290
|
+
if not source:
|
|
291
|
+
print(
|
|
292
|
+
"Error: could not derive template name from URL. "
|
|
293
|
+
"Provide one explicitly.",
|
|
294
|
+
file=sys.stderr,
|
|
295
|
+
)
|
|
296
|
+
sys.exit(1)
|
|
297
|
+
|
|
298
|
+
if source == PROTECTED_SOURCE:
|
|
299
|
+
print(
|
|
300
|
+
f"Error: source name '{PROTECTED_SOURCE}' is reserved.",
|
|
301
|
+
file=sys.stderr,
|
|
302
|
+
)
|
|
303
|
+
sys.exit(1)
|
|
304
|
+
|
|
305
|
+
template_file = _fetch_and_cache(template_file, source)
|
|
306
|
+
print(f"Fetched template from URL (source: '{source}')")
|
|
307
|
+
else:
|
|
308
|
+
source = source_override or Path(template_file).stem
|
|
309
|
+
if source == PROTECTED_SOURCE:
|
|
310
|
+
print(
|
|
311
|
+
f"Error: source name '{PROTECTED_SOURCE}' is reserved. "
|
|
312
|
+
"Rename your template file.",
|
|
313
|
+
file=sys.stderr,
|
|
314
|
+
)
|
|
315
|
+
sys.exit(1)
|
|
316
|
+
|
|
317
|
+
try:
|
|
318
|
+
template_data = load_json(template_file)
|
|
319
|
+
except json.JSONDecodeError as exc:
|
|
320
|
+
print(f"Error: malformed JSON in {template_file}: {exc}", file=sys.stderr)
|
|
321
|
+
sys.exit(2)
|
|
322
|
+
except OSError as exc:
|
|
323
|
+
print(f"Error reading template file: {exc}", file=sys.stderr)
|
|
324
|
+
sys.exit(1)
|
|
325
|
+
|
|
326
|
+
new_servers = template_data.get("mcpServers", {})
|
|
327
|
+
if not new_servers:
|
|
328
|
+
print(f"Warning: no 'mcpServers' key found in {template_file}", file=sys.stderr)
|
|
329
|
+
return
|
|
330
|
+
|
|
331
|
+
mcp_path = Path(target_dir) / ".mcp.json"
|
|
332
|
+
config: dict = {"mcpServers": {}}
|
|
333
|
+
if mcp_path.is_file():
|
|
334
|
+
try:
|
|
335
|
+
config = load_json(str(mcp_path))
|
|
336
|
+
except json.JSONDecodeError as exc:
|
|
337
|
+
print(f"Error: malformed JSON in {mcp_path}: {exc}", file=sys.stderr)
|
|
338
|
+
sys.exit(2)
|
|
339
|
+
|
|
340
|
+
if "mcpServers" not in config or not isinstance(config["mcpServers"], dict):
|
|
341
|
+
config["mcpServers"] = {}
|
|
342
|
+
|
|
343
|
+
_check_collisions(config["mcpServers"], new_servers, source, force)
|
|
344
|
+
|
|
345
|
+
tagged = _tag_servers(new_servers, source)
|
|
346
|
+
stripped = _strip_servers_by_source(config["mcpServers"], source)
|
|
347
|
+
stripped.update(tagged)
|
|
348
|
+
config["mcpServers"] = stripped
|
|
349
|
+
|
|
350
|
+
save_json(str(mcp_path), config)
|
|
351
|
+
print(f"Injected MCP template '{source}' into {mcp_path}")
|
|
352
|
+
print(f" Servers: {', '.join(sorted(new_servers.keys()))}")
|
|
353
|
+
|
|
354
|
+
if not is_url:
|
|
355
|
+
try:
|
|
356
|
+
from mcp_sources import register_path_source
|
|
357
|
+
register_path_source(
|
|
358
|
+
None, source, Path(template_file),
|
|
359
|
+
content=Path(template_file).read_bytes(),
|
|
360
|
+
)
|
|
361
|
+
except Exception as exc:
|
|
362
|
+
print(f"Warning: could not register local source: {exc}", file=sys.stderr)
|
|
363
|
+
|
|
364
|
+
servers_clean = {name: _strip_source_tag(s) for name, s in new_servers.items()}
|
|
365
|
+
_propagate_to_editors(servers_clean, source, target_dir, force)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def remove(source_name: str, target_dir: str) -> None:
|
|
369
|
+
"""Remove all server entries tagged with *source_name*.
|
|
370
|
+
|
|
371
|
+
Also unregisters the URL source if it was URL-sourced, removes cached file,
|
|
372
|
+
and cleans matching server names from every global editor config.
|
|
373
|
+
"""
|
|
374
|
+
if source_name == PROTECTED_SOURCE:
|
|
375
|
+
print(
|
|
376
|
+
f"Error: cannot remove '{PROTECTED_SOURCE}' entries. "
|
|
377
|
+
"Use 'ai-toolkit uninstall' instead.",
|
|
378
|
+
file=sys.stderr,
|
|
379
|
+
)
|
|
380
|
+
sys.exit(1)
|
|
381
|
+
|
|
382
|
+
mcp_path = Path(target_dir) / ".mcp.json"
|
|
383
|
+
|
|
384
|
+
server_names: list[str] = []
|
|
385
|
+
if mcp_path.is_file():
|
|
386
|
+
try:
|
|
387
|
+
config = load_json(str(mcp_path))
|
|
388
|
+
except json.JSONDecodeError as exc:
|
|
389
|
+
print(f"Error: malformed JSON in {mcp_path}: {exc}", file=sys.stderr)
|
|
390
|
+
sys.exit(2)
|
|
391
|
+
|
|
392
|
+
servers = config.get("mcpServers", {})
|
|
393
|
+
if isinstance(servers, dict):
|
|
394
|
+
server_names = _server_names_for_source(servers, source_name)
|
|
395
|
+
config["mcpServers"] = _strip_servers_by_source(servers, source_name)
|
|
396
|
+
save_json(str(mcp_path), config)
|
|
397
|
+
if server_names:
|
|
398
|
+
print(
|
|
399
|
+
f"Removed source '{source_name}' from {mcp_path} "
|
|
400
|
+
f"(servers: {', '.join(server_names)})"
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
if server_names:
|
|
404
|
+
_remove_from_editors(server_names, target_dir)
|
|
405
|
+
|
|
406
|
+
try:
|
|
407
|
+
from mcp_sources import unregister_source
|
|
408
|
+
from paths import EXTERNAL_MCP_DIR
|
|
409
|
+
|
|
410
|
+
if unregister_source(None, source_name):
|
|
411
|
+
print(f"Unregistered MCP source '{source_name}'")
|
|
412
|
+
|
|
413
|
+
cached = EXTERNAL_MCP_DIR / f"{source_name}.json"
|
|
414
|
+
if cached.is_file():
|
|
415
|
+
cached.unlink()
|
|
416
|
+
except ImportError:
|
|
417
|
+
pass
|
|
418
|
+
|
|
419
|
+
if not server_names:
|
|
420
|
+
print(f"No entries with source '{source_name}' found.")
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _parse_args(argv: list[str]) -> dict:
|
|
424
|
+
"""Parse CLI arguments."""
|
|
425
|
+
result: dict = {
|
|
426
|
+
"remove_mode": False,
|
|
427
|
+
"remove_name": "",
|
|
428
|
+
"source_file": "",
|
|
429
|
+
"template_name": "",
|
|
430
|
+
"target_dir": str(Path.home()),
|
|
431
|
+
"force": False,
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
i = 0
|
|
435
|
+
positional = 0
|
|
436
|
+
while i < len(argv):
|
|
437
|
+
arg = argv[i]
|
|
438
|
+
if arg == "--remove":
|
|
439
|
+
result["remove_mode"] = True
|
|
440
|
+
i += 1
|
|
441
|
+
if i >= len(argv):
|
|
442
|
+
print("--remove requires a template source name", file=sys.stderr)
|
|
443
|
+
sys.exit(1)
|
|
444
|
+
result["remove_name"] = argv[i]
|
|
445
|
+
elif arg == "--name":
|
|
446
|
+
i += 1
|
|
447
|
+
if i >= len(argv):
|
|
448
|
+
print("--name requires a template source name", file=sys.stderr)
|
|
449
|
+
sys.exit(1)
|
|
450
|
+
result["template_name"] = argv[i]
|
|
451
|
+
elif arg == "--force":
|
|
452
|
+
result["force"] = True
|
|
453
|
+
elif arg.startswith("-"):
|
|
454
|
+
print(f"Unknown option: {arg}", file=sys.stderr)
|
|
455
|
+
sys.exit(1)
|
|
456
|
+
else:
|
|
457
|
+
if positional == 0:
|
|
458
|
+
if not result["remove_mode"]:
|
|
459
|
+
result["source_file"] = arg
|
|
460
|
+
else:
|
|
461
|
+
result["target_dir"] = arg
|
|
462
|
+
elif positional == 1:
|
|
463
|
+
if _is_url(result["source_file"]):
|
|
464
|
+
if arg.startswith(("/", "~", ".")):
|
|
465
|
+
result["target_dir"] = arg
|
|
466
|
+
else:
|
|
467
|
+
result["template_name"] = arg
|
|
468
|
+
else:
|
|
469
|
+
result["target_dir"] = arg
|
|
470
|
+
elif positional == 2:
|
|
471
|
+
result["target_dir"] = arg
|
|
472
|
+
positional += 1
|
|
473
|
+
i += 1
|
|
474
|
+
|
|
475
|
+
return result
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def main() -> None:
|
|
479
|
+
"""Inject or remove external MCP templates."""
|
|
480
|
+
args = _parse_args(sys.argv[1:])
|
|
481
|
+
|
|
482
|
+
if args["remove_mode"]:
|
|
483
|
+
remove(args["remove_name"], args["target_dir"])
|
|
484
|
+
return
|
|
485
|
+
|
|
486
|
+
source_file = args["source_file"]
|
|
487
|
+
if not source_file:
|
|
488
|
+
print(
|
|
489
|
+
"Usage: inject_mcp_cli.py <template-file-or-url> [template-name] "
|
|
490
|
+
"[target-dir] [--force]",
|
|
491
|
+
file=sys.stderr,
|
|
492
|
+
)
|
|
493
|
+
print(
|
|
494
|
+
" inject_mcp_cli.py --remove <template-source-name> [target-dir]",
|
|
495
|
+
file=sys.stderr,
|
|
496
|
+
)
|
|
497
|
+
sys.exit(1)
|
|
498
|
+
|
|
499
|
+
if not _is_url(source_file):
|
|
500
|
+
source_path = Path(source_file)
|
|
501
|
+
if not source_path.is_file():
|
|
502
|
+
print(f"Template file not found: {source_path}", file=sys.stderr)
|
|
503
|
+
sys.exit(1)
|
|
504
|
+
|
|
505
|
+
inject(
|
|
506
|
+
source_file,
|
|
507
|
+
args["target_dir"],
|
|
508
|
+
source_override=args["template_name"],
|
|
509
|
+
force=args["force"],
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
if __name__ == "__main__":
|
|
514
|
+
main()
|
package/scripts/install.py
CHANGED
|
@@ -52,7 +52,7 @@ from emission import agent_count as count_agents, skill_count as count_skills
|
|
|
52
52
|
# Step modules
|
|
53
53
|
from install_steps.symlinks import install_agents, install_skills, clean_legacy_commands
|
|
54
54
|
from install_steps.hooks import install_hooks
|
|
55
|
-
from install_steps.markers import install_marker_files, inject_rules, refresh_url_hooks
|
|
55
|
+
from install_steps.markers import install_marker_files, inject_rules, refresh_url_hooks, refresh_url_mcp
|
|
56
56
|
from install_steps.ai_tools import install_ai_tools, install_local_project, run_script
|
|
57
57
|
from install_steps.install_state import (
|
|
58
58
|
load_state,
|
|
@@ -449,6 +449,7 @@ def install_claude_code(target_dir: Path, hooks_scripts_dir: Path,
|
|
|
449
449
|
|
|
450
450
|
if not dry_run:
|
|
451
451
|
refresh_url_hooks(str(target_dir))
|
|
452
|
+
refresh_url_mcp(str(target_dir))
|
|
452
453
|
|
|
453
454
|
_sync_mcp_templates(dry_run)
|
|
454
455
|
|
|
@@ -58,6 +58,9 @@ def _copy_hook_scripts(claude_dir: Path, hooks_scripts_dir: Path) -> None:
|
|
|
58
58
|
shutil.copy2(hook_file, dst)
|
|
59
59
|
dst.chmod(dst.stat().st_mode | 0o111)
|
|
60
60
|
copied += 1
|
|
61
|
+
for runtime_file in sorted(hooks_src.glob("*.json")):
|
|
62
|
+
shutil.copy2(runtime_file, hooks_scripts_dir / runtime_file.name)
|
|
63
|
+
copied += 1
|
|
61
64
|
print(f" Copied: {copied} hook scripts to ~/.softspark/ai-toolkit/hooks/")
|
|
62
65
|
legacy_hooks = claude_dir / "hooks"
|
|
63
66
|
if legacy_hooks.is_symlink():
|
|
@@ -68,7 +71,9 @@ def _copy_hook_scripts(claude_dir: Path, hooks_scripts_dir: Path) -> None:
|
|
|
68
71
|
# Python helpers that hooks invoke at runtime. Kept narrow on purpose — only
|
|
69
72
|
# scripts that a deployed hook actually executes belong here.
|
|
70
73
|
HOOK_RUNTIME_SCRIPTS: tuple[str, ...] = (
|
|
74
|
+
"session_state.py",
|
|
71
75
|
"session_token_stats.py",
|
|
76
|
+
"test_cohesion.py",
|
|
72
77
|
"version_check.py",
|
|
73
78
|
)
|
|
74
79
|
|
|
@@ -141,6 +141,46 @@ def refresh_url_hooks(target_dir: str | None = None) -> None:
|
|
|
141
141
|
inject(str(cached_file), target, source_override=hook_name)
|
|
142
142
|
|
|
143
143
|
|
|
144
|
+
def refresh_url_mcp(target_dir: str | None = None) -> None:
|
|
145
|
+
"""Re-fetch all URL-sourced MCP templates and re-inject them.
|
|
146
|
+
|
|
147
|
+
Called during ``ai-toolkit update`` to keep URL-sourced MCP templates
|
|
148
|
+
current. On fetch failure, warns and keeps the cached version.
|
|
149
|
+
"""
|
|
150
|
+
from mcp_sources import get_url_templates, register_url_source
|
|
151
|
+
from paths import EXTERNAL_MCP_DIR
|
|
152
|
+
from url_fetch import fetch_url
|
|
153
|
+
import json
|
|
154
|
+
|
|
155
|
+
url_templates = get_url_templates()
|
|
156
|
+
if not url_templates:
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
print(" Refreshing URL-sourced MCP templates...")
|
|
160
|
+
target = target_dir or str(Path.home())
|
|
161
|
+
|
|
162
|
+
for template_name, url in url_templates.items():
|
|
163
|
+
cached_file = EXTERNAL_MCP_DIR / f"{template_name}.json"
|
|
164
|
+
try:
|
|
165
|
+
data = fetch_url(url)
|
|
166
|
+
json.loads(data)
|
|
167
|
+
cached_file.write_bytes(data)
|
|
168
|
+
register_url_source(None, template_name, url, content=data)
|
|
169
|
+
print(f" Refreshed: {template_name} (from {url})")
|
|
170
|
+
except Exception as exc:
|
|
171
|
+
if cached_file.is_file():
|
|
172
|
+
print(f" Warning: could not refresh '{template_name}' from {url}: {exc}")
|
|
173
|
+
print(f" Using cached version.")
|
|
174
|
+
else:
|
|
175
|
+
print(f" Warning: could not fetch '{template_name}' from {url}: {exc}")
|
|
176
|
+
print(f" No cached version — template will be skipped.")
|
|
177
|
+
continue
|
|
178
|
+
|
|
179
|
+
if cached_file.is_file():
|
|
180
|
+
from inject_mcp_cli import inject
|
|
181
|
+
inject(str(cached_file), target, source_override=template_name, force=True)
|
|
182
|
+
|
|
183
|
+
|
|
144
184
|
def _inject_rules_dry_run(rules_dir: Path) -> None:
|
|
145
185
|
rules_src = app_dir / "rules"
|
|
146
186
|
rule_names = " ".join(
|