@softspark/ai-toolkit 4.10.1 → 4.11.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 +135 -0
- package/CHANGELOG.md +14 -0
- package/README.md +12 -13
- package/app/.claude-plugin/plugin.json +1 -1
- package/benchmarks/ecosystem-doctor-snapshot.json +18 -12
- package/bin/ai-toolkit.js +1 -1
- package/kb/procedures/maintenance-sop.md +5 -3
- package/kb/reference/architecture-overview.md +5 -4
- package/kb/reference/extension-api.md +5 -5
- package/kb/reference/global-install-model.md +18 -3
- package/kb/reference/integrations.md +16 -17
- package/kb/reference/language-rules.md +35 -14
- package/kb/reference/supported-tools-registry.md +2 -2
- package/llms-full.txt +86 -48
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/ecosystem_tools.json +5 -2
- package/scripts/generate_language_rules_skills.py +4 -2
- package/scripts/install.py +3 -3
- package/scripts/install_steps/ai_tools.py +67 -28
- package/scripts/install_steps/markers.py +132 -14
- package/scripts/remove_rule.py +15 -4
|
@@ -661,17 +661,16 @@ def _apply_extends_config(cwd: Path, merged: dict) -> None:
|
|
|
661
661
|
|
|
662
662
|
|
|
663
663
|
def _inject_language_rules(cwd: Path, language_modules: list[str] | None) -> None:
|
|
664
|
-
"""
|
|
664
|
+
"""Install Claude language-rule entrypoints for a project.
|
|
665
665
|
|
|
666
666
|
Per-language rules (``app/rules/<lang>/``) are NOT injected here -- they
|
|
667
667
|
ship as ``<lang>-rules`` knowledge skills under ``app/skills/`` and load
|
|
668
|
-
contextually via the Agent Skills progressive-disclosure mechanism.
|
|
669
|
-
keeps ``CLAUDE.md`` small while ensuring language-specific guidance still
|
|
670
|
-
reaches Claude when relevant.
|
|
668
|
+
contextually via the Agent Skills progressive-disclosure mechanism.
|
|
671
669
|
|
|
672
|
-
Common rules are
|
|
673
|
-
|
|
674
|
-
|
|
670
|
+
Common rules are written as Claude Code path-scoped rules under
|
|
671
|
+
``.claude/rules/``. Current Claude Code guidance targets under 200 lines
|
|
672
|
+
per ``CLAUDE.md`` file; path-scoped rules keep startup context smaller
|
|
673
|
+
while still loading the rule bodies when project files are opened.
|
|
675
674
|
"""
|
|
676
675
|
if not language_modules:
|
|
677
676
|
return
|
|
@@ -681,6 +680,8 @@ def _inject_language_rules(cwd: Path, language_modules: list[str] | None) -> Non
|
|
|
681
680
|
if not common_dir.is_dir():
|
|
682
681
|
return
|
|
683
682
|
|
|
683
|
+
rule_files = _sync_claude_common_rules(cwd, common_dir)
|
|
684
|
+
|
|
684
685
|
# Detect requested per-language modules so we can name the linked skills
|
|
685
686
|
# in the marker block. The modules themselves are not inlined.
|
|
686
687
|
langs: list[str] = []
|
|
@@ -690,38 +691,30 @@ def _inject_language_rules(cwd: Path, language_modules: list[str] | None) -> Non
|
|
|
690
691
|
if name != "common":
|
|
691
692
|
langs.append(name)
|
|
692
693
|
|
|
693
|
-
# Inline full content of every common rule file, stripping YAML
|
|
694
|
-
# frontmatter so the resulting block reads as plain Markdown.
|
|
695
|
-
inlined: list[str] = []
|
|
696
|
-
for f in sorted(common_dir.glob("*.md")):
|
|
697
|
-
body = f.read_text(encoding="utf-8")
|
|
698
|
-
if body.startswith("---"):
|
|
699
|
-
end = body.find("\n---", 3)
|
|
700
|
-
if end != -1:
|
|
701
|
-
body = body[end + 4:].lstrip("\n")
|
|
702
|
-
inlined.append(body.rstrip())
|
|
703
|
-
|
|
704
694
|
lines: list[str] = ["# Language Rules", ""]
|
|
705
695
|
lines.append(
|
|
706
|
-
"Common
|
|
707
|
-
"
|
|
708
|
-
"
|
|
709
|
-
|
|
710
|
-
|
|
696
|
+
"Common ai-toolkit rules live in `.claude/rules/ai-toolkit-*.md` "
|
|
697
|
+
"with Claude Code `paths` frontmatter so they load when project files "
|
|
698
|
+
"are opened instead of expanding this CLAUDE.md at session startup."
|
|
699
|
+
)
|
|
700
|
+
if rule_files:
|
|
701
|
+
lines.append("")
|
|
702
|
+
lines.append("Common rule files: " + ", ".join(f"`{p}`" for p in rule_files) + ".")
|
|
703
|
+
lines.append("")
|
|
704
|
+
lines.append(
|
|
705
|
+
"Language-specific rules live in `<lang>-rules` knowledge skills "
|
|
706
|
+
"(e.g. `python-rules`, `typescript-rules`) and load automatically "
|
|
707
|
+
"when their triggers match -- you do not need to Read them manually."
|
|
711
708
|
)
|
|
712
709
|
if langs:
|
|
713
710
|
skill_names = ", ".join(f"`{l}-rules`" for l in langs)
|
|
714
711
|
lines.append("")
|
|
715
712
|
lines.append(f"Detected languages: {skill_names}.")
|
|
716
|
-
lines.append("")
|
|
717
|
-
lines.append("---")
|
|
718
|
-
lines.append("")
|
|
719
|
-
lines.extend(inlined)
|
|
720
713
|
|
|
721
714
|
# Write to temp file, then inject as a single named section so reruns are
|
|
722
715
|
# idempotent (existing block is replaced, not duplicated).
|
|
723
716
|
import tempfile
|
|
724
|
-
combined = "\n
|
|
717
|
+
combined = "\n".join(lines).rstrip() + "\n"
|
|
725
718
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False,
|
|
726
719
|
encoding="utf-8") as tmp:
|
|
727
720
|
tmp.write(combined)
|
|
@@ -738,6 +731,51 @@ def _inject_language_rules(cwd: Path, language_modules: list[str] | None) -> Non
|
|
|
738
731
|
tmp_path.unlink(missing_ok=True)
|
|
739
732
|
|
|
740
733
|
|
|
734
|
+
def _strip_rule_frontmatter(text: str) -> str:
|
|
735
|
+
if text.startswith("---"):
|
|
736
|
+
end = text.find("\n---", 3)
|
|
737
|
+
if end != -1:
|
|
738
|
+
return text[end + 4:].lstrip("\n")
|
|
739
|
+
return text
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def _sync_claude_common_rules(cwd: Path, common_dir: Path) -> list[str]:
|
|
743
|
+
"""Write common ai-toolkit rules as Claude Code path-scoped rules.
|
|
744
|
+
|
|
745
|
+
Only ``ai-toolkit-*.md`` files are managed. User-authored files in
|
|
746
|
+
``.claude/rules/`` are preserved.
|
|
747
|
+
"""
|
|
748
|
+
rules_dir = cwd / ".claude" / "rules"
|
|
749
|
+
rules_dir.mkdir(parents=True, exist_ok=True)
|
|
750
|
+
|
|
751
|
+
source_files = sorted(common_dir.glob("*.md"))
|
|
752
|
+
expected = {f"ai-toolkit-{src.stem}.md" for src in source_files}
|
|
753
|
+
for stale in sorted(rules_dir.glob("ai-toolkit-*.md")):
|
|
754
|
+
if stale.name not in expected:
|
|
755
|
+
stale.unlink()
|
|
756
|
+
|
|
757
|
+
written: list[str] = []
|
|
758
|
+
for src in source_files:
|
|
759
|
+
body = _strip_rule_frontmatter(src.read_text(encoding="utf-8")).rstrip()
|
|
760
|
+
rel = Path(".claude") / "rules" / f"ai-toolkit-{src.stem}.md"
|
|
761
|
+
target = cwd / rel
|
|
762
|
+
target.write_text(
|
|
763
|
+
"\n".join([
|
|
764
|
+
"---",
|
|
765
|
+
"paths:",
|
|
766
|
+
' - "**/*"',
|
|
767
|
+
"---",
|
|
768
|
+
"",
|
|
769
|
+
body,
|
|
770
|
+
"",
|
|
771
|
+
]),
|
|
772
|
+
encoding="utf-8",
|
|
773
|
+
)
|
|
774
|
+
written.append(rel.as_posix())
|
|
775
|
+
|
|
776
|
+
return written
|
|
777
|
+
|
|
778
|
+
|
|
741
779
|
def _install_local_dry_run(reset: bool, editors: list[str] | None = None,
|
|
742
780
|
profile: str = "standard",
|
|
743
781
|
codex_skills: bool = False) -> None:
|
|
@@ -750,6 +788,7 @@ def _install_local_dry_run(reset: bool, editors: list[str] | None = None,
|
|
|
750
788
|
print(" Would create: CLAUDE.md (if missing)")
|
|
751
789
|
print(" Would create: .claude/settings.local.json (if missing)")
|
|
752
790
|
print(" Would inject: .claude/constitution.md")
|
|
791
|
+
print(" Would generate: .claude/rules/ai-toolkit-*.md")
|
|
753
792
|
|
|
754
793
|
add_copilot_dir = profile in {"standard", "strict", "full"}
|
|
755
794
|
add_gemini_hooks = profile in {"standard", "strict", "full"}
|
|
@@ -1,9 +1,21 @@
|
|
|
1
|
-
"""Marker file injection and rule
|
|
1
|
+
"""Marker file injection and rule installation."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
import re
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
|
|
6
|
-
from _common import
|
|
7
|
+
from _common import (
|
|
8
|
+
app_dir,
|
|
9
|
+
inject_section,
|
|
10
|
+
remove_rule_section,
|
|
11
|
+
should_install,
|
|
12
|
+
_collapse_blank_runs,
|
|
13
|
+
_strip_section,
|
|
14
|
+
_trim_trailing_blanks,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
GLOBAL_RULES_SECTION = "global-rules"
|
|
7
19
|
|
|
8
20
|
|
|
9
21
|
def install_marker_files(claude_dir: Path, only: str, skip: str,
|
|
@@ -34,7 +46,7 @@ def install_marker_files(claude_dir: Path, only: str, skip: str,
|
|
|
34
46
|
def inject_rules(claude_dir: Path, target_dir: Path, rules_dir: Path,
|
|
35
47
|
only: str, skip: str, dry_run: bool,
|
|
36
48
|
refresh_urls: bool = False) -> None:
|
|
37
|
-
"""
|
|
49
|
+
"""Install Claude Code user-level rules.
|
|
38
50
|
|
|
39
51
|
When refresh_urls is True, re-fetches URL-sourced rules before injection.
|
|
40
52
|
Only the global install path should set this to True (once per update).
|
|
@@ -53,26 +65,131 @@ def inject_rules(claude_dir: Path, target_dir: Path, rules_dir: Path,
|
|
|
53
65
|
claude_md.touch()
|
|
54
66
|
print(" Created: ~/.claude/CLAUDE.md")
|
|
55
67
|
|
|
56
|
-
|
|
57
|
-
|
|
68
|
+
install_toolkit_rules = should_install("rules", only, skip)
|
|
69
|
+
if not install_toolkit_rules:
|
|
70
|
+
print(" Skipped: toolkit rule files")
|
|
58
71
|
|
|
59
|
-
|
|
72
|
+
expected: set[str] = set()
|
|
73
|
+
rules_synced: list[str] = []
|
|
60
74
|
|
|
61
75
|
if rules_dir.is_dir():
|
|
62
76
|
for rule_file in sorted(rules_dir.glob("*.md")):
|
|
63
77
|
rule_name = rule_file.stem
|
|
64
|
-
|
|
65
|
-
|
|
78
|
+
output_name = f"ai-toolkit-registered-{_safe_rule_name(rule_name)}"
|
|
79
|
+
_write_claude_rule_file(claude_dir, rule_file, output_name)
|
|
80
|
+
_remove_legacy_rule_marker(target_dir, rule_name)
|
|
81
|
+
expected.add(output_name)
|
|
82
|
+
rules_synced.append(rule_name)
|
|
66
83
|
|
|
67
|
-
if
|
|
84
|
+
if install_toolkit_rules:
|
|
68
85
|
rules_src = app_dir / "rules"
|
|
69
86
|
if rules_src.is_dir():
|
|
70
87
|
for source_file in sorted(rules_src.glob("*.md")):
|
|
71
88
|
rule_name = source_file.stem
|
|
72
|
-
|
|
73
|
-
|
|
89
|
+
output_name = f"ai-toolkit-{_safe_rule_name(rule_name)}"
|
|
90
|
+
_write_claude_rule_file(claude_dir, source_file, output_name)
|
|
91
|
+
_remove_legacy_rule_marker(target_dir, rule_name)
|
|
92
|
+
expected.add(output_name)
|
|
93
|
+
rules_synced.append(rule_name)
|
|
94
|
+
|
|
95
|
+
if not install_toolkit_rules and not rules_synced:
|
|
96
|
+
return
|
|
74
97
|
|
|
75
|
-
|
|
98
|
+
removed = _cleanup_managed_claude_rules(
|
|
99
|
+
claude_dir,
|
|
100
|
+
expected,
|
|
101
|
+
cleanup_toolkit_rules=install_toolkit_rules,
|
|
102
|
+
)
|
|
103
|
+
_inject_global_rules_index(claude_md, sorted(expected), rules_synced)
|
|
104
|
+
|
|
105
|
+
print(f" Rules synced: {' '.join(rules_synced)}")
|
|
106
|
+
if removed:
|
|
107
|
+
print(f" Cleaned: {removed} stale .claude/rules/ai-toolkit-*.md file(s)")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _safe_rule_name(name: str) -> str:
|
|
111
|
+
"""Return a Claude-safe filename/marker stem."""
|
|
112
|
+
return re.sub(r"[^a-zA-Z0-9_-]", "", name)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _write_claude_rule_file(
|
|
116
|
+
claude_dir: Path,
|
|
117
|
+
source_file: Path,
|
|
118
|
+
output_name: str,
|
|
119
|
+
) -> None:
|
|
120
|
+
"""Write a managed user-level rule under ``~/.claude/rules``."""
|
|
121
|
+
rules_root = claude_dir / "rules"
|
|
122
|
+
rules_root.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
dst = rules_root / f"{output_name}.md"
|
|
124
|
+
content = source_file.read_text(encoding="utf-8").rstrip() + "\n"
|
|
125
|
+
dst.write_text(content, encoding="utf-8")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _remove_legacy_rule_marker(target_dir: Path, rule_name: str) -> None:
|
|
129
|
+
"""Remove old CLAUDE.md marker sections for rules now stored as files."""
|
|
130
|
+
remove_rule_section(_safe_rule_name(rule_name), target_dir)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _cleanup_managed_claude_rules(
|
|
134
|
+
claude_dir: Path,
|
|
135
|
+
expected: set[str],
|
|
136
|
+
*,
|
|
137
|
+
cleanup_toolkit_rules: bool,
|
|
138
|
+
) -> int:
|
|
139
|
+
"""Remove stale ai-toolkit-managed user-level rule files only."""
|
|
140
|
+
rules_root = claude_dir / "rules"
|
|
141
|
+
if not rules_root.is_dir():
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
removed = 0
|
|
145
|
+
for path in sorted(rules_root.glob("ai-toolkit-*.md")):
|
|
146
|
+
if path.stem in expected:
|
|
147
|
+
continue
|
|
148
|
+
if not cleanup_toolkit_rules and not path.stem.startswith("ai-toolkit-registered-"):
|
|
149
|
+
continue
|
|
150
|
+
path.unlink()
|
|
151
|
+
removed += 1
|
|
152
|
+
return removed
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _inject_global_rules_index(
|
|
156
|
+
claude_md: Path,
|
|
157
|
+
managed_rule_names: list[str],
|
|
158
|
+
display_names: list[str],
|
|
159
|
+
) -> None:
|
|
160
|
+
"""Keep CLAUDE.md as a compact pointer to user-level rule files."""
|
|
161
|
+
existing = claude_md.read_text(encoding="utf-8") if claude_md.is_file() else ""
|
|
162
|
+
existing = _trim_trailing_blanks(_strip_section(existing, GLOBAL_RULES_SECTION))
|
|
163
|
+
|
|
164
|
+
lines = [
|
|
165
|
+
"# Global ai-toolkit Rules",
|
|
166
|
+
"",
|
|
167
|
+
"ai-toolkit rules live in `~/.claude/rules/ai-toolkit-*.md` as Claude Code user-level rules.",
|
|
168
|
+
"They are intentionally not inlined into this `CLAUDE.md`; use `/memory` to inspect loaded rule files.",
|
|
169
|
+
]
|
|
170
|
+
if display_names:
|
|
171
|
+
names = ", ".join(f"`{name}`" for name in display_names)
|
|
172
|
+
lines.extend(["", f"Rules: {names}"])
|
|
173
|
+
if managed_rule_names:
|
|
174
|
+
files = ", ".join(f"`~/.claude/rules/{name}.md`" for name in managed_rule_names)
|
|
175
|
+
lines.extend(["", f"Files: {files}"])
|
|
176
|
+
|
|
177
|
+
parts: list[str] = []
|
|
178
|
+
if existing.strip():
|
|
179
|
+
parts.append(existing)
|
|
180
|
+
parts.append("")
|
|
181
|
+
parts.extend([
|
|
182
|
+
f"<!-- TOOLKIT:{GLOBAL_RULES_SECTION} START -->",
|
|
183
|
+
"<!-- Auto-injected by ai-toolkit. Re-run to update. -->",
|
|
184
|
+
"",
|
|
185
|
+
"\n".join(lines),
|
|
186
|
+
"",
|
|
187
|
+
f"<!-- TOOLKIT:{GLOBAL_RULES_SECTION} END -->",
|
|
188
|
+
])
|
|
189
|
+
|
|
190
|
+
output = _collapse_blank_runs("\n".join(parts) + "\n").lstrip("\n")
|
|
191
|
+
claude_md.parent.mkdir(parents=True, exist_ok=True)
|
|
192
|
+
claude_md.write_text(output, encoding="utf-8")
|
|
76
193
|
|
|
77
194
|
|
|
78
195
|
def _refresh_url_rules(rules_dir: Path) -> None:
|
|
@@ -186,9 +303,10 @@ def _inject_rules_dry_run(rules_dir: Path) -> None:
|
|
|
186
303
|
rule_names = " ".join(
|
|
187
304
|
f.stem for f in sorted(rules_src.glob("*.md"))
|
|
188
305
|
) if rules_src.is_dir() else ""
|
|
189
|
-
print(f" Would
|
|
306
|
+
print(f" Would generate: ~/.claude/rules/ai-toolkit-*.md ({rule_names})")
|
|
190
307
|
if rules_dir.is_dir():
|
|
191
308
|
registered = list(rules_dir.glob("*.md"))
|
|
192
309
|
if registered:
|
|
193
310
|
reg_names = " ".join(f.stem for f in sorted(registered))
|
|
194
|
-
print(f" Would
|
|
311
|
+
print(f" Would generate: ~/.claude/rules/ai-toolkit-registered-*.md ({reg_names})")
|
|
312
|
+
print(" Would update: ~/.claude/CLAUDE.md global rules index")
|
package/scripts/remove_rule.py
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
"""remove-rule -- Unregister a rule (opposite of add-rule).
|
|
3
3
|
|
|
4
4
|
Removes the rule file from ~/.softspark/ai-toolkit/rules/ (so it is no longer
|
|
5
|
-
re-applied on future 'ai-toolkit install' runs)
|
|
6
|
-
|
|
5
|
+
re-applied on future 'ai-toolkit install' runs), removes the generated Claude
|
|
6
|
+
Code user-level rule file, and strips the legacy injected block from
|
|
7
|
+
CLAUDE.md.
|
|
7
8
|
|
|
8
9
|
Usage:
|
|
9
10
|
remove_rule.py <rule-name> [target-dir]
|
|
@@ -16,13 +17,14 @@ from __future__ import annotations
|
|
|
16
17
|
|
|
17
18
|
import sys
|
|
18
19
|
from pathlib import Path
|
|
20
|
+
import re
|
|
19
21
|
|
|
20
22
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
21
23
|
from _common import remove_rule_section
|
|
22
24
|
|
|
23
25
|
|
|
24
26
|
def main() -> None:
|
|
25
|
-
"""Unregister a rule and
|
|
27
|
+
"""Unregister a rule and remove generated Claude rule artifacts."""
|
|
26
28
|
if len(sys.argv) < 2:
|
|
27
29
|
print("Usage: remove_rule.py <rule-name> [target-dir]", file=sys.stderr)
|
|
28
30
|
sys.exit(1)
|
|
@@ -48,7 +50,16 @@ def main() -> None:
|
|
|
48
50
|
if unregister_source(rules_dir, rule_name):
|
|
49
51
|
print(f"Removed URL source for '{rule_name}'")
|
|
50
52
|
|
|
51
|
-
|
|
53
|
+
safe_rule_name = re.sub(r"[^a-zA-Z0-9_-]", "", rule_name)
|
|
54
|
+
|
|
55
|
+
# 2. Remove generated Claude Code user-level rule file
|
|
56
|
+
generated_rule = target_dir / ".claude" / "rules" / f"ai-toolkit-registered-{safe_rule_name}.md"
|
|
57
|
+
if generated_rule.is_file():
|
|
58
|
+
generated_rule.unlink()
|
|
59
|
+
print(f"Removed generated Claude rule: {generated_rule}")
|
|
60
|
+
removed += 1
|
|
61
|
+
|
|
62
|
+
# 3. Strip legacy injected block from .claude/CLAUDE.md
|
|
52
63
|
found = remove_rule_section(rule_name, target_dir)
|
|
53
64
|
if found:
|
|
54
65
|
print(f"Removed rule '{rule_name}' from {target_dir / '.claude' / 'CLAUDE.md'}")
|