@softspark/ai-toolkit 2.1.3 → 2.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/AGENTS.md +80 -10
- package/CHANGELOG.md +26 -0
- package/README.md +3 -3
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/ARCHITECTURE.md +2 -2
- package/app/mcp-templates/README.md +5 -3
- package/app/mcp-templates/jira.json +11 -0
- package/bin/ai-toolkit.js +8 -6
- package/kb/procedures/maintenance-sop.md +1 -0
- package/kb/procedures/release-preparation-sop.md +3 -2
- package/kb/reference/architecture-overview.md +1 -1
- package/kb/reference/cli-reference.md +1 -1
- package/kb/reference/manifest-install.md +1 -1
- package/kb/reference/mcp-templates.md +2 -2
- package/kb/reference/skills-catalog.md +3 -1
- package/llms-full.txt +12 -8
- package/manifest.json +2 -2
- package/package.json +1 -1
- package/scripts/add_rule.py +60 -18
- package/scripts/install.py +40 -2
- package/scripts/install_steps/install_state.py +29 -0
- package/scripts/install_steps/markers.py +35 -2
- package/scripts/install_steps/project_registry.py +132 -60
- package/scripts/mcp_editors.py +2 -2
- package/scripts/mcp_manager.py +29 -0
- package/scripts/remove_rule.py +5 -0
- package/scripts/rule_sources.py +138 -0
- package/scripts/update_projects.py +15 -2
- package/scripts/validate.py +33 -0
|
@@ -60,6 +60,31 @@ def get_installed_profile() -> str:
|
|
|
60
60
|
return state.get("profile", "")
|
|
61
61
|
|
|
62
62
|
|
|
63
|
+
def get_mcp_templates() -> list[str]:
|
|
64
|
+
"""Return list of globally tracked MCP template names."""
|
|
65
|
+
state = load_state()
|
|
66
|
+
templates = state.get("mcp_templates", [])
|
|
67
|
+
return templates if isinstance(templates, list) else []
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def record_mcp_template(name: str) -> None:
|
|
71
|
+
"""Add a template name to the tracked set in state.json."""
|
|
72
|
+
state = load_state()
|
|
73
|
+
templates = set(state.get("mcp_templates", []))
|
|
74
|
+
templates.add(name)
|
|
75
|
+
state["mcp_templates"] = sorted(templates)
|
|
76
|
+
save_state(state)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def remove_mcp_template(name: str) -> None:
|
|
80
|
+
"""Remove a template name from the tracked set in state.json."""
|
|
81
|
+
state = load_state()
|
|
82
|
+
templates = set(state.get("mcp_templates", []))
|
|
83
|
+
templates.discard(name)
|
|
84
|
+
state["mcp_templates"] = sorted(templates)
|
|
85
|
+
save_state(state)
|
|
86
|
+
|
|
87
|
+
|
|
63
88
|
def _now_iso() -> str:
|
|
64
89
|
"""Return current UTC time in ISO 8601 format."""
|
|
65
90
|
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
@@ -138,6 +163,10 @@ def print_status() -> None:
|
|
|
138
163
|
langs = [m.replace("rules-", "") for m in detected]
|
|
139
164
|
print(f" Detected: {', '.join(langs)}")
|
|
140
165
|
|
|
166
|
+
mcp = state.get("mcp_templates", [])
|
|
167
|
+
if mcp:
|
|
168
|
+
print(f" MCP: {', '.join(mcp)}")
|
|
169
|
+
|
|
141
170
|
extends = state.get("extends")
|
|
142
171
|
if extends:
|
|
143
172
|
print(f" Extends: {extends.get('source', 'unknown')}")
|
|
@@ -32,14 +32,23 @@ def install_marker_files(claude_dir: Path, only: str, skip: str,
|
|
|
32
32
|
|
|
33
33
|
|
|
34
34
|
def inject_rules(claude_dir: Path, target_dir: Path, rules_dir: Path,
|
|
35
|
-
only: str, skip: str, dry_run: bool
|
|
36
|
-
|
|
35
|
+
only: str, skip: str, dry_run: bool,
|
|
36
|
+
refresh_urls: bool = False) -> None:
|
|
37
|
+
"""Inject rules into CLAUDE.md.
|
|
38
|
+
|
|
39
|
+
When refresh_urls is True, re-fetches URL-sourced rules before injection.
|
|
40
|
+
Only the global install path should set this to True (once per update).
|
|
41
|
+
"""
|
|
37
42
|
claude_md = claude_dir / "CLAUDE.md"
|
|
38
43
|
|
|
39
44
|
if dry_run:
|
|
40
45
|
_inject_rules_dry_run(rules_dir)
|
|
41
46
|
return
|
|
42
47
|
|
|
48
|
+
# Refresh URL-sourced rules before injection (global update only)
|
|
49
|
+
if refresh_urls:
|
|
50
|
+
_refresh_url_rules(rules_dir)
|
|
51
|
+
|
|
43
52
|
if not claude_md.is_file():
|
|
44
53
|
claude_md.touch()
|
|
45
54
|
print(" Created: ~/.claude/CLAUDE.md")
|
|
@@ -66,6 +75,30 @@ def inject_rules(claude_dir: Path, target_dir: Path, rules_dir: Path,
|
|
|
66
75
|
print(f" Rules injected: {' '.join(rules_injected)}")
|
|
67
76
|
|
|
68
77
|
|
|
78
|
+
def _refresh_url_rules(rules_dir: Path) -> None:
|
|
79
|
+
"""Re-fetch all URL-sourced rules. Warn on failure, use cached copy."""
|
|
80
|
+
from rule_sources import get_url_rules, fetch_url, register_url_source
|
|
81
|
+
|
|
82
|
+
url_rules = get_url_rules(rules_dir)
|
|
83
|
+
if not url_rules:
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
for rule_name, url in url_rules.items():
|
|
87
|
+
rule_file = rules_dir / f"{rule_name}.md"
|
|
88
|
+
try:
|
|
89
|
+
data = fetch_url(url)
|
|
90
|
+
rule_file.write_bytes(data)
|
|
91
|
+
register_url_source(rules_dir, rule_name, url)
|
|
92
|
+
print(f" Refreshed: {rule_name} (from {url})")
|
|
93
|
+
except Exception as exc:
|
|
94
|
+
if rule_file.is_file():
|
|
95
|
+
print(f" Warning: could not refresh '{rule_name}' from {url}: {exc}")
|
|
96
|
+
print(f" Using cached version.")
|
|
97
|
+
else:
|
|
98
|
+
print(f" Warning: could not fetch '{rule_name}' from {url}: {exc}")
|
|
99
|
+
print(f" No cached version — rule will be skipped.")
|
|
100
|
+
|
|
101
|
+
|
|
69
102
|
def _inject_rules_dry_run(rules_dir: Path) -> None:
|
|
70
103
|
rules_src = app_dir / "rules"
|
|
71
104
|
rule_names = " ".join(
|
|
@@ -8,15 +8,43 @@ Stdlib-only — no external dependencies.
|
|
|
8
8
|
"""
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
import contextlib
|
|
12
|
+
import fcntl
|
|
11
13
|
import json
|
|
14
|
+
import os
|
|
12
15
|
import sys
|
|
16
|
+
import tempfile
|
|
17
|
+
import time
|
|
13
18
|
from datetime import datetime, timezone
|
|
14
19
|
from pathlib import Path
|
|
15
|
-
from typing import Any
|
|
20
|
+
from typing import Any, Generator
|
|
16
21
|
|
|
17
22
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
18
23
|
from paths import PROJECTS_FILE
|
|
19
24
|
|
|
25
|
+
# Max retries when reading a partially-written file
|
|
26
|
+
_LOAD_RETRIES = 3
|
|
27
|
+
_LOAD_RETRY_DELAY = 0.05 # 50ms
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@contextlib.contextmanager
|
|
31
|
+
def _registry_lock() -> Generator[None, None, None]:
|
|
32
|
+
"""Exclusive file lock for read-modify-write on projects.json.
|
|
33
|
+
|
|
34
|
+
Prevents concurrent processes from interleaving loads and saves,
|
|
35
|
+
which can silently drop entries.
|
|
36
|
+
"""
|
|
37
|
+
path = _registry_path()
|
|
38
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
lock_path = path.with_suffix(".lock")
|
|
40
|
+
fd = os.open(str(lock_path), os.O_CREAT | os.O_RDWR)
|
|
41
|
+
try:
|
|
42
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
43
|
+
yield
|
|
44
|
+
finally:
|
|
45
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
46
|
+
os.close(fd)
|
|
47
|
+
|
|
20
48
|
|
|
21
49
|
def _registry_path() -> Path:
|
|
22
50
|
"""Return the canonical path to projects.json."""
|
|
@@ -32,28 +60,66 @@ def _now_iso() -> str:
|
|
|
32
60
|
# ---------------------------------------------------------------------------
|
|
33
61
|
|
|
34
62
|
def load_registry() -> list[dict[str, Any]]:
|
|
35
|
-
"""Load project registry
|
|
63
|
+
"""Load project registry with retry for partially-written files.
|
|
64
|
+
|
|
65
|
+
Retries on JSONDecodeError (another process mid-write).
|
|
66
|
+
Returns empty list only if the file genuinely doesn't exist.
|
|
67
|
+
"""
|
|
36
68
|
path = _registry_path()
|
|
37
69
|
if not path.is_file():
|
|
38
70
|
return []
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
71
|
+
|
|
72
|
+
last_err: Exception | None = None
|
|
73
|
+
for attempt in range(_LOAD_RETRIES):
|
|
74
|
+
try:
|
|
75
|
+
with open(path, encoding="utf-8") as f:
|
|
76
|
+
data = json.load(f)
|
|
77
|
+
if isinstance(data, dict):
|
|
78
|
+
projects = data.get("projects", [])
|
|
79
|
+
return projects if isinstance(projects, list) else []
|
|
80
|
+
return []
|
|
81
|
+
except json.JSONDecodeError as exc:
|
|
82
|
+
last_err = exc
|
|
83
|
+
if attempt < _LOAD_RETRIES - 1:
|
|
84
|
+
time.sleep(_LOAD_RETRY_DELAY)
|
|
85
|
+
except OSError:
|
|
86
|
+
return []
|
|
87
|
+
|
|
88
|
+
# All retries exhausted — file is genuinely corrupt, not mid-write
|
|
89
|
+
import sys as _sys
|
|
90
|
+
print(
|
|
91
|
+
f"Warning: {path} is corrupt after {_LOAD_RETRIES} retries: {last_err}",
|
|
92
|
+
file=_sys.stderr,
|
|
93
|
+
)
|
|
94
|
+
return []
|
|
48
95
|
|
|
49
96
|
|
|
50
97
|
def save_registry(projects: list[dict[str, Any]]) -> None:
|
|
51
|
-
"""Save project registry.
|
|
98
|
+
"""Save project registry atomically (write-to-temp + rename).
|
|
99
|
+
|
|
100
|
+
Uses os.rename which is atomic on POSIX, preventing other processes
|
|
101
|
+
from reading a partially-written file.
|
|
102
|
+
"""
|
|
52
103
|
path = _registry_path()
|
|
53
104
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
105
|
+
|
|
106
|
+
fd, tmp_path = tempfile.mkstemp(
|
|
107
|
+
dir=str(path.parent), prefix=".projects_", suffix=".tmp"
|
|
108
|
+
)
|
|
109
|
+
try:
|
|
110
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
111
|
+
json.dump({"projects": projects}, f, indent=2)
|
|
112
|
+
f.write("\n")
|
|
113
|
+
f.flush()
|
|
114
|
+
os.fsync(f.fileno())
|
|
115
|
+
os.rename(tmp_path, str(path))
|
|
116
|
+
except BaseException:
|
|
117
|
+
# Clean up temp file on failure
|
|
118
|
+
try:
|
|
119
|
+
os.unlink(tmp_path)
|
|
120
|
+
except OSError:
|
|
121
|
+
pass
|
|
122
|
+
raise
|
|
57
123
|
|
|
58
124
|
|
|
59
125
|
# ---------------------------------------------------------------------------
|
|
@@ -68,47 +134,52 @@ def register_project(
|
|
|
68
134
|
"""Register a project directory. Returns True if newly added, False if updated.
|
|
69
135
|
|
|
70
136
|
Idempotent — updates existing entry if path already registered.
|
|
137
|
+
Uses file lock to prevent concurrent read-modify-write races.
|
|
71
138
|
"""
|
|
72
139
|
project_path = str(Path(project_path).resolve())
|
|
73
|
-
projects = load_registry()
|
|
74
|
-
now = _now_iso()
|
|
75
|
-
|
|
76
|
-
for p in projects:
|
|
77
|
-
if p.get("path") == project_path:
|
|
78
|
-
# Update existing
|
|
79
|
-
p["last_updated"] = now
|
|
80
|
-
if profile:
|
|
81
|
-
p["profile"] = profile
|
|
82
|
-
if extends:
|
|
83
|
-
p["extends"] = extends
|
|
84
|
-
elif "extends" in p and not extends:
|
|
85
|
-
# Clear extends if project no longer uses it
|
|
86
|
-
pass
|
|
87
|
-
save_registry(projects)
|
|
88
|
-
return False
|
|
89
140
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
141
|
+
with _registry_lock():
|
|
142
|
+
projects = load_registry()
|
|
143
|
+
now = _now_iso()
|
|
144
|
+
|
|
145
|
+
for p in projects:
|
|
146
|
+
if p.get("path") == project_path:
|
|
147
|
+
# Update existing
|
|
148
|
+
p["last_updated"] = now
|
|
149
|
+
if profile:
|
|
150
|
+
p["profile"] = profile
|
|
151
|
+
if extends:
|
|
152
|
+
p["extends"] = extends
|
|
153
|
+
elif "extends" in p and not extends:
|
|
154
|
+
# Clear extends if project no longer uses it
|
|
155
|
+
pass
|
|
156
|
+
save_registry(projects)
|
|
157
|
+
return False
|
|
158
|
+
|
|
159
|
+
# New registration
|
|
160
|
+
projects.append({
|
|
161
|
+
"path": project_path,
|
|
162
|
+
"registered_at": now,
|
|
163
|
+
"last_updated": now,
|
|
164
|
+
"profile": profile or "standard",
|
|
165
|
+
"extends": extends or "",
|
|
166
|
+
})
|
|
167
|
+
save_registry(projects)
|
|
168
|
+
return True
|
|
100
169
|
|
|
101
170
|
|
|
102
171
|
def unregister_project(project_path: str | Path) -> bool:
|
|
103
172
|
"""Unregister a project. Returns True if found and removed."""
|
|
104
173
|
project_path = str(Path(project_path).resolve())
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
174
|
+
|
|
175
|
+
with _registry_lock():
|
|
176
|
+
projects = load_registry()
|
|
177
|
+
original_len = len(projects)
|
|
178
|
+
projects = [p for p in projects if p.get("path") != project_path]
|
|
179
|
+
if len(projects) < original_len:
|
|
180
|
+
save_registry(projects)
|
|
181
|
+
return True
|
|
182
|
+
return False
|
|
112
183
|
|
|
113
184
|
|
|
114
185
|
def list_projects() -> list[dict[str, Any]]:
|
|
@@ -121,18 +192,19 @@ def list_projects() -> list[dict[str, Any]]:
|
|
|
121
192
|
|
|
122
193
|
def prune_stale() -> list[str]:
|
|
123
194
|
"""Remove projects whose directories no longer exist. Returns pruned paths."""
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
195
|
+
with _registry_lock():
|
|
196
|
+
projects = load_registry()
|
|
197
|
+
pruned: list[str] = []
|
|
198
|
+
kept: list[dict[str, Any]] = []
|
|
199
|
+
|
|
200
|
+
for p in projects:
|
|
201
|
+
if Path(p["path"]).is_dir():
|
|
202
|
+
kept.append(p)
|
|
203
|
+
else:
|
|
204
|
+
pruned.append(p["path"])
|
|
205
|
+
|
|
206
|
+
if pruned:
|
|
207
|
+
save_registry(kept)
|
|
136
208
|
|
|
137
209
|
return pruned
|
|
138
210
|
|
package/scripts/mcp_editors.py
CHANGED
|
@@ -15,8 +15,8 @@ except ModuleNotFoundError: # pragma: no cover - Python 3.11+ should have tomll
|
|
|
15
15
|
EDITOR_SPECS: dict[str, dict[str, str | None]] = {
|
|
16
16
|
"claude": {
|
|
17
17
|
"label": "Claude Code",
|
|
18
|
-
"project_path": ".
|
|
19
|
-
"global_path": ".claude
|
|
18
|
+
"project_path": ".mcp.json",
|
|
19
|
+
"global_path": ".claude.json",
|
|
20
20
|
"format": "json",
|
|
21
21
|
"doc_scope": "project + global",
|
|
22
22
|
},
|
package/scripts/mcp_manager.py
CHANGED
|
@@ -130,6 +130,11 @@ def cmd_show(name: str) -> None:
|
|
|
130
130
|
for key, val in env_vars:
|
|
131
131
|
print(f" {key} = {val}")
|
|
132
132
|
|
|
133
|
+
post_install = data.get("postInstall")
|
|
134
|
+
if post_install:
|
|
135
|
+
print()
|
|
136
|
+
print(f"Setup: {post_install}")
|
|
137
|
+
|
|
133
138
|
|
|
134
139
|
def cmd_add(names: list[str], target_dir: Path) -> None:
|
|
135
140
|
"""Add one or more MCP templates to .mcp.json."""
|
|
@@ -155,6 +160,13 @@ def cmd_add(names: list[str], target_dir: Path) -> None:
|
|
|
155
160
|
write_mcp_config(target_dir, config)
|
|
156
161
|
print(f"Added: {', '.join(added)}")
|
|
157
162
|
|
|
163
|
+
# Show postInstall hints from added templates
|
|
164
|
+
for name in names:
|
|
165
|
+
data = load_template(name)
|
|
166
|
+
post_install = data.get("postInstall")
|
|
167
|
+
if post_install:
|
|
168
|
+
print(f"\n Note ({name}): {post_install}")
|
|
169
|
+
|
|
158
170
|
|
|
159
171
|
def cmd_install(
|
|
160
172
|
names: list[str],
|
|
@@ -203,6 +215,20 @@ def cmd_install(
|
|
|
203
215
|
for path in updated:
|
|
204
216
|
print(f"Updated: {path}")
|
|
205
217
|
|
|
218
|
+
# Track globally installed templates in state.json
|
|
219
|
+
if eff_scope == "global" and names:
|
|
220
|
+
from install_steps.install_state import record_mcp_template
|
|
221
|
+
for name in names:
|
|
222
|
+
record_mcp_template(name)
|
|
223
|
+
|
|
224
|
+
# Show postInstall hints from installed templates
|
|
225
|
+
if names:
|
|
226
|
+
for name in names:
|
|
227
|
+
data = load_template(name)
|
|
228
|
+
post_install = data.get("postInstall")
|
|
229
|
+
if post_install:
|
|
230
|
+
print(f"\n Note ({name}): {post_install}")
|
|
231
|
+
|
|
206
232
|
|
|
207
233
|
def cmd_remove(name: str, target_dir: Path | None, *, editors: list[str], scope: str | None) -> None:
|
|
208
234
|
"""Remove an MCP server from .mcp.json."""
|
|
@@ -223,6 +249,9 @@ def cmd_remove(name: str, target_dir: Path | None, *, editors: list[str], scope:
|
|
|
223
249
|
)
|
|
224
250
|
else:
|
|
225
251
|
updated = remove_servers(editors, [name], scope="global")
|
|
252
|
+
# Untrack globally removed template from state.json
|
|
253
|
+
from install_steps.install_state import remove_mcp_template
|
|
254
|
+
remove_mcp_template(name)
|
|
226
255
|
for path in updated:
|
|
227
256
|
print(f"Updated: {path}")
|
|
228
257
|
print(f"Removed: {name}")
|
package/scripts/remove_rule.py
CHANGED
|
@@ -43,6 +43,11 @@ def main() -> None:
|
|
|
43
43
|
else:
|
|
44
44
|
print(f"Not registered: '{rule_name}' not found in {rules_dir}")
|
|
45
45
|
|
|
46
|
+
# 1b. Clean up URL source metadata (if any)
|
|
47
|
+
from rule_sources import unregister_source
|
|
48
|
+
if unregister_source(rules_dir, rule_name):
|
|
49
|
+
print(f"Removed URL source for '{rule_name}'")
|
|
50
|
+
|
|
46
51
|
# 2. Strip injected block from .claude/CLAUDE.md
|
|
47
52
|
found = remove_rule_section(rule_name, target_dir)
|
|
48
53
|
if found:
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""URL source registry for remotely-sourced rules.
|
|
3
|
+
|
|
4
|
+
Tracks which rules were registered from a URL so that `ai-toolkit update`
|
|
5
|
+
can re-fetch the latest version before injection.
|
|
6
|
+
|
|
7
|
+
Metadata stored in ~/.softspark/ai-toolkit/rules/sources.json.
|
|
8
|
+
|
|
9
|
+
Stdlib-only — no external dependencies.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import ssl
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
import urllib.request
|
|
19
|
+
import urllib.error
|
|
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 RULES_DIR
|
|
26
|
+
|
|
27
|
+
_SOURCES_FILENAME = "sources.json"
|
|
28
|
+
_FETCH_TIMEOUT = 30 # seconds
|
|
29
|
+
_FETCH_MAX_BYTES = 10 * 1024 * 1024 # 10MB
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Load / Save
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
def _sources_path(rules_dir: Path | None = None) -> Path:
|
|
37
|
+
return (rules_dir or RULES_DIR) / _SOURCES_FILENAME
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load_sources(rules_dir: Path | None = None) -> dict[str, dict[str, Any]]:
|
|
41
|
+
"""Load sources.json. Returns {} if missing or corrupt."""
|
|
42
|
+
path = _sources_path(rules_dir)
|
|
43
|
+
if not path.is_file():
|
|
44
|
+
return {}
|
|
45
|
+
try:
|
|
46
|
+
with open(path, encoding="utf-8") as f:
|
|
47
|
+
data = json.load(f)
|
|
48
|
+
if isinstance(data, dict):
|
|
49
|
+
return data.get("rules", {})
|
|
50
|
+
return {}
|
|
51
|
+
except (json.JSONDecodeError, OSError):
|
|
52
|
+
return {}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def save_sources(rules_dir: Path | None = None,
|
|
56
|
+
sources: dict[str, dict[str, Any]] | None = None) -> None:
|
|
57
|
+
"""Write sources.json atomically."""
|
|
58
|
+
rules_dir = rules_dir or RULES_DIR
|
|
59
|
+
path = _sources_path(rules_dir)
|
|
60
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
|
|
62
|
+
payload = json.dumps({"schema_version": 1, "rules": sources or {}}, indent=2)
|
|
63
|
+
|
|
64
|
+
fd, tmp_path = tempfile.mkstemp(
|
|
65
|
+
dir=str(path.parent), prefix=".sources_", suffix=".tmp"
|
|
66
|
+
)
|
|
67
|
+
try:
|
|
68
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
69
|
+
f.write(payload)
|
|
70
|
+
f.write("\n")
|
|
71
|
+
f.flush()
|
|
72
|
+
os.fsync(f.fileno())
|
|
73
|
+
os.rename(tmp_path, str(path))
|
|
74
|
+
except BaseException:
|
|
75
|
+
try:
|
|
76
|
+
os.unlink(tmp_path)
|
|
77
|
+
except OSError:
|
|
78
|
+
pass
|
|
79
|
+
raise
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
# CRUD
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def register_url_source(rules_dir: Path | None, rule_name: str, url: str) -> None:
|
|
87
|
+
"""Add or update a URL source entry."""
|
|
88
|
+
rules_dir = rules_dir or RULES_DIR
|
|
89
|
+
sources = load_sources(rules_dir)
|
|
90
|
+
sources[rule_name] = {
|
|
91
|
+
"url": url,
|
|
92
|
+
"fetched_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
93
|
+
}
|
|
94
|
+
save_sources(rules_dir, sources)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def unregister_source(rules_dir: Path | None, rule_name: str) -> bool:
|
|
98
|
+
"""Remove a source entry. Returns True if found and removed."""
|
|
99
|
+
rules_dir = rules_dir or RULES_DIR
|
|
100
|
+
sources = load_sources(rules_dir)
|
|
101
|
+
if rule_name in sources:
|
|
102
|
+
del sources[rule_name]
|
|
103
|
+
save_sources(rules_dir, sources)
|
|
104
|
+
return True
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def get_url_rules(rules_dir: Path | None = None) -> dict[str, str]:
|
|
109
|
+
"""Return {rule_name: url} for all URL-sourced rules."""
|
|
110
|
+
sources = load_sources(rules_dir)
|
|
111
|
+
return {name: entry["url"] for name, entry in sources.items() if "url" in entry}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
# Fetch
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
def fetch_url(url: str) -> bytes:
|
|
119
|
+
"""Fetch URL content. HTTPS only, 30s timeout, 10MB cap.
|
|
120
|
+
|
|
121
|
+
Raises:
|
|
122
|
+
ValueError: if URL is not HTTPS
|
|
123
|
+
urllib.error.URLError: on network failure
|
|
124
|
+
"""
|
|
125
|
+
if not url.startswith("https://"):
|
|
126
|
+
raise ValueError(
|
|
127
|
+
f"Only HTTPS URLs are supported (got: {url.split('://')[0]}://)"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
ctx = ssl.create_default_context()
|
|
131
|
+
with urllib.request.urlopen(url, timeout=_FETCH_TIMEOUT, context=ctx) as resp:
|
|
132
|
+
data = resp.read(_FETCH_MAX_BYTES)
|
|
133
|
+
|
|
134
|
+
# Basic binary detection — reject if null bytes present
|
|
135
|
+
if b"\x00" in data:
|
|
136
|
+
raise ValueError(f"URL returned binary content, expected markdown: {url}")
|
|
137
|
+
|
|
138
|
+
return data
|
|
@@ -17,7 +17,7 @@ from pathlib import Path
|
|
|
17
17
|
from typing import Any
|
|
18
18
|
|
|
19
19
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
20
|
-
from install_steps.project_registry import get_active_projects, prune_stale
|
|
20
|
+
from install_steps.project_registry import get_active_projects, prune_stale, register_project
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
def _update_project(project: dict[str, Any], install_script: str, extra_args: list[str]) -> dict:
|
|
@@ -91,13 +91,17 @@ def main() -> None:
|
|
|
91
91
|
print(f" Updating {len(projects)} registered project(s)...")
|
|
92
92
|
print()
|
|
93
93
|
|
|
94
|
+
# --skip-register: parallel installs must NOT write to projects.json
|
|
95
|
+
# concurrently. We re-register sequentially after all installs complete.
|
|
96
|
+
parallel_args = extra_args + ["--skip-register"]
|
|
97
|
+
|
|
94
98
|
# Run in parallel (max 8 workers — don't overwhelm the system)
|
|
95
99
|
max_workers = min(len(projects), 8)
|
|
96
100
|
results: list[dict] = []
|
|
97
101
|
|
|
98
102
|
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
|
99
103
|
futures = {
|
|
100
|
-
pool.submit(_update_project, p, install_script,
|
|
104
|
+
pool.submit(_update_project, p, install_script, parallel_args): p
|
|
101
105
|
for p in projects
|
|
102
106
|
}
|
|
103
107
|
for future in as_completed(futures):
|
|
@@ -118,6 +122,15 @@ def main() -> None:
|
|
|
118
122
|
for line in result["error"].strip().split("\n"):
|
|
119
123
|
print(f" ERROR: {line}")
|
|
120
124
|
|
|
125
|
+
# Re-register projects sequentially (safe — no concurrent writes)
|
|
126
|
+
for result in results:
|
|
127
|
+
if result["success"]:
|
|
128
|
+
register_project(
|
|
129
|
+
result["path"],
|
|
130
|
+
profile=result.get("profile", "standard"),
|
|
131
|
+
extends=result.get("extends", ""),
|
|
132
|
+
)
|
|
133
|
+
|
|
121
134
|
# Summary
|
|
122
135
|
passed = sum(1 for r in results if r["success"])
|
|
123
136
|
failed = len(results) - passed
|
package/scripts/validate.py
CHANGED
|
@@ -610,10 +610,43 @@ def validate_metadata_contracts(
|
|
|
610
610
|
else:
|
|
611
611
|
print(f" OK: tests ({actual_tests})")
|
|
612
612
|
|
|
613
|
+
# Cross-validate versions: package.json vs manifest.json vs plugin.json
|
|
614
|
+
_validate_version_sync(tk_dir, vr)
|
|
615
|
+
|
|
613
616
|
print()
|
|
614
617
|
return actual_tests
|
|
615
618
|
|
|
616
619
|
|
|
620
|
+
def _validate_version_sync(tk_dir: Path, vr: ValidationResult) -> None:
|
|
621
|
+
"""Ensure package.json, manifest.json, and plugin.json versions match."""
|
|
622
|
+
import json as _json
|
|
623
|
+
|
|
624
|
+
version_files = {
|
|
625
|
+
"package.json": tk_dir / "package.json",
|
|
626
|
+
"manifest.json": tk_dir / "manifest.json",
|
|
627
|
+
"plugin.json": tk_dir / "app" / ".claude-plugin" / "plugin.json",
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
versions: dict[str, str] = {}
|
|
631
|
+
for name, path in version_files.items():
|
|
632
|
+
if path.is_file():
|
|
633
|
+
try:
|
|
634
|
+
data = _json.loads(path.read_text(encoding="utf-8"))
|
|
635
|
+
versions[name] = data.get("version", "")
|
|
636
|
+
except Exception:
|
|
637
|
+
pass
|
|
638
|
+
|
|
639
|
+
if len(versions) < 2:
|
|
640
|
+
return # Not enough files to compare (e.g., installed copy without source)
|
|
641
|
+
|
|
642
|
+
unique = set(versions.values())
|
|
643
|
+
if len(unique) == 1:
|
|
644
|
+
print(f" OK: version sync ({unique.pop()})")
|
|
645
|
+
else:
|
|
646
|
+
detail = ", ".join(f"{k}={v}" for k, v in versions.items())
|
|
647
|
+
vr.error(f"Version mismatch across files: {detail}")
|
|
648
|
+
|
|
649
|
+
|
|
617
650
|
def validate_content_quality(tk_dir: Path, vr: ValidationResult) -> None:
|
|
618
651
|
"""Check content quality: name matches directory, non-empty body."""
|
|
619
652
|
print()
|