@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.
@@ -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, extra_args): p
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
@@ -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()