@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.
- package/AGENTS.md +32 -19
- package/CHANGELOG.md +45 -0
- package/README.md +13 -12
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/ARCHITECTURE.md +2 -2
- package/app/agents/code-reviewer.md +6 -7
- package/app/agents/frontend-specialist.md +33 -2
- package/app/agents/seo-specialist.md +1 -1
- package/app/personas/frontend-lead.md +48 -5
- package/app/skills/a11y-validate/SKILL.md +377 -0
- package/app/skills/a11y-validate/reference/aria-patterns.md +259 -0
- package/app/skills/a11y-validate/reference/eaa-compliance.md +252 -0
- package/app/skills/a11y-validate/reference/mobile-eaa.md +329 -0
- package/app/skills/a11y-validate/reference/wcag-2-1-aa.md +285 -0
- package/app/skills/a11y-validate/reference/wcag-2-2-aa.md +221 -0
- package/app/skills/a11y-validate/scripts/a11y-scanner.py +639 -0
- package/app/skills/clean-code/reference/python.md +3 -3
- package/app/skills/design-engineering/SKILL.md +2 -5
- package/app/skills/review/SKILL.md +30 -6
- package/app/skills/seo-validate/SKILL.md +460 -0
- package/app/skills/seo-validate/reference/core-web-vitals.md +445 -0
- package/app/skills/seo-validate/reference/geo-aeo-patterns.md +259 -0
- package/app/skills/seo-validate/reference/geo-guidelines.md +248 -0
- package/app/skills/seo-validate/reference/schema-types.md +465 -0
- package/app/skills/seo-validate/reference/spa-ssg-patterns.md +351 -0
- package/app/skills/seo-validate/reference/w3c-guidelines.md +289 -0
- package/app/skills/seo-validate/scripts/seo-scanner.py +549 -0
- package/bin/ai-toolkit.js +32 -5
- package/kb/reference/architecture-overview.md +3 -3
- package/kb/reference/cli-reference.md +1 -1
- package/kb/reference/codex-cli-compatibility.md +4 -0
- package/kb/reference/comparison.md +1 -1
- package/kb/reference/extension-api.md +2 -0
- package/kb/reference/skills-catalog.md +3 -1
- package/llms-full.txt +16 -6
- package/manifest.json +3 -3
- package/package.json +2 -2
- package/scripts/config_cli.py +4 -10
- package/scripts/config_resolver.py +23 -5
- package/scripts/doctor.py +76 -4
- package/scripts/hook_sources.py +3 -0
- package/scripts/inject_hook_cli.py +74 -1
- package/scripts/install.py +34 -3
- package/scripts/install_steps/ai_tools.py +79 -16
- package/scripts/install_steps/install_state.py +25 -0
- package/scripts/install_steps/markers.py +2 -1
- package/scripts/install_steps/project_registry.py +9 -0
- package/scripts/plugin.py +1 -1
- package/scripts/propagate_global.py +92 -0
- package/scripts/rule_sources.py +3 -2
- package/scripts/update_projects.py +7 -1
- 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 |
|
|
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 (
|
|
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-
|
|
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 |
|
|
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/
|
|
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-
|
|
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 |
|
|
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 (
|
|
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.
|
|
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": "
|
|
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": "
|
|
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
|
-
"description": "Professional-grade AI coding toolkit:
|
|
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",
|
package/scripts/config_cli.py
CHANGED
|
@@ -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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
457
|
+
dr.warn(f"Stale symlink: {rule_file}")
|
|
456
458
|
stale += 1
|
|
457
459
|
if fix_mode:
|
|
458
460
|
rule_file.unlink()
|
|
459
|
-
|
|
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
|
-
|
|
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("========================")
|
package/scripts/hook_sources.py
CHANGED
|
@@ -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(
|
|
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
|
package/scripts/install.py
CHANGED
|
@@ -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
|
-
|
|
714
|
+
local_editors_arg: str = cfg["editors"]
|
|
712
715
|
install_local_project(rules_dir, dry_run, reset, lang_modules or None,
|
|
713
|
-
editors=
|
|
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
|
-
|
|
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'}")
|