@softspark/ai-toolkit 2.4.0 → 2.5.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.
Files changed (52) hide show
  1. package/AGENTS.md +32 -19
  2. package/CHANGELOG.md +45 -0
  3. package/README.md +13 -12
  4. package/app/.claude-plugin/plugin.json +1 -1
  5. package/app/ARCHITECTURE.md +2 -2
  6. package/app/agents/code-reviewer.md +6 -7
  7. package/app/agents/frontend-specialist.md +33 -2
  8. package/app/agents/seo-specialist.md +1 -1
  9. package/app/personas/frontend-lead.md +48 -5
  10. package/app/skills/a11y-validate/SKILL.md +377 -0
  11. package/app/skills/a11y-validate/reference/aria-patterns.md +259 -0
  12. package/app/skills/a11y-validate/reference/eaa-compliance.md +252 -0
  13. package/app/skills/a11y-validate/reference/mobile-eaa.md +329 -0
  14. package/app/skills/a11y-validate/reference/wcag-2-1-aa.md +285 -0
  15. package/app/skills/a11y-validate/reference/wcag-2-2-aa.md +221 -0
  16. package/app/skills/a11y-validate/scripts/a11y-scanner.py +639 -0
  17. package/app/skills/clean-code/reference/python.md +3 -3
  18. package/app/skills/design-engineering/SKILL.md +2 -5
  19. package/app/skills/review/SKILL.md +30 -6
  20. package/app/skills/seo-validate/SKILL.md +460 -0
  21. package/app/skills/seo-validate/reference/core-web-vitals.md +445 -0
  22. package/app/skills/seo-validate/reference/geo-aeo-patterns.md +259 -0
  23. package/app/skills/seo-validate/reference/geo-guidelines.md +248 -0
  24. package/app/skills/seo-validate/reference/schema-types.md +465 -0
  25. package/app/skills/seo-validate/reference/spa-ssg-patterns.md +351 -0
  26. package/app/skills/seo-validate/reference/w3c-guidelines.md +289 -0
  27. package/app/skills/seo-validate/scripts/seo-scanner.py +549 -0
  28. package/bin/ai-toolkit.js +32 -5
  29. package/kb/reference/architecture-overview.md +3 -3
  30. package/kb/reference/cli-reference.md +1 -1
  31. package/kb/reference/codex-cli-compatibility.md +4 -0
  32. package/kb/reference/comparison.md +1 -1
  33. package/kb/reference/extension-api.md +2 -0
  34. package/kb/reference/skills-catalog.md +3 -1
  35. package/llms-full.txt +16 -6
  36. package/manifest.json +3 -3
  37. package/package.json +2 -2
  38. package/scripts/config_cli.py +4 -10
  39. package/scripts/config_resolver.py +23 -5
  40. package/scripts/doctor.py +76 -4
  41. package/scripts/hook_sources.py +3 -0
  42. package/scripts/inject_hook_cli.py +74 -1
  43. package/scripts/install.py +34 -3
  44. package/scripts/install_steps/ai_tools.py +79 -16
  45. package/scripts/install_steps/install_state.py +25 -0
  46. package/scripts/install_steps/markers.py +2 -1
  47. package/scripts/install_steps/project_registry.py +9 -0
  48. package/scripts/plugin.py +1 -1
  49. package/scripts/propagate_global.py +92 -0
  50. package/scripts/rule_sources.py +3 -2
  51. package/scripts/update_projects.py +7 -1
  52. package/scripts/url_fetch.py +5 -0
@@ -12,7 +12,7 @@ description: "Feature comparison of ai-toolkit vs other Claude Code toolkits and
12
12
 
13
13
  | Feature | ai-toolkit | everything-claude-code | wshobson/agents | ruflo |
14
14
  |---------|---------------|----------------------|-----------------|-------|
15
- | Skills | 92 | 100+ | 146 | 20+ |
15
+ | Skills | 93 | 100+ | 146 | 20+ |
16
16
  | Agents | 44 | 30+ | 112 | 20+ |
17
17
  | Machine-enforced constitution | **Yes** | No (docs only) | No | No |
18
18
  | Skill-scoped lifecycle hooks | **Yes** | No | No | No |
@@ -103,6 +103,8 @@ npx @softspark/ai-toolkit inject-hook https://example.com/hooks.json my-tool-hoo
103
103
 
104
104
  **Safety:** Entries tagged `"_source": "ai-toolkit"` are never modified or removed by this command. External tools cannot affect the toolkit's own hooks. Only HTTPS URLs are accepted.
