ghost-qa 1.3.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +88 -32
  2. package/SKILL.md +83 -80
  3. package/dist/bin/ghost-qa.d.ts +11 -3
  4. package/dist/bin/ghost-qa.d.ts.map +1 -1
  5. package/dist/bin/ghost-qa.js +498 -405
  6. package/dist/bin/ghost-qa.js.map +1 -1
  7. package/dist/src/engine/audit-runner.d.ts +6 -5
  8. package/dist/src/engine/audit-runner.d.ts.map +1 -1
  9. package/dist/src/engine/audit-runner.js +117 -60
  10. package/dist/src/engine/audit-runner.js.map +1 -1
  11. package/dist/src/engine/browser-session.d.ts +3 -3
  12. package/dist/src/engine/browser-session.d.ts.map +1 -1
  13. package/dist/src/engine/browser-session.js +18 -7
  14. package/dist/src/engine/browser-session.js.map +1 -1
  15. package/dist/src/engine/compare.d.ts +37 -0
  16. package/dist/src/engine/compare.d.ts.map +1 -0
  17. package/dist/src/engine/compare.js +63 -0
  18. package/dist/src/engine/compare.js.map +1 -0
  19. package/dist/src/engine/health-score.d.ts +14 -0
  20. package/dist/src/engine/health-score.d.ts.map +1 -0
  21. package/dist/src/engine/health-score.js +74 -0
  22. package/dist/src/engine/health-score.js.map +1 -0
  23. package/dist/src/engine/version.d.ts +14 -0
  24. package/dist/src/engine/version.d.ts.map +1 -0
  25. package/dist/src/engine/version.js +43 -0
  26. package/dist/src/engine/version.js.map +1 -0
  27. package/dist/src/index.d.ts +6 -0
  28. package/dist/src/index.d.ts.map +1 -1
  29. package/dist/src/index.js +6 -0
  30. package/dist/src/index.js.map +1 -1
  31. package/dist/src/mcp/server.d.ts +25 -5
  32. package/dist/src/mcp/server.d.ts.map +1 -1
  33. package/dist/src/mcp/server.js +45 -7
  34. package/dist/src/mcp/server.js.map +1 -1
  35. package/dist/src/reporter/agent-diagnostic.d.ts +6 -0
  36. package/dist/src/reporter/agent-diagnostic.d.ts.map +1 -1
  37. package/dist/src/reporter/agent-diagnostic.js +53 -3
  38. package/dist/src/reporter/agent-diagnostic.js.map +1 -1
  39. package/dist/src/reporter/html-report.d.ts.map +1 -1
  40. package/dist/src/reporter/html-report.js +79 -4
  41. package/dist/src/reporter/html-report.js.map +1 -1
  42. package/dist/src/reporter/terminal-summary.d.ts.map +1 -1
  43. package/dist/src/reporter/terminal-summary.js +6 -0
  44. package/dist/src/reporter/terminal-summary.js.map +1 -1
  45. package/dist/src/traps/a11y-trap.d.ts +23 -0
  46. package/dist/src/traps/a11y-trap.d.ts.map +1 -1
  47. package/dist/src/traps/a11y-trap.js +185 -0
  48. package/dist/src/traps/a11y-trap.js.map +1 -1
  49. package/dist/src/traps/console-trap.d.ts +3 -1
  50. package/dist/src/traps/console-trap.d.ts.map +1 -1
  51. package/dist/src/traps/console-trap.js +41 -0
  52. package/dist/src/traps/console-trap.js.map +1 -1
  53. package/dist/src/traps/layout-trap.d.ts +31 -0
  54. package/dist/src/traps/layout-trap.d.ts.map +1 -1
  55. package/dist/src/traps/layout-trap.js +173 -0
  56. package/dist/src/traps/layout-trap.js.map +1 -1
  57. package/dist/src/traps/links-trap.d.ts +34 -0
  58. package/dist/src/traps/links-trap.d.ts.map +1 -0
  59. package/dist/src/traps/links-trap.js +136 -0
  60. package/dist/src/traps/links-trap.js.map +1 -0
  61. package/dist/src/traps/network-trap.d.ts +4 -1
  62. package/dist/src/traps/network-trap.d.ts.map +1 -1
  63. package/dist/src/traps/network-trap.js +29 -2
  64. package/dist/src/traps/network-trap.js.map +1 -1
  65. package/dist/src/traps/perf-trap.d.ts +18 -0
  66. package/dist/src/traps/perf-trap.d.ts.map +1 -1
  67. package/dist/src/traps/perf-trap.js +148 -8
  68. package/dist/src/traps/perf-trap.js.map +1 -1
  69. package/dist/src/traps/seo-trap.d.ts +30 -0
  70. package/dist/src/traps/seo-trap.d.ts.map +1 -0
  71. package/dist/src/traps/seo-trap.js +98 -0
  72. package/dist/src/traps/seo-trap.js.map +1 -0
  73. package/dist/src/traps/theme-trap.d.ts +32 -0
  74. package/dist/src/traps/theme-trap.d.ts.map +1 -0
  75. package/dist/src/traps/theme-trap.js +109 -0
  76. package/dist/src/traps/theme-trap.js.map +1 -0
  77. package/dist/src/types.d.ts +55 -3
  78. package/dist/src/types.d.ts.map +1 -1
  79. package/dist/src/types.js +30 -2
  80. package/dist/src/types.js.map +1 -1
  81. package/package.json +99 -91
