@softspark/ai-toolkit 2.4.1 → 2.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/AGENTS.md +33 -20
  2. package/CHANGELOG.md +57 -0
  3. package/README.md +29 -13
  4. package/app/.claude-plugin/plugin.json +3 -2
  5. package/app/ARCHITECTURE.md +11 -0
  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/hipaa-validate/SKILL.md +39 -23
  20. package/app/skills/hipaa-validate/scripts/hipaa_scan.py +64 -7
  21. package/app/skills/review/SKILL.md +30 -6
  22. package/app/skills/seo-validate/SKILL.md +460 -0
  23. package/app/skills/seo-validate/reference/core-web-vitals.md +445 -0
  24. package/app/skills/seo-validate/reference/geo-aeo-patterns.md +259 -0
  25. package/app/skills/seo-validate/reference/geo-guidelines.md +248 -0
  26. package/app/skills/seo-validate/reference/schema-types.md +465 -0
  27. package/app/skills/seo-validate/reference/spa-ssg-patterns.md +351 -0
  28. package/app/skills/seo-validate/reference/w3c-guidelines.md +289 -0
  29. package/app/skills/seo-validate/scripts/seo-scanner.py +549 -0
  30. package/bin/ai-toolkit.js +24 -9
  31. package/kb/reference/architecture-overview.md +1 -1
  32. package/kb/reference/comparison.md +1 -1
  33. package/kb/reference/opencode-compatibility.md +161 -0
  34. package/kb/reference/skills-catalog.md +3 -1
  35. package/llms-full.txt +177 -6
  36. package/llms.txt +1 -0
  37. package/manifest.json +3 -3
  38. package/package.json +6 -3
  39. package/scripts/config_cli.py +4 -10
  40. package/scripts/doctor.py +3 -3
  41. package/scripts/generate_opencode.py +117 -0
  42. package/scripts/generate_opencode_agents.py +126 -0
  43. package/scripts/generate_opencode_commands.py +158 -0
  44. package/scripts/generate_opencode_json.py +133 -0
  45. package/scripts/generate_opencode_plugin.py +169 -0
  46. package/scripts/install_steps/ai_tools.py +117 -1
  47. package/scripts/install_steps/install_state.py +1 -1
  48. 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
@@ -30,6 +30,7 @@ const GENERATORS = {
30
30
  'augment-rules': { script: 'generate_augment.py', dest: path.join('.augment', 'rules', 'ai-toolkit.md'), mkdir: '.augment/rules' },
31
31
  'agents-md': { script: 'generate_agents_md.py', dest: 'AGENTS.md' },
32
32
  'codex-md': { script: 'generate_codex.py', dest: 'AGENTS.md' },
33
+ 'opencode-md': { script: 'generate_opencode.py', dest: 'AGENTS.md' },
33
34
  };
34
35
 
35
36
  // ---------------------------------------------------------------------------
@@ -58,8 +59,8 @@ const SCRIPT_COMMANDS = {
58
59
 
59
60
  /** @type {Record<string, string>} */
60
61
  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)',
62
+ install: 'First-time global install into ~/.claude/ (use --local for project-local configs only)',
63
+ update: 'Re-apply toolkit with saved modules from state.json (use --local for project-local only)',
63
64
  status: 'Show installed modules, version, and profile from state.json',
64
65
  reset: 'Wipe and recreate project-local configs from scratch (requires --local)',
65
66
  uninstall: 'Remove ai-toolkit from ~/.claude/',
@@ -98,10 +99,15 @@ const COMMANDS = {
98
99
  'codex-md': 'Generate AGENTS.md for OpenAI Codex CLI',
99
100
  'codex-rules': 'Generate .agents/rules/ for OpenAI Codex CLI',
100
101
  'codex-hooks': 'Generate .codex/hooks.json for OpenAI Codex CLI',
102
+ 'opencode-md': 'Generate AGENTS.md for opencode',
103
+ 'opencode-agents': 'Generate .opencode/agents/ for opencode (subagents)',
104
+ 'opencode-commands': 'Generate .opencode/commands/ for opencode (slash commands)',
105
+ 'opencode-plugin': 'Generate .opencode/plugins/ai-toolkit-hooks.js (lifecycle bridge)',
106
+ 'opencode-json': 'Merge .mcp.json servers into opencode.json',
101
107
  'agents-md': 'Regenerate AGENTS.md from agent definitions',
102
108
  'compile-slm': 'Compile toolkit into a minimal SLM system prompt (--budget, --model-size, --dry-run)',
103
109
  'llms-txt': 'Generate llms.txt and llms-full.txt',
104
- 'generate-all': 'Generate all platform configs at once (agents, cursor, windsurf, copilot, gemini, cline, roo, aider, augment, antigravity, codex, llms)',
110
+ 'generate-all': 'Generate all platform configs at once (agents, cursor, windsurf, copilot, gemini, cline, roo, aider, augment, antigravity, codex, opencode, llms)',
105
111
  help: 'Show this help message',
106
112
  };
