@softspark/ai-toolkit 4.10.0 → 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.
@@ -352,6 +352,39 @@ def inject_with_rules(
352
352
  print(f" Updated: {target_file}")
353
353
 
354
354
 
355
+ def _inject_text_section(target_file: Path, section: str, text: str) -> None:
356
+ """Inject generated text into one marker section without touching others."""
357
+ target_file.parent.mkdir(parents=True, exist_ok=True)
358
+ existing = target_file.read_text(encoding="utf-8") if target_file.is_file() else ""
359
+ existing = _trim_trailing_blanks(_strip_section(existing, section))
360
+
361
+ parts: list[str] = []
362
+ if existing.strip():
363
+ parts.append(existing)
364
+ parts.append("")
365
+ parts.extend([
366
+ f"<!-- TOOLKIT:{section} START -->",
367
+ "<!-- Auto-injected by ai-toolkit. Re-run to update. -->",
368
+ "",
369
+ text.rstrip("\n"),
370
+ "",
371
+ f"<!-- TOOLKIT:{section} END -->",
372
+ ])
373
+
374
+ output = _collapse_blank_runs("\n".join(parts) + "\n").lstrip("\n")
375
+ target_file.write_text(output, encoding="utf-8")
376
+
377
+
378
+ def _install_copilot_agents_md(cwd: Path) -> None:
379
+ """Emit root AGENTS.md for GitHub Copilot without clobbering other tools."""
380
+ generated = run_script("generate_agents_md.py", capture=True)
381
+ if not generated.strip():
382
+ print(" ERROR: generate_agents_md.py produced no output")
383
+ return
384
+ _inject_text_section(cwd / "AGENTS.md", "copilot-agents", generated)
385
+ print(" Updated: AGENTS.md (Copilot agent instructions)")
386
+
387
+
355
388
  def run_script(script_name: str, *args: str, capture: bool = False) -> str:
356
389
  """Run a script from the scripts/ directory (prefers .py over .sh)."""
357
390
  scripts_dir = toolkit_dir / "scripts"
@@ -628,17 +661,16 @@ def _apply_extends_config(cwd: Path, merged: dict) -> None:
628
661
 
629
662
 
630
663
  def _inject_language_rules(cwd: Path, language_modules: list[str] | None) -> None:
631
- """Inject ``app/rules/common/*.md`` content into project's ``.claude/CLAUDE.md``.
664
+ """Install Claude language-rule entrypoints for a project.
632
665
 
633
666
  Per-language rules (``app/rules/<lang>/``) are NOT injected here -- they
634
667
  ship as ``<lang>-rules`` knowledge skills under ``app/skills/`` and load
635
- contextually via the Agent Skills progressive-disclosure mechanism. This
636
- keeps ``CLAUDE.md`` small while ensuring language-specific guidance still
637
- reaches Claude when relevant.
668
+ contextually via the Agent Skills progressive-disclosure mechanism.
638
669
 
639
- Common rules are language-agnostic (security, git workflow, testing,
640
- coding-style, performance) and stay inlined so they remain in scope for
641
- 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.
642
674
  """
643
675
  if not language_modules:
644
676
  return
@@ -648,6 +680,8 @@ def _inject_language_rules(cwd: Path, language_modules: list[str] | None) -> Non
648
680
  if not common_dir.is_dir():
649
681
  return
650
682
 
683
+ rule_files = _sync_claude_common_rules(cwd, common_dir)
684
+
651
685
  # Detect requested per-language modules so we can name the linked skills
652
686
  # in the marker block. The modules themselves are not inlined.
653
687
  langs: list[str] = []
@@ -657,38 +691,30 @@ def _inject_language_rules(cwd: Path, language_modules: list[str] | None) -> Non
657
691
  if name != "common":
658
692
  langs.append(name)
659
693
 
660
- # Inline full content of every common rule file, stripping YAML
661
- # frontmatter so the resulting block reads as plain Markdown.
662
- inlined: list[str] = []
663
- for f in sorted(common_dir.glob("*.md")):
664
- body = f.read_text(encoding="utf-8")
665
- if body.startswith("---"):
666
- end = body.find("\n---", 3)
667
- if end != -1:
668
- body = body[end + 4:].lstrip("\n")
669
- inlined.append(body.rstrip())
670
-
671
694
  lines: list[str] = ["# Language Rules", ""]
672
695
  lines.append(
673
- "Common (language-agnostic) rules apply to every change in this "
674
- "project. Language-specific rules live in `<lang>-rules` knowledge "
675
- "skills (e.g. `python-rules`, `typescript-rules`) and load "
676
- "automatically when their triggers match -- you do not need to "
677
- "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."
678
708
  )
679
709
  if langs:
680
710
  skill_names = ", ".join(f"`{l}-rules`" for l in langs)
681
711
  lines.append("")
682
712
  lines.append(f"Detected languages: {skill_names}.")
683
- lines.append("")
684
- lines.append("---")
685
- lines.append("")
686
- lines.extend(inlined)
687
713
 
688
714
  # Write to temp file, then inject as a single named section so reruns are
689
715
  # idempotent (existing block is replaced, not duplicated).
690
716
  import tempfile
691
- combined = "\n\n".join(lines).rstrip() + "\n"
717
+ combined = "\n".join(lines).rstrip() + "\n"
692
718
  with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False,
693
719
  encoding="utf-8") as tmp:
694
720
  tmp.write(combined)
@@ -705,6 +731,51 @@ def _inject_language_rules(cwd: Path, language_modules: list[str] | None) -> Non
705
731
  tmp_path.unlink(missing_ok=True)
706
732
 
707
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
+
708
779
  def _install_local_dry_run(reset: bool, editors: list[str] | None = None,
709
780
  profile: str = "standard",
710
781
  codex_skills: bool = False) -> None:
@@ -717,6 +788,7 @@ def _install_local_dry_run(reset: bool, editors: list[str] | None = None,
717
788
  print(" Would create: CLAUDE.md (if missing)")
718
789
  print(" Would create: .claude/settings.local.json (if missing)")
719
790
  print(" Would inject: .claude/constitution.md")
791
+ print(" Would generate: .claude/rules/ai-toolkit-*.md")
720
792
 
721
793
  add_copilot_dir = profile in {"standard", "strict", "full"}
722
794
  add_gemini_hooks = profile in {"standard", "strict", "full"}
@@ -724,7 +796,7 @@ def _install_local_dry_run(reset: bool, editors: list[str] | None = None,
724
796
 
725
797
  # Editor-specific dry-run messages
726
798
  _EDITOR_DRY_RUN = {
727
- "copilot": " Would inject: .github/copilot-instructions.md",
799
+ "copilot": " Would inject: .github/copilot-instructions.md + AGENTS.md",
728
800
  "cursor": " Would generate: .cursorrules + .cursor/rules/*.mdc",
729
801
  "windsurf": " Would generate: .windsurfrules + .devin/rules/*.md + .windsurf/rules/*.md",
730
802
  "cline": " Would generate: .clinerules/*.md",
@@ -949,6 +1021,7 @@ def _create_local_ai_tool_configs(cwd: Path, rules_dir: Path,
949
1021
  cwd / ".github" / "copilot-instructions.md",
950
1022
  rules_dir,
951
1023
  )
1024
+ _install_copilot_agents_md(cwd)
952
1025
  # `standard` and above: emit path-specific instructions + prompt files
953
1026
  # (directory mode). `minimal` stays backwards-compatible with v2.
954
1027
  if add_copilot_dir:
@@ -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'}")