@softspark/ai-toolkit 2.1.2 → 2.2.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 +272 -10
- package/CHANGELOG.md +17 -2
- package/README.md +3 -3
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/ARCHITECTURE.md +1 -1
- package/bin/ai-toolkit.js +8 -6
- package/kb/procedures/maintenance-sop.md +1 -0
- package/kb/procedures/release-preparation-sop.md +3 -2
- package/kb/reference/skills-catalog.md +3 -1
- package/llms-full.txt +7 -3
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/add_rule.py +60 -18
- package/scripts/generate_agents_md.py +12 -14
- package/scripts/generate_codex.py +11 -14
- package/scripts/generator_base.py +11 -13
- package/scripts/install.py +8 -2
- package/scripts/install_steps/ai_tools.py +13 -14
- package/scripts/install_steps/markers.py +35 -2
- package/scripts/install_steps/project_registry.py +132 -60
- package/scripts/remove_rule.py +5 -0
- package/scripts/rule_sources.py +138 -0
- package/scripts/update_projects.py +15 -2
- package/scripts/validate.py +33 -0
package/scripts/add_rule.py
CHANGED
|
@@ -1,52 +1,94 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""add-rule -- Register a rule file in ~/.softspark/ai-toolkit/rules/.
|
|
2
|
+
"""add-rule -- Register a rule file or URL in ~/.softspark/ai-toolkit/rules/.
|
|
3
3
|
|
|
4
4
|
Registered rules are automatically injected into all AI tool configs
|
|
5
|
-
on next 'ai-toolkit install' or 'ai-toolkit update'
|
|
5
|
+
on next 'ai-toolkit install' or 'ai-toolkit update'.
|
|
6
|
+
URL-sourced rules are auto-refreshed on every update.
|
|
7
|
+
|
|
6
8
|
Global: Claude, Cursor, Windsurf, Gemini, Augment
|
|
7
9
|
Local (--local): all of the above + Copilot, Cline, Roo, Aider, Antigravity
|
|
8
10
|
|
|
9
11
|
Usage:
|
|
10
|
-
add_rule.py <rule-file> [rule-name]
|
|
12
|
+
add_rule.py <rule-file-or-url> [rule-name]
|
|
11
13
|
|
|
12
14
|
Arguments:
|
|
13
|
-
rule-file
|
|
14
|
-
rule-name
|
|
15
|
+
rule-file-or-url Path to .md file or HTTPS URL to register globally
|
|
16
|
+
rule-name Override the rule name (default: filename without .md)
|
|
15
17
|
"""
|
|
16
18
|
from __future__ import annotations
|
|
17
19
|
|
|
18
20
|
import re
|
|
19
21
|
import shutil
|
|
20
22
|
import sys
|
|
23
|
+
import urllib.parse
|
|
21
24
|
from pathlib import Path
|
|
22
25
|
|
|
23
26
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
24
27
|
|
|
25
28
|
|
|
29
|
+
def _name_from_url(url: str) -> str:
|
|
30
|
+
"""Derive a rule name from a URL's last path segment."""
|
|
31
|
+
parsed = urllib.parse.urlparse(url)
|
|
32
|
+
filename = parsed.path.rstrip("/").split("/")[-1]
|
|
33
|
+
stem = filename.rsplit(".", 1)[0] if "." in filename else filename
|
|
34
|
+
return re.sub(r"[^a-zA-Z0-9_-]", "", stem)
|
|
35
|
+
|
|
36
|
+
|
|
26
37
|
def main() -> None:
|
|
27
|
-
"""Register a rule file in the global rules directory."""
|
|
38
|
+
"""Register a rule file or URL in the global rules directory."""
|
|
28
39
|
if len(sys.argv) < 2:
|
|
29
|
-
print("Usage: add_rule.py <rule-file> [rule-name]", file=sys.stderr)
|
|
40
|
+
print("Usage: add_rule.py <rule-file-or-url> [rule-name]", file=sys.stderr)
|
|
30
41
|
sys.exit(1)
|
|
31
42
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
print(f"Rule file not found: {rule_file}", file=sys.stderr)
|
|
35
|
-
sys.exit(1)
|
|
43
|
+
source = sys.argv[1]
|
|
44
|
+
is_url = source.startswith("https://") or source.startswith("http://")
|
|
36
45
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if not rule_name:
|
|
40
|
-
print("Error: rule name is empty after sanitization", file=sys.stderr)
|
|
46
|
+
if is_url and source.startswith("http://"):
|
|
47
|
+
print("Error: only HTTPS URLs are supported. Use https:// for security.", file=sys.stderr)
|
|
41
48
|
sys.exit(1)
|
|
49
|
+
|
|
42
50
|
from paths import RULES_DIR
|
|
43
51
|
rules_dir = RULES_DIR
|
|
44
52
|
rules_dir.mkdir(parents=True, exist_ok=True)
|
|
45
53
|
|
|
46
|
-
|
|
47
|
-
|
|
54
|
+
if is_url:
|
|
55
|
+
from rule_sources import fetch_url, register_url_source
|
|
56
|
+
|
|
57
|
+
rule_name = sys.argv[2] if len(sys.argv) > 2 else _name_from_url(source)
|
|
58
|
+
rule_name = re.sub(r"[^a-zA-Z0-9_-]", "", rule_name)
|
|
59
|
+
if not rule_name:
|
|
60
|
+
print("Error: could not derive rule name from URL. Provide one explicitly.", file=sys.stderr)
|
|
61
|
+
sys.exit(1)
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
data = fetch_url(source)
|
|
65
|
+
except Exception as exc:
|
|
66
|
+
print(f"Error fetching URL: {exc}", file=sys.stderr)
|
|
67
|
+
sys.exit(1)
|
|
68
|
+
|
|
69
|
+
dest = rules_dir / f"{rule_name}.md"
|
|
70
|
+
dest.write_bytes(data)
|
|
71
|
+
register_url_source(rules_dir, rule_name, source)
|
|
72
|
+
|
|
73
|
+
print(f"Registered: '{rule_name}' -> {dest}")
|
|
74
|
+
print(f"Source URL: {source} (auto-refreshed on update)")
|
|
75
|
+
else:
|
|
76
|
+
rule_file = Path(source)
|
|
77
|
+
if not rule_file.is_file():
|
|
78
|
+
print(f"Rule file not found: {rule_file}", file=sys.stderr)
|
|
79
|
+
sys.exit(1)
|
|
80
|
+
|
|
81
|
+
rule_name = sys.argv[2] if len(sys.argv) > 2 else rule_file.stem
|
|
82
|
+
rule_name = re.sub(r"[^a-zA-Z0-9_-]", "", rule_name)
|
|
83
|
+
if not rule_name:
|
|
84
|
+
print("Error: rule name is empty after sanitization", file=sys.stderr)
|
|
85
|
+
sys.exit(1)
|
|
86
|
+
|
|
87
|
+
dest = rules_dir / f"{rule_name}.md"
|
|
88
|
+
shutil.copy2(rule_file, dest)
|
|
89
|
+
|
|
90
|
+
print(f"Registered: '{rule_name}' -> {dest}")
|
|
48
91
|
|
|
49
|
-
print(f"Registered: '{rule_name}' -> {dest}")
|
|
50
92
|
print()
|
|
51
93
|
print("Apply now:")
|
|
52
94
|
print(" ai-toolkit update # global (Claude, Cursor, Windsurf, Gemini, Augment)")
|
|
@@ -6,13 +6,13 @@ Usage: ./scripts/generate_agents_md.py > AGENTS.md
|
|
|
6
6
|
"""
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
-
import os
|
|
10
9
|
import sys
|
|
11
10
|
from pathlib import Path
|
|
12
11
|
|
|
13
12
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
13
|
+
import subprocess
|
|
14
|
+
|
|
14
15
|
from _common import agents_dir, frontmatter_field
|
|
15
|
-
from paths import RULES_DIR
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
def main() -> None:
|
|
@@ -88,18 +88,16 @@ def main() -> None:
|
|
|
88
88
|
print("---")
|
|
89
89
|
print()
|
|
90
90
|
|
|
91
|
-
#
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
print(f"<!-- TOOLKIT:{rule_name} END -->")
|
|
102
|
-
print()
|
|
91
|
+
# Codex CLI configuration block (agents, skills, guidelines)
|
|
92
|
+
codex_script = Path(__file__).resolve().parent / "generate_codex.py"
|
|
93
|
+
result = subprocess.run(
|
|
94
|
+
["python3", str(codex_script)],
|
|
95
|
+
capture_output=True, text=True,
|
|
96
|
+
)
|
|
97
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
98
|
+
print(result.stdout.rstrip())
|
|
99
|
+
|
|
100
|
+
# Note: custom rules are included via generate_codex.py output above
|
|
103
101
|
|
|
104
102
|
|
|
105
103
|
if __name__ == "__main__":
|
|
@@ -8,7 +8,6 @@ Usage: ./scripts/generate_codex.py > AGENTS.md
|
|
|
8
8
|
"""
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
-
import os
|
|
12
11
|
import sys
|
|
13
12
|
from pathlib import Path
|
|
14
13
|
|
|
@@ -89,19 +88,17 @@ def main() -> None:
|
|
|
89
88
|
|
|
90
89
|
print_toolkit_end()
|
|
91
90
|
|
|
92
|
-
# Registered custom rules
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
print()
|
|
104
|
-
print(f"<!-- TOOLKIT:{rule_name} END -->")
|
|
91
|
+
# Registered custom rules from ~/.softspark/ai-toolkit/rules/
|
|
92
|
+
if RULES_DIR.is_dir():
|
|
93
|
+
for rule_file in sorted(RULES_DIR.glob("*.md")):
|
|
94
|
+
rule_name = rule_file.stem
|
|
95
|
+
print()
|
|
96
|
+
print(f"<!-- TOOLKIT:{rule_name} START -->")
|
|
97
|
+
print("<!-- Auto-injected by ai-toolkit. Re-run to update. -->")
|
|
98
|
+
print()
|
|
99
|
+
print(rule_file.read_text(encoding="utf-8").rstrip())
|
|
100
|
+
print()
|
|
101
|
+
print(f"<!-- TOOLKIT:{rule_name} END -->")
|
|
105
102
|
|
|
106
103
|
|
|
107
104
|
if __name__ == "__main__":
|
|
@@ -26,7 +26,6 @@ Usage::
|
|
|
26
26
|
"""
|
|
27
27
|
from __future__ import annotations
|
|
28
28
|
|
|
29
|
-
import os
|
|
30
29
|
import sys
|
|
31
30
|
from pathlib import Path
|
|
32
31
|
|
|
@@ -143,15 +142,14 @@ def render_generator(config: dict) -> None:
|
|
|
143
142
|
print()
|
|
144
143
|
print_toolkit_end()
|
|
145
144
|
|
|
146
|
-
# Registered custom rules
|
|
147
|
-
if
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
print(f"<!-- TOOLKIT:{rule_name} END -->")
|
|
145
|
+
# Registered custom rules from ~/.softspark/ai-toolkit/rules/
|
|
146
|
+
if RULES_DIR.is_dir():
|
|
147
|
+
for rule_file in sorted(RULES_DIR.glob("*.md")):
|
|
148
|
+
rule_name = rule_file.stem
|
|
149
|
+
print()
|
|
150
|
+
print(f"<!-- TOOLKIT:{rule_name} START -->")
|
|
151
|
+
print("<!-- Auto-injected by ai-toolkit. Re-run to update. -->")
|
|
152
|
+
print()
|
|
153
|
+
print(rule_file.read_text(encoding="utf-8").rstrip())
|
|
154
|
+
print()
|
|
155
|
+
print(f"<!-- TOOLKIT:{rule_name} END -->")
|
package/scripts/install.py
CHANGED
|
@@ -212,6 +212,7 @@ def parse_args(argv: list[str]) -> dict:
|
|
|
212
212
|
"editors": "",
|
|
213
213
|
"config": "",
|
|
214
214
|
"refresh_base": False,
|
|
215
|
+
"skip_register": False,
|
|
215
216
|
}
|
|
216
217
|
i = 0
|
|
217
218
|
while i < len(argv):
|
|
@@ -268,6 +269,8 @@ def parse_args(argv: list[str]) -> dict:
|
|
|
268
269
|
cfg["config"] = argv[i] if i < len(argv) else ""
|
|
269
270
|
elif arg == "--refresh-base":
|
|
270
271
|
cfg["refresh_base"] = True
|
|
272
|
+
elif arg == "--skip-register":
|
|
273
|
+
cfg["skip_register"] = True
|
|
271
274
|
elif arg.startswith("-"):
|
|
272
275
|
print(f"Unknown option: {arg}")
|
|
273
276
|
sys.exit(1)
|
|
@@ -426,7 +429,8 @@ def install_claude_code(target_dir: Path, hooks_scripts_dir: Path,
|
|
|
426
429
|
print()
|
|
427
430
|
print(f" Available: {count_agents()} agents, {count_skills()} skills")
|
|
428
431
|
|
|
429
|
-
inject_rules(claude_dir, target_dir, rules_dir, only, skip, dry_run
|
|
432
|
+
inject_rules(claude_dir, target_dir, rules_dir, only, skip, dry_run,
|
|
433
|
+
refresh_urls=True)
|
|
430
434
|
|
|
431
435
|
|
|
432
436
|
VALID_PERSONAS = ("backend-lead", "frontend-lead", "devops-eng", "junior-dev")
|
|
@@ -711,7 +715,9 @@ def main() -> None:
|
|
|
711
715
|
)
|
|
712
716
|
|
|
713
717
|
# Register project in global registry (for `ai-toolkit update` propagation)
|
|
714
|
-
|
|
718
|
+
# Skipped when called from update_projects.py (--skip-register) to avoid
|
|
719
|
+
# concurrent writes to projects.json during parallel updates.
|
|
720
|
+
if local and not cfg.get("skip_register"):
|
|
715
721
|
extends_source = ""
|
|
716
722
|
if extends_info:
|
|
717
723
|
extends_source = extends_info.get("source", "")
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
"""Install AI tool configs (Cursor, Windsurf, Gemini, Augment, Codex) and local project setup."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
-
import os
|
|
5
4
|
import shutil
|
|
6
5
|
import subprocess
|
|
7
6
|
from pathlib import Path
|
|
@@ -79,14 +78,12 @@ def inject_with_rules(
|
|
|
79
78
|
else:
|
|
80
79
|
cmd = ["bash", str(scripts_dir / generator_script)]
|
|
81
80
|
|
|
82
|
-
|
|
83
|
-
result = subprocess.run(cmd, capture_output=True, text=True, env=env)
|
|
81
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
84
82
|
if result.returncode != 0:
|
|
85
83
|
print(f" ERROR: {generator_script} failed: {result.stderr.strip()}")
|
|
86
84
|
return
|
|
87
85
|
|
|
88
86
|
generated = result.stdout
|
|
89
|
-
start_marker = "<!-- TOOLKIT:ai-toolkit START -->"
|
|
90
87
|
|
|
91
88
|
target_file = Path(target_file)
|
|
92
89
|
target_file.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -94,26 +91,28 @@ def inject_with_rules(
|
|
|
94
91
|
target_file.touch()
|
|
95
92
|
|
|
96
93
|
existing = target_file.read_text(encoding="utf-8")
|
|
97
|
-
|
|
98
|
-
|
|
94
|
+
# Strip ALL toolkit sections from existing — generated output is the
|
|
95
|
+
# complete source of truth (includes ai-toolkit block + custom rules)
|
|
96
|
+
import re
|
|
97
|
+
existing = re.sub(
|
|
98
|
+
r"<!-- TOOLKIT:[^ ]+ START -->.*?<!-- TOOLKIT:[^ ]+ END -->\n?",
|
|
99
|
+
"",
|
|
100
|
+
existing,
|
|
101
|
+
flags=re.DOTALL,
|
|
102
|
+
)
|
|
99
103
|
existing = _trim_trailing_blanks(existing)
|
|
100
|
-
existing = existing.lstrip("\n")
|
|
104
|
+
existing = existing.lstrip("\n")
|
|
101
105
|
|
|
102
106
|
parts: list[str] = []
|
|
103
107
|
if existing.strip():
|
|
104
108
|
parts.append(existing)
|
|
105
|
-
parts.append("")
|
|
109
|
+
parts.append("") # blank line separator
|
|
106
110
|
parts.append(generated.rstrip("\n"))
|
|
107
111
|
|
|
108
112
|
output = "\n".join(parts) + "\n"
|
|
109
113
|
output = _collapse_blank_runs(output)
|
|
110
|
-
output = output.lstrip("\n")
|
|
114
|
+
output = output.lstrip("\n")
|
|
111
115
|
target_file.write_text(output, encoding="utf-8")
|
|
112
|
-
|
|
113
|
-
if rules_dir.is_dir():
|
|
114
|
-
for rule_file in sorted(rules_dir.glob("*.md")):
|
|
115
|
-
inject_section(rule_file, target_file, rule_file.stem)
|
|
116
|
-
|
|
117
116
|
print(f" Updated: {target_file}")
|
|
118
117
|
|
|
119
118
|
|
|
@@ -32,14 +32,23 @@ def install_marker_files(claude_dir: Path, only: str, skip: str,
|
|
|
32
32
|
|
|
33
33
|
|
|
34
34
|
def inject_rules(claude_dir: Path, target_dir: Path, rules_dir: Path,
|
|
35
|
-
only: str, skip: str, dry_run: bool
|
|
36
|
-
|
|
35
|
+
only: str, skip: str, dry_run: bool,
|
|
36
|
+
refresh_urls: bool = False) -> None:
|
|
37
|
+
"""Inject rules into CLAUDE.md.
|
|
38
|
+
|
|
39
|
+
When refresh_urls is True, re-fetches URL-sourced rules before injection.
|
|
40
|
+
Only the global install path should set this to True (once per update).
|
|
41
|
+
"""
|
|
37
42
|
claude_md = claude_dir / "CLAUDE.md"
|
|
38
43
|
|
|
39
44
|
if dry_run:
|
|
40
45
|
_inject_rules_dry_run(rules_dir)
|
|
41
46
|
return
|
|
42
47
|
|
|
48
|
+
# Refresh URL-sourced rules before injection (global update only)
|
|
49
|
+
if refresh_urls:
|
|
50
|
+
_refresh_url_rules(rules_dir)
|
|
51
|
+
|
|
43
52
|
if not claude_md.is_file():
|
|
44
53
|
claude_md.touch()
|
|
45
54
|
print(" Created: ~/.claude/CLAUDE.md")
|
|
@@ -66,6 +75,30 @@ def inject_rules(claude_dir: Path, target_dir: Path, rules_dir: Path,
|
|
|
66
75
|
print(f" Rules injected: {' '.join(rules_injected)}")
|
|
67
76
|
|
|
68
77
|
|
|
78
|
+
def _refresh_url_rules(rules_dir: Path) -> None:
|
|
79
|
+
"""Re-fetch all URL-sourced rules. Warn on failure, use cached copy."""
|
|
80
|
+
from rule_sources import get_url_rules, fetch_url, register_url_source
|
|
81
|
+
|
|
82
|
+
url_rules = get_url_rules(rules_dir)
|
|
83
|
+
if not url_rules:
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
for rule_name, url in url_rules.items():
|
|
87
|
+
rule_file = rules_dir / f"{rule_name}.md"
|
|
88
|
+
try:
|
|
89
|
+
data = fetch_url(url)
|
|
90
|
+
rule_file.write_bytes(data)
|
|
91
|
+
register_url_source(rules_dir, rule_name, url)
|
|
92
|
+
print(f" Refreshed: {rule_name} (from {url})")
|
|
93
|
+
except Exception as exc:
|
|
94
|
+
if rule_file.is_file():
|
|
95
|
+
print(f" Warning: could not refresh '{rule_name}' from {url}: {exc}")
|
|
96
|
+
print(f" Using cached version.")
|
|
97
|
+
else:
|
|
98
|
+
print(f" Warning: could not fetch '{rule_name}' from {url}: {exc}")
|
|
99
|
+
print(f" No cached version — rule will be skipped.")
|
|
100
|
+
|
|
101
|
+
|
|
69
102
|
def _inject_rules_dry_run(rules_dir: Path) -> None:
|
|
70
103
|
rules_src = app_dir / "rules"
|
|
71
104
|
rule_names = " ".join(
|
|
@@ -8,15 +8,43 @@ Stdlib-only — no external dependencies.
|
|
|
8
8
|
"""
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
import contextlib
|
|
12
|
+
import fcntl
|
|
11
13
|
import json
|
|
14
|
+
import os
|
|
12
15
|
import sys
|
|
16
|
+
import tempfile
|
|
17
|
+
import time
|
|
13
18
|
from datetime import datetime, timezone
|
|
14
19
|
from pathlib import Path
|
|
15
|
-
from typing import Any
|
|
20
|
+
from typing import Any, Generator
|
|
16
21
|
|
|
17
22
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
18
23
|
from paths import PROJECTS_FILE
|
|
19
24
|
|
|
25
|
+
# Max retries when reading a partially-written file
|
|
26
|
+
_LOAD_RETRIES = 3
|
|
27
|
+
_LOAD_RETRY_DELAY = 0.05 # 50ms
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@contextlib.contextmanager
|
|
31
|
+
def _registry_lock() -> Generator[None, None, None]:
|
|
32
|
+
"""Exclusive file lock for read-modify-write on projects.json.
|
|
33
|
+
|
|
34
|
+
Prevents concurrent processes from interleaving loads and saves,
|
|
35
|
+
which can silently drop entries.
|
|
36
|
+
"""
|
|
37
|
+
path = _registry_path()
|
|
38
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
lock_path = path.with_suffix(".lock")
|
|
40
|
+
fd = os.open(str(lock_path), os.O_CREAT | os.O_RDWR)
|
|
41
|
+
try:
|
|
42
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
43
|
+
yield
|
|
44
|
+
finally:
|
|
45
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
46
|
+
os.close(fd)
|
|
47
|
+
|
|
20
48
|
|
|
21
49
|
def _registry_path() -> Path:
|
|
22
50
|
"""Return the canonical path to projects.json."""
|
|
@@ -32,28 +60,66 @@ def _now_iso() -> str:
|
|
|
32
60
|
# ---------------------------------------------------------------------------
|
|
33
61
|
|
|
34
62
|
def load_registry() -> list[dict[str, Any]]:
|
|
35
|
-
"""Load project registry
|
|
63
|
+
"""Load project registry with retry for partially-written files.
|
|
64
|
+
|
|
65
|
+
Retries on JSONDecodeError (another process mid-write).
|
|
66
|
+
Returns empty list only if the file genuinely doesn't exist.
|
|
67
|
+
"""
|
|
36
68
|
path = _registry_path()
|
|
37
69
|
if not path.is_file():
|
|
38
70
|
return []
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
71
|
+
|
|
72
|
+
last_err: Exception | None = None
|
|
73
|
+
for attempt in range(_LOAD_RETRIES):
|
|
74
|
+
try:
|
|
75
|
+
with open(path, encoding="utf-8") as f:
|
|
76
|
+
data = json.load(f)
|
|
77
|
+
if isinstance(data, dict):
|
|
78
|
+
projects = data.get("projects", [])
|
|
79
|
+
return projects if isinstance(projects, list) else []
|
|
80
|
+
return []
|
|
81
|
+
except json.JSONDecodeError as exc:
|
|
82
|
+
last_err = exc
|
|
83
|
+
if attempt < _LOAD_RETRIES - 1:
|
|
84
|
+
time.sleep(_LOAD_RETRY_DELAY)
|
|
85
|
+
except OSError:
|
|
86
|
+
return []
|
|
87
|
+
|
|
88
|
+
# All retries exhausted — file is genuinely corrupt, not mid-write
|
|
89
|
+
import sys as _sys
|
|
90
|
+
print(
|
|
91
|
+
f"Warning: {path} is corrupt after {_LOAD_RETRIES} retries: {last_err}",
|
|
92
|
+
file=_sys.stderr,
|
|
93
|
+
)
|
|
94
|
+
return []
|
|
48
95
|
|
|
49
96
|
|
|
50
97
|
def save_registry(projects: list[dict[str, Any]]) -> None:
|
|
51
|
-
"""Save project registry.
|
|
98
|
+
"""Save project registry atomically (write-to-temp + rename).
|
|
99
|
+
|
|
100
|
+
Uses os.rename which is atomic on POSIX, preventing other processes
|
|
101
|
+
from reading a partially-written file.
|
|
102
|
+
"""
|
|
52
103
|
path = _registry_path()
|
|
53
104
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
105
|
+
|
|
106
|
+
fd, tmp_path = tempfile.mkstemp(
|
|
107
|
+
dir=str(path.parent), prefix=".projects_", suffix=".tmp"
|
|
108
|
+
)
|
|
109
|
+
try:
|
|
110
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
111
|
+
json.dump({"projects": projects}, f, indent=2)
|
|
112
|
+
f.write("\n")
|
|
113
|
+
f.flush()
|
|
114
|
+
os.fsync(f.fileno())
|
|
115
|
+
os.rename(tmp_path, str(path))
|
|
116
|
+
except BaseException:
|
|
117
|
+
# Clean up temp file on failure
|
|
118
|
+
try:
|
|
119
|
+
os.unlink(tmp_path)
|
|
120
|
+
except OSError:
|
|
121
|
+
pass
|
|
122
|
+
raise
|
|
57
123
|
|
|
58
124
|
|
|
59
125
|
# ---------------------------------------------------------------------------
|
|
@@ -68,47 +134,52 @@ def register_project(
|
|
|
68
134
|
"""Register a project directory. Returns True if newly added, False if updated.
|
|
69
135
|
|
|
70
136
|
Idempotent — updates existing entry if path already registered.
|
|
137
|
+
Uses file lock to prevent concurrent read-modify-write races.
|
|
71
138
|
"""
|
|
72
139
|
project_path = str(Path(project_path).resolve())
|
|
73
|
-
projects = load_registry()
|
|
74
|
-
now = _now_iso()
|
|
75
|
-
|
|
76
|
-
for p in projects:
|
|
77
|
-
if p.get("path") == project_path:
|
|
78
|
-
# Update existing
|
|
79
|
-
p["last_updated"] = now
|
|
80
|
-
if profile:
|
|
81
|
-
p["profile"] = profile
|
|
82
|
-
if extends:
|
|
83
|
-
p["extends"] = extends
|
|
84
|
-
elif "extends" in p and not extends:
|
|
85
|
-
# Clear extends if project no longer uses it
|
|
86
|
-
pass
|
|
87
|
-
save_registry(projects)
|
|
88
|
-
return False
|
|
89
140
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
141
|
+
with _registry_lock():
|
|
142
|
+
projects = load_registry()
|
|
143
|
+
now = _now_iso()
|
|
144
|
+
|
|
145
|
+
for p in projects:
|
|
146
|
+
if p.get("path") == project_path:
|
|
147
|
+
# Update existing
|
|
148
|
+
p["last_updated"] = now
|
|
149
|
+
if profile:
|
|
150
|
+
p["profile"] = profile
|
|
151
|
+
if extends:
|
|
152
|
+
p["extends"] = extends
|
|
153
|
+
elif "extends" in p and not extends:
|
|
154
|
+
# Clear extends if project no longer uses it
|
|
155
|
+
pass
|
|
156
|
+
save_registry(projects)
|
|
157
|
+
return False
|
|
158
|
+
|
|
159
|
+
# New registration
|
|
160
|
+
projects.append({
|
|
161
|
+
"path": project_path,
|
|
162
|
+
"registered_at": now,
|
|
163
|
+
"last_updated": now,
|
|
164
|
+
"profile": profile or "standard",
|
|
165
|
+
"extends": extends or "",
|
|
166
|
+
})
|
|
167
|
+
save_registry(projects)
|
|
168
|
+
return True
|
|
100
169
|
|
|
101
170
|
|
|
102
171
|
def unregister_project(project_path: str | Path) -> bool:
|
|
103
172
|
"""Unregister a project. Returns True if found and removed."""
|
|
104
173
|
project_path = str(Path(project_path).resolve())
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
174
|
+
|
|
175
|
+
with _registry_lock():
|
|
176
|
+
projects = load_registry()
|
|
177
|
+
original_len = len(projects)
|
|
178
|
+
projects = [p for p in projects if p.get("path") != project_path]
|
|
179
|
+
if len(projects) < original_len:
|
|
180
|
+
save_registry(projects)
|
|
181
|
+
return True
|
|
182
|
+
return False
|
|
112
183
|
|
|
113
184
|
|
|
114
185
|
def list_projects() -> list[dict[str, Any]]:
|
|
@@ -121,18 +192,19 @@ def list_projects() -> list[dict[str, Any]]:
|
|
|
121
192
|
|
|
122
193
|
def prune_stale() -> list[str]:
|
|
123
194
|
"""Remove projects whose directories no longer exist. Returns pruned paths."""
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
195
|
+
with _registry_lock():
|
|
196
|
+
projects = load_registry()
|
|
197
|
+
pruned: list[str] = []
|
|
198
|
+
kept: list[dict[str, Any]] = []
|
|
199
|
+
|
|
200
|
+
for p in projects:
|
|
201
|
+
if Path(p["path"]).is_dir():
|
|
202
|
+
kept.append(p)
|
|
203
|
+
else:
|
|
204
|
+
pruned.append(p["path"])
|
|
205
|
+
|
|
206
|
+
if pruned:
|
|
207
|
+
save_registry(kept)
|
|
136
208
|
|
|
137
209
|
return pruned
|
|
138
210
|
|
package/scripts/remove_rule.py
CHANGED
|
@@ -43,6 +43,11 @@ def main() -> None:
|
|
|
43
43
|
else:
|
|
44
44
|
print(f"Not registered: '{rule_name}' not found in {rules_dir}")
|
|
45
45
|
|
|
46
|
+
# 1b. Clean up URL source metadata (if any)
|
|
47
|
+
from rule_sources import unregister_source
|
|
48
|
+
if unregister_source(rules_dir, rule_name):
|
|
49
|
+
print(f"Removed URL source for '{rule_name}'")
|
|
50
|
+
|
|
46
51
|
# 2. Strip injected block from .claude/CLAUDE.md
|
|
47
52
|
found = remove_rule_section(rule_name, target_dir)
|
|
48
53
|
if found:
|