@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
@@ -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
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
 
4
- const { execFileSync, spawnSync, execSync } = require('child_process');
4
+ const { execFileSync, spawnSync } = require('child_process');
5
5
  const path = require('path');
6
6
  const fs = require('fs');
7
7
 
@@ -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/',
@@ -151,6 +151,23 @@ function run(script, args = [], opts = {}) {
151
151
  }
152
152
  }
153
153
 
154
+ /**
155
+ * Propagate changes to globally installed editors (from state.json).
156
+ * Silently skips if no global editors are configured.
157
+ * @param {...string} flags - Flags to pass: --rules, --hooks, --mcp
158
+ */
159
+ function propagateGlobal(...flags) {
160
+ const result = spawnSync('python3', [scriptPath('propagate_global.py'), ...flags], {
161
+ stdio: 'inherit',
162
+ cwd: CWD,
163
+ env: { ...process.env },
164
+ });
165
+ // Non-fatal — propagation failure shouldn't block the primary operation
166
+ if (result.status !== 0) {
167
+ console.error('Warning: global editor propagation had issues (non-fatal)');
168
+ }
169
+ }
170
+
154
171
  /**
155
172
  * Generic dispatcher for SCRIPT_COMMANDS entries.
156
173
  * Resolves the script path and selects the correct cwd.
@@ -201,9 +218,9 @@ function showHelp() {
201
218
  console.log(` ${cmd.padEnd(16)} ${desc}`);
202
219
  }
203
220
  console.log('\nOptions for install / update:');
204
- 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)');
205
222
  console.log(' --skip <list> Skip listed components');
206
- 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)');
207
224
  console.log(' --profile <p> Install profile: minimal (agents+skills), standard (default), strict (all+git hooks)');
208
225
  console.log(' --persona <p> Persona preset: backend-lead, frontend-lead, devops-eng, junior-dev');
209
226
  console.log(' --modules <list> Install specific modules (e.g. core,agents,rules-typescript)');
@@ -331,6 +348,7 @@ function handleRemoveRule(args) {
331
348
  }
332
349
  const targetDir = args[1] || process.env.HOME;
333
350
  run(scriptPath('remove_rule.py'), [ruleName, targetDir]);
351
+ propagateGlobal('--rules');
334
352
  }
335
353
 
336
354
  /**
@@ -345,9 +363,14 @@ function handleAddRule(args) {
345
363
  }
346
364
  // Pass URLs through directly (don't resolve as filesystem path)
347
365
  const isUrl = ruleFile.startsWith('https://') || ruleFile.startsWith('http://');
366
+ if (ruleFile.startsWith('http://')) {
367
+ console.error('Error: only HTTPS URLs are supported. Use https:// for security.');
368
+ process.exit(1);
369
+ }
348
370
  const absRuleFile = isUrl ? ruleFile : path.resolve(CWD, ruleFile);
349
371
  const ruleName = args[1];
350
372
  run(scriptPath('add_rule.py'), ruleName ? [absRuleFile, ruleName] : [absRuleFile]);
373
+ propagateGlobal('--rules');
351
374
  }
352
375
 
353
376
  /**
@@ -392,6 +415,10 @@ function handleMcp(args) {
392
415
  process.exit(1);
393
416
  }
394
417
  run(scriptPath('mcp_manager.py'), args);
418
+ // After `mcp add`, propagate to global editors
419
+ if (args[0] === 'add') {
420
+ propagateGlobal('--mcp');
421
+ }
395
422
  }
396
423
 
397
424
  /**
@@ -5,7 +5,7 @@ service: ai-toolkit
5
5
  tags: [architecture, overview, design, structure]
6
6
  version: "1.4.4"
7
7
  created: "2026-03-23"
8
- last_updated: "2026-04-13"
8
+ last_updated: "2026-04-15"
9
9
  description: "Architecture of ai-toolkit: directory layout, global install model, editor-aware MCP install, Codex translation layer, skill tiers, and integration with projects."
10
10
  ---
11
11
 
@@ -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
 
@@ -335,7 +335,7 @@ Severity levels: HIGH (blocks deployment), WARN (should fix), INFO (best practic
335
335
  ## Extension Points
336
336
 
337
337
  ### MCP Templates
338
- `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`.
338
+ `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`.
339
339
 
340
340
  ### Language Rules
341
341
  `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.
@@ -4,7 +4,7 @@ category: reference
4
4
  service: ai-toolkit
5
5
  tags: [cli, commands, reference, install, update, plugin, mcp]
6
6
  created: "2026-04-13"
7
- last_updated: "2026-04-13"
7
+ last_updated: "2026-04-15"
8
8
  description: "Complete CLI reference for all ai-toolkit commands, options, and flags."
9
9
  ---
10
10
 
@@ -107,6 +107,10 @@ This means Claude-only events such as `TaskCompleted`, `TeammateIdle`,
107
107
  `SubagentStart`, `SubagentStop`, `PreCompact`, `SessionEnd`, and
108
108
  `Notification` are not available in `.codex/hooks.json`.
109
109
 
110
+ `inject-hook` automatically propagates Codex-compatible events to
111
+ `~/.codex/hooks.json` (global layer). Non-Codex events are silently skipped.
112
+ `remove-hook` cleans both Claude and Codex targets.
113
+
110
114
  ## Behavioral Limits
111
115
 
112
116
  Codex wrappers preserve workflow intent, but not every Claude runtime behavior