105
105
 
106
+ **Codex propagation:** Codex-compatible events (`SessionStart`, `PreToolUse`, `PostToolUse`, `UserPromptSubmit`, `Stop`) are automatically propagated to `~/.codex/hooks.json`. Non-Codex events are silently skipped. No extra flags needed.
107
+
106
108
  ## remove-hook
107
109
 
108
110
  Strips all hook entries from `~/.claude/settings.json` that carry a given `_source` tag. If the hook was URL-sourced, also unregisters the URL from `sources.json` and removes the cached file.
@@ -23,7 +23,7 @@ All functionality is unified under skills. Task and hybrid skills are user-invoc
23
23
  | **2 — Multi-agent workflow** | `/workflow <type>` | Cross-cutting task with known pattern |
24
24
  | **3 — Custom parallelism** | `/orchestrate`, `/swarm` | No predefined workflow matches |
25
25
 
26
- ## Task Skills (29)
26
+ ## Task Skills (30)
27
27
 
28
28
  Task skills execute a specific action. Invoked via slash commands. `disable-model-invocation: true`.
29
29
 
@@ -58,6 +58,8 @@ Task skills execute a specific action. Invoked via slash commands. `disable-mode
58
58
  | **prd-to-issues** | `/prd-to-issues` | medium | Break PRD into GitHub issues with vertical slices and HITL/AFK tagging |
59
59
  | **skill-audit** | `/skill-audit` | medium | Scan skills and agents for security risks: dangerous patterns, secrets, excessive permissions |
60
60
  | **hipaa-validate** | `/hipaa-validate` | medium | Scan codebase for HIPAA compliance issues: PHI exposure, missing audit logging, unencrypted transmission/storage, access control gaps, temp file exposure, and missing BAA references |
61
+ | **a11y-validate** | `/a11y-validate` | medium | Scan codebase for accessibility violations: WCAG 2.1 Level AA, EN 301 549, European Accessibility Act (EAA / EU 2019/882). Covers semantics, keyboard, focus, color contrast, forms, media, ARIA, motion, mobile (React Native + Flutter), and EAA accessibility-statement documentation. |
62
+ | **seo-validate** | `/seo-validate` | medium | Scan codebase for SEO issues: W3C semantics, meta/OG tags, Schema.org, hreflang, Core Web Vitals (LCP/INP/CLS), resource hints, GEO, SPA/SSG/CSR crawlability, technical SEO, accessibility-for-SEO. Framework-aware (Next/Nuxt/Astro/Gatsby/SvelteKit/Remix/Angular/Vue/static HTML). |
61
63
 
62
64
  ## Hybrid Skills (31)
63
65
 
package/llms-full.txt CHANGED
@@ -56,6 +56,7 @@
56
56
 
57
57
  ## Skills
58
58
 
59
+ - **a11y-validate**: Validate code against accessibility standards: WCAG 2.1 Level AA, EN 301 549, and the European Accessibility Act (EAA / Directive EU 2019/882). Covers semantics, text alternatives, keyboard/focus, color/contrast, forms, media, ARIA, motion, mobile, and EAA documentation requirements. Framework-aware (React/Next/Nuxt/Astro/Gatsby/SvelteKit/Remix/Angular/Vue/React Native/Flutter/static HTML).
59
60
  - **agent-creator**: Creates new specialized agents with frontmatter, tool selection, and delegation guidance
60
61
  - **analyze**: Analyze code quality, complexity, and patterns
61
62
  - **api-patterns**: Loaded when user asks about REST API design or GraphQL patterns
@@ -133,6 +134,7 @@
133
134
  - **rust-patterns**: Loaded when user asks about Rust development patterns
134
135
  - **search**: Search the knowledge base with semantic and hybrid modes
135
136
  - **security-patterns**: Loaded when user asks about security, OWASP, or auth patterns
137
+ - **seo-validate**: Validate code against SEO best practices: W3C semantics, meta/OG tags, Schema.org, hreflang, Core Web Vitals (LCP/INP/CLS), resource hints, GEO, SPA/SSG/CSR crawlability, technical SEO, accessibility-for-SEO. Supports React/Next/Nuxt/Astro/Gatsby/SvelteKit/Remix/Angular/Vue/static HTML.
136
138
  - **skill-audit**: Scan skills and agents for security risks: dangerous patterns, secrets, excessive permissions
137
139
  - **skill-creator**: Create new skills from templates with guided workflow
138
140
  - **subagent-development**: Execute implementation plans using fresh subagents per task with two-stage review: spec compliance first, then code quality. Use when executing plans with independent tasks.
@@ -4456,7 +4458,7 @@ service: ai-toolkit
4456
4458
  tags: [architecture, overview, design, structure]
4457
4459
  version: "1.4.4"
4458
4460
  created: "2026-03-23"
4459
- last_updated: "2026-04-13"
4461
+ last_updated: "2026-04-15"
4460
4462
  description: "Architecture of ai-toolkit: directory layout, global install model, editor-aware MCP install, Codex translation layer, skill tiers, and integration with projects."
4461
4463
  ---
4462
4464
 
@@ -4636,7 +4638,7 @@ Three tiers determine how to approach a task:
4636
4638
 
4637
4639
  | Type | Field | Invocation | Count |
4638
4640
  |------|-------|-----------|-------|
4639
- | Task | `disable-model-invocation: true` | User via `/skill` only | 29 |
4641
+ | Task | `disable-model-invocation: true` | User via `/skill` only | 30 |
4640
4642
  | Hybrid | (neither) | User via `/skill` + agent knowledge | 31 |
4641
4643
  | Knowledge | `user-invocable: false` | Claude auto-loads | 32 |
4642
4644
 
@@ -4786,7 +4788,7 @@ Severity levels: HIGH (blocks deployment), WARN (should fix), INFO (best practic
4786
4788
  ## Extension Points
4787
4789
 
4788
4790
  ### MCP Templates
4789
- `app/plugins/mcp-templates/` contains 26 ready-to-use MCP server config templates. Opt-in via `ai-toolkit install --modules mcp-templates` or activated automatically with `--profile strict|full`.
4791
+ `app/mcp-templates/` contains 26 ready-to-use MCP server config templates. Opt-in via `ai-toolkit install --modules mcp-templates` or activated automatically with `--profile strict|full`.
4790
4792
 
4791
4793
  ### Language Rules
4792
4794
  `app/rules/` provides language-specific rule files covering 13 languages (TypeScript, Python, Go, Rust, Java, Kotlin, Swift, Dart, C#, PHP, C++, Ruby, common). Auto-detected from project files via `--auto-detect` or selectable with `--modules rules-<lang>`. See README.md for current count.
@@ -5137,7 +5139,7 @@ category: reference
5137
5139
  service: ai-toolkit
5138
5140
  tags: [cli, commands, reference, install, update, plugin, mcp]
5139
5141
  created: "2026-04-13"
5140
- last_updated: "2026-04-13"
5142
+ last_updated: "2026-04-15"
5141
5143
  description: "Complete CLI reference for all ai-toolkit commands, options, and flags."
5142
5144
  ---
5143
5145
 
@@ -5380,6 +5382,10 @@ This means Claude-only events such as `TaskCompleted`, `TeammateIdle`,
5380
5382
  `SubagentStart`, `SubagentStop`, `PreCompact`, `SessionEnd`, and
5381
5383
  `Notification` are not available in `.codex/hooks.json`.
5382
5384
 
5385
+ `inject-hook` automatically propagates Codex-compatible events to
5386
+ `~/.codex/hooks.json` (global layer). Non-Codex events are silently skipped.
5387
+ `remove-hook` cleans both Claude and Codex targets.
5388
+
5383
5389
  ## Behavioral Limits
5384
5390
 
5385
5391
  Codex wrappers preserve workflow intent, but not every Claude runtime behavior
@@ -5426,7 +5432,7 @@ description: "Feature comparison of ai-toolkit vs other Claude Code toolkits and
5426
5432
 
5427
5433
  | Feature | ai-toolkit | everything-claude-code | wshobson/agents | ruflo |
5428
5434
  |---------|---------------|----------------------|-----------------|-------|
5429
- | Skills | 92 | 100+ | 146 | 20+ |
5435
+ | Skills | 93 | 100+ | 146 | 20+ |
5430
5436
  | Agents | 44 | 30+ | 112 | 20+ |
5431
5437
  | Machine-enforced constitution | **Yes** | No (docs only) | No | No |
5432
5438
  | Skill-scoped lifecycle hooks | **Yes** | No | No | No |
@@ -6634,6 +6640,8 @@ npx @softspark/ai-toolkit inject-hook https://example.com/hooks.json my-tool-hoo
6634
6640
 
6635
6641
  **Safety:** Entries tagged `"_source": "ai-toolkit"` are never modified or removed by this command. External tools cannot affect the toolkit's own hooks. Only HTTPS URLs are accepted.
6636
6642
 
6643
+ **Codex propagation:** Codex-compatible events (`SessionStart`, `PreToolUse`, `PostToolUse`, `UserPromptSubmit`, `Stop`) are automatically propagated to `~/.codex/hooks.json`. Non-Codex events are silently skipped. No extra flags needed.
6644
+
6637
6645
  ## remove-hook
6638
6646
 
6639
6647
  Strips all hook entries from `~/.claude/settings.json` that carry a given `_source` tag. If the hook was URL-sourced, also unregisters the URL from `sources.json` and removes the cached file.
@@ -8547,7 +8555,7 @@ All functionality is unified under skills. Task and hybrid skills are user-invoc
8547
8555
  | **2 — Multi-agent workflow** | `/workflow <type>` | Cross-cutting task with known pattern |
8548
8556
  | **3 — Custom parallelism** | `/orchestrate`, `/swarm` | No predefined workflow matches |
8549
8557
 
8550
- ## Task Skills (29)
8558
+ ## Task Skills (30)
8551
8559
 
8552
8560
  Task skills execute a specific action. Invoked via slash commands. `disable-model-invocation: true`.
8553
8561
 
@@ -8582,6 +8590,8 @@ Task skills execute a specific action. Invoked via slash commands. `disable-mode
8582
8590
  | **prd-to-issues** | `/prd-to-issues` | medium | Break PRD into GitHub issues with vertical slices and HITL/AFK tagging |
