@softspark/ai-toolkit 2.1.2 → 2.2.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 +272 -10
- package/CHANGELOG.md +17 -2
- package/README.md +3 -3
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/ARCHITECTURE.md +1 -1
- 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/skills-catalog.md +3 -1
- package/llms-full.txt +7 -3
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/add_rule.py +60 -18
- package/scripts/generate_agents_md.py +12 -14
- package/scripts/generate_codex.py +11 -14
- package/scripts/generator_base.py +11 -13
- package/scripts/install.py +8 -2
- package/scripts/install_steps/ai_tools.py +13 -14
- package/scripts/install_steps/markers.py +35 -2
- package/scripts/install_steps/project_registry.py +132 -60
- 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
|
@@ -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()
|