@softspark/ai-toolkit 3.2.1 → 3.4.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 +42 -0
- package/README.md +7 -5
- package/app/.claude-plugin/plugin.json +1 -1
- package/benchmarks/ecosystem-doctor-snapshot.json +14 -15
- package/bin/ai-toolkit.js +1 -1
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/add_rule.py +4 -0
- package/scripts/hook_sources.py +26 -0
- package/scripts/inject_hook_cli.py +12 -0
- package/scripts/install_steps/hooks.py +26 -0
- package/scripts/install_steps/install_state.py +59 -1
- package/scripts/rule_sources.py +26 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,48 @@ Versioning follows [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## v3.4.0 — Local-path source tracking & orphan detection (2026-05-06)
|
|
11
|
+
|
|
12
|
+
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.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- **`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).
|
|
17
|
+
- **`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.
|
|
18
|
+
- **`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.
|
|
19
|
+
- **`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>)`.
|
|
20
|
+
- **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).
|
|
21
|
+
|
|
22
|
+
### Migration
|
|
23
|
+
|
|
24
|
+
- Existing URL-tracked sources continue to work unchanged.
|
|
25
|
+
- 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.
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## v3.3.0 — External-source visibility & skill-listing budget default (2026-05-06)
|
|
30
|
+
|
|
31
|
+
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.
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- **`ai-toolkit status` shows external sources** — `scripts/install_steps/install_state.py` reads `~/.softspark/ai-toolkit/rules/sources.json` and `~/.softspark/ai-toolkit/hooks/external/sources.json` and prints an `External sources:` section listing each registered rule/hook with its source URL and last-fetched timestamp. The section is omitted entirely when no external sources are registered. Closes the long-standing gap where `add-rule`/`inject-hook` had no read-side counterpart.
|
|
36
|
+
- **`skillListingBudgetFraction` default in `~/.claude/settings.json`** — new `_install_skill_listing_budget()` step in `scripts/install_steps/hooks.py` writes `"skillListingBudgetFraction": 0.02` on install/update, but only when the key is absent. Existing user values are never overwritten. Prevents Claude Code's default 1% budget from truncating ~60 of the toolkit's 112 skill descriptions.
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
|
|
40
|
+
- **`ai-toolkit status` help text** — updated in `bin/ai-toolkit.js` to mention external rules/hooks alongside modules, version, and profile.
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
Patch release. Lint-only fix in the default status line hook — no behavior change.
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
|
|
48
|
+
- **`app/hooks/ai-toolkit-statusline.sh` shellcheck warnings** — removed the unused `C_GRAY` color variable (SC2034) and replaced the deprecated `[ p -o q ]` form with `{ [ p ] || [ q ]; }` (SC2166). CI shellcheck gate at `--severity=warning` now passes clean across `app/hooks/*.sh`.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
10
52
|
## v3.2.1 — Statusline shellcheck cleanup (2026-05-04)
|
|
11
53
|
|
|
12
54
|
Patch release. Lint-only fix in the default status line hook — no behavior change.
|
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,11 +14,13 @@
|
|
|
14
14
|
|
|
15
15
|
---
|
|
16
16
|
|
|
17
|
-
## What's New in v3.
|
|
17
|
+
## What's New in v3.4.0
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
Minor release. Closes the visibility gap for rules and hooks installed from local files.
|
|
20
20
|
|
|
21
|
-
-
|
|
21
|
+
- **`add-rule` and `inject-hook` now track local paths** — file-sourced rules and hooks record `{"path": ..., "sha256": ...}` in `sources.json`, alongside the existing URL entries. URL flow unchanged.
|
|
22
|
+
- **`status` distinguishes URL vs local sources** — local entries show the absolute origin path with a `[local]` tag; URL entries show the URL.
|
|
23
|
+
- **Orphan rule detection** — rule files on disk that have no source recorded (e.g., installed before v3.3.0's registry existed) are listed in `status` with `(orphan, no source recorded — re-run add-rule)`. Run `add-rule <path>` again to register them.
|
|
22
24
|
|
|
23
25
|
See [CHANGELOG.md](CHANGELOG.md) for full history.
|
|
24
26
|
|
|
@@ -151,7 +153,7 @@ ai-toolkit/
|
|
|
151
153
|
│ └── ARCHITECTURE.md # Full system design
|
|
152
154
|
├── kb/ # Reference docs, procedures, plans
|
|
153
155
|
├── scripts/ # Validation, install, evaluation scripts
|
|
154
|
-
├── tests/ # Bats test suite (
|
|
156
|
+
├── tests/ # Bats test suite (1046 tests)
|
|
155
157
|
└── CHANGELOG.md
|
|
156
158
|
```
|
|
157
159
|
|
|
@@ -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.0",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "SoftSpark",
|
|
7
7
|
"url": "https://github.com/softspark"
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"last_run": "2026-05-
|
|
2
|
+
"last_run": "2026-05-06T08:17:20Z",
|
|
3
3
|
"schema_version": 1,
|
|
4
4
|
"tools": {
|
|
5
5
|
"aider": {
|
|
@@ -24,13 +24,12 @@
|
|
|
24
24
|
}
|
|
25
25
|
},
|
|
26
26
|
"augment": {
|
|
27
|
-
"docs_hash": "
|
|
27
|
+
"docs_hash": "ac138d99235e1f3e",
|
|
28
28
|
"headings": [
|
|
29
29
|
"Agent",
|
|
30
30
|
"Code Completions",
|
|
31
31
|
"Documentation Index",
|
|
32
32
|
"Introduction",
|
|
33
|
-
"Next Edit",
|
|
34
33
|
"\u200bAuggie CLI",
|
|
35
34
|
"\u200bGet started in minutes",
|
|
36
35
|
"\u200bJetBrains IDEs",
|
|
@@ -43,7 +42,7 @@
|
|
|
43
42
|
"Agent mode": false,
|
|
44
43
|
"Auggie CLI": true,
|
|
45
44
|
"MCP": true,
|
|
46
|
-
"Next Edit":
|
|
45
|
+
"Next Edit": false,
|
|
47
46
|
"PostToolUse": false,
|
|
48
47
|
"PreToolUse": false,
|
|
49
48
|
"SKILL.md": false,
|
|
@@ -58,7 +57,7 @@
|
|
|
58
57
|
}
|
|
59
58
|
},
|
|
60
59
|
"claude-code": {
|
|
61
|
-
"docs_hash": "
|
|
60
|
+
"docs_hash": "789113e1bc4cd34c",
|
|
62
61
|
"headings": [
|
|
63
62
|
"Claude Code overview",
|
|
64
63
|
"Documentation Index",
|
|
@@ -104,10 +103,10 @@
|
|
|
104
103
|
"slash command": true,
|
|
105
104
|
"sub-agent": true
|
|
106
105
|
},
|
|
107
|
-
"version": "2.1.
|
|
106
|
+
"version": "2.1.131 (Claude Code)"
|
|
108
107
|
},
|
|
109
108
|
"cline": {
|
|
110
|
-
"docs_hash": "
|
|
109
|
+
"docs_hash": "b4009dc8e6d40af9",
|
|
111
110
|
"headings": [
|
|
112
111
|
"Cline CLI",
|
|
113
112
|
"Cline Documentation",
|
|
@@ -138,7 +137,7 @@
|
|
|
138
137
|
}
|
|
139
138
|
},
|
|
140
139
|
"codex-cli": {
|
|
141
|
-
"docs_hash": "
|
|
140
|
+
"docs_hash": "c6b51936294d38e7",
|
|
142
141
|
"headings": [
|
|
143
142
|
"About",
|
|
144
143
|
"Contributing",
|
|
@@ -157,7 +156,7 @@
|
|
|
157
156
|
"Packages 0",
|
|
158
157
|
"Provide feedback",
|
|
159
158
|
"Quickstart",
|
|
160
|
-
"Releases
|
|
159
|
+
"Releases 765",
|
|
161
160
|
"Repository files navigation",
|
|
162
161
|
"Resources",
|
|
163
162
|
"Saved searches",
|
|
@@ -187,10 +186,10 @@
|
|
|
187
186
|
"mcp_servers": false,
|
|
188
187
|
"sandbox": true
|
|
189
188
|
},
|
|
190
|
-
"version": "codex-cli 0.
|
|
189
|
+
"version": "codex-cli 0.128.0"
|
|
191
190
|
},
|
|
192
191
|
"cursor": {
|
|
193
|
-
"docs_hash": "
|
|
192
|
+
"docs_hash": "8ed1f5cf7441b6c0",
|
|
194
193
|
"headings": [],
|
|
195
194
|
"markers": {
|
|
196
195
|
".cursor/rules": false,
|
|
@@ -206,7 +205,7 @@
|
|
|
206
205
|
}
|
|
207
206
|
},
|
|
208
207
|
"gemini-cli": {
|
|
209
|
-
"docs_hash": "
|
|
208
|
+
"docs_hash": "21a2f14121ff27fa",
|
|
210
209
|
"headings": [
|
|
211
210
|
"Breadcrumbs",
|
|
212
211
|
"Directory actions",
|
|
@@ -245,7 +244,7 @@
|
|
|
245
244
|
}
|
|
246
245
|
},
|
|
247
246
|
"github-copilot": {
|
|
248
|
-
"docs_hash": "
|
|
247
|
+
"docs_hash": "99a5ab7bd5f7556f",
|
|
249
248
|
"headings": [
|
|
250
249
|
"About Copilot auto model selection",
|
|
251
250
|
"About Copilot integrations",
|
|
@@ -293,7 +292,7 @@
|
|
|
293
292
|
}
|
|
294
293
|
},
|
|
295
294
|
"opencode": {
|
|
296
|
-
"docs_hash": "
|
|
295
|
+
"docs_hash": "fe845a4bffb6371b",
|
|
297
296
|
"headings": [
|
|
298
297
|
"Add features",
|
|
299
298
|
"Ask questions",
|
|
@@ -354,7 +353,7 @@
|
|
|
354
353
|
}
|
|
355
354
|
},
|
|
356
355
|
"windsurf": {
|
|
357
|
-
"docs_hash": "
|
|
356
|
+
"docs_hash": "9b2879646f041aea",
|
|
358
357
|
"headings": [
|
|
359
358
|
"Advanced",
|
|
360
359
|
"App Deploys",
|
package/bin/ai-toolkit.js
CHANGED
|
@@ -62,7 +62,7 @@ const SCRIPT_COMMANDS = {
|
|
|
62
62
|
const COMMANDS = {
|
|
63
63
|
install: 'First-time global install into ~/.claude/ (use --local for project-local configs only)',
|
|
64
64
|
update: 'Re-apply toolkit with saved modules from state.json (use --local for project-local only)',
|
|
65
|
-
status: 'Show installed modules, version, and
|
|
65
|
+
status: 'Show installed modules, version, profile, and registered external rules/hooks',
|
|
66
66
|
reset: 'Wipe and recreate project-local configs from scratch (requires --local)',
|
|
67
67
|
uninstall: 'Remove ai-toolkit from ~/.claude/',
|
|
68
68
|
'add-rule': 'Register a rule file or URL in ~/.softspark/ai-toolkit/rules/ (URL rules auto-refresh on update)',
|
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.0",
|
|
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,32 @@ 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
|
+
entry: dict[str, Any] = {
|
|
139
|
+
"path": str(Path(path).resolve()),
|
|
140
|
+
"fetched_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
141
|
+
}
|
|
142
|
+
if content is not None:
|
|
143
|
+
entry["sha256"] = hashlib.sha256(content).hexdigest()
|
|
144
|
+
sources[hook_name] = entry
|
|
145
|
+
save_sources(hooks_dir, sources)
|
|
146
|
+
|
|
147
|
+
|
|
122
148
|
def unregister_source(hooks_dir: Path | None, hook_name: str) -> bool:
|
|
123
149
|
"""Remove a source entry. Returns True if found and removed."""
|
|
124
150
|
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
|
|
|
@@ -26,6 +26,7 @@ def install_hooks(claude_dir: Path, hooks_scripts_dir: Path,
|
|
|
26
26
|
if output_styles_dir.is_dir():
|
|
27
27
|
print(" Would install: output styles to ~/.claude/output-styles/ + set outputStyle in settings.json")
|
|
28
28
|
print(" Would set: env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 in settings.json")
|
|
29
|
+
print(" Would set: skillListingBudgetFraction=0.02 in settings.json (only if absent)")
|
|
29
30
|
return
|
|
30
31
|
|
|
31
32
|
_copy_hook_scripts(claude_dir, hooks_scripts_dir)
|
|
@@ -44,6 +45,7 @@ def install_hooks(claude_dir: Path, hooks_scripts_dir: Path,
|
|
|
44
45
|
|
|
45
46
|
_install_output_styles(claude_dir)
|
|
46
47
|
_install_env_vars(claude_dir)
|
|
48
|
+
_install_skill_listing_budget(claude_dir)
|
|
47
49
|
|
|
48
50
|
|
|
49
51
|
def _copy_hook_scripts(claude_dir: Path, hooks_scripts_dir: Path) -> None:
|
|
@@ -151,3 +153,27 @@ def _install_env_vars(claude_dir: Path) -> None:
|
|
|
151
153
|
with open(settings_path, "w", encoding="utf-8") as f:
|
|
152
154
|
json.dump(settings_data, f, indent=4)
|
|
153
155
|
print(" Set: env vars in settings.json (CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1)")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# Default for skill listing budget. 0.02 (~2% of context) keeps all 112+ skill
|
|
159
|
+
# descriptions loadable without truncation. Only applied if the user has not
|
|
160
|
+
# already set the key — explicit user overrides are preserved.
|
|
161
|
+
SKILL_LISTING_BUDGET_DEFAULT = 0.02
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _install_skill_listing_budget(claude_dir: Path) -> None:
|
|
165
|
+
"""Set skillListingBudgetFraction default if user has not configured one."""
|
|
166
|
+
settings_path = claude_dir / "settings.json"
|
|
167
|
+
if settings_path.is_file():
|
|
168
|
+
with open(settings_path, encoding="utf-8") as f:
|
|
169
|
+
settings_data = json.load(f)
|
|
170
|
+
else:
|
|
171
|
+
settings_data = {}
|
|
172
|
+
|
|
173
|
+
if "skillListingBudgetFraction" in settings_data:
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
settings_data["skillListingBudgetFraction"] = SKILL_LISTING_BUDGET_DEFAULT
|
|
177
|
+
with open(settings_path, "w", encoding="utf-8") as f:
|
|
178
|
+
json.dump(settings_data, f, indent=4)
|
|
179
|
+
print(f" Set: skillListingBudgetFraction = {SKILL_LISTING_BUDGET_DEFAULT} in settings.json")
|
|
@@ -7,7 +7,48 @@ from datetime import datetime, timezone
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
|
|
9
9
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
10
|
-
from paths import TOOLKIT_DATA_DIR, STATE_FILE
|
|
10
|
+
from paths import TOOLKIT_DATA_DIR, STATE_FILE, RULES_DIR, EXTERNAL_HOOKS_DIR
|
|
11
|
+
|
|
12
|
+
|
|
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
|
+
"""
|
|
20
|
+
if not sources_file.is_file():
|
|
21
|
+
return []
|
|
22
|
+
try:
|
|
23
|
+
with open(sources_file, encoding="utf-8") as f:
|
|
24
|
+
data = json.load(f)
|
|
25
|
+
except (json.JSONDecodeError, OSError):
|
|
26
|
+
return []
|
|
27
|
+
entries = data.get(key, {}) if isinstance(data, dict) else {}
|
|
28
|
+
if not isinstance(entries, dict):
|
|
29
|
+
return []
|
|
30
|
+
out: list[tuple[str, str, str, str]] = []
|
|
31
|
+
for name, meta in sorted(entries.items()):
|
|
32
|
+
if not isinstance(meta, dict):
|
|
33
|
+
continue
|
|
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))
|
|
41
|
+
return out
|
|
42
|
+
|
|
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
|
+
)
|
|
11
52
|
|
|
12
53
|
|
|
13
54
|
def _state_path() -> Path:
|
|
@@ -204,6 +245,23 @@ def print_status() -> None:
|
|
|
204
245
|
if mcp:
|
|
205
246
|
print(f" MCP: {', '.join(mcp)}")
|
|
206
247
|
|
|
248
|
+
ext_rules = _load_sources(RULES_DIR / "sources.json", "rules")
|
|
249
|
+
ext_hooks = _load_sources(EXTERNAL_HOOKS_DIR / "sources.json", "hooks")
|
|
250
|
+
rule_orphans = _orphan_rule_files(RULES_DIR, {n for n, _, _, _ in ext_rules})
|
|
251
|
+
if ext_rules or ext_hooks or rule_orphans:
|
|
252
|
+
print()
|
|
253
|
+
print(" External sources:")
|
|
254
|
+
for name, origin, fetched_at, kind in ext_rules:
|
|
255
|
+
tag = " [local]" if kind == "local" else ""
|
|
256
|
+
stamp = f" ({fetched_at})" if fetched_at else ""
|
|
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 ""
|
|
262
|
+
stamp = f" ({fetched_at})" if fetched_at else ""
|
|
263
|
+
print(f" hook {name} <- {origin}{tag}{stamp}")
|
|
264
|
+
|
|
207
265
|
extends = state.get("extends")
|
|
208
266
|
if extends:
|
|
209
267
|
print(f" Extends: {extends.get('source', 'unknown')}")
|
package/scripts/rule_sources.py
CHANGED
|
@@ -120,6 +120,32 @@ 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
|
+
entry: dict[str, Any] = {
|
|
140
|
+
"path": str(Path(path).resolve()),
|
|
141
|
+
"fetched_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
142
|
+
}
|
|
143
|
+
if content is not None:
|
|
144
|
+
entry["sha256"] = hashlib.sha256(content).hexdigest()
|
|
145
|
+
sources[rule_name] = entry
|
|
146
|
+
save_sources(rules_dir, sources)
|
|
147
|
+
|
|
148
|
+
|
|
123
149
|
def unregister_source(rules_dir: Path | None, rule_name: str) -> bool:
|
|
124
150
|
"""Remove a source entry. Returns True if found and removed."""
|
|
125
151
|
rules_dir = rules_dir or RULES_DIR
|