@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.
@@ -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
- """Inject ``app/rules/common/*.md`` content into project's ``.claude/CLAUDE.md``.
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. This
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 language-agnostic (security, git workflow, testing,
673
- coding-style, performance) and stay inlined so they remain in scope for
674
- every prompt.
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 (language-agnostic) rules apply to every change in this "
707
- "project. Language-specific rules live in `<lang>-rules` knowledge "
708
- "skills (e.g. `python-rules`, `typescript-rules`) and load "
709
- "automatically when their triggers match -- you do not need to "
710
- "Read them manually."
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\n".join(lines).rstrip() + "\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 injection."""
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 app_dir, inject_rule, inject_section, should_install
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
- """Inject rules into CLAUDE.md.
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
- if not should_install("rules", only, skip):
57
- print(" Skipped: rules injection")
68
+ install_toolkit_rules = should_install("rules", only, skip)
69
+ if not install_toolkit_rules:
70
+ print(" Skipped: toolkit rule files")
58
71
 
59
- rules_injected: list[str] = []
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
- inject_rule(rule_file, target_dir)
65
- rules_injected.append(rule_name)
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 should_install("rules", only, skip):
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
- inject_rule(source_file, target_dir)
73
- rules_injected.append(rule_name)
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
- print(f" Rules injected: {' '.join(rules_injected)}")
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 inject rules: {rule_names}")
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 inject registered rules: {reg_names}")
311
+ print(f" Would generate: ~/.claude/rules/ai-toolkit-registered-*.md ({reg_names})")
312
+ print(" Would update: ~/.claude/CLAUDE.md global rules index")
@@ -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) AND strips its injected
6
- block from the target CLAUDE.md.
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 strip its injected block."""
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
- # 2. Strip injected block from .claude/CLAUDE.md
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'}")