@softspark/ai-toolkit 2.4.1 → 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 (37) hide show
  1. package/AGENTS.md +32 -19
  2. package/CHANGELOG.md +23 -0
  3. package/README.md +11 -11
  4. package/app/.claude-plugin/plugin.json +1 -1
  5. package/app/agents/code-reviewer.md +6 -7
  6. package/app/agents/frontend-specialist.md +33 -2
  7. package/app/agents/seo-specialist.md +1 -1
  8. package/app/personas/frontend-lead.md +48 -5
  9. package/app/skills/a11y-validate/SKILL.md +377 -0
  10. package/app/skills/a11y-validate/reference/aria-patterns.md +259 -0
  11. package/app/skills/a11y-validate/reference/eaa-compliance.md +252 -0
  12. package/app/skills/a11y-validate/reference/mobile-eaa.md +329 -0
  13. package/app/skills/a11y-validate/reference/wcag-2-1-aa.md +285 -0
  14. package/app/skills/a11y-validate/reference/wcag-2-2-aa.md +221 -0
  15. package/app/skills/a11y-validate/scripts/a11y-scanner.py +639 -0
  16. package/app/skills/clean-code/reference/python.md +3 -3
  17. package/app/skills/design-engineering/SKILL.md +2 -5
  18. package/app/skills/review/SKILL.md +30 -6
  19. package/app/skills/seo-validate/SKILL.md +460 -0
  20. package/app/skills/seo-validate/reference/core-web-vitals.md +445 -0
  21. package/app/skills/seo-validate/reference/geo-aeo-patterns.md +259 -0
  22. package/app/skills/seo-validate/reference/geo-guidelines.md +248 -0
  23. package/app/skills/seo-validate/reference/schema-types.md +465 -0
  24. package/app/skills/seo-validate/reference/spa-ssg-patterns.md +351 -0
  25. package/app/skills/seo-validate/reference/w3c-guidelines.md +289 -0
  26. package/app/skills/seo-validate/scripts/seo-scanner.py +549 -0
  27. package/bin/ai-toolkit.js +4 -4
  28. package/kb/reference/architecture-overview.md +1 -1
  29. package/kb/reference/comparison.md +1 -1
  30. package/kb/reference/skills-catalog.md +3 -1
  31. package/llms-full.txt +10 -6
  32. package/manifest.json +3 -3
  33. package/package.json +2 -2
  34. package/scripts/config_cli.py +4 -10
  35. package/scripts/doctor.py +3 -3
  36. package/scripts/install_steps/ai_tools.py +1 -1
  37. package/scripts/plugin.py +1 -1