8583
8591
  | **skill-audit** | `/skill-audit` | medium | Scan skills and agents for security risks: dangerous patterns, secrets, excessive permissions |
8584
8592
  | **hipaa-validate** | `/hipaa-validate` | medium | Scan codebase for HIPAA compliance issues: PHI exposure, missing audit logging, unencrypted transmission/storage, access control gaps, temp file exposure, and missing BAA references |
8593
+ | **a11y-validate** | `/a11y-validate` | medium | Scan codebase for accessibility violations: WCAG 2.1 Level AA, EN 301 549, European Accessibility Act (EAA / EU 2019/882). Covers semantics, keyboard, focus, color contrast, forms, media, ARIA, motion, mobile (React Native + Flutter), and EAA accessibility-statement documentation. |
8594
+ | **seo-validate** | `/seo-validate` | medium | Scan codebase for SEO issues: W3C semantics, meta/OG tags, Schema.org, hreflang, Core Web Vitals (LCP/INP/CLS), resource hints, GEO, SPA/SSG/CSR crawlability, technical SEO, accessibility-for-SEO. Framework-aware (Next/Nuxt/Astro/Gatsby/SvelteKit/Remix/Angular/Vue/static HTML). |
8585
8595
 
8586
8596
  ## Hybrid Skills (31)
