agent-readiness 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +142 -0
- package/bin/agent-ready.mjs +296 -0
- package/lib/core.mjs +971 -0
- package/lib/fix.mjs +564 -0
- package/lib/github.mjs +51 -0
- package/lib/report.mjs +57 -0
- package/package.json +54 -0
package/lib/fix.mjs
ADDED
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
// The fixer: turn a scanned analysis into concrete file writes + entry-file edits
|
|
2
|
+
// for a real project, place them in the right spot per framework, and (optionally)
|
|
3
|
+
// open a pull request. Kept separate from core.mjs so core stays framework-agnostic.
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'
|
|
5
|
+
import { writeFile, mkdir } from 'node:fs/promises'
|
|
6
|
+
import { execFileSync } from 'node:child_process'
|
|
7
|
+
import { join, dirname, resolve, relative } from 'node:path'
|
|
8
|
+
import { tmpdir } from 'node:os'
|
|
9
|
+
import { score, buildLlmsTxt, buildWebMcp, buildStructuredData } from './core.mjs'
|
|
10
|
+
|
|
11
|
+
const SCRIPT_SRC = '/webmcp.tools.js'
|
|
12
|
+
const MCP_SCRIPT = `<script src="${SCRIPT_SRC}" defer></script>`
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Framework detection
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
const LAYOUT_EXT = ['tsx', 'jsx', 'ts', 'js', 'mjs']
|
|
19
|
+
|
|
20
|
+
function readJson(p) {
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(readFileSync(p, 'utf8'))
|
|
23
|
+
} catch {
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function firstExisting(paths) {
|
|
29
|
+
for (const p of paths) if (p && existsSync(p)) return p
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function layoutCandidates(root, dirs, base) {
|
|
34
|
+
return dirs.flatMap((d) => LAYOUT_EXT.map((e) => join(root, d, `${base}.${e}`)))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function mk(id, label, root, publicDir, entry, entryKind) {
|
|
38
|
+
return { id, label, root, publicDir, entry, entryKind }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Returns { id, label, root, publicDir, entry, entryKind }.
|
|
42
|
+
// entryKind: 'html' (a real HTML doc we can fully inject) | 'jsx' (a React/Next
|
|
43
|
+
// layout we edit conservatively) | null (nothing to inject into).
|
|
44
|
+
export function detectFramework(repoDir) {
|
|
45
|
+
const root = resolve(repoDir)
|
|
46
|
+
const pkg = readJson(join(root, 'package.json')) || {}
|
|
47
|
+
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) }
|
|
48
|
+
const dep = (n) => Boolean(deps[n])
|
|
49
|
+
const cfg = (names) => firstExisting(names.map((n) => join(root, n)))
|
|
50
|
+
|
|
51
|
+
if (dep('next') || cfg(['next.config.js', 'next.config.mjs', 'next.config.ts', 'next.config.cjs'])) {
|
|
52
|
+
const appLayout = firstExisting(layoutCandidates(root, ['app', 'src/app'], 'layout'))
|
|
53
|
+
if (appLayout) return mk('next-app', 'Next.js (App Router)', root, join(root, 'public'), appLayout, 'jsx')
|
|
54
|
+
const entry = firstExisting(layoutCandidates(root, ['pages', 'src/pages'], '_document')) || firstExisting(layoutCandidates(root, ['pages', 'src/pages'], '_app'))
|
|
55
|
+
return mk('next-pages', 'Next.js (Pages Router)', root, join(root, 'public'), entry, entry ? 'jsx' : null)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (dep('vite') || cfg(['vite.config.js', 'vite.config.ts', 'vite.config.mjs'])) {
|
|
59
|
+
const html = firstExisting([join(root, 'index.html')])
|
|
60
|
+
return mk('vite', 'Vite', root, join(root, 'public'), html, html ? 'html' : null)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const html = firstExisting([join(root, 'index.html'), join(root, 'public', 'index.html'), join(root, 'src', 'index.html'), join(root, 'dist', 'index.html')])
|
|
64
|
+
if (html) {
|
|
65
|
+
const publicDir = existsSync(join(root, 'public')) ? join(root, 'public') : dirname(html)
|
|
66
|
+
return mk('static', 'Static / plain HTML', root, publicDir, html, 'html')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return mk('unknown', 'Unknown project', root, existsSync(join(root, 'public')) ? join(root, 'public') : root, null, null)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Injection (idempotent — re-running never duplicates)
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
function escHtml(s) {
|
|
77
|
+
return String(s == null ? '' : s)
|
|
78
|
+
.replace(/&/g, '&')
|
|
79
|
+
.replace(/</g, '<')
|
|
80
|
+
.replace(/>/g, '>')
|
|
81
|
+
.replace(/"/g, '"')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Blank out spans that must NOT host an injection anchor — HTML comments,
|
|
85
|
+
// script/style/pre/textarea bodies (for 'html'); JS comments + string/template
|
|
86
|
+
// literals (for 'jsx'). Same length, newlines preserved, so an index found in
|
|
87
|
+
// the mask maps 1:1 back into the original string. This stops us from splicing a
|
|
88
|
+
// tag inside a comment or a string (which would silently corrupt the file).
|
|
89
|
+
function maskNonInjectable(s, kind) {
|
|
90
|
+
const patterns =
|
|
91
|
+
kind === 'jsx'
|
|
92
|
+
? [/\/\*[\s\S]*?\*\//g, /\/\/[^\n]*/g, /"(?:[^"\\]|\\.)*"/g, /'(?:[^'\\]|\\.)*'/g, /`(?:[^`\\]|\\.)*`/g]
|
|
93
|
+
: [/<!--[\s\S]*?-->/g, /<script\b[\s\S]*?<\/script\s*>/gi, /<style\b[\s\S]*?<\/style\s*>/gi, /<pre\b[\s\S]*?<\/pre\s*>/gi, /<textarea\b[\s\S]*?<\/textarea\s*>/gi]
|
|
94
|
+
const arr = s.split('')
|
|
95
|
+
for (const re of patterns) {
|
|
96
|
+
for (const m of s.matchAll(re)) {
|
|
97
|
+
for (let i = m.index; i < m.index + m[0].length && i < arr.length; i++) {
|
|
98
|
+
if (arr[i] !== '\n') arr[i] = ' '
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return arr.join('')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Insert `lines` just before the first real closing </tag> (located via `mask`),
|
|
106
|
+
// matching its indentation and preserving its original casing/spacing.
|
|
107
|
+
function insertBeforeClose(str, mask, tag, lines) {
|
|
108
|
+
const m = mask.match(new RegExp(`([ \\t]*)</${tag}\\s*>`, 'i'))
|
|
109
|
+
if (!m) return null
|
|
110
|
+
const indent = m[1] || ''
|
|
111
|
+
const tagStart = m.index + indent.length
|
|
112
|
+
const tagEnd = m.index + m[0].length
|
|
113
|
+
const closeText = str.slice(tagStart, tagEnd) // author's exact closing tag
|
|
114
|
+
const block = lines.map((l) => `${indent} ${l}`).join('\n')
|
|
115
|
+
return str.slice(0, m.index) + block + '\n' + indent + closeText + str.slice(tagEnd)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Insert `lines` immediately after the first real opening <tag ...> (via `mask`).
|
|
119
|
+
function insertAfterOpen(str, mask, tag, lines) {
|
|
120
|
+
const m = mask.match(new RegExp(`<${tag}(?:\\s[^>]*)?>`, 'i'))
|
|
121
|
+
if (!m) return null
|
|
122
|
+
const at = m.index + m[0].length
|
|
123
|
+
const block = '\n' + lines.map((l) => ` ${l}`).join('\n')
|
|
124
|
+
return str.slice(0, at) + block + str.slice(at)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Add lang="…" to the first real <html|Html> open tag, preserving its casing
|
|
128
|
+
// (so next/document's <Html> isn't lowercased into a tag-mismatch build error).
|
|
129
|
+
function addHtmlLang(out, mask, lang) {
|
|
130
|
+
if (!lang) return null
|
|
131
|
+
const m = mask.match(/<html(?=[\s>{])/i)
|
|
132
|
+
if (!m) return null
|
|
133
|
+
const gt = out.indexOf('>', m.index)
|
|
134
|
+
const openTag = out.slice(m.index, gt === -1 ? out.length : gt)
|
|
135
|
+
if (/\slang\s*=/i.test(openTag)) return null
|
|
136
|
+
return out.slice(0, m.index) + out.slice(m.index).replace(/<html(?=[\s>{])/i, (mm) => `${mm} lang="${escHtml(lang)}"`)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Inject into a real HTML document: lang, title, meta description, canonical,
|
|
140
|
+
// structured-data block, and the WebMCP <script>. Each guarded so it only adds
|
|
141
|
+
// what's missing; anything it can't place safely is reported via `manual`.
|
|
142
|
+
export function injectHtml(src, gaps) {
|
|
143
|
+
let out = src
|
|
144
|
+
const applied = []
|
|
145
|
+
const manual = []
|
|
146
|
+
let mask = maskNonInjectable(out, 'html')
|
|
147
|
+
|
|
148
|
+
const langed = addHtmlLang(out, mask, gaps.lang)
|
|
149
|
+
if (langed) {
|
|
150
|
+
out = langed
|
|
151
|
+
applied.push('html-lang')
|
|
152
|
+
mask = maskNonInjectable(out, 'html')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Presence checks run against the ORIGINAL source (not the mask) — otherwise a
|
|
156
|
+
// real <script type="application/ld+json"> would read as absent (it's masked)
|
|
157
|
+
// and get duplicated on re-runs. The mask is only for locating injection anchors.
|
|
158
|
+
const head = []
|
|
159
|
+
const headLabels = []
|
|
160
|
+
if (gaps.title && !/<title[\s>]/i.test(out)) {
|
|
161
|
+
head.push(`<title>${escHtml(gaps.title)}</title>`)
|
|
162
|
+
headLabels.push('title')
|
|
163
|
+
}
|
|
164
|
+
if (gaps.description && !/<meta[^>]+name=["']?description/i.test(out)) {
|
|
165
|
+
head.push(`<meta name="description" content="${escHtml(gaps.description)}" />`)
|
|
166
|
+
headLabels.push('meta-description')
|
|
167
|
+
}
|
|
168
|
+
if (gaps.canonical && !/<link[^>]+rel=["']?canonical/i.test(out)) {
|
|
169
|
+
head.push(`<link rel="canonical" href="${escHtml(gaps.canonical)}" />`)
|
|
170
|
+
headLabels.push('canonical')
|
|
171
|
+
}
|
|
172
|
+
if (gaps.structured && !/application\/ld\+json/i.test(out)) {
|
|
173
|
+
head.push(...String(gaps.structured).trim().split('\n'))
|
|
174
|
+
headLabels.push('structured-data')
|
|
175
|
+
}
|
|
176
|
+
if (head.length) {
|
|
177
|
+
const placed = insertBeforeClose(out, mask, 'head', head) || insertAfterOpen(out, mask, 'head', head) || insertAfterOpen(out, mask, 'html', head)
|
|
178
|
+
if (placed) {
|
|
179
|
+
out = placed
|
|
180
|
+
applied.push(...headLabels)
|
|
181
|
+
mask = maskNonInjectable(out, 'html')
|
|
182
|
+
} else {
|
|
183
|
+
manual.push(`Could not find a <head>/<html> tag — add to your <head> manually: ${headLabels.join(', ')}.`)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (gaps.webmcp && !out.includes(SCRIPT_SRC)) {
|
|
188
|
+
const placed = insertBeforeClose(out, mask, 'body', [MCP_SCRIPT]) || insertBeforeClose(out, mask, 'head', [MCP_SCRIPT])
|
|
189
|
+
if (placed) {
|
|
190
|
+
out = placed
|
|
191
|
+
applied.push('webmcp-script')
|
|
192
|
+
} else {
|
|
193
|
+
manual.push(`Could not find </body> or </head> — add \`${MCP_SCRIPT}\` to your page manually.`)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return { html: out, applied, manual }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Inject into a React/Next layout: only the two edits that are safe in JSX —
|
|
201
|
+
// the <html lang> attribute and a <script> before </body>. Metadata/structured
|
|
202
|
+
// data is left to a note (App Router uses the `metadata` export, not raw tags).
|
|
203
|
+
export function injectLayout(src, gaps) {
|
|
204
|
+
let out = src
|
|
205
|
+
const applied = []
|
|
206
|
+
const manual = []
|
|
207
|
+
let mask = maskNonInjectable(out, 'jsx')
|
|
208
|
+
|
|
209
|
+
const langed = addHtmlLang(out, mask, gaps.lang)
|
|
210
|
+
if (langed) {
|
|
211
|
+
out = langed
|
|
212
|
+
applied.push('html-lang')
|
|
213
|
+
mask = maskNonInjectable(out, 'jsx')
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (gaps.webmcp && !out.includes(SCRIPT_SRC)) {
|
|
217
|
+
const placed = insertBeforeClose(out, mask, 'body', [MCP_SCRIPT])
|
|
218
|
+
if (placed) {
|
|
219
|
+
out = placed
|
|
220
|
+
applied.push('webmcp-script')
|
|
221
|
+
} else {
|
|
222
|
+
manual.push(`Add \`${MCP_SCRIPT}\` to your layout (no \`</body>\` found to inject before).`)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return { html: out, applied, manual }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
// Gap analysis + planning
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
// Which gap keys a framework can apply automatically. HTML docs get the full set;
|
|
234
|
+
// JSX layouts only get the two safe edits; an unknown project gets none (file
|
|
235
|
+
// writes still happen, but presence checks that need an entry edit won't flip).
|
|
236
|
+
function autoApplicable(framework) {
|
|
237
|
+
if (framework.entryKind === 'html') return new Set(['lang', 'title', 'description', 'structured', 'webmcp', 'canonical'])
|
|
238
|
+
if (framework.entryKind === 'jsx') return new Set(['lang', 'webmcp'])
|
|
239
|
+
return new Set()
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function titleFrom(baseUrl) {
|
|
243
|
+
try {
|
|
244
|
+
if (baseUrl) return new URL(baseUrl).hostname
|
|
245
|
+
} catch {
|
|
246
|
+
/* ignore */
|
|
247
|
+
}
|
|
248
|
+
return 'Site'
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function computeGaps(facts, baseUrl) {
|
|
252
|
+
return {
|
|
253
|
+
lang: facts.htmlLang ? null : 'en',
|
|
254
|
+
title: facts.title ? null : titleFrom(baseUrl),
|
|
255
|
+
// Seed the description from the page <title> when present — a sensible starting
|
|
256
|
+
// point the dev should refine (flagged in notes), better than a bare placeholder.
|
|
257
|
+
description: facts.description ? null : facts.title || 'A short description of what this site is and does.',
|
|
258
|
+
structured: facts.hasJsonLd || facts.hasOg ? null : true,
|
|
259
|
+
webmcp: facts.hasWebMcp ? null : true,
|
|
260
|
+
canonical: facts.hasCanonical ? null : baseUrl || null
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function hasContentFile(paths) {
|
|
265
|
+
for (const p of paths) {
|
|
266
|
+
try {
|
|
267
|
+
const s = readFileSync(p, 'utf8')
|
|
268
|
+
if (s && s.trim().length > 20) return true
|
|
269
|
+
} catch {
|
|
270
|
+
/* ignore */
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return false
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Filesystem presence (for local mode, where the crawl can't tell us).
|
|
277
|
+
function repoPresence(framework) {
|
|
278
|
+
const pub = (n) => join(framework.publicDir, n)
|
|
279
|
+
const root = (n) => join(framework.root, n)
|
|
280
|
+
return {
|
|
281
|
+
hasLlmsTxt: hasContentFile([pub('llms.txt'), root('llms.txt')]),
|
|
282
|
+
hasRobots: existsSync(pub('robots.txt')) || existsSync(root('robots.txt')),
|
|
283
|
+
hasSitemap: existsSync(pub('sitemap.xml')) || existsSync(root('sitemap.xml'))
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function writeSpec(framework, name, content, label) {
|
|
288
|
+
const abs = join(framework.publicDir, name)
|
|
289
|
+
return { abs, rel: relative(framework.root, abs) || name, content, label, exists: existsSync(abs) }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Build the full fix plan: file writes, the entry-file injection, projected
|
|
293
|
+
// before/after scores, and honest notes about what still needs a human.
|
|
294
|
+
// `baseUrl` (truthy when scanned via --url) switches "after" to a projection
|
|
295
|
+
// and unlocks the canonical injection.
|
|
296
|
+
export function buildFixPlan({ framework, analysis, baseUrl = null }) {
|
|
297
|
+
const fromUrl = Boolean(baseUrl)
|
|
298
|
+
|
|
299
|
+
const beforeFacts = { ...analysis }
|
|
300
|
+
if (!fromUrl) {
|
|
301
|
+
const pres = repoPresence(framework)
|
|
302
|
+
beforeFacts.hasLlmsTxt = pres.hasLlmsTxt
|
|
303
|
+
beforeFacts.hasRobots = pres.hasRobots
|
|
304
|
+
beforeFacts.hasSitemap = pres.hasSitemap
|
|
305
|
+
}
|
|
306
|
+
const before = score(beforeFacts)
|
|
307
|
+
|
|
308
|
+
const gaps = computeGaps(beforeFacts, baseUrl)
|
|
309
|
+
const auto = autoApplicable(framework)
|
|
310
|
+
const eligible = {}
|
|
311
|
+
for (const k of ['lang', 'title', 'description', 'structured', 'webmcp', 'canonical']) eligible[k] = auto.has(k) && Boolean(gaps[k])
|
|
312
|
+
|
|
313
|
+
// Seed the structured-data snippet with the same description we inject as <meta>,
|
|
314
|
+
// and the live origin when we have one, so the JSON-LD isn't full of placeholders.
|
|
315
|
+
const structuredHtml = buildStructuredData({
|
|
316
|
+
...analysis,
|
|
317
|
+
base: analysis.base || baseUrl || '',
|
|
318
|
+
description: analysis.description || (typeof gaps.description === 'string' ? gaps.description : '')
|
|
319
|
+
})
|
|
320
|
+
const writes = [
|
|
321
|
+
writeSpec(framework, 'llms.txt', buildLlmsTxt(analysis), 'token-cheap site map for agents'),
|
|
322
|
+
writeSpec(framework, 'webmcp.tools.js', buildWebMcp(analysis), 'WebMCP tool scaffold (document.modelContext.registerTool)')
|
|
323
|
+
]
|
|
324
|
+
|
|
325
|
+
const notes = []
|
|
326
|
+
const injectStructured = eligible.structured && framework.entryKind === 'html'
|
|
327
|
+
// When we can't inject structured data into the entry (JSX/no-entry), ship it as
|
|
328
|
+
// a snippet file the dev pastes in — with router-accurate guidance.
|
|
329
|
+
if (gaps.structured && !injectStructured) {
|
|
330
|
+
writes.push(writeSpec(framework, 'structured-data.html', structuredHtml, 'JSON-LD + OpenGraph snippet for your <head>'))
|
|
331
|
+
if (framework.id === 'next-app') {
|
|
332
|
+
notes.push('Add the OpenGraph fields via your `metadata` export and render the JSON-LD as a `<script type="application/ld+json">` in the App Router layout.')
|
|
333
|
+
} else if (framework.id === 'next-pages') {
|
|
334
|
+
notes.push('Add the JSON-LD + OpenGraph from `structured-data.html` via `<Head>` from `next/document` in `_document` (or `next/head` per page).')
|
|
335
|
+
} else {
|
|
336
|
+
notes.push('Add the JSON-LD + OpenGraph from `structured-data.html` to your `<head>`.')
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
let injection = null
|
|
341
|
+
if (framework.entry && framework.entryKind) {
|
|
342
|
+
const src = readFileSync(framework.entry, 'utf8')
|
|
343
|
+
const injGaps = {
|
|
344
|
+
lang: eligible.lang ? gaps.lang : null,
|
|
345
|
+
title: eligible.title ? gaps.title : null,
|
|
346
|
+
description: eligible.description ? gaps.description : null,
|
|
347
|
+
canonical: eligible.canonical ? gaps.canonical : null,
|
|
348
|
+
structured: injectStructured ? structuredHtml : null,
|
|
349
|
+
webmcp: eligible.webmcp ? true : null
|
|
350
|
+
}
|
|
351
|
+
const res = framework.entryKind === 'html' ? injectHtml(src, injGaps) : injectLayout(src, injGaps)
|
|
352
|
+
injection = {
|
|
353
|
+
entryAbs: framework.entry,
|
|
354
|
+
entryRel: relative(framework.root, framework.entry),
|
|
355
|
+
kind: framework.entryKind,
|
|
356
|
+
before: src,
|
|
357
|
+
after: res.html,
|
|
358
|
+
applied: res.applied
|
|
359
|
+
}
|
|
360
|
+
if (res.manual && res.manual.length) notes.push(...res.manual)
|
|
361
|
+
if (injection.applied.includes('meta-description')) notes.push('Review the injected `<meta name="description">` — it was seeded from your `<title>`.')
|
|
362
|
+
} else if (gaps.webmcp) {
|
|
363
|
+
notes.push(`No entry HTML/layout detected — add \`${MCP_SCRIPT}\` to your site's \`<head>\`/\`<body>\` so the WebMCP tools load.`)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Project the "after" score from what injection ACTUALLY applied (not from what
|
|
367
|
+
// was merely eligible) — a no-op/failed splice must never inflate the score.
|
|
368
|
+
// llms.txt is unconditional because applyFix always writes the file.
|
|
369
|
+
const afterFacts = { ...beforeFacts, hasLlmsTxt: true }
|
|
370
|
+
const did = new Set(injection ? injection.applied : [])
|
|
371
|
+
if (did.has('webmcp-script')) afterFacts.hasWebMcp = true
|
|
372
|
+
if (did.has('html-lang')) afterFacts.htmlLang = gaps.lang
|
|
373
|
+
if (did.has('meta-description')) afterFacts.description = gaps.description
|
|
374
|
+
if (did.has('title')) afterFacts.title = gaps.title
|
|
375
|
+
if (did.has('canonical')) afterFacts.hasCanonical = true
|
|
376
|
+
if (did.has('structured-data')) {
|
|
377
|
+
afterFacts.hasJsonLd = true
|
|
378
|
+
afterFacts.hasOg = true
|
|
379
|
+
}
|
|
380
|
+
const after = score(afterFacts)
|
|
381
|
+
after.projected = fromUrl
|
|
382
|
+
|
|
383
|
+
if (!afterFacts.hasRobots || !afterFacts.hasSitemap) notes.push('`fix` does not generate robots.txt + sitemap.xml yet — add them for the discovery check (+8).')
|
|
384
|
+
if (!baseUrl && (injectStructured || gaps.canonical) && framework.entryKind === 'html') {
|
|
385
|
+
notes.push('Re-run with `--url https://yoursite.com` to fill real URLs in the structured data and inject `<link rel="canonical">` (+4).')
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return { framework, before, after, gaps, eligible, writes, injection, notes }
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Perform the plan's writes + entry edit. Returns the absolute paths it changed.
|
|
392
|
+
export async function applyFix(plan) {
|
|
393
|
+
const changed = []
|
|
394
|
+
await mkdir(plan.framework.publicDir, { recursive: true })
|
|
395
|
+
for (const w of plan.writes) {
|
|
396
|
+
await writeFile(w.abs, w.content)
|
|
397
|
+
changed.push(w.abs)
|
|
398
|
+
}
|
|
399
|
+
if (plan.injection && plan.injection.after !== plan.injection.before) {
|
|
400
|
+
await writeFile(plan.injection.entryAbs, plan.injection.after)
|
|
401
|
+
changed.push(plan.injection.entryAbs)
|
|
402
|
+
}
|
|
403
|
+
return changed
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ---------------------------------------------------------------------------
|
|
407
|
+
// Git / GitHub PR
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
function git(args, cwd) {
|
|
411
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim()
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Returns { root, branch, detached, hasOrigin, defaultBranch } for the git repo
|
|
415
|
+
// containing `cwd`, or null. defaultBranch is resolved from origin/HEAD, then a
|
|
416
|
+
// local main/master — so a PR targets the real default, not whatever branch the
|
|
417
|
+
// user happens to be on.
|
|
418
|
+
export function gitInfo(cwd) {
|
|
419
|
+
try {
|
|
420
|
+
const root = git(['rev-parse', '--show-toplevel'], cwd)
|
|
421
|
+
const branch = git(['rev-parse', '--abbrev-ref', 'HEAD'], root)
|
|
422
|
+
const detached = branch === 'HEAD'
|
|
423
|
+
let hasOrigin = true
|
|
424
|
+
try {
|
|
425
|
+
git(['remote', 'get-url', 'origin'], root)
|
|
426
|
+
} catch {
|
|
427
|
+
hasOrigin = false
|
|
428
|
+
}
|
|
429
|
+
let defaultBranch = null
|
|
430
|
+
if (hasOrigin) {
|
|
431
|
+
try {
|
|
432
|
+
defaultBranch = git(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], root).replace(/^origin\//, '')
|
|
433
|
+
} catch {
|
|
434
|
+
/* origin/HEAD not set locally */
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
if (!defaultBranch) {
|
|
438
|
+
for (const cand of ['main', 'master']) {
|
|
439
|
+
try {
|
|
440
|
+
git(['rev-parse', '--verify', '--quiet', `refs/heads/${cand}`], root)
|
|
441
|
+
defaultBranch = cand
|
|
442
|
+
break
|
|
443
|
+
} catch {
|
|
444
|
+
/* not present */
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return { root, branch, detached, hasOrigin, defaultBranch }
|
|
449
|
+
} catch {
|
|
450
|
+
return null
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// gh installed AND authenticated? (`gh auth status` exits non-zero for both a
|
|
455
|
+
// missing binary — ENOENT — and an unauthenticated CLI, so one check covers both.)
|
|
456
|
+
export function ghReady(cwd) {
|
|
457
|
+
try {
|
|
458
|
+
execFileSync('gh', ['auth', 'status'], { cwd, stdio: 'ignore' })
|
|
459
|
+
return true
|
|
460
|
+
} catch {
|
|
461
|
+
return false
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function branchExists(root, branch) {
|
|
466
|
+
try {
|
|
467
|
+
git(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], root)
|
|
468
|
+
return true
|
|
469
|
+
} catch {
|
|
470
|
+
return false
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function nothingStaged(root) {
|
|
475
|
+
// `git diff --cached --quiet` exits 0 when nothing is staged, non-zero otherwise.
|
|
476
|
+
try {
|
|
477
|
+
execFileSync('git', ['diff', '--cached', '--quiet'], { cwd: root, stdio: 'ignore' })
|
|
478
|
+
return true
|
|
479
|
+
} catch {
|
|
480
|
+
return false
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function stageError(message, stage, branch) {
|
|
485
|
+
const e = new Error(message)
|
|
486
|
+
e.agentReadyStage = stage
|
|
487
|
+
e.branch = branch
|
|
488
|
+
return e
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Branch, commit the given (repo-root-relative) files, push, and open a PR via gh.
|
|
492
|
+
// With dryRun, returns the exact command list instead of executing anything.
|
|
493
|
+
// Returns { url } on success, { noChanges: true } when the tree already matches.
|
|
494
|
+
// On failure, throws with err.agentReadyStage ∈ {preflight, start, committed, pushed}
|
|
495
|
+
// so the caller can describe the repo's true state. Always returns the user to the
|
|
496
|
+
// branch they started on; cleans up the temp body file.
|
|
497
|
+
export function openPullRequest({ root, branch, base, files, title, body, dryRun = false }) {
|
|
498
|
+
const bodyFile = join(tmpdir(), `agent-ready-pr-${Date.now()}.md`)
|
|
499
|
+
const commands = [
|
|
500
|
+
`git checkout -b ${branch}`,
|
|
501
|
+
`git add ${files.join(' ')}`,
|
|
502
|
+
`git commit -m "${title}"`,
|
|
503
|
+
`git push -u origin ${branch}`,
|
|
504
|
+
`gh pr create --base ${base} --head ${branch} --title "${title}" --body-file ${bodyFile}`
|
|
505
|
+
]
|
|
506
|
+
if (dryRun) return { dryRun: true, branch, base, bodyFile, commands }
|
|
507
|
+
|
|
508
|
+
if (branchExists(root, branch)) {
|
|
509
|
+
throw stageError(`branch "${branch}" already exists — pass a different --branch <name> or delete it (git branch -D ${branch}).`, 'preflight', branch)
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
let startRef = null
|
|
513
|
+
try {
|
|
514
|
+
startRef = git(['rev-parse', '--abbrev-ref', 'HEAD'], root)
|
|
515
|
+
} catch {
|
|
516
|
+
/* leave null */
|
|
517
|
+
}
|
|
518
|
+
writeFileSync(bodyFile, body, { mode: 0o600 })
|
|
519
|
+
|
|
520
|
+
let stage = 'start'
|
|
521
|
+
let onBranch = false
|
|
522
|
+
try {
|
|
523
|
+
git(['checkout', '-b', branch], root)
|
|
524
|
+
onBranch = true
|
|
525
|
+
git(['add', ...files], root)
|
|
526
|
+
if (nothingStaged(root)) {
|
|
527
|
+
// Identical re-run / already agent-ready — undo the empty branch and bail cleanly.
|
|
528
|
+
if (startRef && startRef !== 'HEAD') {
|
|
529
|
+
try {
|
|
530
|
+
git(['checkout', startRef], root)
|
|
531
|
+
git(['branch', '-D', branch], root)
|
|
532
|
+
onBranch = false
|
|
533
|
+
} catch {
|
|
534
|
+
/* if we can't return, leave the branch rather than throw */
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
return { noChanges: true, branch }
|
|
538
|
+
}
|
|
539
|
+
git(['commit', '-m', title], root)
|
|
540
|
+
stage = 'committed'
|
|
541
|
+
git(['push', '-u', 'origin', branch], root)
|
|
542
|
+
stage = 'pushed'
|
|
543
|
+
const url = execFileSync('gh', ['pr', 'create', '--base', base, '--head', branch, '--title', title, '--body-file', bodyFile], { cwd: root, encoding: 'utf8' }).trim()
|
|
544
|
+
return { url, branch, base }
|
|
545
|
+
} catch (err) {
|
|
546
|
+
if (!err.agentReadyStage) err.agentReadyStage = stage
|
|
547
|
+
err.branch = branch
|
|
548
|
+
throw err
|
|
549
|
+
} finally {
|
|
550
|
+
try {
|
|
551
|
+
unlinkSync(bodyFile)
|
|
552
|
+
} catch {
|
|
553
|
+
/* ignore */
|
|
554
|
+
}
|
|
555
|
+
// Return to the branch the user started on (the fix branch stays for the PR).
|
|
556
|
+
if (onBranch && startRef && startRef !== 'HEAD') {
|
|
557
|
+
try {
|
|
558
|
+
if (git(['rev-parse', '--abbrev-ref', 'HEAD'], root) === branch) git(['checkout', startRef], root)
|
|
559
|
+
} catch {
|
|
560
|
+
/* ignore */
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
package/lib/github.mjs
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Minimal GitHub REST client (fetch-based, no deps) for the Action's PR-comment
|
|
2
|
+
// mode. Reads the standard env GitHub Actions sets for every step.
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
|
|
5
|
+
// Returns { token, repo, apiUrl, prNumber } when running inside a PR job with a
|
|
6
|
+
// token, else null (so callers can no-op cleanly outside that context).
|
|
7
|
+
export function ghContext(env = process.env) {
|
|
8
|
+
const token = env.GITHUB_TOKEN || env.GH_TOKEN
|
|
9
|
+
const repo = env.GITHUB_REPOSITORY // "owner/name"
|
|
10
|
+
const apiUrl = env.GITHUB_API_URL || 'https://api.github.com'
|
|
11
|
+
let prNumber = null
|
|
12
|
+
if (env.GITHUB_EVENT_PATH) {
|
|
13
|
+
try {
|
|
14
|
+
const ev = JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, 'utf8'))
|
|
15
|
+
prNumber = ev?.pull_request?.number ?? ev?.issue?.number ?? null
|
|
16
|
+
} catch {
|
|
17
|
+
/* not a PR event payload */
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
if (!token || !repo || !prNumber) return null
|
|
21
|
+
return { token, repo, apiUrl, prNumber }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function api(ctx, path, opts = {}) {
|
|
25
|
+
const res = await fetch(`${ctx.apiUrl}${path}`, {
|
|
26
|
+
...opts,
|
|
27
|
+
headers: {
|
|
28
|
+
authorization: `Bearer ${ctx.token}`,
|
|
29
|
+
accept: 'application/vnd.github+json',
|
|
30
|
+
'user-agent': 'agent-ready',
|
|
31
|
+
'x-github-api-version': '2022-11-28',
|
|
32
|
+
...(opts.body ? { 'content-type': 'application/json' } : {}),
|
|
33
|
+
...(opts.headers || {})
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
if (!res.ok) throw new Error(`GitHub API ${res.status} ${res.statusText} (${path})`)
|
|
37
|
+
return res.status === 204 ? null : res.json()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Create — or update, if one already exists — a single comment identified by a
|
|
41
|
+
// hidden marker, so re-runs refresh one comment instead of spamming the PR.
|
|
42
|
+
export async function upsertPrComment(ctx, body, marker) {
|
|
43
|
+
const existing = await api(ctx, `/repos/${ctx.repo}/issues/${ctx.prNumber}/comments?per_page=100`).catch(() => [])
|
|
44
|
+
const mine = Array.isArray(existing) ? existing.find((c) => typeof c.body === 'string' && c.body.includes(marker)) : null
|
|
45
|
+
if (mine) {
|
|
46
|
+
await api(ctx, `/repos/${ctx.repo}/issues/comments/${mine.id}`, { method: 'PATCH', body: JSON.stringify({ body }) })
|
|
47
|
+
return { updated: true, id: mine.id }
|
|
48
|
+
}
|
|
49
|
+
const created = await api(ctx, `/repos/${ctx.repo}/issues/${ctx.prNumber}/comments`, { method: 'POST', body: JSON.stringify({ body }) })
|
|
50
|
+
return { updated: false, id: created?.id }
|
|
51
|
+
}
|
package/lib/report.mjs
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Markdown report builders shared by the PR body (`fix --pr`) and the PR comment
|
|
2
|
+
// (GitHub Action `--comment` mode). Kept dependency-free.
|
|
3
|
+
import { badgeUrl } from './core.mjs'
|
|
4
|
+
|
|
5
|
+
export const COMMENT_MARKER = '<!-- agent-ready-report -->'
|
|
6
|
+
|
|
7
|
+
function cell(s) {
|
|
8
|
+
return String(s).replace(/\|/g, '\\|')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function checksTable(checks) {
|
|
12
|
+
const rows = checks.map((c) => `| ${c.pass ? '✅' : '❌'} | ${cell(c.label)} | ${c.pass ? '+' + c.weight : 0} / ${c.weight} |`)
|
|
13
|
+
return ['| | Check | Points |', '|:--:|---|---:|', ...rows].join('\n')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function scoreHeadline(total) {
|
|
17
|
+
return `**Agent-Readiness: ${total}/100** })`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// The body for the pull request opened by `agent-ready fix --pr`.
|
|
21
|
+
export function prBody({ before, after, framework, writes, injection, notes }) {
|
|
22
|
+
const projected = Boolean(after.projected)
|
|
23
|
+
const L = []
|
|
24
|
+
L.push('## 🤖 agent-ready — make this site agent-ready', '')
|
|
25
|
+
L.push(
|
|
26
|
+
'Generated by [agent-ready](https://github.com/VeldinS/agent-ready). It adds the artifacts the agentic web now expects — an [`llms.txt`](https://llmstxt.org/) and a [WebMCP](https://webmachinelearning.github.io/webmcp/) tool scaffold — plus the missing metadata, so AI agents can *understand and act on* this site instead of blindly scraping it.',
|
|
27
|
+
''
|
|
28
|
+
)
|
|
29
|
+
L.push(`**Detected framework:** ${framework.label}`, '')
|
|
30
|
+
L.push('### Score', '')
|
|
31
|
+
L.push(`| | Before | After${projected ? ' (projected)' : ''} |`, '|---|:--:|:--:|', `| Agent-Readiness | **${before.total}/100** | **${after.total}/100** |`, '')
|
|
32
|
+
L.push('<details><summary>Per-check breakdown (after)</summary>', '', checksTable(after.checks), '</details>', '')
|
|
33
|
+
L.push('### What changed', '')
|
|
34
|
+
for (const w of writes) L.push(`- \`${w.rel}\`${w.exists ? ' (updated)' : ''} — ${w.label}`)
|
|
35
|
+
if (injection && injection.applied.length) L.push(`- \`${injection.entryRel}\` — injected: ${injection.applied.join(', ')}`)
|
|
36
|
+
L.push('')
|
|
37
|
+
if (notes && notes.length) {
|
|
38
|
+
L.push('### Notes / remaining manual steps', '')
|
|
39
|
+
for (const n of notes) L.push(`- ${n}`)
|
|
40
|
+
L.push('')
|
|
41
|
+
}
|
|
42
|
+
if (projected) L.push('> "After (projected)" is the score once this is deployed; re-scan the live URL to confirm.', '')
|
|
43
|
+
L.push('---', '<sub>🤖 Opened by <a href="https://github.com/VeldinS/agent-ready">agent-ready</a> — review before merging.</sub>')
|
|
44
|
+
return L.join('\n')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The sticky comment posted on a PR by the GitHub Action.
|
|
48
|
+
export function commentBody({ url, total, checks, counts }) {
|
|
49
|
+
const L = []
|
|
50
|
+
L.push(COMMENT_MARKER)
|
|
51
|
+
L.push(`## 🤖 agent-ready — \`${url}\``, '')
|
|
52
|
+
L.push(scoreHeadline(total), '')
|
|
53
|
+
L.push(checksTable(checks), '')
|
|
54
|
+
if (counts) L.push(`<sub>pages: ${counts.pages} • forms: ${counts.forms} • inferred actions: ${counts.actions}</sub>`, '')
|
|
55
|
+
L.push('<sub>Generate the fixes locally: <code>npx agent-readiness fix .</code> · <a href="https://github.com/VeldinS/agent-ready">agent-ready</a></sub>')
|
|
56
|
+
return L.join('\n')
|
|
57
|
+
}
|