@softspark/ai-toolkit 2.3.1 → 2.4.1

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/scripts/doctor.py CHANGED
@@ -10,6 +10,7 @@ Checks:
10
10
  6. Planned assets
11
11
  7. Benchmark freshness
12
12
  8. Stale rules
13
+ 9. URL hook sources
13
14
 
14
15
  Exit codes:
15
16
  0 all checks pass
@@ -27,7 +28,7 @@ from pathlib import Path
27
28
 
28
29
  sys.path.insert(0, str(Path(__file__).resolve().parent))
29
30
  from _common import toolkit_dir
30
- from paths import HOOKS_DIR as _HOOKS_DIR, RULES_DIR as _RULES_DIR
31
+ from paths import HOOKS_DIR as _HOOKS_DIR, RULES_DIR as _RULES_DIR, EXTERNAL_HOOKS_DIR as _EXTERNAL_HOOKS_DIR
31
32
 
32
33
 
33
34
  # ---------------------------------------------------------------------------
@@ -37,6 +38,7 @@ from paths import HOOKS_DIR as _HOOKS_DIR, RULES_DIR as _RULES_DIR
37
38
  CLAUDE_DIR = Path.home() / ".claude"
38
39
  HOOKS_DIR = _HOOKS_DIR
39
40
  RULES_DIR = _RULES_DIR
41
+ EXTERNAL_HOOKS_DIR = _EXTERNAL_HOOKS_DIR
40
42
  BENCHMARK_DASHBOARD = toolkit_dir / "benchmarks" / "ecosystem-dashboard.json"
41
43
 
