@softspark/ai-toolkit 4.10.1 → 4.12.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.
@@ -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")
package/scripts/plugin.py CHANGED
@@ -23,6 +23,7 @@ Actions:
23
23
  from __future__ import annotations
24
24
 
25
25
  import json
26
+ import re
26
27
  import shutil
27
28
  import sqlite3 as sqlite
28
29
  import subprocess
@@ -31,6 +32,7 @@ from pathlib import Path
31
32
 
32
33
  sys.path.insert(0, str(Path(__file__).resolve().parent))
33
34
  from _common import app_dir, inject_section, inject_rule, remove_rule_section
35
+ from injection import strip_section, trim_trailing_blanks
34
36
  from codex_skill_adapter import cleanup_codex_skills, sync_codex_skill
35
37
  from generate_codex_hooks import generate as generate_codex_hooks
36
38
  from install_steps.ai_tools import inject_with_rules
@@ -536,8 +538,9 @@ def _install_codex_extra_skills(pack: dict, pack_dir: Path) -> None:
536
538
  def _install_codex_base() -> None:
537
539
  _ensure_core_hook_scripts()
538
540
  # Universal coding rules are inlined into AGENTS.md by generate_codex.py;
539
- # Codex does not read a .agents/rules/ directory.
540
- inject_with_rules("generate_codex.py", CODEX_ROOT / "AGENTS.md", RULES_DIR)
541
+ # Codex does not read a .agents/rules/ directory. Global instructions must
542
+ # live at $CODEX_HOME/AGENTS.md (default ~/.codex/AGENTS.md), not ~/AGENTS.md.
543
+ inject_with_rules("generate_codex.py", CODEX_ROOT / ".codex" / "AGENTS.md", RULES_DIR)
541
544
  hooks_path = CODEX_ROOT / ".codex" / "hooks.json"
542
545
  existing = _load_json(hooks_path, {"hooks": {}})
543
546
  plugin_entries: dict[str, list[dict]] = {}
@@ -632,21 +635,41 @@ def _strip_codex_hooks(name: str) -> None:
632
635
 
633
636
 
634
637
  def _install_codex_rules(name: str, rule_specs: list[dict]) -> None:
635
- rules_dir = CODEX_ROOT / ".agents" / "rules"
636
- rules_dir.mkdir(parents=True, exist_ok=True)
638
+ # Codex reads instructions only from AGENTS.md, never from a .agents/rules/
639
+ # directory, so pack rules are marker-injected into ~/.codex/AGENTS.md (the
640
+ # documented global instruction file) instead of written as dead files.
641
+ agents_md = CODEX_ROOT / ".codex" / "AGENTS.md"
637
642
  for spec in rule_specs:
638
- dest = rules_dir / f"plugin-{name}-{spec['name']}.md"
639
- shutil.copy2(spec["source"], dest)
640
- print(f" Installed Codex rule: {dest.name}")
643
+ section = f"plugin-{name}-{spec['name']}"
644
+ inject_section(spec["source"], agents_md, section)
645
+ print(f" Injected Codex rule: {spec['name']} -> ~/.codex/AGENTS.md")
646
+ _clean_legacy_codex_rule_files(name)
641
647
 
642
648
 
643
649
  def _remove_codex_rules(name: str) -> None:
650
+ agents_md = CODEX_ROOT / ".codex" / "AGENTS.md"
651
+ if agents_md.is_file():
652
+ content = agents_md.read_text(encoding="utf-8")
653
+ changed = False
654
+ for match in re.findall(r"<!-- TOOLKIT:(plugin-" + re.escape(name) + r"-[^ ]+) START -->", content):
655
+ content = strip_section(content, match)
656
+ changed = True
657
+ if changed:
658
+ agents_md.write_text(trim_trailing_blanks(content) + "\n", encoding="utf-8")
659
+ print(f" Removed Codex rules for {name} from ~/.codex/AGENTS.md")
660
+ _clean_legacy_codex_rule_files(name)
661
+
662
+
663
+ def _clean_legacy_codex_rule_files(name: str) -> None:
664
+ """Remove dead ~/.agents/rules/plugin-<name>-*.md files written by earlier
665
+ versions. Codex never read them; Antigravity's .agents/rules/ is a separate,
666
+ project-local surface, so this only touches our own plugin-prefixed files."""
644
667
  rules_dir = CODEX_ROOT / ".agents" / "rules"
645
668
  if not rules_dir.is_dir():
646
669
  return
647
670
  for rule_file in sorted(rules_dir.glob(f"plugin-{name}-*.md")):
648
671
  rule_file.unlink()
649
- print(f" Removed Codex rule: {rule_file.name}")
672
+ print(f" Removed legacy Codex rule file: {rule_file.name}")
650
673
 
651
674
 
652
675
  def install_pack_codex(name: str, pack: dict, pack_dir: Path) -> bool:
@@ -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'}")