@@ -0,0 +1,549 @@
1
+ #!/usr/bin/env python3
2
+ """SEO scanner -- pattern-matching heuristics for common SEO issues.
3
+
4
+ Stdlib only. No external dependencies.
5
+ Scans HTML/JSX/TSX/Vue/Astro/Svelte files for SEO problems across 9 categories.
6
+ """
7
+
8
+ import argparse
9
+ import json
10
+ import os
11
+ import re
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Constants
17
+ # ---------------------------------------------------------------------------
18
+
19
+ SCAN_EXTENSIONS = {
20
+ ".html", ".htm", ".jsx", ".tsx", ".vue", ".astro", ".svelte",
21
+ }
22
+
23
+ SKIP_DIRS = {
24
+ "node_modules", "vendor", ".git", "dist", "build", "out", ".next",
25
+ ".nuxt", ".svelte-kit", "__pycache__", ".venv", "venv", ".tox",
26
+ "public/build", "coverage", ".turbo", ".vercel",
27
+ }
28
+
29
+ SKIP_FILES = {
30
+ "package-lock.json", "yarn.lock", "pnpm-lock.yaml",
31
+ }
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # File collection
35
+ # ---------------------------------------------------------------------------
36
+
37
+
38
+ def collect_files(scan_path: Path) -> list[Path]:
39
+ """Collect scannable files under the given path, respecting skip rules."""
40
+ files: list[Path] = []
41
+ for dirpath, dirnames, filenames in os.walk(scan_path):
42
+ dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
43
+ for fname in filenames:
44
+ fpath = Path(dirpath) / fname
45
+ if fpath.suffix.lower() in SCAN_EXTENSIONS and fname not in SKIP_FILES:
46
+ files.append(fpath)
47
+ return sorted(files)
48
+
49
+
50
+ def find_project_root(start: Path) -> Path:
51
+ """Walk up to find .git or package.json as project root indicator."""
52
+ current = start if start.is_dir() else start.parent
53
+ while current != current.parent:
54
+ if (current / ".git").exists() or (current / "package.json").exists():
55
+ return current
56
+ current = current.parent
57
+ return start if start.is_dir() else start.parent
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Finding accumulator
62
+ # ---------------------------------------------------------------------------
63
+
64
+ Findings = list[dict]
65
+
66
+
67
+ def add_finding(
68
+ findings: Findings,
69
+ severity: str,
70
+ category: str,
71
+ file_path: str,
72
+ line: int,
73
+ message: str,
74
+ ) -> None:
75
+ """Append a finding to the list."""
76
+ findings.append({
77
+ "severity": severity,
78
+ "category": category,
79
+ "file": file_path,
80
+ "line": line,
81
+ "message": message,
82
+ })
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # Category 1: Meta tags
87
+ # ---------------------------------------------------------------------------
88
+
89
+ _RE_TITLE = re.compile(r"<title[\s>]", re.IGNORECASE)
90
+ _RE_META_DESC = re.compile(
91
+ r"""<meta\s[^>]*name\s*=\s*["']description["']""", re.IGNORECASE,
92
+ )
93
+ _RE_OG_TITLE = re.compile(
94
+ r"""<meta\s[^>]*property\s*=\s*["']og:title["']""", re.IGNORECASE,
95
+ )
96
+ _RE_OG_DESC = re.compile(
97
+ r"""<meta\s[^>]*property\s*=\s*["']og:description["']""", re.IGNORECASE,
98
+ )
99
+ _RE_OG_IMAGE = re.compile(
100
+ r"""<meta\s[^>]*property\s*=\s*["']og:image["']""", re.IGNORECASE,
101
+ )
102
+ _RE_CANONICAL = re.compile(
103
+ r"""<link\s[^>]*rel\s*=\s*["']canonical["']""", re.IGNORECASE,
104
+ )
105
+ # Framework metadata exports (Next.js App Router)
106
+ _RE_METADATA_EXPORT = re.compile(r"export\s+(const\s+metadata|async\s+function\s+generateMetadata)")
107
+
108
+
109
+ def check_meta_tags(content: str, rel_path: str, findings: Findings) -> None:
110
+ """Check for missing meta tags in a file."""
111
+ has_head = re.search(r"<head[\s>]|<Head[\s>]", content) is not None
112
+ has_metadata_export = _RE_METADATA_EXPORT.search(content) is not None
113
+
114
+ # Only check files that define a head section or are page-level components
115
+ is_page = (
116
+ has_head
117
+ or has_metadata_export
118
+ or "layout" in rel_path.lower()
119
+ or "/page." in rel_path.lower()
120
+ or "/index." in rel_path.lower()
121
+ or rel_path.lower().endswith("index.html")
122
+ )
123
+ if not is_page:
124
+ return
125
+
126
+ # Skip if using framework metadata API
127
+ if has_metadata_export:
128
+ return
129
+
130
+ checks = [
131
+ (_RE_TITLE, "HIGH", "Missing <title> tag"),
132
+ (_RE_META_DESC, "HIGH", "Missing meta description"),
133
+ (_RE_CANONICAL, "HIGH", "Missing canonical link"),
134
+ (_RE_OG_TITLE, "WARN", "Missing og:title meta tag"),
135
+ (_RE_OG_DESC, "WARN", "Missing og:description meta tag"),
136
+ (_RE_OG_IMAGE, "WARN", "Missing og:image meta tag"),
137
+ ]
138
+ for pattern, severity, message in checks:
139
+ if not pattern.search(content):
140
+ add_finding(findings, severity, "meta", rel_path, 1, message)
141
+
142
+
143
+ # ---------------------------------------------------------------------------
144
+ # Category 2: Heading hierarchy
145
+ # ---------------------------------------------------------------------------
146
+
147
+ _RE_HEADING = re.compile(r"<[hH]([1-6])[\s>]")
148
+
149
+
150
+ def check_headings(content: str, rel_path: str, findings: Findings) -> None:
151
+ """Check for multiple H1 tags and skipped heading levels."""
152
+ h1_lines: list[int] = []
153
+ heading_levels: list[tuple[int, int]] = [] # (level, line_number)
154
+
155
+ for line_num, line in enumerate(content.splitlines(), 1):
156
+ for match in _RE_HEADING.finditer(line):
157
+ level = int(match.group(1))
158
+ heading_levels.append((level, line_num))
159
+ if level == 1:
160
+ h1_lines.append(line_num)
161
+
162
+ if len(h1_lines) > 1:
163
+ for line_num in h1_lines[1:]:
164
+ add_finding(
165
+ findings, "WARN", "headings", rel_path, line_num,
166
+ f"Multiple <h1> tags found (also at line {h1_lines[0]})",
167
+ )
168
+
169
+ # Check for skipped levels
170
+ for i in range(1, len(heading_levels)):
171
+ prev_level, _ = heading_levels[i - 1]
172
+ curr_level, curr_line = heading_levels[i]
173
+ if curr_level > prev_level + 1:
174
+ add_finding(
175
+ findings, "WARN", "headings", rel_path, curr_line,
176
+ f"Heading level skipped: h{prev_level} -> h{curr_level}",
177
+ )
178
+
179
+
180
+ # ---------------------------------------------------------------------------
181
+ # Category 3: Image alt text
182
+ # ---------------------------------------------------------------------------
183
+
184
+ _RE_IMG_TAG = re.compile(r"<(?:img|Image)\b([^>]*)>", re.IGNORECASE | re.DOTALL)
185
+ _RE_ALT_ATTR = re.compile(r"""\balt\s*=\s*["'{]""", re.IGNORECASE)
186
+
187
+
188
+ def check_image_alt(content: str, rel_path: str, findings: Findings) -> None:
189
+ """Check for images missing alt attributes."""
190
+ for line_num, line in enumerate(content.splitlines(), 1):
191
+ for match in re.finditer(r"<(?:img|Image)\b([^>]*)/?/?>", line, re.IGNORECASE):
192
+ attrs = match.group(1)
193
+ if not _RE_ALT_ATTR.search(attrs):
194
+ add_finding(
195
+ findings, "WARN", "images", rel_path, line_num,
196
+ "Image missing alt attribute",
197
+ )
198
+
199
+
200
+ # ---------------------------------------------------------------------------
201
+ # Category 4: Structured data (JSON-LD)
202
+ # ---------------------------------------------------------------------------
203
+
204
+ _RE_JSON_LD = re.compile(
205
+ r"""<script\s[^>]*type\s*=\s*["']application/ld\+json["']""", re.IGNORECASE,
206
+ )
207
+
208
+
209
+ def check_structured_data(content: str, rel_path: str, findings: Findings) -> None:
210
+ """Check for presence of JSON-LD structured data."""
211
+ is_page = (
212
+ "layout" in rel_path.lower()
213
+ or "/page." in rel_path.lower()
214
+ or "/index." in rel_path.lower()
215
+ or rel_path.lower().endswith("index.html")
216
+ )
217
+ if not is_page:
218
+ return
219
+
220
+ if not _RE_JSON_LD.search(content):
221
+ add_finding(
222
+ findings, "INFO", "structured-data", rel_path, 1,
223
+ "No JSON-LD structured data found",
224
+ )
225
+
226
+
227
+ # ---------------------------------------------------------------------------
228
+ # Category 5: Hreflang
229
+ # ---------------------------------------------------------------------------
230
+
231
+ _RE_HREFLANG = re.compile(r"""hreflang\s*=\s*["'][^"']*["']""", re.IGNORECASE)
232
+
233
+
234
+ def check_hreflang(project_root: Path, findings: Findings) -> None:
235
+ """Check for hreflang tags if i18n directories exist."""
236
+ i18n_indicators = [
237
+ "locales", "i18n", "translations", "lang", "messages",
238
+ ]
239
+ has_i18n = False
240
+ for indicator in i18n_indicators:
241
+ if (project_root / indicator).is_dir():
242
+ has_i18n = True
243
+ break
244
+
245
+ # Also check for next-i18next, nuxt i18n modules, etc.
246
+ pkg_path = project_root / "package.json"
247
+ if pkg_path.exists():
248
+ try:
249
+ pkg = json.loads(pkg_path.read_text(errors="replace"))
250
+ all_deps = {}
251
+ all_deps.update(pkg.get("dependencies", {}))
252
+ all_deps.update(pkg.get("devDependencies", {}))
253
+ i18n_deps = ["next-i18next", "@nuxtjs/i18n", "i18next", "vue-i18n", "react-intl"]
254
+ for dep in i18n_deps:
255
+ if dep in all_deps:
256
+ has_i18n = True
257
+ break
258
+ except (json.JSONDecodeError, OSError):
259
+ pass
260
+
261
+ if not has_i18n:
262
+ return
263
+
264
+ # If i18n is detected, check for hreflang in HTML files
265
+ found_hreflang = False
266
+ for dirpath, dirnames, filenames in os.walk(project_root):
267
+ dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
268
+ for fname in filenames:
269
+ fpath = Path(dirpath) / fname
270
+ if fpath.suffix.lower() not in SCAN_EXTENSIONS:
271
+ continue
272
+ try:
273
+ content = fpath.read_text(errors="replace")
274
+ except (OSError, PermissionError):
275
+ continue
276
+ if _RE_HREFLANG.search(content):
277
+ found_hreflang = True
278
+ break
279
+ if found_hreflang:
280
+ break
281
+
282
+ if not found_hreflang:
283
+ add_finding(
284
+ findings, "WARN", "hreflang", "project", 0,
285
+ "i18n detected but no hreflang tags found in any template",
286
+ )
287
+
288
+
289
+ # ---------------------------------------------------------------------------
290
+ # Category 6: robots.txt
291
+ # ---------------------------------------------------------------------------
292
+
293
+
294
+ def check_robots(project_root: Path, findings: Findings) -> None:
295
+ """Check for robots.txt at project root or public directory."""
296
+ candidates = [
297
+ project_root / "robots.txt",
298
+ project_root / "public" / "robots.txt",
299
+ project_root / "static" / "robots.txt",
300
+ ]
301
+ for candidate in candidates:
302
+ if candidate.exists():
303
+ return
304
+
305
+ add_finding(
306
+ findings, "HIGH", "robots", "project", 0,
307
+ "No robots.txt found at project root or public/",
308
+ )
309
+
310
+
311
+ # ---------------------------------------------------------------------------
312
+ # Category 7: Sitemap
313
+ # ---------------------------------------------------------------------------
314
+
315
+
316
+ def check_sitemap(project_root: Path, findings: Findings) -> None:
317
+ """Check for sitemap.xml or sitemap configuration."""
318
+ # Direct file check
319
+ sitemap_paths = [
320
+ project_root / "sitemap.xml",
321
+ project_root / "public" / "sitemap.xml",
322
+ project_root / "static" / "sitemap.xml",
323
+ ]
324
+ for candidate in sitemap_paths:
325
+ if candidate.exists():
326
+ return
327
+
328
+ # Check for sitemap generation in package.json deps
329
+ pkg_path = project_root / "package.json"
330
+ if pkg_path.exists():
331
+ try:
332
+ pkg_content = pkg_path.read_text(errors="replace")
333
+ sitemap_deps = [
334
+ "next-sitemap", "gatsby-plugin-sitemap", "@nuxtjs/sitemap",
335
+ "sitemap", "vite-plugin-sitemap", "astro-sitemap",
336
+ ]
337
+ for dep in sitemap_deps:
338
+ if dep in pkg_content:
339
+ return
340
+ except (OSError, PermissionError):
341
+ pass
342
+
343
+ # Check for sitemap reference in robots.txt
344
+ for robots_path in [
345
+ project_root / "robots.txt",
346
+ project_root / "public" / "robots.txt",
347
+ ]:
348
+ if robots_path.exists():
349
+ try:
350
+ robots_content = robots_path.read_text(errors="replace").lower()
351
+ if "sitemap:" in robots_content:
352
+ return
353
+ except (OSError, PermissionError):
354
+ pass
355
+
356
+ add_finding(
357
+ findings, "HIGH", "sitemap", "project", 0,
358
+ "No sitemap.xml or sitemap generator detected",
359
+ )
360
+
361
+
362
+ # ---------------------------------------------------------------------------
363
+ # Category 8: Core Web Vitals static signals
364
+ # ---------------------------------------------------------------------------
365
+
366
+ _RE_LAZY_IMG = re.compile(
367
+ r"""<(?:img|Image)\b[^>]*loading\s*=\s*["']lazy["']""", re.IGNORECASE,
368
+ )
369
+ _RE_HERO_COMPONENT = re.compile(
370
+ r"(?:Hero|Banner|Masthead|Jumbotron|HeroSection|CoverImage)", re.IGNORECASE,
371
+ )
372
+
373
+
374
+ def check_cwv_signals(content: str, rel_path: str, findings: Findings) -> None:
375
+ """Detect lazy loading on above-fold images and other CWV issues."""
376
+ lines = content.splitlines()
377
+ in_hero_section = False
378
+
379
+ for line_num, line in enumerate(lines, 1):
380
+ # Track hero/banner context
381
+ if _RE_HERO_COMPONENT.search(line):
382
+ in_hero_section = True
383
+
384
+ # Reset hero context after closing tags or significant gaps
385
+ if in_hero_section and re.search(r"</(?:section|div|header)>", line, re.IGNORECASE):
386
+ in_hero_section = False
387
+
388
+ # Lazy loading on first/hero images is harmful for LCP
389
+ if _RE_LAZY_IMG.search(line) and (in_hero_section or line_num <= 30):
390
+ add_finding(
391
+ findings, "HIGH", "cwv", rel_path, line_num,
392
+ "Lazy loading on above-the-fold image delays LCP",
393
+ )
394
+
395
+ # Script in head without async/defer
396
+ if re.search(r"<script\b", line, re.IGNORECASE):
397
+ has_async_defer = re.search(r"\b(async|defer|type\s*=\s*[\"']module[\"'])\b", line, re.IGNORECASE)
398
+ has_json_ld = re.search(r"""type\s*=\s*["']application/ld\+json["']""", line, re.IGNORECASE)
399
+ if not has_async_defer and not has_json_ld:
400
+ # Only flag if in head section
401
+ head_start = content.lower().find("<head")
402
+ head_end = content.lower().find("</head>")
403
+ line_offset = sum(len(l) + 1 for l in lines[:line_num - 1])
404
+ if head_start != -1 and head_end != -1 and head_start < line_offset < head_end:
405
+ add_finding(
406
+ findings, "WARN", "cwv", rel_path, line_num,
407
+ "Script in <head> without async/defer blocks rendering",
408
+ )
409
+
410
+
411
+ # ---------------------------------------------------------------------------
412
+ # Category 9: llms.txt (GEO signal)
413
+ # ---------------------------------------------------------------------------
414
+
415
+
416
+ def check_llms_txt(project_root: Path, findings: Findings) -> None:
417
+ """Check for llms.txt file (Generative Engine Optimization signal)."""
418
+ candidates = [
419
+ project_root / "llms.txt",
420
+ project_root / "public" / "llms.txt",
421
+ project_root / "static" / "llms.txt",
422
+ ]
423
+ for candidate in candidates:
424
+ if candidate.exists():
425
+ return
426
+
427
+ add_finding(
428
+ findings, "INFO", "geo", "project", 0,
429
+ "No llms.txt found (recommended for Generative Engine Optimization)",
430
+ )
431
+
432
+
433
+ # ---------------------------------------------------------------------------
434
+ # Main scan orchestrator
435
+ # ---------------------------------------------------------------------------
436
+
437
+
438
+ def scan(scan_path: Path, project_root: Path) -> dict:
439
+ """Run all SEO checks and return structured results."""
440
+ files = collect_files(scan_path)
441
+ findings: Findings = []
442
+
443
+ # Per-file checks
444
+ for fpath in files:
445
+ try:
446
+ content = fpath.read_text(errors="replace")
447
+ except (OSError, PermissionError):
448
+ continue
449
+
450
+ rel_path = str(fpath.relative_to(project_root))
451
+
452
+ check_meta_tags(content, rel_path, findings)
453
+ check_headings(content, rel_path, findings)
454
+ check_image_alt(content, rel_path, findings)
455
+ check_structured_data(content, rel_path, findings)
456
+ check_cwv_signals(content, rel_path, findings)
457
+
458
+ # Project-level checks
459
+ check_hreflang(project_root, findings)
460
+ check_robots(project_root, findings)
461
+ check_sitemap(project_root, findings)
462
+ check_llms_txt(project_root, findings)
463
+
464
+ # Deduplicate
465
+ seen: set[tuple[str, int, str]] = set()
466
+ deduped: Findings = []
467
+ for f in findings:
468
+ key = (f["file"], f["line"], f["message"])
469
+ if key not in seen:
470
+ seen.add(key)
471
+ deduped.append(f)
472
+ findings = deduped
473
+
474
+ # Sort by severity (HIGH -> WARN -> INFO), then file, then line
475
+ sev_order = {"HIGH": 0, "WARN": 1, "INFO": 2}
476
+ findings.sort(key=lambda f: (sev_order.get(f["severity"], 9), f["file"], f["line"]))
477
+
478
+ # Build summary
479
+ summary = {"HIGH": 0, "WARN": 0, "INFO": 0}
480
+ for f in findings:
481
+ sev = f["severity"]
482
+ summary[sev] = summary.get(sev, 0) + 1
483
+
484
+ rel_scan = str(scan_path.relative_to(project_root)) if scan_path != project_root else "."
485
+
486
+ return {
487
+ "scan_path": rel_scan,
488
+ "files_scanned": len(files),
489
+ "findings": findings,
490
+ "summary": summary,
491
+ }
492
+
493
+
494
+ # ---------------------------------------------------------------------------
495
+ # Entry point
496
+ # ---------------------------------------------------------------------------
497
+
498
+
499
+ def main() -> None:
500
+ parser = argparse.ArgumentParser(
501
+ description="SEO scanner -- detect common SEO issues in HTML/JSX/TSX/Vue/Astro/Svelte files",
502
+ )
503
+ parser.add_argument(
504
+ "path", nargs="?", default=".",
505
+ help="Directory or file to scan (default: current directory)",
506
+ )
507
+ parser.add_argument(
508
+ "--output", choices=["json", "text"], default="json",
509
+ help="Output format (default: json)",
510
+ )
511
+ args = parser.parse_args()
512
+
513
+ target = Path(args.path).resolve()
514
+ if not target.exists():
515
+ print(json.dumps({"error": f"Path does not exist: {target}"}), file=sys.stderr)
516
+ sys.exit(2)
517
+
518
+ scan_path = target if target.is_dir() else target.parent
519
+ project_root = find_project_root(scan_path)
520
+
521
+ result = scan(scan_path, project_root)
522
+
523
+ if args.output == "json":
524
+ print(json.dumps(result, indent=2))
525
+ else:
526
+ _print_text_report(result)
527
+
528
+ # Exit code: non-zero if HIGH findings exist
529
+ if result["summary"].get("HIGH", 0) > 0:
530
+ sys.exit(1)
531
+
532
+
533
+ def _print_text_report(result: dict) -> None:
534
+ """Print a human-readable text report."""
535
+ summary = result["summary"]
536
+ print(f"\nSEO Scan: {result['scan_path']}")
537
+ print(f"Files scanned: {result['files_scanned']}")
538
+ print(f"HIGH: {summary.get('HIGH', 0)} WARN: {summary.get('WARN', 0)} INFO: {summary.get('INFO', 0)}")
539
+ print()
540
+
541
+ for f in result["findings"]:
542
+ line_str = f":{f['line']}" if f["line"] > 0 else ""
543
+ print(f"[{f['severity']}] {f['category']} | {f['file']}{line_str}")
544
+ print(f" {f['message']}")
545
+ print()
546
+
547
+
548
+ if __name__ == "__main__":
549
+ main()
package/bin/ai-toolkit.js CHANGED
@@ -58,8 +58,8 @@ const SCRIPT_COMMANDS = {
58
58
 
59
59
  /** @type {Record<string, string>} */
60
60
  const COMMANDS = {
61
- install: 'First-time global install into ~/.claude/ (use --local to also set up project configs)',
62
- update: 'Re-apply toolkit with saved modules from state.json (use --local to also refresh project configs)',
61
+ install: 'First-time global install into ~/.claude/ (use --local for project-local configs only)',
62
+ update: 'Re-apply toolkit with saved modules from state.json (use --local for project-local only)',
63
63
  status: 'Show installed modules, version, and profile from state.json',
64
64
  reset: 'Wipe and recreate project-local configs from scratch (requires --local)',
65
65
  uninstall: 'Remove ai-toolkit from ~/.claude/',
@@ -218,9 +218,9 @@ function showHelp() {
218
218
  console.log(` ${cmd.padEnd(16)} ${desc}`);
219
219
  }
220
220
  console.log('\nOptions for install / update:');
221
- console.log(' --only <list> Apply only listed components (e.g. agents,hooks,cursor,windsurf,gemini)');
221
+ console.log(' --only <list> Apply only listed components (e.g. agents,hooks,rules,skills,constitution)');
222
222
  console.log(' --skip <list> Skip listed components');
223
- console.log(' --local Also set up project-local configs (CLAUDE.md, settings, constitution, language rules, git hooks)');
223
+ console.log(' --local Project-local configs only (CLAUDE.md, settings, constitution, language rules, git hooks)');
224
224
  console.log(' --profile <p> Install profile: minimal (agents+skills), standard (default), strict (all+git hooks)');
225
225
  console.log(' --persona <p> Persona preset: backend-lead, frontend-lead, devops-eng, junior-dev');
226
226
  console.log(' --modules <list> Install specific modules (e.g. core,agents,rules-typescript)');
@@ -185,7 +185,7 @@ Three tiers determine how to approach a task:
185
185
 
186
186
  | Type | Field | Invocation | Count |
187
187
  |------|-------|-----------|-------|
188
- | Task | `disable-model-invocation: true` | User via `/skill` only | 29 |
188
+ | Task | `disable-model-invocation: true` | User via `/skill` only | 31 |
189
189
  | Hybrid | (neither) | User via `/skill` + agent knowledge | 31 |
190
190
  | Knowledge | `user-invocable: false` | Claude auto-loads | 32 |
191
191
 
@@ -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 |
@@ -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