@softspark/ai-toolkit 3.3.0 → 3.4.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 +34 -0
- package/README.md +8 -7
- package/app/.claude-plugin/plugin.json +1 -1
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/add_rule.py +4 -0
- package/scripts/hook_sources.py +32 -0
- package/scripts/inject_hook_cli.py +12 -0
- package/scripts/install_steps/install_state.py +35 -9
- package/scripts/rule_sources.py +31 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,40 @@ Versioning follows [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## v3.4.1 — URL-tracked sources protected from path-write demotion (2026-05-06)
|
|
11
|
+
|
|
12
|
+
Patch release. Fixes a regression introduced in v3.4.0 where `ai-toolkit update` could rewrite a URL-tracked hook entry into a local-path entry.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- **`register_path_source` no longer overwrites URL entries** in `scripts/hook_sources.py` and `scripts/rule_sources.py`. The function now returns early when an existing entry has a `url` field. Previously, `scripts/install_steps/markers.py:refresh_url_hooks` re-injected URL hooks from their cached file, and `inject_hook_cli.py` would call `register_path_source` with that cached path — silently demoting the URL record to a local one on every update.
|
|
17
|
+
- **Regression test** in `tests/test_inject_hook.bats` that pre-seeds a URL entry, runs the local-path inject path, and asserts the URL entry survives untouched.
|
|
18
|
+
|
|
19
|
+
### Recovery
|
|
20
|
+
|
|
21
|
+
- If your `~/.softspark/ai-toolkit/hooks/external/sources.json` has a `path` instead of a `url` for a hook that was originally URL-installed, run `ai-toolkit remove-hook <name>` followed by `ai-toolkit inject-hook <original-url>` to re-register from the URL.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## v3.4.0 — Local-path source tracking & orphan detection (2026-05-06)
|
|
26
|
+
|
|
27
|
+
Minor release. Closes the visibility gap for rules and hooks installed from local files: `add-rule` and `inject-hook` now record the origin path in `sources.json`, and `status` flags any rule files on disk that have no source recorded.
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
|
|
31
|
+
- **`register_path_source()` in `scripts/rule_sources.py` and `scripts/hook_sources.py`** — records `{"path": <abs_path>, "fetched_at": ..., "sha256": ...}` for local-file injections. Schema-compatible with existing URL entries (mutually exclusive `url` vs `path` field, `schema_version` unchanged at 1).
|
|
32
|
+
- **`add-rule` registers local paths** — `scripts/add_rule.py` now calls `register_path_source` after copying a local file, and prints the origin path with a hint to re-run `add-rule` to refresh.
|
|
33
|
+
- **`inject-hook` registers local paths** — `scripts/inject_hook_cli.py` writes a `path`-tracked entry into `~/.softspark/ai-toolkit/hooks/external/sources.json` for file-sourced hooks. URL behavior unchanged. Registration failures are non-fatal — injection always proceeds.
|
|
34
|
+
- **`ai-toolkit status` distinguishes URL vs local sources** — local-path entries display the absolute path with a `[local]` tag; URL entries display the URL. Format: `rule <name> <- <origin>[ [local]] (<fetched_at>)`.
|
|
35
|
+
- **Orphan rule detection in `status`** — files in `~/.softspark/ai-toolkit/rules/` that have no entry in `sources.json` are listed as `(orphan, no source recorded — re-run add-rule)`. Surfaces rules registered before the registry existed (pre-v3.3.0).
|
|
36
|
+
|
|
37
|
+
### Migration
|
|
38
|
+
|
|
39
|
+
- Existing URL-tracked sources continue to work unchanged.
|
|
40
|
+
- To upgrade an orphan local rule into the registry, re-run `ai-toolkit add-rule <path>` from the rule's source directory. The new run records the path.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
10
44
|
## v3.3.0 — External-source visibility & skill-listing budget default (2026-05-06)
|
|
11
45
|
|
|
12
46
|
Minor release. Two coordinated install-time additions: `ai-toolkit status` now surfaces every registered external rule and hook, and fresh installs / updates set a sane `skillListingBudgetFraction` so all 112 skill descriptions stay loadable in Claude Code.
|
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
[](app/skills/)
|
|
8
8
|
[](app/agents/)
|
|
9
|
-
[](tests/)
|
|
10
10
|
|
|
11
11
|
<p align="center">
|
|
12
12
|
<img src="assets/ai-toolkit-readme-hero.png" alt="ai-toolkit 3 README hero showing one toolkit for 12 AI editors" width="900">
|
|
@@ -14,13 +14,14 @@
|
|
|
14
14
|
|
|
15
15
|
---
|
|
16
16
|
|
|
17
|
-
## What's New in v3.
|
|
17
|
+
## What's New in v3.4.1
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
Patch release. Fixes a v3.4.0 regression where `ai-toolkit update` could demote URL-tracked hook entries to local-path entries on every refresh. `register_path_source` now never overwrites a URL-tracked entry. Also adds a regression test.
|
|
20
20
|
|
|
21
|
-
-
|
|
22
|
-
- **`
|
|
23
|
-
-
|
|
21
|
+
- **URL-tracked hooks and rules are protected** — `register_path_source` returns early when the existing entry has a `url` field.
|
|
22
|
+
- **`add-rule` and `inject-hook` still track local paths** — file-sourced rules and hooks record `{"path": ..., "sha256": ...}` in `sources.json`, alongside URL entries.
|
|
23
|
+
- **`status` distinguishes URL vs local sources** — local entries show the absolute origin path with a `[local]` tag.
|
|
24
|
+
- **Orphan rule detection** — rule files on disk with no source recorded are listed with `(orphan, no source recorded — re-run add-rule)`.
|
|
24
25
|
|
|
25
26
|
See [CHANGELOG.md](CHANGELOG.md) for full history.
|
|
26
27
|
|
|
@@ -153,7 +154,7 @@ ai-toolkit/
|
|
|
153
154
|
│ └── ARCHITECTURE.md # Full system design
|
|
154
155
|
├── kb/ # Reference docs, procedures, plans
|
|
155
156
|
├── scripts/ # Validation, install, evaluation scripts
|
|
156
|
-
├── tests/ # Bats test suite (
|
|
157
|
+
├── tests/ # Bats test suite (1047 tests)
|
|
157
158
|
└── CHANGELOG.md
|
|
158
159
|
```
|
|
159
160
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-toolkit",
|
|
3
3
|
"description": "Professional-grade Claude Code toolkit with persona presets, skill security auditor, expanded lifecycle hooks, experimental opt-in plugin packs, benchmark harvesting, and multi-tool support.",
|
|
4
|
-
"version": "3.
|
|
4
|
+
"version": "3.4.1",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "SoftSpark",
|
|
7
7
|
"url": "https://github.com/softspark"
|
package/manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@softspark/ai-toolkit",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.1",
|
|
4
4
|
"description": "AI coding toolkit: 112 skills, 44 agents, 12-editor write-through (Claude, Cursor, Windsurf, Copilot, Gemini, Cline, Roo, Aider, Augment, Antigravity, Codex, opencode), machine-enforced safety constitution, SARIF audit, signed npm provenance.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
package/scripts/add_rule.py
CHANGED
|
@@ -87,7 +87,11 @@ def main() -> None:
|
|
|
87
87
|
dest = rules_dir / f"{rule_name}.md"
|
|
88
88
|
shutil.copy2(rule_file, dest)
|
|
89
89
|
|
|
90
|
+
from rule_sources import register_path_source
|
|
91
|
+
register_path_source(rules_dir, rule_name, rule_file, content=dest.read_bytes())
|
|
92
|
+
|
|
90
93
|
print(f"Registered: '{rule_name}' -> {dest}")
|
|
94
|
+
print(f"Source path: {rule_file.resolve()} (re-run add-rule to refresh)")
|
|
91
95
|
|
|
92
96
|
print()
|
|
93
97
|
print("Apply now:")
|
package/scripts/hook_sources.py
CHANGED
|
@@ -119,6 +119,38 @@ def register_url_source(
|
|
|
119
119
|
save_sources(hooks_dir, sources)
|
|
120
120
|
|
|
121
121
|
|
|
122
|
+
def register_path_source(
|
|
123
|
+
hooks_dir: Path | None,
|
|
124
|
+
hook_name: str,
|
|
125
|
+
path: Path,
|
|
126
|
+
content: bytes | None = None,
|
|
127
|
+
) -> None:
|
|
128
|
+
"""Add or update a local-file source entry for a hook.
|
|
129
|
+
|
|
130
|
+
Stores the absolute origin path so subsequent ``ai-toolkit update`` runs
|
|
131
|
+
can detect drift, plus a sha256 of the injected content.
|
|
132
|
+
"""
|
|
133
|
+
import re
|
|
134
|
+
if not hook_name or not re.fullmatch(r"[a-zA-Z0-9_-]+", hook_name):
|
|
135
|
+
raise ValueError(f"Invalid hook name: {hook_name!r}")
|
|
136
|
+
hooks_dir = hooks_dir or EXTERNAL_HOOKS_DIR
|
|
137
|
+
sources = load_sources(hooks_dir)
|
|
138
|
+
existing = sources.get(hook_name) or {}
|
|
139
|
+
# Never demote a URL-tracked entry to a local-path entry. update() flows
|
|
140
|
+
# call inject() with the cached file path after URL fetch, which would
|
|
141
|
+
# otherwise overwrite the URL.
|
|
142
|
+
if "url" in existing:
|
|
143
|
+
return
|
|
144
|
+
entry: dict[str, Any] = {
|
|
145
|
+
"path": str(Path(path).resolve()),
|
|
146
|
+
"fetched_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
147
|
+
}
|
|
148
|
+
if content is not None:
|
|
149
|
+
entry["sha256"] = hashlib.sha256(content).hexdigest()
|
|
150
|
+
sources[hook_name] = entry
|
|
151
|
+
save_sources(hooks_dir, sources)
|
|
152
|
+
|
|
153
|
+
|
|
122
154
|
def unregister_source(hooks_dir: Path | None, hook_name: str) -> bool:
|
|
123
155
|
"""Remove a source entry. Returns True if found and removed."""
|
|
124
156
|
hooks_dir = hooks_dir or EXTERNAL_HOOKS_DIR
|
|
@@ -377,6 +377,18 @@ def inject(hooks_file: str, target_dir: str, source_override: str = "") -> None:
|
|
|
377
377
|
save_json(str(settings_path), settings)
|
|
378
378
|
print(f"Injected hooks from '{source}' into {settings_path}")
|
|
379
379
|
|
|
380
|
+
# Register local-file source so `status` and future updates can see it.
|
|
381
|
+
# URL sources are already registered earlier inside _fetch_and_cache().
|
|
382
|
+
if not is_url:
|
|
383
|
+
try:
|
|
384
|
+
from hook_sources import register_path_source
|
|
385
|
+
register_path_source(
|
|
386
|
+
None, source, Path(hooks_file),
|
|
387
|
+
content=Path(hooks_file).read_bytes(),
|
|
388
|
+
)
|
|
389
|
+
except Exception as exc: # registration failure must not block injection
|
|
390
|
+
print(f"Warning: could not register local source: {exc}", file=sys.stderr)
|
|
391
|
+
|
|
380
392
|
# Propagate Codex-compatible events to ~/.codex/hooks.json
|
|
381
393
|
_inject_codex(tagged, source, target_dir)
|
|
382
394
|
|
|
@@ -10,8 +10,13 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
10
10
|
from paths import TOOLKIT_DATA_DIR, STATE_FILE, RULES_DIR, EXTERNAL_HOOKS_DIR
|
|
11
11
|
|
|
12
12
|
|
|
13
|
-
def _load_sources(sources_file: Path, key: str) -> list[tuple[str, str, str]]:
|
|
14
|
-
"""Read a sources.json registry
|
|
13
|
+
def _load_sources(sources_file: Path, key: str) -> list[tuple[str, str, str, str]]:
|
|
14
|
+
"""Read a sources.json registry.
|
|
15
|
+
|
|
16
|
+
Returns a list of ``(name, origin, fetched_at, kind)`` tuples where
|
|
17
|
+
``kind`` is ``"url"`` for remote sources, ``"local"`` for path-tracked
|
|
18
|
+
sources, and ``"unknown"`` if neither field is present.
|
|
19
|
+
"""
|
|
15
20
|
if not sources_file.is_file():
|
|
16
21
|
return []
|
|
17
22
|
try:
|
|
@@ -22,14 +27,30 @@ def _load_sources(sources_file: Path, key: str) -> list[tuple[str, str, str]]:
|
|
|
22
27
|
entries = data.get(key, {}) if isinstance(data, dict) else {}
|
|
23
28
|
if not isinstance(entries, dict):
|
|
24
29
|
return []
|
|
25
|
-
out: list[tuple[str, str, str]] = []
|
|
30
|
+
out: list[tuple[str, str, str, str]] = []
|
|
26
31
|
for name, meta in sorted(entries.items()):
|
|
27
32
|
if not isinstance(meta, dict):
|
|
28
33
|
continue
|
|
29
|
-
|
|
34
|
+
if "url" in meta:
|
|
35
|
+
origin, kind = str(meta["url"]), "url"
|
|
36
|
+
elif "path" in meta:
|
|
37
|
+
origin, kind = str(meta["path"]), "local"
|
|
38
|
+
else:
|
|
39
|
+
origin, kind = "", "unknown"
|
|
40
|
+
out.append((name, origin, str(meta.get("fetched_at", "")), kind))
|
|
30
41
|
return out
|
|
31
42
|
|
|
32
43
|
|
|
44
|
+
def _orphan_rule_files(rules_dir: Path, registered: set[str]) -> list[str]:
|
|
45
|
+
"""Return rule names present on disk but missing from sources.json."""
|
|
46
|
+
if not rules_dir.is_dir():
|
|
47
|
+
return []
|
|
48
|
+
return sorted(
|
|
49
|
+
f.stem for f in rules_dir.glob("*.md")
|
|
50
|
+
if f.stem not in registered
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
33
54
|
def _state_path() -> Path:
|
|
34
55
|
"""Return the canonical path to state.json."""
|
|
35
56
|
return STATE_FILE
|
|
@@ -226,15 +247,20 @@ def print_status() -> None:
|
|
|
226
247
|
|
|
227
248
|
ext_rules = _load_sources(RULES_DIR / "sources.json", "rules")
|
|
228
249
|
ext_hooks = _load_sources(EXTERNAL_HOOKS_DIR / "sources.json", "hooks")
|
|
229
|
-
|
|
250
|
+
rule_orphans = _orphan_rule_files(RULES_DIR, {n for n, _, _, _ in ext_rules})
|
|
251
|
+
if ext_rules or ext_hooks or rule_orphans:
|
|
230
252
|
print()
|
|
231
253
|
print(" External sources:")
|
|
232
|
-
for name,
|
|
254
|
+
for name, origin, fetched_at, kind in ext_rules:
|
|
255
|
+
tag = " [local]" if kind == "local" else ""
|
|
233
256
|
stamp = f" ({fetched_at})" if fetched_at else ""
|
|
234
|
-
print(f" rule {name} <- {
|
|
235
|
-
for name
|
|
257
|
+
print(f" rule {name} <- {origin}{tag}{stamp}")
|
|
258
|
+
for name in rule_orphans:
|
|
259
|
+
print(f" rule {name} <- (orphan, no source recorded — re-run add-rule)")
|
|
260
|
+
for name, origin, fetched_at, kind in ext_hooks:
|
|
261
|
+
tag = " [local]" if kind == "local" else ""
|
|
236
262
|
stamp = f" ({fetched_at})" if fetched_at else ""
|
|
237
|
-
print(f" hook {name} <- {
|
|
263
|
+
print(f" hook {name} <- {origin}{tag}{stamp}")
|
|
238
264
|
|
|
239
265
|
extends = state.get("extends")
|
|
240
266
|
if extends:
|
package/scripts/rule_sources.py
CHANGED
|
@@ -120,6 +120,37 @@ def register_url_source(
|
|
|
120
120
|
save_sources(rules_dir, sources)
|
|
121
121
|
|
|
122
122
|
|
|
123
|
+
def register_path_source(
|
|
124
|
+
rules_dir: Path | None,
|
|
125
|
+
rule_name: str,
|
|
126
|
+
path: Path,
|
|
127
|
+
content: bytes | None = None,
|
|
128
|
+
) -> None:
|
|
129
|
+
"""Add or update a local-file source entry.
|
|
130
|
+
|
|
131
|
+
Stores the absolute origin path (so future ``add-rule`` re-runs from the
|
|
132
|
+
same project register cleanly) plus a sha256 of the current content.
|
|
133
|
+
"""
|
|
134
|
+
import re
|
|
135
|
+
if not rule_name or not re.fullmatch(r"[a-zA-Z0-9_-]+", rule_name):
|
|
136
|
+
raise ValueError(f"Invalid rule name: {rule_name!r}")
|
|
137
|
+
rules_dir = rules_dir or RULES_DIR
|
|
138
|
+
sources = load_sources(rules_dir)
|
|
139
|
+
existing = sources.get(rule_name) or {}
|
|
140
|
+
# Never demote a URL-tracked entry to a local-path entry. Symmetric with
|
|
141
|
+
# register_path_source() in hook_sources.py.
|
|
142
|
+
if "url" in existing:
|
|
143
|
+
return
|
|
144
|
+
entry: dict[str, Any] = {
|
|
145
|
+
"path": str(Path(path).resolve()),
|
|
146
|
+
"fetched_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
147
|
+
}
|
|
148
|
+
if content is not None:
|
|
149
|
+
entry["sha256"] = hashlib.sha256(content).hexdigest()
|
|
150
|
+
sources[rule_name] = entry
|
|
151
|
+
save_sources(rules_dir, sources)
|
|
152
|
+
|
|
153
|
+
|
|
123
154
|
def unregister_source(rules_dir: Path | None, rule_name: str) -> bool:
|
|
124
155
|
"""Remove a source entry. Returns True if found and removed."""
|
|
125
156
|
rules_dir = rules_dir or RULES_DIR
|