@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.
- package/AGENTS.md +33 -20
- package/CHANGELOG.md +57 -0
- package/README.md +29 -13
- package/app/.claude-plugin/plugin.json +3 -2
- package/app/ARCHITECTURE.md +11 -0
- 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/hipaa-validate/SKILL.md +39 -23
- package/app/skills/hipaa-validate/scripts/hipaa_scan.py +64 -7
- 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 +24 -9
- package/kb/reference/architecture-overview.md +1 -1
- package/kb/reference/comparison.md +1 -1
- package/kb/reference/opencode-compatibility.md +161 -0
- package/kb/reference/skills-catalog.md +3 -1
- package/llms-full.txt +177 -6
- package/llms.txt +1 -0
- package/manifest.json +3 -3
- package/package.json +6 -3
- package/scripts/config_cli.py +4 -10
- package/scripts/doctor.py +3 -3
- package/scripts/generate_opencode.py +117 -0
- package/scripts/generate_opencode_agents.py +126 -0
- package/scripts/generate_opencode_commands.py +158 -0
- package/scripts/generate_opencode_json.py +133 -0
- package/scripts/generate_opencode_plugin.py +169 -0
- package/scripts/install_steps/ai_tools.py +117 -1
- package/scripts/install_steps/install_state.py +1 -1
- 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
|
|
62
|
-
update: 'Re-apply toolkit with saved modules from state.json (use --local
|
|
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,
|
|
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
|
|
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
|
|
442
|
-
//
|
|
443
|
-
|
|
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 |
|
|
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 |
|
|
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 |
|