42
44
  VALID_EVENTS = frozenset({
@@ -465,6 +467,75 @@ def check_stale_rules(dr: DiagResult, fix_mode: bool) -> None:
465
467
  dr.ok("All rules healthy")
466
468
 
467
469
 
470
+ # ---------------------------------------------------------------------------
471
+ # Check 9: URL Hook Sources
472
+ # ---------------------------------------------------------------------------
473
+
474
+ def check_url_hooks(dr: DiagResult, fix_mode: bool) -> None:
475
+ """Check URL-sourced hook cache integrity."""
476
+ print()
477
+ print("## 9. URL Hook Sources")
478
+
479
+ sources_file = EXTERNAL_HOOKS_DIR / "sources.json"
480
+ if not sources_file.is_file():
481
+ dr.skip("No URL hook sources registered")
482
+ return
483
+
484
+ try:
485
+ with open(sources_file, encoding="utf-8") as f:
486
+ data = json.load(f)
487
+ sources = data.get("hooks", {})
488
+ except (json.JSONDecodeError, OSError) as exc:
489
+ dr.fail(f"Corrupt sources.json: {exc}")
490
+ return
491
+
492
+ if not sources:
493
+ dr.ok("No URL hook sources registered")
494
+ return
495
+
496
+ issues = 0
497
+ for name, entry in sources.items():
498
+ cached = EXTERNAL_HOOKS_DIR / f"{name}.json"
499
+ url = entry.get("url", "")
500
+
501
+ if not cached.is_file():
502
+ dr.warn(f"Missing cached file for '{name}' ({url})")
503
+ issues += 1
504
+ if fix_mode:
505
+ try:
506
+ from url_fetch import fetch_url
507
+ content = fetch_url(url)
508
+ json.loads(content) # validate
509
+ EXTERNAL_HOOKS_DIR.mkdir(parents=True, exist_ok=True)
510
+ cached.write_bytes(content)
511
+ print(f" FIXED: re-fetched {name}")
512
+ except Exception as exc:
513
+ print(f" Could not re-fetch: {exc}")
514
+ continue
515
+
516
+ # Validate cached file is valid JSON with hooks key
517
+ try:
518
+ with open(cached, encoding="utf-8") as f:
519
+ hook_data = json.load(f)
520
+ if "hooks" not in hook_data:
521
+ dr.warn(f"Cached file '{name}' missing 'hooks' key")
522
+ issues += 1
523
+ else:
524
+ dr.ok(f"{name} ({url})")
525
+ except json.JSONDecodeError:
526
+ dr.warn(f"Corrupt cached file: {cached}")
527
+ issues += 1
528
+ if fix_mode:
529
+ try:
530
+ from url_fetch import fetch_url
531
+ content = fetch_url(url)
532
+ json.loads(content)
533
+ cached.write_bytes(content)
534
+ print(f" FIXED: re-fetched {name}")
535
+ except Exception as exc:
536
+ print(f" Could not re-fetch: {exc}")
537
+
538
+
468
539
  # ---------------------------------------------------------------------------
469
540
  # Main
470
541
  # ---------------------------------------------------------------------------
@@ -486,6 +557,7 @@ def main() -> None:
486
557
  check_planned_assets(dr)
487
558
  check_benchmark_freshness(dr)
488
559
  check_stale_rules(dr, fix_mode)
560
+ check_url_hooks(dr, fix_mode)
489
561
 
490
562
  # Summary
491
563
  print("========================")
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env python3
2
+ """URL source registry for remotely-sourced hooks.
3
+
4
+ Tracks which hooks were registered from a URL so that `ai-toolkit update`
5
+ can re-fetch the latest version before injection.
6
+
7
+ Metadata stored in ~/.softspark/ai-toolkit/hooks/external/sources.json.
8
+
9
+ Stdlib-only — no external dependencies.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import sys
16
+ import tempfile
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
22
+ from paths import EXTERNAL_HOOKS_DIR
23
+
24
+ _SOURCES_FILENAME = "sources.json"
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Load / Save
29
+ # ---------------------------------------------------------------------------
30
+
31
+ def _sources_path(hooks_dir: Path | None = None) -> Path:
32
+ return (hooks_dir or EXTERNAL_HOOKS_DIR) / _SOURCES_FILENAME
33
+
34
+
35
+ def load_sources(hooks_dir: Path | None = None) -> dict[str, dict[str, Any]]:
36
+ """Load sources.json. Returns {} if missing or corrupt."""
37
+ path = _sources_path(hooks_dir)
38
+ if not path.is_file():
39
+ return {}
40
+ try:
41
+ with open(path, encoding="utf-8") as f:
42
+ data = json.load(f)
43
+ if isinstance(data, dict):
44
+ return data.get("hooks", {})
45
+ return {}
46
+ except (json.JSONDecodeError, OSError):
47
+ return {}
48
+
49
+
50
+ def save_sources(hooks_dir: Path | None = None,
51
+ sources: dict[str, dict[str, Any]] | None = None) -> None:
52
+ """Write sources.json atomically."""
53
+ hooks_dir = hooks_dir or EXTERNAL_HOOKS_DIR
54
+ path = _sources_path(hooks_dir)
55
+ path.parent.mkdir(parents=True, exist_ok=True)
56
+
57
+ payload = json.dumps({"schema_version": 1, "hooks": sources or {}}, indent=2)
58
+
59
+ fd, tmp_path = tempfile.mkstemp(
60
+ dir=str(path.parent), prefix=".sources_", suffix=".tmp"
61
+ )
62
+ try:
63
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
64
+ f.write(payload)
65
+ f.write("\n")
66
+ f.flush()
67
+ os.fsync(f.fileno())
68
+ os.rename(tmp_path, str(path))
69
+ except BaseException:
70
+ try:
71
+ os.unlink(tmp_path)
72
+ except OSError:
73
+ pass
74
+ raise
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # CRUD
79
+ # ---------------------------------------------------------------------------
80
+
81
+ def register_url_source(hooks_dir: Path | None, hook_name: str, url: str) -> None:
82
+ """Add or update a URL source entry."""
83
+ import re
84
+ if not hook_name or not re.fullmatch(r"[a-zA-Z0-9_-]+", hook_name):
85
+ raise ValueError(f"Invalid hook name: {hook_name!r}")
86
+ hooks_dir = hooks_dir or EXTERNAL_HOOKS_DIR
87
+ sources = load_sources(hooks_dir)
88
+ sources[hook_name] = {
89
+ "url": url,
90
+ "fetched_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
91
+ }
92
+ save_sources(hooks_dir, sources)
93
+
94
+
95
+ def unregister_source(hooks_dir: Path | None, hook_name: str) -> bool:
96
+ """Remove a source entry. Returns True if found and removed."""
97
+ hooks_dir = hooks_dir or EXTERNAL_HOOKS_DIR
98
+ sources = load_sources(hooks_dir)
99
+ if hook_name in sources:
100
+ del sources[hook_name]
101
+ save_sources(hooks_dir, sources)
102
+ return True
103
+ return False
104
+
105
+
106
+ def get_url_hooks(hooks_dir: Path | None = None) -> dict[str, str]:
107
+ """Return {hook_name: url} for all URL-sourced hooks."""
108
+ sources = load_sources(hooks_dir)
109
+ return {name: entry["url"] for name, entry in sources.items() if "url" in entry}
@@ -7,15 +7,20 @@ hooks alongside ai-toolkit's hooks. Each injected file is tagged with a
7
7
  and removal is safe.
8
8
 
9
9
  Usage:
10
- inject_hook_cli.py <hooks-file.json> [target-dir]
10
+ inject_hook_cli.py <hooks-file-or-url> [hook-name] [target-dir]
11
11
  inject_hook_cli.py --remove <hook-source-name> [target-dir]
12
12
 
13
13
  Arguments:
14
- hooks-file Path to a JSON file with ``{"hooks": {"EventName": [...]}}``
15
- target-dir Directory containing ``.claude/settings.json`` (default: $HOME)
14
+ hooks-file-or-url Path to a JSON file or HTTPS URL with
15
+ ``{"hooks": {"EventName": [...]}}``
16
+ hook-name Override the source name (default: filename stem or
17
+ URL last segment)
18
+ target-dir Directory containing ``.claude/settings.json``
19
+ (default: $HOME)
16
20
 
17
21
  Flags:
18
22
  --remove Remove all hook entries tagged with the given source name
23
+ (also unregisters URL source if present)
19
24
 
20
25
  The source name is derived from the filename stem (e.g.,
21
26
  ``rag-mcp-hooks.json`` becomes ``"rag-mcp-hooks"``). All entries are tagged
@@ -34,12 +39,19 @@ from __future__ import annotations
34
39
 
35
40
  import json
36
41
  import os
42
+ import re
37
43
  import sys
44
+ import urllib.parse
38
45
  from pathlib import Path
39
46
 
47
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
48
+
40
49
  # Protected source tag -- this CLI must never touch ai-toolkit's own entries.
41
50
  PROTECTED_SOURCE = "ai-toolkit"
42
51
 
52
+ # Codex CLI supports only these 5 hook events.
53
+ CODEX_EVENTS = {"SessionStart", "PreToolUse", "PostToolUse", "UserPromptSubmit", "Stop"}
54
+
43
55
 
44
56
  # ---------------------------------------------------------------------------
45
57
  # JSON helpers (same style as merge-hooks.py)
@@ -70,6 +82,23 @@ def save_json(path: str, data: dict) -> None:
70
82
  f.write("\n")
71
83
 
72
84
 
85
+ # ---------------------------------------------------------------------------
86
+ # URL helpers
87
+ # ---------------------------------------------------------------------------
88
+
89
+ def _is_url(source: str) -> bool:
90
+ """Check if source looks like an HTTP(S) URL."""
91
+ return source.startswith("https://") or source.startswith("http://")
92
+
93
+
94
+ def _name_from_url(url: str) -> str:
95
+ """Derive a hook source name from a URL's last path segment."""
96
+ parsed = urllib.parse.urlparse(url)
97
+ filename = parsed.path.rstrip("/").split("/")[-1]
98
+ stem = filename.rsplit(".", 1)[0] if "." in filename else filename
99
+ return re.sub(r"[^a-zA-Z0-9_-]", "", stem)
100
+
101
+
73
102
  # ---------------------------------------------------------------------------
74
103
  # Core logic
75
104
  # ---------------------------------------------------------------------------
@@ -153,27 +182,161 @@ def merge_hooks(new_hooks: dict, existing_hooks: dict, source: str) -> dict:
153
182
  return merged
154
183
 
155
184
 
185
+ # ---------------------------------------------------------------------------
186
+ # Codex propagation
187
+ # ---------------------------------------------------------------------------
188
+
189
+ def _codex_hooks_path(target_dir: str) -> Path:
190
+ """Return the global Codex hooks.json path."""
191
+ return Path(target_dir) / ".codex" / "hooks.json"
192
+
193
+
194
+ def _filter_codex_events(hooks: dict) -> dict:
195
+ """Keep only events supported by Codex CLI."""
196
+ return {event: entries for event, entries in hooks.items()
197
+ if event in CODEX_EVENTS}
198
+
199
+
200
+ def _inject_codex(tagged_hooks: dict, source: str, target_dir: str) -> None:
201
+ """Propagate hook entries to ~/.codex/hooks.json (Codex global layer).
202
+
203
+ Only events in CODEX_EVENTS are propagated. Non-Codex events are silently
204
+ skipped.
205
+ """
206
+ codex_hooks = _filter_codex_events(tagged_hooks)
207
+ if not codex_hooks:
208
+ return
209
+
210
+ codex_path = _codex_hooks_path(target_dir)
211
+ codex_path.parent.mkdir(parents=True, exist_ok=True)
212
+
213
+ existing: dict = {}
214
+ if codex_path.is_file():
215
+ try:
216
+ data = load_json(str(codex_path))
217
+ existing = data.get("hooks", {})
218
+ except (json.JSONDecodeError, OSError):
219
+ existing = {}
220
+
221
+ merged = merge_hooks(codex_hooks, existing, source)
222
+ save_json(str(codex_path), {"hooks": merged})
223
+ events = ", ".join(sorted(codex_hooks.keys()))
224
+ print(f"Propagated to Codex: {codex_path} (events: {events})")
225
+
226
+
227
+ def _remove_codex(source_name: str, target_dir: str) -> None:
228
+ """Remove hook entries from ~/.codex/hooks.json."""
229
+ codex_path = _codex_hooks_path(target_dir)
230
+ if not codex_path.is_file():
231
+ return
232
+
233
+ try:
234
+ data = load_json(str(codex_path))
235
+ except (json.JSONDecodeError, OSError):
236
+ return
237
+
238
+ existing = data.get("hooks", {})
239
+ cleaned = strip_source(existing, source_name)
240
+
241
+ if cleaned:
242
+ save_json(str(codex_path), {"hooks": cleaned})
243
+ else:
244
+ save_json(str(codex_path), {"hooks": {}})
245
+
246
+ print(f"Removed '{source_name}' from Codex: {codex_path}")
247
+
248
+
156
249
  # ---------------------------------------------------------------------------
157
250
  # CLI actions
158
251
  # ---------------------------------------------------------------------------
159
252
 
160
- def inject(hooks_file: str, target_dir: str) -> None:
161
- """Inject hooks from *hooks_file* into the target settings.json.
253
+ def _fetch_and_cache(url: str, source: str) -> str:
254
+ """Fetch hooks JSON from URL, cache locally, register source.
162
255
 
163
256
  Args:
164
- hooks_file: Path to the external hooks JSON file.
165
- target_dir: Directory containing ``.claude/settings.json``.
257
+ url: HTTPS URL to fetch.
258
+ source: Source name for caching and registry.
259
+
260
+ Returns:
261
+ Path to the cached hooks JSON file.
166
262
  """
167
- # Derive source name from filename stem
168
- source = Path(hooks_file).stem
169
- if source == PROTECTED_SOURCE:
170
- print(
171
- f"Error: source name '{PROTECTED_SOURCE}' is reserved. "
172
- "Rename your hooks file.",
173
- file=sys.stderr,
174
- )
263
+ from url_fetch import fetch_url
264
+ from hook_sources import register_url_source
265
+ from paths import EXTERNAL_HOOKS_DIR
266
+
267
+ EXTERNAL_HOOKS_DIR.mkdir(parents=True, exist_ok=True)
268
+
269
+ try:
270
+ data = fetch_url(url)
271
+ except Exception as exc:
272
+ print(f"Error fetching URL: {exc}", file=sys.stderr)
175
273
  sys.exit(1)
176
274
 
275
+ # Validate JSON before caching
276
+ try:
277
+ parsed = json.loads(data)
278
+ except json.JSONDecodeError as exc:
279
+ print(f"Error: URL returned invalid JSON: {exc}", file=sys.stderr)
280
+ sys.exit(2)
281
+
282
+ if "hooks" not in parsed:
283
+ print("Warning: no 'hooks' key found in URL response", file=sys.stderr)
284
+
285
+ cached_path = EXTERNAL_HOOKS_DIR / f"{source}.json"
286
+ cached_path.write_bytes(data)
287
+ register_url_source(None, source, url)
288
+
289
+ return str(cached_path)
290
+
291
+
292
+ def inject(hooks_file: str, target_dir: str, source_override: str = "") -> None:
293
+ """Inject hooks from *hooks_file* (or URL) into the target settings.json.
294
+
295
+ Args:
296
+ hooks_file: Path to the external hooks JSON file, or an HTTPS URL.
297
+ target_dir: Directory containing ``.claude/settings.json``.
298
+ source_override: Explicit source name (overrides filename-derived name).
299
+ """
300
+ is_url = _is_url(hooks_file)
301
+
302
+ if is_url:
303
+ if hooks_file.startswith("http://"):
304
+ print(
305
+ "Error: only HTTPS URLs are supported. Use https:// for security.",
306
+ file=sys.stderr,
307
+ )
308
+ sys.exit(1)
309
+
310
+ source = source_override or _name_from_url(hooks_file)
311
+ source = re.sub(r"[^a-zA-Z0-9_-]", "", source)
312
+ if not source:
313
+ print(
314
+ "Error: could not derive hook name from URL. "
315
+ "Provide one explicitly.",
316
+ file=sys.stderr,
317
+ )
318
+ sys.exit(1)
319
+
320
+ if source == PROTECTED_SOURCE:
321
+ print(
322
+ f"Error: source name '{PROTECTED_SOURCE}' is reserved.",
323
+ file=sys.stderr,
324
+ )
325
+ sys.exit(1)
326
+
327
+ hooks_file = _fetch_and_cache(hooks_file, source)
328
+ print(f"Fetched hooks from URL (source: '{source}')")
329
+ else:
330
+ # Derive source name from filename stem
331
+ source = source_override or Path(hooks_file).stem
332
+ if source == PROTECTED_SOURCE:
333
+ print(
334
+ f"Error: source name '{PROTECTED_SOURCE}' is reserved. "
335
+ "Rename your hooks file.",
336
+ file=sys.stderr,
337
+ )
338
+ sys.exit(1)
339
+
177
340
  # Load the hooks file
178
341
  try:
179
342
  hooks_data = load_json(hooks_file)
@@ -214,10 +377,15 @@ def inject(hooks_file: str, target_dir: str) -> None:
214
377
  save_json(str(settings_path), settings)
215
378
  print(f"Injected hooks from '{source}' into {settings_path}")
216
379
 
380
+ # Propagate Codex-compatible events to ~/.codex/hooks.json
381
+ _inject_codex(tagged, source, target_dir)
382
+
217
383
 
218
384
  def remove(source_name: str, target_dir: str) -> None:
219
385
  """Remove all hook entries tagged with *source_name*.
220
386
 
387
+ Also unregisters the URL source if it was URL-sourced.
388
+
221
389
  Args:
222
390
  source_name: The ``_source`` tag to remove.
223
391
  target_dir: Directory containing ``.claude/settings.json``.
@@ -256,6 +424,24 @@ def remove(source_name: str, target_dir: str) -> None:
256
424
  save_json(str(settings_path), settings)
257
425
  print(f"Removed hooks with source '{source_name}' from {settings_path}")
258
426
 
427
+ # Remove from Codex global hooks
428
+ _remove_codex(source_name, target_dir)
429
+
430
+ # Unregister URL source if present
431
+ try:
432
+ from hook_sources import unregister_source
433
+ from paths import EXTERNAL_HOOKS_DIR
434
+
435
+ if unregister_source(None, source_name):
436
+ print(f"Unregistered URL source '{source_name}'")
437
+
438
+ # Remove cached file if exists
439
+ cached = EXTERNAL_HOOKS_DIR / f"{source_name}.json"
440
+ if cached.is_file():
441
+ cached.unlink()
442
+ except ImportError:
443
+ pass
444
+
259
445
 
260
446
  # ---------------------------------------------------------------------------
261
447
  # Argument parsing
@@ -265,16 +451,19 @@ def _parse_args(argv: list[str]) -> dict:
265
451
  """Parse CLI arguments.
266
452
 
267
453
  Returns:
268
- Dict with keys: remove_mode, remove_name, source_file, target_dir.
454
+ Dict with keys: remove_mode, remove_name, source_file, hook_name,
455
+ target_dir.
269
456
  """
270
457
  result: dict = {
271
458
  "remove_mode": False,
272
459
  "remove_name": "",
273
460
  "source_file": "",
461
+ "hook_name": "",
274
462
  "target_dir": str(Path.home()),
275
463
  }
276
464
 
277
465
  i = 0
466
+ positional = 0
278
467
  while i < len(argv):
279
468
  arg = argv[i]
280
469
  if arg == "--remove":
@@ -287,10 +476,25 @@ def _parse_args(argv: list[str]) -> dict:
287
476
  elif arg.startswith("-"):
288
477
  print(f"Unknown option: {arg}", file=sys.stderr)
289
478
  sys.exit(1)
290
- elif not result["source_file"] and not result["remove_mode"]:
291
- result["source_file"] = arg
292
479
  else:
293
- result["target_dir"] = arg
480
+ if positional == 0:
481
+ if not result["remove_mode"]:
482
+ result["source_file"] = arg
483
+ else:
484
+ result["target_dir"] = arg
485
+ elif positional == 1:
486
+ if _is_url(result["source_file"]):
487
+ # Second positional after URL could be hook-name or target-dir
488
+ # If it looks like a path (starts with / or ~ or .), it's target-dir
489
+ if arg.startswith(("/", "~", ".")):
490
+ result["target_dir"] = arg
491
+ else:
492
+ result["hook_name"] = arg
493
+ else:
494
+ result["target_dir"] = arg
495
+ elif positional == 2:
496
+ result["target_dir"] = arg
497
+ positional += 1
294
498
  i += 1
295
499
 
296
500
  return result
@@ -309,7 +513,7 @@ def main() -> None:
309
513
  source_file = args["source_file"]
310
514
  if not source_file:
311
515
  print(
312
- "Usage: inject_hook_cli.py <hooks-file.json> [target-dir]",
516
+ "Usage: inject_hook_cli.py <hooks-file-or-url> [hook-name] [target-dir]",
313
517
  file=sys.stderr,
314
518
  )
315
519
  print(
@@ -318,12 +522,13 @@ def main() -> None:
318
522
  )
319
523
  sys.exit(1)
320
524
 
321
- source_path = Path(source_file)
322
- if not source_path.is_file():
323
- print(f"Hooks file not found: {source_path}", file=sys.stderr)
324
- sys.exit(1)
525
+ if not _is_url(source_file):
526
+ source_path = Path(source_file)
527
+ if not source_path.is_file():
528
+ print(f"Hooks file not found: {source_path}", file=sys.stderr)
529
+ sys.exit(1)
325
530
 
326
- inject(source_file, args["target_dir"])
531
+ inject(source_file, args["target_dir"], source_override=args["hook_name"])
327
532
 
328
533
 
329
534
  if __name__ == "__main__":
@@ -47,14 +47,17 @@ from emission import agent_count as count_agents, skill_count as count_skills
47
47
  # Step modules
48
48
  from install_steps.symlinks import install_agents, install_skills, clean_legacy_commands
49
49
  from install_steps.hooks import install_hooks
50
- from install_steps.markers import install_marker_files, inject_rules
50
+ from install_steps.markers import install_marker_files, inject_rules, refresh_url_hooks
51
51
  from install_steps.ai_tools import install_ai_tools, install_local_project, run_script
52
52
  from install_steps.install_state import (
53
53
  load_state,
54
54
  record_install,
55
55
  get_installed_modules,
56
56
  get_installed_profile,
57
+ get_global_editors,
58
+ record_global_editors,
57
59
  print_status,
60
+ GLOBAL_CAPABLE_EDITORS,
58
61
  )
59
62
  from install_steps.detect_language import detect_languages
60
63
  from install_steps.project_registry import register_project
@@ -432,6 +435,9 @@ def install_claude_code(target_dir: Path, hooks_scripts_dir: Path,
432
435
  inject_rules(claude_dir, target_dir, rules_dir, only, skip, dry_run,
433
436
  refresh_urls=True)
434
437
 
438
+ if not dry_run:
439
+ refresh_url_hooks(str(target_dir))
440
+
435
441
  _sync_mcp_templates(dry_run)
436
442
 
437
443
 
@@ -705,17 +711,31 @@ def main() -> None:
705
711
  profile = cfg["profile"]
706
712
 
707
713
  lang_modules = [m for m in (resolved_modules or []) if m.startswith("rules-")]
708
- editors_arg: str = cfg["editors"]
714
+ local_editors_arg: str = cfg["editors"]
709
715
  install_local_project(rules_dir, dry_run, reset, lang_modules or None,
710
- editors=editors_arg,
716
+ editors=local_editors_arg,
711
717
  merged_config=merged_config)
718
+ installed_eds: list[str] = [] # local install doesn't track global editors
712
719
  install_strict_git_hooks(profile, local, dry_run)
713
720
  else:
714
721
  # Global install
715
722
  print_banner(target_dir, rules_dir, profile, only, skip, dry_run,
716
723
  modules=resolved_modules)
717
724
  install_claude_code(target_dir, hooks_scripts_dir, rules_dir, only, skip, dry_run)
718
- install_ai_tools(target_dir, rules_dir, only, skip, dry_run)
725
+
726
+ # Determine global editors: --editors flag > state > default (none)
727
+ editors_arg: str = cfg["editors"]
728
+ if editors_arg:
729
+ if editors_arg == "all":
730
+ global_eds = list(GLOBAL_CAPABLE_EDITORS)
731
+ else:
732
+ global_eds = [e.strip() for e in editors_arg.split(",") if e.strip()]
733
+ else:
734
+ # On update: use editors from state; on fresh install: none
735
+ global_eds = get_global_editors() or None
736
+
737
+ installed_eds = install_ai_tools(target_dir, rules_dir, dry_run,
738
+ editors=global_eds)
719
739
  install_persona(target_dir, persona, dry_run)
720
740
  install_strict_git_hooks(profile, local, dry_run)
721
741
 
@@ -746,6 +766,10 @@ def main() -> None:
746
766
  extends_info=extends_info,
747
767
  )
748
768
 
769
+ # Record global editors (only for global install, not --local)
770
+ if not local and installed_eds:
771
+ record_global_editors(installed_eds)
772
+
749
773
  # Register project in global registry (for `ai-toolkit update` propagation)
750
774
  # Skipped when called from update_projects.py (--skip-register) to avoid
751
775
  # concurrent writes to projects.json during parallel updates.
@@ -753,10 +777,20 @@ def main() -> None:
753
777
  extends_source = ""
754
778
  if extends_info:
755
779
  extends_source = extends_info.get("source", "")
780
+ # Determine editors to record for this project
781
+ local_eds_for_registry: list[str] | None = None
782
+ if local and local_editors_arg:
783
+ if local_editors_arg == "all":
784
+ from install_steps.ai_tools import ALL_EDITORS
785
+ local_eds_for_registry = list(ALL_EDITORS)
786
+ else:
787
+ local_eds_for_registry = [e.strip() for e in local_editors_arg.split(",") if e.strip()]
788
+
756
789
  is_new = register_project(
757
790
  project_dir,
758
791
  profile=profile or "standard",
759
792
  extends=extends_source,
793
+ editors=local_eds_for_registry,
760
794
  )
761
795
  if is_new:
762
796
  print(f" Registered project in {TOOLKIT_DATA_DIR / 'projects.json'}")