@@ -1,406 +1,499 @@
1
- #!/usr/bin/env node
2
- import { existsSync, realpathSync } from 'node:fs';
3
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
- import { dirname, join, resolve } from 'node:path';
5
- import { fileURLToPath, pathToFileURL } from 'node:url';
6
- import { runGhostQAAudit, GHOST_QA_VERSION } from '../src/engine/audit-runner.js';
7
- import { ManagedDevServer } from '../src/engine/server-lifecycle.js';
8
- import { mcpServerConfig, runDoctorCheck, startMcpServer } from '../src/mcp/server.js';
9
- import { formatAgentDiagnostic, formatDiagnostic } from '../src/reporter/agent-diagnostic.js';
10
- import { formatHtmlReport } from '../src/reporter/html-report.js';
11
- import { formatTerminalSummary } from '../src/reporter/terminal-summary.js';
12
- import { DEFAULT_OPTIONS } from '../src/types.js';
13
- export const VERSION = GHOST_QA_VERSION;
14
- const PROTOCOL_START = '<!-- ghost-qa:start -->';
15
- const PROTOCOL_END = '<!-- ghost-qa:end -->';
16
- export const AGENT_PROTOCOL = `${PROTOCOL_START}
17
- ## šŸ‘» ghost-qa — Browser Verification Protocol (MANDATORY)
18
-
19
- Before reporting any task that touches frontend code as complete, you MUST verify it in a real browser:
20
-
21
- 1. Ensure the app is running locally (e.g. \`npm run dev\`).
22
- 2. Run: \`npx github:avivgerz-hue/ghost-qa test <local-url>\` (e.g. \`npx github:avivgerz-hue/ghost-qa test http://localhost:3000\`) — or call the \`ghost_qa_audit_url\` MCP tool if your host exposes it. Add \`--server-cmd "npm run dev"\` to let ghost-qa manage the dev server itself.
23
- 3. If ghost-qa reports ANY critical or major issue (hydration errors, console errors, 4xx/5xx responses, mobile overflow, accessibility or performance failures, broken interactions), fix the root cause in source and re-run.
24
- 4. Repeat until ghost-qa prints **PASS**. Only then may you tell the user the task is done.
25
-
26
- Never declare a UI task finished based only on code review, type-checking, or unit tests. The browser is the source of truth.
27
- ${PROTOCOL_END}`;
28
- const FALLBACK_SKILL = `# ghost-qa — Verify in Browser Before Done
29
-
30
- You are a coding agent. Never report a frontend task complete without running it in a real browser — the browser is the source of truth, not your mental model of the DOM.
31
-
32
- ## The Self-Healing Loop
33
-
34
- 1. Run the audit against the final code:
35
- - Server already running: \`npx github:avivgerz-hue/ghost-qa test <local-url>\`
36
- - Otherwise: \`npx github:avivgerz-hue/ghost-qa test <local-url> --server-cmd "npm run dev"\` (ghost-qa spawns, awaits and tears down the server)
37
- - Or call the \`ghost_qa_audit_url\` MCP tool if your host exposes it (identical engine).
38
- It crawls routes on Desktop 1440x900 + Mobile 390x844 and runs the 5-Trap Engine: console/hydration, network, layout, accessibility, performance — plus safe interaction testing (never destructive controls).
39
- 2. Read the Self-Healing Bug Report. Fix every critical/major issue by \`#id\`, at the root cause in source — never silence the checker, delete failing UI, or add suppressHydrationWarning.
40
- 3. Re-run the identical command until it prints \`GHOST-QA PASS\`. If stuck on the same \`#id\` after 5 loops, report that honestly instead of declaring success.
41
- 4. Only then report completion, naming the URL verified, routes crawled, interactions tested, and issues fixed.
42
-
43
- Useful flags: \`--max-depth\`, \`--max-routes\`, \`--viewport mobile\`, \`--json\`, \`--report <file>\`, \`--html <file>\`, \`--fail-on minor\`.
44
- If Chromium is missing: \`npx playwright install chromium\`, then \`npx github:avivgerz-hue/ghost-qa doctor\`.
45
- Full skill: https://github.com/avivgerz-hue/ghost-qa/blob/main/SKILL.md
46
- `;
47
- const HELP = `
48
- šŸ‘» ghost-qa v${VERSION} — autonomous headless browser QA for coding agents
49
-
50
- Usage:
51
- ghost-qa test <url> [options] Crawl a running app and trap browser failures
52
- ghost-qa init [--force] Install the agent skill, protocol + MCP config
53
- ghost-qa mcp Run the native MCP server (JSON-RPC over stdio)
54
- ghost-qa doctor Check Node, Playwright and Chromium setup
55
- ghost-qa --help | --version
56
-
57
- test options:
58
- --max-depth <n> Link-following depth from the start URL (default ${DEFAULT_OPTIONS.maxDepth})
59
- --max-routes <n> Maximum routes to visit (default ${DEFAULT_OPTIONS.maxRoutes})
60
- --timeout <ms> Per-navigation timeout (default ${DEFAULT_OPTIONS.timeoutMs})
61
- --viewport <name> both | desktop | mobile (default both)
62
- --no-interactions Only load pages; do not click/fill anything
63
- --headed Show the browser window (debugging)
64
- --fail-on <severity> Fail on critical | major | minor (default ${DEFAULT_OPTIONS.failOn})
65
- --server-cmd <cmd> Spawn this dev-server command first, wait for the URL,
66
- run the audit, then terminate the server tree
67
- --server-timeout <ms> How long to wait for --server-cmd to respond (default 30000)
68
- --json Print the machine-readable JSON report to stdout
69
- --report <file> Write the diagnostic to a file (.md or .json by extension)
70
- --html <file> Write the visual HTML evidence dashboard to a file
71
- --quiet Suppress the terminal summary (report/exit code only)
72
-
73
- Examples:
74
- npx github:avivgerz-hue/ghost-qa test http://localhost:3000
75
- npx github:avivgerz-hue/ghost-qa test http://localhost:3000 --server-cmd "npm run dev"
76
- npx github:avivgerz-hue/ghost-qa test http://localhost:5173 --max-depth 3 --html ghost-qa-report.html
77
- npx github:avivgerz-hue/ghost-qa test http://localhost:3000 --json > report.json
78
- npx github:avivgerz-hue/ghost-qa init
79
- npx github:avivgerz-hue/ghost-qa mcp
80
- `;
81
- function parsePositiveInt(value, flag) {
82
- const parsed = Number.parseInt(value ?? '', 10);
83
- if (!Number.isFinite(parsed) || parsed <= 0) {
84
- throw new Error(`${flag} expects a positive integer, got "${value ?? ''}"`);
85
- }
86
- return parsed;
87
- }
88
- function parseNonNegativeInt(value, flag) {
89
- const parsed = Number.parseInt(value ?? '', 10);
90
- if (!Number.isFinite(parsed) || parsed < 0) {
91
- throw new Error(`${flag} expects a non-negative integer, got "${value ?? ''}"`);
92
- }
93
- return parsed;
94
- }
95
- export function parseTestArgs(args) {
96
- const options = { ...DEFAULT_OPTIONS, viewports: [...DEFAULT_OPTIONS.viewports] };
97
- let target = '';
98
- let json = false;
99
- let quiet = false;
100
- let reportFile;
101
- let htmlFile;
102
- let serverCmd;
103
- let serverTimeoutMs = 30_000;
104
- for (let i = 0; i < args.length; i++) {
105
- const arg = args[i];
106
- switch (arg) {
107
- case '--max-depth':
108
- options.maxDepth = parseNonNegativeInt(args[++i], arg);
109
- break;
110
- case '--max-routes':
111
- options.maxRoutes = parsePositiveInt(args[++i], arg);
112
- break;
113
- case '--timeout':
114
- options.timeoutMs = parsePositiveInt(args[++i], arg);
115
- break;
116
- case '--viewport': {
117
- const value = args[++i];
118
- if (value === 'desktop' || value === 'mobile')
119
- options.viewports = [value];
120
- else if (value === 'both')
121
- options.viewports = ['desktop', 'mobile'];
122
- else
123
- throw new Error(`--viewport expects both|desktop|mobile, got "${value ?? ''}"`);
124
- break;
125
- }
126
- case '--no-interactions':
127
- options.interactions = false;
128
- break;
129
- case '--headed':
130
- options.headless = false;
131
- break;
132
- case '--fail-on': {
133
- const value = args[++i];
134
- if (value === 'critical' || value === 'major' || value === 'minor')
135
- options.failOn = value;
136
- else
137
- throw new Error(`--fail-on expects critical|major|minor, got "${value ?? ''}"`);
138
- break;
139
- }
140
- case '--json':
141
- json = true;
142
- break;
143
- case '--quiet':
144
- quiet = true;
145
- break;
146
- case '--report':
147
- reportFile = args[++i];
148
- if (!reportFile)
149
- throw new Error('--report expects a file path');
150
- break;
151
- case '--html':
152
- htmlFile = args[++i];
153
- if (!htmlFile)
154
- throw new Error('--html expects a file path');
155
- break;
156
- case '--server-cmd':
157
- serverCmd = args[++i];
158
- if (!serverCmd)
159
- throw new Error('--server-cmd expects a command, e.g. --server-cmd "npm run dev"');
160
- break;
161
- case '--server-timeout':
162
- serverTimeoutMs = parsePositiveInt(args[++i], arg);
163
- break;
164
- default:
165
- if (arg.startsWith('--'))
166
- throw new Error(`Unknown flag: ${arg}`);
167
- if (!target)
168
- target = arg;
169
- else
170
- throw new Error(`Unexpected extra argument: ${arg}`);
171
- }
172
- }
173
- if (!target)
174
- throw new Error('Missing target URL. Usage: ghost-qa test <url>');
175
- return {
176
- target,
177
- options,
178
- json,
179
- quiet,
180
- serverTimeoutMs,
181
- ...(reportFile ? { reportFile } : {}),
182
- ...(htmlFile ? { htmlFile } : {}),
183
- ...(serverCmd ? { serverCmd } : {}),
184
- };
185
- }
186
- async function executeTest(parsed) {
187
- const audit = () => runGhostQAAudit(parsed.target, parsed.options);
188
- if (!parsed.serverCmd)
189
- return audit();
190
- const server = new ManagedDevServer(parsed.serverCmd);
191
- process.stderr.write(`\nšŸ‘» ghost-qa: starting dev server: ${parsed.serverCmd}\n`);
192
- server.start();
193
- try {
194
- await server.waitUntilReady(parsed.target, parsed.serverTimeoutMs);
195
- process.stderr.write(`šŸ‘» ghost-qa: server is responding at ${parsed.target}\n`);
196
- return await audit();
197
- }
198
- finally {
199
- await server.stop();
200
- process.stderr.write('šŸ‘» ghost-qa: dev server terminated.\n');
201
- }
202
- }
203
- async function loadSkillTemplate() {
204
- const moduleDir = dirname(fileURLToPath(import.meta.url));
205
- const candidates = [
206
- resolve(moduleDir, '../../SKILL.md'), // dist/bin -> package root
207
- resolve(moduleDir, '../SKILL.md'),
208
- resolve(process.cwd(), 'SKILL.md'),
209
- ];
210
- for (const candidate of candidates) {
211
- try {
212
- const content = await readFile(candidate, 'utf8');
213
- if (content.includes('ghost-qa'))
214
- return content;
215
- }
216
- catch {
217
- /* try next candidate */
218
- }
219
- }
220
- return FALLBACK_SKILL;
221
- }
222
- async function upsertProtocol(filePath) {
223
- let existing = '';
224
- try {
225
- existing = await readFile(filePath, 'utf8');
226
- }
227
- catch {
228
- await writeFile(filePath, `# Agent Instructions\n\n${AGENT_PROTOCOL}\n`, 'utf8');
229
- return 'created';
230
- }
231
- const start = existing.indexOf(PROTOCOL_START);
232
- const end = existing.indexOf(PROTOCOL_END);
233
- if (start !== -1 && end !== -1 && end > start) {
234
- const next = `${existing.slice(0, start)}${AGENT_PROTOCOL}${existing.slice(end + PROTOCOL_END.length)}`;
235
- if (next === existing)
236
- return 'unchanged';
237
- await writeFile(filePath, next, 'utf8');
238
- return 'updated';
239
- }
240
- await writeFile(filePath, `${existing.replace(/\s*$/, '')}\n\n${AGENT_PROTOCOL}\n`, 'utf8');
241
- return 'updated';
242
- }
243
- export async function installSkill(targetDir, force = false) {
244
- const results = [];
245
- const skillPath = join(targetDir, '.claude', 'skills', 'ghost-qa', 'SKILL.md');
246
- await mkdir(dirname(skillPath), { recursive: true });
247
- const skillContent = await loadSkillTemplate();
248
- const skillExisted = existsSync(skillPath);
249
- if (!skillExisted || force) {
250
- await writeFile(skillPath, skillContent.endsWith('\n') ? skillContent : `${skillContent}\n`, 'utf8');
251
- results.push({ file: '.claude/skills/ghost-qa/SKILL.md', action: skillExisted ? 'updated (--force)' : 'installed' });
252
- }
253
- else {
254
- results.push({ file: '.claude/skills/ghost-qa/SKILL.md', action: 'already exists (use --force to overwrite)' });
255
- }
256
- for (const name of ['CLAUDE.md', 'AGENTS.md', '.cursorrules']) {
257
- const action = await upsertProtocol(join(targetDir, name));
258
- results.push({ file: name, action: `protocol ${action}` });
259
- }
260
- const mcpAction = await installMcpConfig(join(targetDir, '.mcp.json'), force);
261
- results.push({ file: '.mcp.json', action: mcpAction });
262
- return results;
263
- }
264
- /**
265
- * Writes (or merges into) `.mcp.json`, preserving any MCP servers the
266
- * project already configured. The ghost-qa entry is always refreshed so
267
- * hosts invoke the published CLI: `npx -y github:avivgerz-hue/ghost-qa mcp`.
268
- */
269
- export async function installMcpConfig(mcpJsonPath, force = false) {
270
- const desired = mcpServerConfig();
271
- let existing = {};
272
- let action = 'installed';
273
- try {
274
- const raw = await readFile(mcpJsonPath, 'utf8');
275
- const parsed = JSON.parse(raw);
276
- if (parsed && typeof parsed === 'object') {
277
- existing = parsed;
278
- action = 'merged';
279
- }
280
- }
281
- catch {
282
- action = 'installed';
283
- }
284
- const servers = (existing.mcpServers ?? {});
285
- const previous = servers['ghost-qa'];
286
- if (previous && !force && JSON.stringify(previous) === JSON.stringify(desired.mcpServers['ghost-qa'])) {
287
- return 'already configured';
288
- }
289
- servers['ghost-qa'] = desired.mcpServers['ghost-qa'];
290
- existing.mcpServers = servers;
291
- await writeFile(mcpJsonPath, `${JSON.stringify(existing, null, 2)}\n`, 'utf8');
292
- return previous ? 'updated' : action;
293
- }
294
- async function runInit(args) {
295
- const force = args.includes('--force');
296
- const dirFlagIndex = args.indexOf('--dir');
297
- const targetDir = dirFlagIndex !== -1 ? resolve(args[dirFlagIndex + 1] ?? '.') : process.cwd();
298
- const results = await installSkill(targetDir, force);
299
- process.stdout.write(`\nšŸ‘» ghost-qa agent skill installed in ${targetDir}\n`);
300
- for (const result of results) {
301
- process.stdout.write(` • ${result.file} — ${result.action}\n`);
302
- }
303
- process.stdout.write('\nYour coding agent will now run `npx github:avivgerz-hue/ghost-qa test <local-url>` and fix browser failures before reporting a task complete.\n' +
304
- 'MCP hosts (Claude Code, Claude Desktop, Cursor, Codex) can also call the ghost_qa_audit_url / ghost_qa_doctor tools directly via the .mcp.json entry above.\n');
305
- return 0;
306
- }
307
- async function runDoctor() {
308
- process.stdout.write(`\nšŸ‘» ghost-qa doctor v${VERSION}\n`);
309
- const result = await runDoctorCheck();
310
- process.stdout.write(` ${result.nodeOk ? 'āœ”' : 'āœ–'} Node.js ${result.node} ${result.nodeOk ? '' : '(requires >= 20)'}\n`);
311
- if (result.chromiumFound) {
312
- process.stdout.write(` āœ” Chromium binary: ${result.chromiumPath}\n`);
313
- const smokeOk = result.smokeTest.startsWith('Chromium launched');
314
- process.stdout.write(` ${smokeOk ? 'āœ”' : 'āœ–'} Browser smoke test: ${result.smokeTest}\n`);
315
- }
316
- else {
317
- process.stdout.write(' āœ– Chromium binary not found. Run: npx playwright install chromium\n');
318
- }
319
- process.stdout.write(result.ready
320
- ? '\nāœ” ghost-qa is ready: npx github:avivgerz-hue/ghost-qa test http://localhost:3000\n'
321
- : '\nāœ– Setup incomplete — fix the items above.\n');
322
- return result.ready ? 0 : 1;
323
- }
324
- export async function main(argv = process.argv.slice(2)) {
325
- const [command, ...rest] = argv;
326
- try {
327
- switch (command) {
328
- case 'test': {
329
- const parsed = parseTestArgs(rest);
330
- const report = await executeTest(parsed);
331
- if (parsed.reportFile) {
332
- const isJson = parsed.reportFile.endsWith('.json');
333
- await writeFile(resolve(parsed.reportFile), formatDiagnostic(report, isJson ? 'json' : 'markdown'), 'utf8');
334
- }
335
- if (parsed.htmlFile) {
336
- await writeFile(resolve(parsed.htmlFile), formatHtmlReport(report), 'utf8');
337
- }
338
- if (parsed.json) {
339
- process.stdout.write(formatDiagnostic(report, 'json'));
340
- }
341
- else if (!parsed.quiet) {
342
- process.stdout.write(`${formatTerminalSummary(report)}\n`);
343
- if (!report.passed) {
344
- process.stdout.write(`\n${formatAgentDiagnostic(report)}\n`);
345
- }
346
- if (parsed.reportFile)
347
- process.stdout.write(`Report written to ${parsed.reportFile}\n`);
348
- if (parsed.htmlFile)
349
- process.stdout.write(`HTML evidence report written to ${parsed.htmlFile}\n`);
350
- }
351
- return report.passed ? 0 : 1;
352
- }
353
- case 'mcp':
354
- await startMcpServer();
355
- return 0;
356
- case 'init':
357
- return await runInit(rest);
358
- case 'doctor':
359
- return await runDoctor();
360
- case '--version':
361
- case '-v':
362
- case 'version':
363
- process.stdout.write(`ghost-qa ${VERSION}\n`);
364
- return 0;
365
- case '--help':
366
- case '-h':
367
- case 'help':
368
- case undefined:
369
- process.stdout.write(`${HELP}\n`);
370
- return 0;
371
- default:
372
- process.stderr.write(`Unknown command: ${command}\n${HELP}\n`);
373
- return 2;
374
- }
375
- }
376
- catch (error) {
377
- process.stderr.write(`\nšŸ‘» ghost-qa error: ${error instanceof Error ? error.message : String(error)}\n`);
378
- return 2;
379
- }
380
- }
381
- /**
382
- * True when this module is the process entry point. The entry path MUST be
383
- * realpath-resolved first: package managers invoke bins through a
384
- * `node_modules/.bin/ghost-qa` symlink, while `import.meta.url` always
385
- * points at the real file — comparing unresolved paths would make the CLI
386
- * silently no-op (exit 0, no output) under `npx`.
387
- */
388
- export function isMainEntry(argvEntry, moduleUrl = import.meta.url) {
389
- if (!argvEntry)
390
- return false;
391
- try {
392
- return moduleUrl === pathToFileURL(realpathSync(resolve(argvEntry))).href;
393
- }
394
- catch {
395
- return false;
396
- }
397
- }
398
- if (isMainEntry(process.argv[1])) {
399
- main().then((code) => {
400
- process.exitCode = code;
401
- }, (error) => {
402
- process.stderr.write(`ghost-qa crashed: ${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
403
- process.exitCode = 2;
404
- });
405
- }
1
+ #!/usr/bin/env node
2
+ import { existsSync, realpathSync } from 'node:fs';
3
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { fileURLToPath, pathToFileURL } from 'node:url';
6
+ import { runGhostQAAudit, GHOST_QA_VERSION } from '../src/engine/audit-runner.js';
7
+ import { ManagedDevServer } from '../src/engine/server-lifecycle.js';
8
+ import { mcpServerConfig, runDoctorCheck, startMcpServer } from '../src/mcp/server.js';
9
+ import { compareReports, formatCompareReport } from '../src/engine/compare.js';
10
+ import { formatAgentDiagnostic, formatDiagnostic, formatPatchPrompt } from '../src/reporter/agent-diagnostic.js';
11
+ import { formatHtmlReport } from '../src/reporter/html-report.js';
12
+ import { formatTerminalSummary } from '../src/reporter/terminal-summary.js';
13
+ import { DEFAULT_OPTIONS } from '../src/types.js';
14
+ export const VERSION = GHOST_QA_VERSION;
15
+ const PROTOCOL_START = '<!-- ghost-qa:start -->';
16
+ const PROTOCOL_END = '<!-- ghost-qa:end -->';
17
+ export const AGENT_PROTOCOL = `${PROTOCOL_START}
18
+ ## šŸ‘» ghost-qa — Browser Verification Protocol (MANDATORY)
19
+
20
+ Before reporting any task that touches frontend code as complete, you MUST verify it in a real browser — the closed-loop Self-Healing Browser Verification Protocol:
21
+
22
+ 1. **Readiness:** run \`ghost-qa doctor\` (or the \`ghost_qa_doctor\` MCP tool). Do not proceed on red.
23
+ 2. **Baseline audit:** ensure the app runs locally, then \`npx -y ghost-qa test <local-url> --out-dir ghost-qa-out\` (add \`--server-cmd "npm run dev"\` to let ghost-qa manage the server) — or call \`ghost_qa_audit_url\`. This runs the 8-Trap Engine on Desktop 1440Ɨ900, Tablet 768Ɨ1024 and Mobile 375Ɨ812, light + dark. Save the baseline as \`before.json\` (it is \`ghost-qa-out/report.json\`).
24
+ 3. **Root-cause mapping:** for every critical/major finding, map the exact DOM selector, bounding box, stack trace or dark-mode contrast failure back to the component file that renders it.
25
+ 4. **Surgical remediation:** fix root causes in source (hydration, overflow, tap targets, ARIA/labels, dark-mode tokens…). Never silence the checker or delete failing UI.
26
+ 5. **Delta verification:** re-run the audit to \`after.json\`, then \`npx -y ghost-qa compare before.json after.json\` (or \`ghost_qa_compare\`). Repeat until QA Health Score is **95–100 (A+)**, zero critical/major remain, and the delta shows **zero new regressions**. Only then report done, citing score, routes, viewports and the delta.
27
+
28
+ Never declare a UI task finished based only on code review, type-checking, or unit tests. The browser is the source of truth.
29
+ ${PROTOCOL_END}`;
30
+ const FALLBACK_SKILL = `# ghost-qa — Verify in Browser Before Done
31
+
32
+ You are a coding agent. Never report a frontend task complete without running it in a real browser — the browser is the source of truth, not your mental model of the DOM.
33
+
34
+ ## The Self-Healing Loop
35
+
36
+ 1. Run the audit against the final code:
37
+ - Server already running: \`npx -y ghost-qa test <local-url>\`
38
+ - Otherwise: \`npx -y ghost-qa test <local-url> --server-cmd "npm run dev"\` (ghost-qa spawns, awaits and tears down the server)
39
+ - Or call the \`ghost_qa_audit_url\` MCP tool if your host exposes it (identical engine).
40
+ It crawls routes on Desktop 1440Ɨ900 + Tablet 768Ɨ1024 + Mobile 375Ɨ812 (light + dark) and runs the 8-Trap Engine: console/framework runtime, network/asset integrity, layout/geometry/collision, WCAG a11y + keyboard focus, Core Web Vitals, link integrity, SEO/metadata, theme parity — plus safe interaction testing (never destructive controls). Save the baseline JSON as before.json.
41
+ 2. Read the Self-Healing Bug Report (GHOST-QA-REPORT.md) and PATCH-PROMPT.md. Map each selector/bounding box/stack to the component that renders it; fix every critical/major issue by \`#id\`, at the root cause in source — never silence the checker, delete failing UI, or add suppressHydrationWarning.
42
+ 3. Re-audit to after.json, then run \`npx -y ghost-qa compare before.json after.json\` (or \`ghost_qa_compare\`). Repeat until QA Health Score is 95–100 (A+), zero critical/major remain and the delta shows zero new regressions. If stuck on the same \`#id\` after 5 loops, report that honestly instead of declaring success.
43
+ 4. Only then report completion, naming the URL verified, routes crawled, interactions tested, score/grade and issues fixed.
44
+
45
+ Useful flags: \`--max-depth\`, \`--max-routes\`, \`--viewport mobile\`, \`--color-scheme dark\`, \`--screenshots\`, \`--out-dir ghost-qa-out\`, \`--json\`, \`--report <file>\`, \`--html <file>\`, \`--fail-on minor\`.
46
+ If Chromium is missing: \`npx playwright install chromium\`, then \`npx -y ghost-qa doctor\`.
47
+ Full skill: https://github.com/avivgerz-hue/ghost-qa/blob/main/SKILL.md
48
+ `;
49
+ const HELP = `
50
+ šŸ‘» ghost-qa v${VERSION} — autonomous headless browser QA for coding agents
51
+
52
+ Usage:
53
+ ghost-qa test <url> [options] Crawl a running app and trap browser failures
54
+ ghost-qa compare <before.json> <after.json> [--json]
55
+ Regression & Verification Delta Report
56
+ ghost-qa init [--force] Install the agent skill, protocol + MCP config
57
+ ghost-qa mcp Run the native MCP server (JSON-RPC over stdio)
58
+ ghost-qa doctor Check Node, Playwright and Chromium setup
59
+ ghost-qa --help | --version
60
+
61
+ test options:
62
+ --max-depth <n> Link-following depth from the start URL (default ${DEFAULT_OPTIONS.maxDepth})
63
+ --max-routes <n> Maximum routes to visit (default ${DEFAULT_OPTIONS.maxRoutes})
64
+ --timeout <ms> Per-navigation timeout (default ${DEFAULT_OPTIONS.timeoutMs})
65
+ --viewport <name> all | desktop | tablet | mobile | both (default all)
66
+ --color-scheme <name> both | light | dark (default both)
67
+ --screenshots Capture route screenshots for the HTML dashboard
68
+ --out-dir <dir> Write report.json + GHOST-QA-REPORT.md +
69
+ PATCH-PROMPT.md + report.html into this directory
70
+ --no-interactions Only load pages; do not click/fill anything
71
+ --headed Show the browser window (debugging)
72
+ --fail-on <severity> Fail on critical | major | minor (default ${DEFAULT_OPTIONS.failOn})
73
+ --server-cmd <cmd> Spawn this dev-server command first, wait for the URL,
74
+ run the audit, then terminate the server tree
75
+ --server-timeout <ms> How long to wait for --server-cmd to respond (default 30000)
76
+ --json Print the machine-readable JSON report to stdout
77
+ --report <file> Write the diagnostic to a file (.md or .json by extension)
78
+ --html <file> Write the visual HTML evidence dashboard to a file
79
+ --quiet Suppress the terminal summary (report/exit code only)
80
+
81
+ Examples:
82
+ npx -y ghost-qa test http://localhost:3000
83
+ npx -y ghost-qa test http://localhost:3000 --server-cmd "npm run dev"
84
+ npx -y ghost-qa test http://localhost:5173 --max-depth 3 --html ghost-qa-report.html
85
+ npx -y ghost-qa test http://localhost:3000 --out-dir ghost-qa-out --screenshots
86
+ npx -y ghost-qa test http://localhost:3000 --json > before.json
87
+ npx -y ghost-qa compare before.json after.json
88
+ npx -y ghost-qa init
89
+ npx -y ghost-qa mcp
90
+
91
+ No npm? Every command also works straight from GitHub, e.g.
92
+ npx -y github:avivgerz-hue/ghost-qa test http://localhost:3000
93
+ `;
94
+ function parsePositiveInt(value, flag) {
95
+ const parsed = Number.parseInt(value ?? '', 10);
96
+ if (!Number.isFinite(parsed) || parsed <= 0) {
97
+ throw new Error(`${flag} expects a positive integer, got "${value ?? ''}"`);
98
+ }
99
+ return parsed;
100
+ }
101
+ function parseNonNegativeInt(value, flag) {
102
+ const parsed = Number.parseInt(value ?? '', 10);
103
+ if (!Number.isFinite(parsed) || parsed < 0) {
104
+ throw new Error(`${flag} expects a non-negative integer, got "${value ?? ''}"`);
105
+ }
106
+ return parsed;
107
+ }
108
+ export function parseTestArgs(args) {
109
+ const options = { ...DEFAULT_OPTIONS, viewports: [...DEFAULT_OPTIONS.viewports] };
110
+ let target = '';
111
+ let json = false;
112
+ let quiet = false;
113
+ let reportFile;
114
+ let htmlFile;
115
+ let outDir;
116
+ let serverCmd;
117
+ let serverTimeoutMs = 30_000;
118
+ for (let i = 0; i < args.length; i++) {
119
+ const arg = args[i];
120
+ switch (arg) {
121
+ case '--max-depth':
122
+ options.maxDepth = parseNonNegativeInt(args[++i], arg);
123
+ break;
124
+ case '--max-routes':
125
+ options.maxRoutes = parsePositiveInt(args[++i], arg);
126
+ break;
127
+ case '--timeout':
128
+ options.timeoutMs = parsePositiveInt(args[++i], arg);
129
+ break;
130
+ case '--viewport': {
131
+ const value = args[++i];
132
+ if (value === 'desktop' || value === 'tablet' || value === 'mobile')
133
+ options.viewports = [value];
134
+ else if (value === 'both')
135
+ options.viewports = ['desktop', 'mobile'];
136
+ else if (value === 'all')
137
+ options.viewports = ['desktop', 'tablet', 'mobile'];
138
+ else
139
+ throw new Error(`--viewport expects all|both|desktop|tablet|mobile, got "${value ?? ''}"`);
140
+ break;
141
+ }
142
+ case '--color-scheme': {
143
+ const value = args[++i];
144
+ if (value === 'light' || value === 'dark')
145
+ options.colorSchemes = [value];
146
+ else if (value === 'both')
147
+ options.colorSchemes = ['light', 'dark'];
148
+ else
149
+ throw new Error(`--color-scheme expects both|light|dark, got "${value ?? ''}"`);
150
+ break;
151
+ }
152
+ case '--screenshots':
153
+ options.screenshots = true;
154
+ break;
155
+ case '--out-dir':
156
+ outDir = args[++i];
157
+ if (!outDir)
158
+ throw new Error('--out-dir expects a directory path');
159
+ break;
160
+ case '--no-interactions':
161
+ options.interactions = false;
162
+ break;
163
+ case '--headed':
164
+ options.headless = false;
165
+ break;
166
+ case '--fail-on': {
167
+ const value = args[++i];
168
+ if (value === 'critical' || value === 'major' || value === 'minor')
169
+ options.failOn = value;
170
+ else
171
+ throw new Error(`--fail-on expects critical|major|minor, got "${value ?? ''}"`);
172
+ break;
173
+ }
174
+ case '--json':
175
+ json = true;
176
+ break;
177
+ case '--quiet':
178
+ quiet = true;
179
+ break;
180
+ case '--report':
181
+ reportFile = args[++i];
182
+ if (!reportFile)
183
+ throw new Error('--report expects a file path');
184
+ break;
185
+ case '--html':
186
+ htmlFile = args[++i];
187
+ if (!htmlFile)
188
+ throw new Error('--html expects a file path');
189
+ break;
190
+ case '--server-cmd':
191
+ serverCmd = args[++i];
192
+ if (!serverCmd)
193
+ throw new Error('--server-cmd expects a command, e.g. --server-cmd "npm run dev"');
194
+ break;
195
+ case '--server-timeout':
196
+ serverTimeoutMs = parsePositiveInt(args[++i], arg);
197
+ break;
198
+ default:
199
+ if (arg.startsWith('--'))
200
+ throw new Error(`Unknown flag: ${arg}`);
201
+ if (!target)
202
+ target = arg;
203
+ else
204
+ throw new Error(`Unexpected extra argument: ${arg}`);
205
+ }
206
+ }
207
+ if (!target)
208
+ throw new Error('Missing target URL. Usage: ghost-qa test <url>');
209
+ return {
210
+ target,
211
+ options,
212
+ json,
213
+ quiet,
214
+ serverTimeoutMs,
215
+ ...(reportFile ? { reportFile } : {}),
216
+ ...(htmlFile ? { htmlFile } : {}),
217
+ ...(outDir ? { outDir } : {}),
218
+ ...(serverCmd ? { serverCmd } : {}),
219
+ };
220
+ }
221
+ export function parseCompareArgs(args) {
222
+ const files = [];
223
+ let json = false;
224
+ let outFile;
225
+ for (let i = 0; i < args.length; i++) {
226
+ const arg = args[i];
227
+ if (arg === '--json')
228
+ json = true;
229
+ else if (arg === '--out') {
230
+ outFile = args[++i];
231
+ if (!outFile)
232
+ throw new Error('--out expects a file path');
233
+ }
234
+ else if (arg.startsWith('--'))
235
+ throw new Error(`Unknown flag: ${arg}`);
236
+ else
237
+ files.push(arg);
238
+ }
239
+ if (files.length !== 2) {
240
+ throw new Error('Usage: ghost-qa compare <before.json> <after.json> [--json] [--out <file>]');
241
+ }
242
+ return { beforeFile: files[0], afterFile: files[1], json, ...(outFile ? { outFile } : {}) };
243
+ }
244
+ async function runCompare(args) {
245
+ const parsed = parseCompareArgs(args);
246
+ const load = async (file) => {
247
+ const parsedJson = JSON.parse(await readFile(resolve(file), 'utf8'));
248
+ if (typeof parsedJson !== 'object' ||
249
+ parsedJson === null ||
250
+ !Array.isArray(parsedJson.issues)) {
251
+ throw new Error(`${file} is not a ghost-qa report JSON (missing issues array)`);
252
+ }
253
+ return parsedJson;
254
+ };
255
+ const delta = compareReports(await load(parsed.beforeFile), await load(parsed.afterFile));
256
+ const rendered = parsed.json ? `${JSON.stringify(delta, null, 2)}\n` : formatCompareReport(delta);
257
+ if (parsed.outFile) {
258
+ await writeFile(resolve(parsed.outFile), rendered, 'utf8');
259
+ process.stdout.write(`Delta report written to ${parsed.outFile}\n`);
260
+ }
261
+ else {
262
+ process.stdout.write(rendered);
263
+ }
264
+ return delta.newRegressions.length > 0 ? 1 : 0;
265
+ }
266
+ async function executeTest(parsed) {
267
+ const audit = () => runGhostQAAudit(parsed.target, parsed.options);
268
+ if (!parsed.serverCmd)
269
+ return audit();
270
+ const server = new ManagedDevServer(parsed.serverCmd);
271
+ process.stderr.write(`\nšŸ‘» ghost-qa: starting dev server: ${parsed.serverCmd}\n`);
272
+ server.start();
273
+ try {
274
+ await server.waitUntilReady(parsed.target, parsed.serverTimeoutMs);
275
+ process.stderr.write(`šŸ‘» ghost-qa: server is responding at ${parsed.target}\n`);
276
+ return await audit();
277
+ }
278
+ finally {
279
+ await server.stop();
280
+ process.stderr.write('šŸ‘» ghost-qa: dev server terminated.\n');
281
+ }
282
+ }
283
+ async function loadSkillTemplate() {
284
+ const moduleDir = dirname(fileURLToPath(import.meta.url));
285
+ const candidates = [
286
+ resolve(moduleDir, '../../SKILL.md'), // dist/bin -> package root
287
+ resolve(moduleDir, '../SKILL.md'),
288
+ resolve(process.cwd(), 'SKILL.md'),
289
+ ];
290
+ for (const candidate of candidates) {
291
+ try {
292
+ const content = await readFile(candidate, 'utf8');
293
+ if (content.includes('ghost-qa'))
294
+ return content;
295
+ }
296
+ catch {
297
+ /* try next candidate */
298
+ }
299
+ }
300
+ return FALLBACK_SKILL;
301
+ }
302
+ async function upsertProtocol(filePath) {
303
+ let existing = '';
304
+ try {
305
+ existing = await readFile(filePath, 'utf8');
306
+ }
307
+ catch {
308
+ await writeFile(filePath, `# Agent Instructions\n\n${AGENT_PROTOCOL}\n`, 'utf8');
309
+ return 'created';
310
+ }
311
+ const start = existing.indexOf(PROTOCOL_START);
312
+ const end = existing.indexOf(PROTOCOL_END);
313
+ if (start !== -1 && end !== -1 && end > start) {
314
+ const next = `${existing.slice(0, start)}${AGENT_PROTOCOL}${existing.slice(end + PROTOCOL_END.length)}`;
315
+ if (next === existing)
316
+ return 'unchanged';
317
+ await writeFile(filePath, next, 'utf8');
318
+ return 'updated';
319
+ }
320
+ await writeFile(filePath, `${existing.replace(/\s*$/, '')}\n\n${AGENT_PROTOCOL}\n`, 'utf8');
321
+ return 'updated';
322
+ }
323
+ export async function installSkill(targetDir, force = false) {
324
+ const results = [];
325
+ const skillPath = join(targetDir, '.claude', 'skills', 'ghost-qa', 'SKILL.md');
326
+ await mkdir(dirname(skillPath), { recursive: true });
327
+ const skillContent = await loadSkillTemplate();
328
+ const skillExisted = existsSync(skillPath);
329
+ if (!skillExisted || force) {
330
+ await writeFile(skillPath, skillContent.endsWith('\n') ? skillContent : `${skillContent}\n`, 'utf8');
331
+ results.push({ file: '.claude/skills/ghost-qa/SKILL.md', action: skillExisted ? 'updated (--force)' : 'installed' });
332
+ }
333
+ else {
334
+ results.push({ file: '.claude/skills/ghost-qa/SKILL.md', action: 'already exists (use --force to overwrite)' });
335
+ }
336
+ for (const name of ['CLAUDE.md', 'AGENTS.md', '.cursorrules']) {
337
+ const action = await upsertProtocol(join(targetDir, name));
338
+ results.push({ file: name, action: `protocol ${action}` });
339
+ }
340
+ const mcpAction = await installMcpConfig(join(targetDir, '.mcp.json'), force);
341
+ results.push({ file: '.mcp.json', action: mcpAction });
342
+ return results;
343
+ }
344
+ /**
345
+ * Writes (or merges into) `.mcp.json`, preserving any MCP servers the
346
+ * project already configured. The ghost-qa entry is always refreshed so
347
+ * hosts invoke the published CLI: `npx -y ghost-qa mcp`.
348
+ */
349
+ export async function installMcpConfig(mcpJsonPath, force = false) {
350
+ const desired = mcpServerConfig();
351
+ let existing = {};
352
+ let action = 'installed';
353
+ try {
354
+ const raw = await readFile(mcpJsonPath, 'utf8');
355
+ const parsed = JSON.parse(raw);
356
+ if (parsed && typeof parsed === 'object') {
357
+ existing = parsed;
358
+ action = 'merged';
359
+ }
360
+ }
361
+ catch {
362
+ action = 'installed';
363
+ }
364
+ const servers = (existing.mcpServers ?? {});
365
+ const previous = servers['ghost-qa'];
366
+ if (previous && !force && JSON.stringify(previous) === JSON.stringify(desired.mcpServers['ghost-qa'])) {
367
+ return 'already configured';
368
+ }
369
+ servers['ghost-qa'] = desired.mcpServers['ghost-qa'];
370
+ existing.mcpServers = servers;
371
+ await writeFile(mcpJsonPath, `${JSON.stringify(existing, null, 2)}\n`, 'utf8');
372
+ return previous ? 'updated' : action;
373
+ }
374
+ async function runInit(args) {
375
+ const force = args.includes('--force');
376
+ const dirFlagIndex = args.indexOf('--dir');
377
+ const targetDir = dirFlagIndex !== -1 ? resolve(args[dirFlagIndex + 1] ?? '.') : process.cwd();
378
+ const results = await installSkill(targetDir, force);
379
+ process.stdout.write(`\nšŸ‘» ghost-qa agent skill installed in ${targetDir}\n`);
380
+ for (const result of results) {
381
+ process.stdout.write(` • ${result.file} — ${result.action}\n`);
382
+ }
383
+ process.stdout.write('\nYour coding agent will now run `npx -y ghost-qa test <local-url>` and fix browser failures before reporting a task complete.\n' +
384
+ 'MCP hosts (Claude Code, Claude Desktop, Cursor, Codex) can also call the ghost_qa_audit_url / ghost_qa_doctor / ghost_qa_compare tools directly via the .mcp.json entry above.\n');
385
+ return 0;
386
+ }
387
+ async function runDoctor() {
388
+ process.stdout.write(`\nšŸ‘» ghost-qa doctor v${VERSION}\n`);
389
+ const result = await runDoctorCheck();
390
+ process.stdout.write(` ${result.nodeOk ? 'āœ”' : 'āœ–'} Node.js ${result.node} ${result.nodeOk ? '' : '(requires >= 20)'}\n`);
391
+ if (result.chromiumFound) {
392
+ process.stdout.write(` āœ” Chromium binary: ${result.chromiumPath}\n`);
393
+ const smokeOk = result.smokeTest.startsWith('Chromium launched');
394
+ process.stdout.write(` ${smokeOk ? 'āœ”' : 'āœ–'} Browser smoke test: ${result.smokeTest}\n`);
395
+ }
396
+ else {
397
+ process.stdout.write(' āœ– Chromium binary not found. Run: npx playwright install chromium\n');
398
+ }
399
+ process.stdout.write(result.ready
400
+ ? '\nāœ” ghost-qa is ready: npx -y ghost-qa test http://localhost:3000\n'
401
+ : '\nāœ– Setup incomplete — fix the items above.\n');
402
+ return result.ready ? 0 : 1;
403
+ }
404
+ export async function main(argv = process.argv.slice(2)) {
405
+ const [command, ...rest] = argv;
406
+ try {
407
+ switch (command) {
408
+ case 'test': {
409
+ const parsed = parseTestArgs(rest);
410
+ const report = await executeTest(parsed);
411
+ if (parsed.reportFile) {
412
+ const isJson = parsed.reportFile.endsWith('.json');
413
+ await writeFile(resolve(parsed.reportFile), formatDiagnostic(report, isJson ? 'json' : 'markdown'), 'utf8');
414
+ }
415
+ if (parsed.htmlFile) {
416
+ await writeFile(resolve(parsed.htmlFile), formatHtmlReport(report), 'utf8');
417
+ }
418
+ if (parsed.outDir) {
419
+ const dir = resolve(parsed.outDir);
420
+ await mkdir(dir, { recursive: true });
421
+ await writeFile(join(dir, 'report.json'), `${JSON.stringify(report, null, 2)}\n`, 'utf8');
422
+ await writeFile(join(dir, 'GHOST-QA-REPORT.md'), `${formatAgentDiagnostic(report)}\n`, 'utf8');
423
+ await writeFile(join(dir, 'PATCH-PROMPT.md'), formatPatchPrompt(report), 'utf8');
424
+ await writeFile(join(dir, 'report.html'), formatHtmlReport(report), 'utf8');
425
+ if (!parsed.quiet) {
426
+ process.stdout.write(`Artifacts written to ${parsed.outDir}/ (report.json, GHOST-QA-REPORT.md, PATCH-PROMPT.md, report.html)\n`);
427
+ }
428
+ }
429
+ if (parsed.json) {
430
+ process.stdout.write(formatDiagnostic(report, 'json'));
431
+ }
432
+ else if (!parsed.quiet) {
433
+ process.stdout.write(`${formatTerminalSummary(report)}\n`);
434
+ if (!report.passed) {
435
+ process.stdout.write(`\n${formatAgentDiagnostic(report)}\n`);
436
+ }
437
+ if (parsed.reportFile)
438
+ process.stdout.write(`Report written to ${parsed.reportFile}\n`);
439
+ if (parsed.htmlFile)
440
+ process.stdout.write(`HTML evidence report written to ${parsed.htmlFile}\n`);
441
+ }
442
+ return report.passed ? 0 : 1;
443
+ }
444
+ case 'compare':
445
+ return await runCompare(rest);
446
+ case 'mcp':
447
+ await startMcpServer();
448
+ return 0;
449
+ case 'init':
450
+ return await runInit(rest);
451
+ case 'doctor':
452
+ return await runDoctor();
453
+ case '--version':
454
+ case '-v':
455
+ case 'version':
456
+ process.stdout.write(`ghost-qa ${VERSION}\n`);
457
+ return 0;
458
+ case '--help':
459
+ case '-h':
460
+ case 'help':
461
+ case undefined:
462
+ process.stdout.write(`${HELP}\n`);
463
+ return 0;
464
+ default:
465
+ process.stderr.write(`Unknown command: ${command}\n${HELP}\n`);
466
+ return 2;
467
+ }
468
+ }
469
+ catch (error) {
470
+ process.stderr.write(`\nšŸ‘» ghost-qa error: ${error instanceof Error ? error.message : String(error)}\n`);
471
+ return 2;
472
+ }
473
+ }
474
+ /**
475
+ * True when this module is the process entry point. The entry path MUST be
476
+ * realpath-resolved first: package managers invoke bins through a
477
+ * `node_modules/.bin/ghost-qa` symlink, while `import.meta.url` always
478
+ * points at the real file — comparing unresolved paths would make the CLI
479
+ * silently no-op (exit 0, no output) under `npx`.
480
+ */
481
+ export function isMainEntry(argvEntry, moduleUrl = import.meta.url) {
482
+ if (!argvEntry)
483
+ return false;
484
+ try {
485
+ return moduleUrl === pathToFileURL(realpathSync(resolve(argvEntry))).href;
486
+ }
487
+ catch {
488
+ return false;
489
+ }
490
+ }
491
+ if (isMainEntry(process.argv[1])) {
492
+ main().then((code) => {
493
+ process.exitCode = code;
494
+ }, (error) => {
495
+ process.stderr.write(`ghost-qa crashed: ${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
496
+ process.exitCode = 2;
497
+ });
498
+ }
406
499
  //# sourceMappingURL=ghost-qa.js.map