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/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, '&amp;')
79
+ .replace(/</g, '&lt;')
80
+ .replace(/>/g, '&gt;')
81
+ .replace(/"/g, '&quot;')
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** &nbsp; ![score](${badgeUrl(total)})`
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
+ }