107
113
 
@@ -218,14 +224,14 @@ function showHelp() {
218
224
  console.log(` ${cmd.padEnd(16)} ${desc}`);
219
225
  }
220
226
  console.log('\nOptions for install / update:');
221
- console.log(' --only <list> Apply only listed components (e.g. agents,hooks,cursor,windsurf,gemini)');
227
+ console.log(' --only <list> Apply only listed components (e.g. agents,hooks,rules,skills,constitution)');
222
228
  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)');
229
+ console.log(' --local Project-local configs only (CLAUDE.md, settings, constitution, language rules, git hooks)');
224
230
  console.log(' --profile <p> Install profile: minimal (agents+skills), standard (default), strict (all+git hooks)');
225
231
  console.log(' --persona <p> Persona preset: backend-lead, frontend-lead, devops-eng, junior-dev');
226
232
  console.log(' --modules <list> Install specific modules (e.g. core,agents,rules-typescript)');
227
233
  console.log(' --lang <list> Explicitly select language rules (e.g. typescript, go,python)');
228
- console.log(' --editors <list> Install editor configs: cursor,windsurf,cline,roo,aider,augment,copilot,antigravity,codex (or "all")');
234
+ console.log(' --editors <list> Install editor configs: cursor,windsurf,cline,roo,aider,augment,copilot,antigravity,codex,opencode (or "all")');
229
235
  console.log(' Default with --local: auto-detect from existing project files');
230
236
  console.log(' --auto-detect Detect project languages and install matching rule modules');
231
237
  console.log(' --list, --dry-run Dry-run: show what would be applied');
@@ -438,9 +444,10 @@ function handleConfig(args) {
438
444
  */
439
445
  function handleGenerateAll(_args) {
440
446
  for (const [name, gen] of Object.entries(GENERATORS)) {
441
- // Skip codex-md it injects a Codex config block via markers (used by install --local --editors codex)
442
- // agents-md generates the full agent list which is the standalone AGENTS.md
443
- if (name === 'codex-md') continue;
447
+ // Skip codex-md and opencode-md they inject into AGENTS.md via markers
448
+ // (used by install --local --editors codex|opencode), not as standalone files.
449
+ // agents-md generates the full agent list which is the standalone AGENTS.md.
450
+ if (name === 'codex-md' || name === 'opencode-md') continue;
444
451
  writeGeneratorOutput(gen);
445
452
  }
446
453
  // Directory-based generators (multi-file output)
@@ -452,6 +459,10 @@ function handleGenerateAll(_args) {
452
459
  run(scriptPath('generate_augment_rules.py'), [CWD]);
453
460
  run(scriptPath('generate_codex_rules.py'), [CWD]);
454
461
  run(scriptPath('generate_codex_hooks.py'), [CWD]);
462
+ run(scriptPath('generate_opencode_agents.py'), [CWD]);
463
+ run(scriptPath('generate_opencode_commands.py'), [CWD]);
464
+ run(scriptPath('generate_opencode_plugin.py'), [CWD]);
465
+ run(scriptPath('generate_opencode_json.py'), [CWD]);
455
466
  // Single-file generators
456
467
  const conventionsOut = runGenerator('generate_conventions.py');
457
468
  fs.writeFileSync(path.join(CWD, 'CONVENTIONS.md'), conventionsOut);
@@ -559,6 +570,10 @@ const SPECIAL_HANDLERS = {
559
570
  'augment-dir-rules': (_args) => run(scriptPath('generate_augment_rules.py'), [CWD]),
560
571
  'codex-rules': (_args) => run(scriptPath('generate_codex_rules.py'), [CWD]),
561
572
  'codex-hooks': (_args) => run(scriptPath('generate_codex_hooks.py'), [CWD]),
573
+ 'opencode-agents': (_args) => run(scriptPath('generate_opencode_agents.py'), [CWD]),
574
+ 'opencode-commands': (_args) => run(scriptPath('generate_opencode_commands.py'), [CWD]),
575
+ 'opencode-plugin': (_args) => run(scriptPath('generate_opencode_plugin.py'), [CWD]),
576
+ 'opencode-json': (_args) => run(scriptPath('generate_opencode_json.py'), [CWD]),
562
577
  'generate-all': handleGenerateAll,
563
578
  };
564
579
 
@@ -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 |