8587
8597
 
package/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.4.0",
2
+ "version": "2.5.0",
3
3
  "components": {
4
4
  "agents": {
5
5
  "description": "44 specialized agents (orchestrator, backend, frontend, security, devops, etc.)",
@@ -9,7 +9,7 @@
9
9
  "tags": ["core", "agents"]
10
10
  },
11
11
  "skills": {
12
- "description": "92 skills (29 task + 31 hybrid + 32 knowledge)",
12
+ "description": "94 skills (31 task + 31 hybrid + 32 knowledge)",
13
13
  "path": "app/skills",
14
14
  "target": ".claude/skills",
15
15
  "type": "symlink",
@@ -90,7 +90,7 @@
90
90
  "default": true
91
91
  },
92
92
  "skills": {
93
- "description": "92 skills (task, hybrid, knowledge)",
93
+ "description": "94 skills (task, hybrid, knowledge)",
94
94
  "default": true
95
95
  },
96
96
  "rules-common": {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@softspark/ai-toolkit",
3
- "version": "2.4.0",
4
- "description": "Professional-grade AI coding toolkit: 92 skills, 44 agents, multi-platform support (Claude, Cursor, Windsurf, Copilot, Gemini, Cline, Roo Code, Aider, Augment, Google Antigravity, Codex CLI), machine-enforced safety constitution, persona presets, skill security auditor, expanded lifecycle hooks, 11 plugin packs, and benchmark tooling.",
3
+ "version": "2.5.0",
4
+ "description": "Professional-grade AI coding toolkit: 94 skills, 44 agents, multi-platform support (Claude, Cursor, Windsurf, Copilot, Gemini, Cline, Roo Code, Aider, Augment, Google Antigravity, Codex CLI), machine-enforced safety constitution, persona presets, skill security auditor, expanded lifecycle hooks, 11 plugin packs, and benchmark tooling.",
5
5
  "keywords": [
6
6
  "claude",
7
7
  "claude-code",
@@ -96,17 +96,11 @@ def cmd_validate(args: list[str]) -> int:
96
96
  print(f" ✗ Merge failed: {e}")
97
97
  return 1
98
98
 
99
- checks = [
100
- ("schema valid", True),
101
- ("no forbidden overrides", True),
102
- ("constitution articles intact", True),
103
- ]
104
-
99
+ # Summary — these passed because validation above would have returned 1
100
+ print(f"schema valid")
101
+ print(f"no forbidden overrides")
105
102
  if extends:
106
- checks.append(("extends resolved", True))
107
-
108
- for label, ok in checks:
109
- print(f" {'✓' if ok else '✗'} {label}")
103
+ print(f"extends resolved")
110
104
 
111
105
  print("\n Config valid ✓")
112
106
  return 0
@@ -299,14 +299,28 @@ def _find_cached_npm(cache_dir: Path) -> Path | None:
299
299
 
300
300
 
301
301
  def _extract_tarball(tarball: Path, dest: Path) -> None:
302
- """Extract npm tarball (which has a package/ prefix) to dest."""
302
+ """Extract npm tarball (which has a package/ prefix) to dest.
303
+
304
+ Validates that extracted paths stay within dest to prevent path traversal.
305
+ Rejects symlinks and absolute paths.
306
+ """
307
+ dest_resolved = dest.resolve()
303
308
  with tarfile.open(tarball, "r:gz") as tf:
304
309
  for member in tf.getmembers():
305
310
  # npm tarballs have a "package/" prefix
306
- if member.name.startswith("package/"):
307
- member.name = member.name[len("package/"):]
308
- if member.name: # skip empty (the "package/" dir itself)
309
- tf.extract(member, dest)
311
+ if not member.name.startswith("package/"):
312
+ continue
313
+ member.name = member.name[len("package/"):]
314
+ if not member.name: # skip empty (the "package/" dir itself)
315
+ continue
316
+ # Reject symlinks and absolute paths
317
+ if member.issym() or member.islnk() or member.name.startswith("/"):
318
+ continue
319
+ # Path traversal protection
320
+ target = (dest / member.name).resolve()
321
+ if not str(target).startswith(str(dest_resolved)):
322
+ continue
323
+ tf.extract(member, dest)
310
324
 
311
325
 
312
326
  def _extract_version_from_tarball(filename: str, package_name: str) -> str:
@@ -332,6 +346,10 @@ def _resolve_git(
332
346
  ) -> BaseConfig:
333
347
  """Resolve from git URL (git+https://...)."""
334
348
  url = source.removeprefix("git+")
349
+ if not url.startswith("https://"):
350
+ raise ConfigResolverError(
351
+ f"Only HTTPS git URLs are supported (got: {url.split('://')[0]}://)"
352
+ )
335
353
  cache_key = hashlib.sha256(url.encode()).hexdigest()[:16]
336
354
  cache_dir = _cache_root() / "git" / cache_key
337
355
 
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({
@@ -452,19 +454,88 @@ def check_stale_rules(dr: DiagResult, fix_mode: bool) -> None:
452
454
  for rule_file in sorted(RULES_DIR.iterdir()):
453
455
  # Check for stale symlinks
454
456
  if rule_file.is_symlink() and not rule_file.exists():
455
- print(f" WARNING: Stale symlink: {rule_file}")
457
+ dr.warn(f"Stale symlink: {rule_file}")
456
458
  stale += 1
457
459
  if fix_mode:
458
460
  rule_file.unlink()
459
- print(" FIXED: removed stale symlink")
461
+ dr.fixed(f"removed stale symlink: {rule_file.name}")
460
462
  elif rule_file.is_file() and rule_file.stat().st_size == 0:
461
- print(f" WARNING: Empty rule file: {rule_file}")
463
+ dr.warn(f"Empty rule file: {rule_file}")
462
464
  stale += 1
463
465
 
464
466
  if stale == 0:
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("========================")
@@ -80,6 +80,9 @@ def save_sources(hooks_dir: Path | None = None,
80
80
 
81
81
  def register_url_source(hooks_dir: Path | None, hook_name: str, url: str) -> None:
82
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}")
83
86
  hooks_dir = hooks_dir or EXTERNAL_HOOKS_DIR
84
87
  sources = load_sources(hooks_dir)
85
88
  sources[hook_name] = {
@@ -49,6 +49,9 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
49
49
  # Protected source tag -- this CLI must never touch ai-toolkit's own entries.
50
50
  PROTECTED_SOURCE = "ai-toolkit"
51
51
 
52
+ # Codex CLI supports only these 5 hook events.
53
+ CODEX_EVENTS = {"SessionStart", "PreToolUse", "PostToolUse", "UserPromptSubmit", "Stop"}
54
+
52
55
 
53
56
  # ---------------------------------------------------------------------------
54
57
  # JSON helpers (same style as merge-hooks.py)
@@ -179,6 +182,70 @@ def merge_hooks(new_hooks: dict, existing_hooks: dict, source: str) -> dict:
179
182
  return merged
180
183
 
181
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
+
182
249
  # ---------------------------------------------------------------------------
183
250
  # CLI actions
184
251
  # ---------------------------------------------------------------------------
@@ -213,7 +280,7 @@ def _fetch_and_cache(url: str, source: str) -> str:
213
280
  sys.exit(2)
214
281
 
215
282
  if "hooks" not in parsed:
216
- print(f"Warning: no 'hooks' key found in URL response", file=sys.stderr)
283
+ print("Warning: no 'hooks' key found in URL response", file=sys.stderr)
217
284
 
218
285
  cached_path = EXTERNAL_HOOKS_DIR / f"{source}.json"
219
286
  cached_path.write_bytes(data)
@@ -310,6 +377,9 @@ def inject(hooks_file: str, target_dir: str, source_override: str = "") -> None:
310
377
  save_json(str(settings_path), settings)
311
378
  print(f"Injected hooks from '{source}' into {settings_path}")
312
379
 
380
+ # Propagate Codex-compatible events to ~/.codex/hooks.json
381
+ _inject_codex(tagged, source, target_dir)
382
+
313
383
 
314
384
  def remove(source_name: str, target_dir: str) -> None:
315
385
  """Remove all hook entries tagged with *source_name*.
@@ -354,6 +424,9 @@ def remove(source_name: str, target_dir: str) -> None:
354
424
  save_json(str(settings_path), settings)
355
425
  print(f"Removed hooks with source '{source_name}' from {settings_path}")
356
426
 
427
+ # Remove from Codex global hooks
428
+ _remove_codex(source_name, target_dir)
429
+
357
430
  # Unregister URL source if present
358
431
  try:
359
432
  from hook_sources import unregister_source
@@ -54,7 +54,10 @@ from install_steps.install_state import (
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
@@ -708,17 +711,31 @@ def main() -> None:
708
711
  profile = cfg["profile"]
709
712
 
710
713
  lang_modules = [m for m in (resolved_modules or []) if m.startswith("rules-")]
711
- editors_arg: str = cfg["editors"]
714
+ local_editors_arg: str = cfg["editors"]
712
715
  install_local_project(rules_dir, dry_run, reset, lang_modules or None,
713
- editors=editors_arg,
716
+ editors=local_editors_arg,
714
717
  merged_config=merged_config)
718
+ installed_eds: list[str] = [] # local install doesn't track global editors
715
719
  install_strict_git_hooks(profile, local, dry_run)
716
720
  else:
717
721
  # Global install
718
722
  print_banner(target_dir, rules_dir, profile, only, skip, dry_run,
719
723
  modules=resolved_modules)
720
724
  install_claude_code(target_dir, hooks_scripts_dir, rules_dir, only, skip, dry_run)
721
- 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)
722
739
  install_persona(target_dir, persona, dry_run)
723
740
  install_strict_git_hooks(profile, local, dry_run)
724
741
 
@@ -749,6 +766,10 @@ def main() -> None:
749
766
  extends_info=extends_info,
750
767
  )
751
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
+
752
773
  # Register project in global registry (for `ai-toolkit update` propagation)
753
774
  # Skipped when called from update_projects.py (--skip-register) to avoid
754
775
  # concurrent writes to projects.json during parallel updates.
@@ -756,10 +777,20 @@ def main() -> None:
756
777
  extends_source = ""
757
778
  if extends_info:
758
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
+
759
789
  is_new = register_project(
760
790
  project_dir,
761
791
  profile=profile or "standard",
762
792
  extends=extends_source,
793
+ editors=local_eds_for_registry,
763
794
  )
764
795
  if is_new:
765
796
  print(f" Registered project in {TOOLKIT_DATA_DIR / 'projects.json'}")