@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.
@@ -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 Path to .md file with the rule content
14
- rule-name Override the rule name (default: filename without .md)
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
- rule_file = Path(sys.argv[1])
33
- if not rule_file.is_file():
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
- rule_name = sys.argv[2] if len(sys.argv) > 2 else rule_file.stem
38
- rule_name = re.sub(r"[^a-zA-Z0-9_-]", "", rule_name)
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
- dest = rules_dir / f"{rule_name}.md"
47
- shutil.copy2(rule_file, dest)
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
- # Registered custom rules skip when called by inject_with_rules()
92
- if not os.environ.get("_TOOLKIT_INJECT_MODE"):
93
- if RULES_DIR.is_dir():
94
- for rule_file in sorted(RULES_DIR.glob("*.md")):
95
- rule_name = rule_file.stem
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 -->")
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 skip when called by inject_with_rules()
93
- # (which handles injection separately via inject_section)
94
- if not os.environ.get("_TOOLKIT_INJECT_MODE"):
95
- if RULES_DIR.is_dir():
96
- for rule_file in sorted(RULES_DIR.glob("*.md")):
97
- rule_name = rule_file.stem
98
- print()
99
- print(f"<!-- TOOLKIT:{rule_name} START -->")
100
- print("<!-- Auto-injected by ai-toolkit. Re-run to update. -->")
101
- print()
102
- print(rule_file.read_text(encoding="utf-8").rstrip())
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 skip when called by inject_with_rules()
147
- if not os.environ.get("_TOOLKIT_INJECT_MODE"):
148
- if RULES_DIR.is_dir():
149
- for rule_file in sorted(RULES_DIR.glob("*.md")):
150
- rule_name = rule_file.stem
151
- print()
152
- print(f"<!-- TOOLKIT:{rule_name} START -->")
153
- print("<!-- Auto-injected by ai-toolkit. Re-run to update. -->")
154
- print()
155
- print(rule_file.read_text(encoding="utf-8").rstrip())
156
- print()
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 -->")
@@ -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
- if local:
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
- env = {**os.environ, "_TOOLKIT_INJECT_MODE": "1"}
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
- if start_marker in existing:
98
- existing = _strip_section(existing, "ai-toolkit")
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") # no leading blank lines
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") # no leading blank lines
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) -> None:
36
- """Inject rules into CLAUDE.md."""
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. Returns empty list if missing/corrupt."""
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
- try:
40
- with open(path, encoding="utf-8") as f:
41
- data = json.load(f)
42
- if isinstance(data, dict):
43
- projects = data.get("projects", [])
44
- return projects if isinstance(projects, list) else []
45
- return []
46
- except (json.JSONDecodeError, OSError):
47
- return []
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
- with open(path, "w", encoding="utf-8") as f:
55
- json.dump({"projects": projects}, f, indent=2)
56
- f.write("\n")
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
- # New registration
91
- projects.append({
92
- "path": project_path,
93
- "registered_at": now,
94
- "last_updated": now,
95
- "profile": profile or "standard",
96
- "extends": extends or "",
97
- })
98
- save_registry(projects)
99
- return True
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
- projects = load_registry()
106
- original_len = len(projects)
107
- projects = [p for p in projects if p.get("path") != project_path]
108
- if len(projects) < original_len:
109
- save_registry(projects)
110
- return True
111
- return False
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
- projects = load_registry()
125
- pruned: list[str] = []
126
- kept: list[dict[str, Any]] = []
127
-
128
- for p in projects:
129
- if Path(p["path"]).is_dir():
130
- kept.append(p)
131
- else:
132
- pruned.append(p["path"])
133
-
134
- if pruned:
135
- save_registry(kept)
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
 
@@ -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: