pqcheck 0.14.2 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/pqcheck.js CHANGED
@@ -6,17 +6,34 @@
6
6
  // Zero deps (uses node:fetch). Works under `npx pqcheck` without installation.
7
7
  // =============================================================================
8
8
 
9
+ // R74-confirm BLOCKING #5 (GPT 2026-05-22): Node version startup guard.
10
+ // Native fetch requires Node 18+. Without this guard, pre-Node-18 users see
11
+ // "fetch is not defined" at runtime — confusing because package.json `engines`
12
+ // is not enforced by npm/npx. Fail loud + actionable.
13
+ (() => {
14
+ const major = Number((process.versions.node || "0").split(".")[0]);
15
+ if (Number.isFinite(major) && major < 18) {
16
+ process.stderr.write(
17
+ `\n ✗ pqcheck requires Node 18 or newer (you have ${process.versions.node}).\n` +
18
+ ` Native fetch is unavailable on this version.\n` +
19
+ ` Install Node 18+ from https://nodejs.org or use nvm: \`nvm install 18 && nvm use 18\`\n` +
20
+ ` Then retry: \`npx pqcheck <domain>\`\n\n`
21
+ );
22
+ process.exit(1);
23
+ }
24
+ })();
25
+
9
26
  const API_BASE = process.env.PQCHECK_API_BASE || "https://cipherwake.io";
10
- const VERSION = "0.14.2";
27
+ const VERSION = "0.15.1";
11
28
 
12
29
  // API-key support — paid tiers (Starter $29 / Growth $79 / Scale $199) get
13
30
  // per-account monthly quotas instead of the per-IP rate limit. Set via:
14
31
  // export CIPHERWAKE_API_KEY=qpk_<32-hex>
15
32
  // Anonymous CLI use still works (no env var → falls back to IP rate limit).
16
33
  //
17
- // QUANTAPACT_API_KEY is honored as a deprecated fallback for existing users
18
- // (rebrand 2026-05-15). Will be removed in v1.0; in the meantime no break.
19
- const QP_API_KEY = (process.env.CIPHERWAKE_API_KEY || process.env.QUANTAPACT_API_KEY || "").trim();
34
+ // Removed QUANTAPACT_API_KEY backwards-compat fallback 2026-05-22.
35
+ // Pre-launch, no customers had it set in production no break.
36
+ const QP_API_KEY = (process.env.CIPHERWAKE_API_KEY || "").trim();
20
37
 
21
38
  // Builds headers with optional Authorization. Use for every CLI → API call
22
39
  // so a single env-var toggle authenticates every endpoint at once.
@@ -127,6 +144,36 @@ async function main() {
127
144
  if (args[0] === "onboard") {
128
145
  return runOnboardCommand(args.slice(1));
129
146
  }
147
+ if (args[0] === "guard") {
148
+ // CLI v0.15.0 — `pqcheck guard --domain X -- <deploy command>`.
149
+ // Wraps any deploy command: runs deploy-check first, conditionally
150
+ // executes the deploy command based on ship_decision. The strongest
151
+ // single artifact for terminal-first AI-coder workflows because the
152
+ // AI doesn't have to remember two commands.
153
+ return runGuardCommand(args.slice(1));
154
+ }
155
+ if (args[0] === "protocol") {
156
+ // CLI v0.15.0 — `pqcheck protocol install` opt-in installer with
157
+ // Rule 17 consent flow. Adds the AI Coder Protocol to ~/.claude/CLAUDE.md
158
+ // / .cursorrules (with auto/manual/no consent). Never silent.
159
+ return runProtocolCommand(args.slice(1));
160
+ }
161
+ if (args[0] === "setup") {
162
+ // CLI v0.15.0 — `pqcheck setup --auto --domain <D>` consolidated installer.
163
+ // One command installs everything: GitHub Action workflow, AI Coder
164
+ // Protocol across detected rules files, git pre-push hook, and
165
+ // statusline config. The launch-story command for AI-coder workflows
166
+ // ("one command sets you up across every AI coder").
167
+ return runSetupCommand(args.slice(1));
168
+ }
169
+ if (args[0] === "debug-network" || args.includes("--debug-network")) {
170
+ // R74-confirm SHIP #14-15 (GPT 2026-05-22): probe connectivity to every
171
+ // upstream Cipherwake depends on + report which ones are reachable.
172
+ // Customer-facing diagnostic for "the scan hung" / "command-not-found"
173
+ // / "corporate proxy" / "VPN-blocked" failure modes — instead of leaving
174
+ // them to guess, surface the actual broken hop.
175
+ return runDebugNetworkCommand();
176
+ }
130
177
 
131
178
  // Multi-domain support: positional args are domains.
132
179
  // --file reads additional domains from a newline-delimited file.
@@ -188,17 +235,18 @@ async function main() {
188
235
  // a cert/key change you just deployed. Subject to a 20/hr per-IP cap on
189
236
  // the server side.
190
237
  const fresh = args.includes("--fresh") || args.includes("--force");
238
+ const aiMode = parseAiMode(args);
191
239
 
192
240
  // One-shot scan(s)
193
241
  let worstExit = 0;
194
242
  for (const domain of domains) {
195
- const exit = await runOneScan({ domain, format, quiet, threshold, webhookUrl, multi: domains.length > 1, fresh });
243
+ const exit = await runOneScan({ domain, format, quiet, threshold, webhookUrl, multi: domains.length > 1, fresh, aiMode });
196
244
  if (exit > worstExit) worstExit = exit;
197
245
  }
198
246
  process.exit(worstExit);
199
247
  }
200
248
 
201
- async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi, fresh }) {
249
+ async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi, fresh, aiMode }) {
202
250
  if (!quiet && format === "text") process.stderr.write(color("dim", `Scanning ${domain}${fresh ? " (forcing fresh)" : ""} ...`));
203
251
  let report;
204
252
  try {
@@ -248,6 +296,71 @@ async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi,
248
296
  console.error(`pqcheck: ⚠ ${domain} — using cached score (live probe failed: ${report._meta.degradedReason || "unknown"}; last verified ${report._meta.lastUpdated || "?"})`);
249
297
  }
250
298
 
299
+ // AI Coder Mode — three-layer output for Claude Code / Cursor / Aider.
300
+ // Overrides --format when --ai is set; emits the structured block at the
301
+ // bottom so downstream agents can parse ship_decision deterministically.
302
+ if (aiMode) {
303
+ const findings = Array.isArray(report.findings) ? report.findings : [];
304
+ const maxSev = highestSeverity(findings);
305
+ const shipDecision = computeShipDecision({ maxSeverity: maxSev });
306
+ const topFinding = [...findings].sort((a, b) => severityRank(b.severity) - severityRank(a.severity))[0];
307
+
308
+ const topIssue = topFinding
309
+ ? `[${String(topFinding.severity || "").toUpperCase()}] ${topFinding.title || topFinding.detail || "finding"}`
310
+ : "No findings at or above LOW severity.";
311
+ const whyMatters = topFinding?.detail || "DBR scoring measures harvest-now-decrypt-later risk. Findings reflect public-surface signals only.";
312
+ const nextActions = shipDecision === "pass"
313
+ ? [`Domain looks healthy. View full report: ${API_BASE}/r/${domain}`]
314
+ : [
315
+ `Review finding above and decide if it was intentional.`,
316
+ `View full report: ${API_BASE}/r/${domain}`,
317
+ `Re-scan with --fresh after fix: npx pqcheck ${domain} --fresh --ai`,
318
+ ];
319
+
320
+ console.log("");
321
+ console.log(formatAiBanner({
322
+ domain,
323
+ kind: "scan",
324
+ dbr: report.score,
325
+ grade: report.grade,
326
+ maxSeverity: maxSev,
327
+ shipDecision,
328
+ }));
329
+ console.log(formatAiBody({ topIssue, whyMatters, nextActions }));
330
+ console.log(formatAiFooterBlock({
331
+ status: shipDecision,
332
+ domain,
333
+ kind: "scan",
334
+ dbr: typeof report.score === "number" ? report.score.toFixed(1) : "",
335
+ grade: report.grade || "",
336
+ max_severity: maxSev,
337
+ ship_decision: shipDecision,
338
+ top_issue: topFinding?.id || topFinding?.title || "none",
339
+ findings_high: findings.filter((f) => severityRank(f.severity) === 3).length,
340
+ findings_critical: findings.filter((f) => severityRank(f.severity) === 4).length,
341
+ scanned_at: new Date().toISOString(),
342
+ advisory_only: "true",
343
+ }));
344
+ console.log("");
345
+
346
+ await writeLastScanFile({
347
+ domain,
348
+ kind: "scan",
349
+ score: typeof report.score === "number" ? report.score : null,
350
+ grade: report.grade || null,
351
+ max_severity: maxSev,
352
+ ship_decision: shipDecision,
353
+ top_issue: topFinding?.id || topFinding?.title || null,
354
+ });
355
+
356
+ // Threshold check still applies under --ai (script-pipeable). Otherwise
357
+ // exit code reflects ship_decision so the caller can route on it.
358
+ if (threshold !== null && typeof report.score === "number" && report.score >= threshold) {
359
+ return 2;
360
+ }
361
+ return shipDecisionExitCode(shipDecision);
362
+ }
363
+
251
364
  // Output dispatch
252
365
  if (quiet) {
253
366
  if (multi) {
@@ -405,6 +518,165 @@ function parseWebhook(args) {
405
518
  return raw;
406
519
  }
407
520
 
521
+ // =============================================================================
522
+ // AI Coder Mode — `--ai` / `--agent` output
523
+ // =============================================================================
524
+ // Three-layer output designed for AI-coder workflows (Claude Code / Cursor /
525
+ // Aider / etc.) where the user can't see GitHub PR comments and the chat
526
+ // scrollback buries verbose CLI output:
527
+ //
528
+ // Layer 1 (top banner) — un-missable one-liner with status + ship_decision
529
+ // Layer 2 (body) — top finding + why it matters + next action (≤12 lines)
530
+ // Layer 3 (footer block) — machine-readable CIPHERWAKE_AI_GUARD_RESULT block
531
+ // that AI agents can deterministically parse to
532
+ // decide pass / review / block
533
+ //
534
+ // The `ship_decision` field is advisory only — Cipherwake is a scanner, not
535
+ // an authoritative deploy-blocker. The methodology page documents this
536
+ // explicitly (Rule 1).
537
+ // =============================================================================
538
+
539
+ function parseAiMode(args) {
540
+ return args.includes("--ai") || args.includes("--agent");
541
+ }
542
+
543
+ function severityRank(s) {
544
+ const map = { critical: 4, high: 3, medium: 2, low: 1, info: 0, none: -1 };
545
+ return map[String(s || "none").toLowerCase()] ?? 0;
546
+ }
547
+
548
+ function highestSeverity(findings) {
549
+ if (!Array.isArray(findings) || findings.length === 0) return "none";
550
+ let best = "none";
551
+ for (const f of findings) {
552
+ if (severityRank(f.severity) > severityRank(best)) best = f.severity;
553
+ }
554
+ return best;
555
+ }
556
+
557
+ // Compute ship_decision: pass | review | block.
558
+ // Used in three contexts:
559
+ // - one-shot scan: severity-only, no diff baseline
560
+ // - trust-diff / preview-diff: severity + diff-since-baseline
561
+ // - deploy-check: same as trust-diff (it's an alias)
562
+ //
563
+ // Decision rules (advisory only):
564
+ // * `block` — critical severity present
565
+ // * `review` — high severity OR diff introduced new high-severity OR DBR drop ≥1.0
566
+ // * `pass` — everything else
567
+ function computeShipDecision({ maxSeverity, hasUnexpectedDiff, scoreDelta }) {
568
+ const sev = String(maxSeverity || "none").toLowerCase();
569
+ if (sev === "critical") return "block";
570
+ if (sev === "high") return "review";
571
+ if (hasUnexpectedDiff) return "review";
572
+ if (typeof scoreDelta === "number" && scoreDelta <= -1.0) return "review";
573
+ return "pass";
574
+ }
575
+
576
+ function aiBannerColor(shipDecision) {
577
+ if (shipDecision === "pass") return "green";
578
+ if (shipDecision === "block") return "red";
579
+ return "yellow"; // review
580
+ }
581
+
582
+ function aiStatusEmoji(shipDecision) {
583
+ if (shipDecision === "pass") return "✓";
584
+ if (shipDecision === "block") return "✗";
585
+ return "⚠";
586
+ }
587
+
588
+ function formatAiBanner({ domain, kind, dbr, grade, maxSeverity, shipDecision }) {
589
+ const emoji = aiStatusEmoji(shipDecision);
590
+ const statusWord = ({ pass: "PASS", review: "REVIEW", block: "BLOCK" })[shipDecision] || "REVIEW";
591
+ const c = aiBannerColor(shipDecision);
592
+ const dbrStr = typeof dbr === "number" ? dbr.toFixed(1) : "—";
593
+ const gradeStr = grade ? ` ${grade}` : "";
594
+ const sevStr = maxSeverity && maxSeverity !== "none" ? ` · ${String(maxSeverity).toUpperCase()}` : "";
595
+ return color(c, `◆ cipherwake · ${kind} · ${emoji} ${statusWord} · ${domain} · DBR ${dbrStr}${gradeStr}${sevStr} · ship_decision=${shipDecision}`);
596
+ }
597
+
598
+ function formatAiBody({ topIssue, whyMatters, nextActions }) {
599
+ const lines = [];
600
+ if (topIssue) {
601
+ lines.push("");
602
+ lines.push(color("bold", "Top finding:"));
603
+ lines.push(` ${topIssue}`);
604
+ }
605
+ if (whyMatters) {
606
+ lines.push("");
607
+ lines.push(color("bold", "Why it matters:"));
608
+ lines.push(` ${whyMatters}`);
609
+ }
610
+ if (Array.isArray(nextActions) && nextActions.length > 0) {
611
+ lines.push("");
612
+ lines.push(color("bold", "Recommended next action:"));
613
+ for (const a of nextActions) {
614
+ lines.push(` ${a}`);
615
+ }
616
+ }
617
+ return lines.join("\n");
618
+ }
619
+
620
+ // Emit the machine-readable block. Keys are normalized; values are
621
+ // newline-stripped. AI agents grep between the CIPHERWAKE_AI_GUARD_RESULT
622
+ // and END_CIPHERWAKE_AI_GUARD_RESULT markers and parse key=value lines.
623
+ function formatAiFooterBlock(fields) {
624
+ const lines = ["", "CIPHERWAKE_AI_GUARD_RESULT"];
625
+ for (const [k, v] of Object.entries(fields)) {
626
+ if (v === undefined || v === null) continue;
627
+ const safeK = String(k).replace(/[\s=]/g, "_");
628
+ const safeV = String(v).replace(/[\r\n]+/g, " ");
629
+ lines.push(`${safeK}=${safeV}`);
630
+ }
631
+ lines.push("END_CIPHERWAKE_AI_GUARD_RESULT");
632
+ return color("dim", lines.join("\n"));
633
+ }
634
+
635
+ // Persist last-scan state to ~/.config/cipherwake/last-scan.json.
636
+ // Feeds the cipherwake-statusline + cipherwake-prompt-hook + cipherwake-chat-hook
637
+ // scripts so users get persistent ambient state in their AI coder's surfaces.
638
+ //
639
+ // v0.15.1 (2026-05-22): ALSO writes a per-repo state file at
640
+ // .cipherwake/last-status.json IF that directory exists in cwd (created by
641
+ // `pqcheck setup --auto`). This gives Cursor / Copilot / Continue / Cline
642
+ // agents a read-on-demand surface inside the repo — they see the latest
643
+ // trust posture for the customer's primary domain when scanning repo state.
644
+ //
645
+ // Best-effort — never throws (a write failure doesn't break the scan).
646
+ async function writeLastScanFile(payload) {
647
+ try {
648
+ const os = await import("node:os");
649
+ const path = await import("node:path");
650
+ const fs = await import("node:fs/promises");
651
+ const enriched = { ...payload, written_at: new Date().toISOString() };
652
+
653
+ // Per-user state file (primary, always written)
654
+ const userDir = path.join(os.homedir(), ".config", "cipherwake");
655
+ await fs.mkdir(userDir, { recursive: true });
656
+ await fs.writeFile(path.join(userDir, "last-scan.json"), JSON.stringify(enriched, null, 2));
657
+
658
+ // Per-repo state file (secondary, only if .cipherwake/ exists in cwd
659
+ // — i.e., this repo went through `pqcheck setup --auto`). Gives Cursor/
660
+ // Copilot/Continue/Cline agents a repo-local artifact they pick up
661
+ // automatically when reading workspace state.
662
+ const repoDir = path.join(process.cwd(), ".cipherwake");
663
+ try {
664
+ await fs.access(repoDir);
665
+ await fs.writeFile(path.join(repoDir, "last-status.json"), JSON.stringify(enriched, null, 2));
666
+ } catch {
667
+ // .cipherwake/ doesn't exist here — that's fine, user didn't run setup --auto in this repo
668
+ }
669
+ } catch {
670
+ // best-effort
671
+ }
672
+ }
673
+
674
+ // Map ship_decision → exit code so CI / shell scripts can act on it
675
+ // without parsing the structured block. pass=0, review=1, block=2.
676
+ function shipDecisionExitCode(d) {
677
+ return d === "block" ? 2 : d === "review" ? 1 : 0;
678
+ }
679
+
408
680
  // ---------- format renderers (CSV + markdown) ----------
409
681
 
410
682
  let _csvHeaderPrinted = false;
@@ -660,7 +932,7 @@ ${color("bold", "What you'll see:")} a single letter grade (A–F), the score co
660
932
  top findings, and a link to the full interactive report.
661
933
 
662
934
  ${color("bold", "Free + open methodology.")} No account needed for single-domain scans.
663
- Add ${color("dim", "QUANTAPACT_API_KEY")} env var for higher rate limits + private results
935
+ Add ${color("dim", "CIPHERWAKE_API_KEY")} env var for higher rate limits + private results
664
936
  (create one at ${color("dim", "https://cipherwake.io/signin")}).
665
937
  `);
666
938
  }
@@ -682,7 +954,9 @@ ${color("bold", "Commands:")}
682
954
  npx pqcheck watch <domain> Add a domain to your watched-domain list (requires CIPHERWAKE_API_KEY)
683
955
  npx pqcheck onboard <domain> One-command setup wizard (scan + init + vendors + checklist + open browser)
684
956
  npx pqcheck init Interactive scaffold for .github/workflows/cipherwake.yml
685
- npx pqcheck deploy-check <domain> Pre-deploy trust gate (Trust Diff vs last scan; deploy-friendly framing)
957
+ npx pqcheck deploy-check <domain> Pre-deploy trust gate (Trust Diff vs last scan; deploy-friendly framing) — see also: AI Coder Protocol at https://cipherwake.io/methodology/ai-coder-protocol
958
+ npx pqcheck guard --domain <D> -- <cmd> NEW: wrap any deploy command. Runs deploy-check first; conditionally runs <cmd> based on ship_decision. The strongest single artifact for AI-coder workflows.
959
+ npx pqcheck protocol install NEW: install the AI Coder Protocol into your CLAUDE.md / .cursorrules (Rule 17 consent flow)
686
960
  npx pqcheck release-checklist [domain] Print a pre-release trust checklist (markdown, offline)
687
961
  npx pqcheck vendors export <domain> Write cipherwake.vendors.json from observed third-party scripts
688
962
  npx pqcheck vendors check <domain> Compare current scan to lockfile; exit 4 on new origins (Free CI gate)
@@ -2021,8 +2295,89 @@ async function runChangesCommand(args) {
2021
2295
  * 2 = fail — deltas observed at or above fail-on threshold
2022
2296
  * 3 = error — auth/quota/network failure
2023
2297
  *
2024
- * Requires CIPHERWAKE_API_KEY env var (Free tier: 30 calls/mo at /account#api-keys).
2298
+ * Authentication paths (per server's applyRepoQuota supports all three):
2299
+ * • CIPHERWAKE_API_KEY=qpk_... (paid quota, higher cap, off-Actions usage)
2300
+ * • GITHUB_ACTIONS=true + id-token (OIDC, keyless, Free 100 calls/repo/mo)
2301
+ * • No auth (anonymous per-IP rate limit — first-use friction-free path)
2302
+ *
2303
+ * R74-confirm friction fix (GPT 2026-05-22): previously the CLI hard-gated
2304
+ * on CIPHERWAKE_API_KEY before even attempting the call, which broke the
2305
+ * frictionless first-use AI-coder funnel. The hard-gate was gratuitous —
2306
+ * the server has supported anonymous calls since the applyRepoQuota
2307
+ * middleware shipped. Now the CLI just attempts the call with whatever
2308
+ * auth context is available; server applies the appropriate quota path.
2025
2309
  */
2310
+ // R74-confirm friction fix (GPT 2026-05-22): when deploy-check has no
2311
+ // baseline yet (first-deploy of a brand-new domain), fall through to
2312
+ // /api/scan and emit ship_decision based on current absolute findings.
2313
+ // This makes `pqcheck deploy-check <new-domain> --ai` work on first call
2314
+ // with zero setup — no API key, no prior scan, nothing.
2315
+ async function runScanBasedDeployCheck(domain, args) {
2316
+ const headers = {
2317
+ "Content-Type": "application/json",
2318
+ "User-Agent": `pqcheck-cli/${VERSION}`,
2319
+ };
2320
+ if (QP_API_KEY) headers["Authorization"] = `Bearer ${QP_API_KEY}`;
2321
+
2322
+ let resp;
2323
+ try {
2324
+ resp = await fetch(`${API_BASE}/api/scan?domain=${encodeURIComponent(domain)}`, { headers });
2325
+ } catch (err) {
2326
+ console.error(color("red", `error: network failure calling /api/scan: ${err.message}`));
2327
+ process.exit(3);
2328
+ }
2329
+ if (!resp.ok) {
2330
+ console.error(color("red", `error: /api/scan returned ${resp.status}`));
2331
+ process.exit(3);
2332
+ }
2333
+ const report = await resp.json();
2334
+ const findings = Array.isArray(report.findings) ? report.findings : [];
2335
+ const maxSev = highestSeverity(findings);
2336
+ const shipDecision = computeShipDecision({ maxSeverity: maxSev });
2337
+ const topFinding = [...findings].sort((a, b) => severityRank(b.severity) - severityRank(a.severity))[0];
2338
+ const topIssue = topFinding
2339
+ ? `[${String(topFinding.severity || "").toUpperCase()}] ${topFinding.title || topFinding.detail || "finding"}`
2340
+ : "No findings at or above LOW severity.";
2341
+ const whyMatters = topFinding?.detail || "DBR scoring measures harvest-now-decrypt-later risk on the public TLS surface.";
2342
+ const nextActions = shipDecision === "pass"
2343
+ ? [`Domain looks healthy on first scan. View full report: ${API_BASE}/r/${domain}`]
2344
+ : [
2345
+ `Review finding above and decide if it was intentional.`,
2346
+ `View full report: ${API_BASE}/r/${domain}`,
2347
+ `Subsequent deploy-checks will diff against this scan as baseline.`,
2348
+ ];
2349
+
2350
+ console.log("");
2351
+ console.log(color("dim", ` ℹ first deploy-check for ${domain} — using current scan state (no baseline yet to diff against)`));
2352
+ console.log("");
2353
+ console.log(formatAiBanner({
2354
+ domain,
2355
+ kind: "scan",
2356
+ dbr: report.score,
2357
+ grade: report.grade,
2358
+ maxSeverity: maxSev,
2359
+ shipDecision,
2360
+ }));
2361
+ console.log(formatAiBody({ topIssue, whyMatters, nextActions }));
2362
+ console.log(formatAiFooterBlock({
2363
+ status: shipDecision === "pass" ? "pass" : "review",
2364
+ domain,
2365
+ kind: "scan",
2366
+ dbr: report.score,
2367
+ grade: report.grade,
2368
+ max_severity: maxSev,
2369
+ ship_decision: shipDecision,
2370
+ top_issue: topFinding ? `findings.${topFinding.id || "unknown"}` : "none",
2371
+ findings_high: findings.filter((f) => severityRank(f.severity) >= severityRank("high")).length,
2372
+ findings_critical: findings.filter((f) => severityRank(f.severity) >= severityRank("critical")).length,
2373
+ scanned_at: new Date().toISOString(),
2374
+ advisory_only: true,
2375
+ note: "first-deploy: no baseline yet, scored on current state",
2376
+ }));
2377
+ // Exit code: 0 on pass, non-zero on review/block (matches deploy-check contract)
2378
+ process.exit(shipDecision === "pass" ? 0 : 1);
2379
+ }
2380
+
2026
2381
  async function runTrustDiffCommand(args) {
2027
2382
  const positional = args.filter((a) => !a.startsWith("-") && !isFlagValue(args, a));
2028
2383
  if (positional.length === 0) {
@@ -2035,26 +2390,27 @@ async function runTrustDiffCommand(args) {
2035
2390
  console.error(color("red", `error: invalid domain "${positional[0]}"`));
2036
2391
  process.exit(3);
2037
2392
  }
2038
- if (!QP_API_KEY) {
2039
- console.error(color("red", "error: pqcheck trust-diff requires CIPHERWAKE_API_KEY"));
2040
- console.error(color("dim", "Generate a free key (30 calls/mo) at https://cipherwake.io/account#api-keys"));
2041
- console.error(color("dim", "Then: export CIPHERWAKE_API_KEY=qpk_<32-hex>"));
2042
- process.exit(3);
2043
- }
2044
2393
 
2045
2394
  const baseline = parseFlag(args, "--baseline") || "last-week";
2046
2395
  const failOn = parseFlag(args, "--fail-on") || "high";
2047
2396
  const format = parseFlag(args, "--format") || "pretty";
2048
2397
 
2398
+ // Build headers conditionally — Authorization is set ONLY if the user has
2399
+ // an API key. Without it, the server's applyRepoQuota falls through to the
2400
+ // anonymous per-IP rate limit path (just like /api/scan).
2401
+ const headers = {
2402
+ "Content-Type": "application/json",
2403
+ "User-Agent": `pqcheck-cli/${VERSION}`,
2404
+ };
2405
+ if (QP_API_KEY) {
2406
+ headers["Authorization"] = `Bearer ${QP_API_KEY}`;
2407
+ }
2408
+
2049
2409
  let resp;
2050
2410
  try {
2051
2411
  resp = await fetch(`${API_BASE}/api/trust-diff`, {
2052
2412
  method: "POST",
2053
- headers: {
2054
- "Content-Type": "application/json",
2055
- "Authorization": `Bearer ${QP_API_KEY}`,
2056
- "User-Agent": `pqcheck-cli/${VERSION}`,
2057
- },
2413
+ headers,
2058
2414
  body: JSON.stringify({ domain, baseline, fail_on: failOn }),
2059
2415
  });
2060
2416
  } catch (err) {
@@ -2068,10 +2424,21 @@ async function runTrustDiffCommand(args) {
2068
2424
  }
2069
2425
  if (resp.status === 429) {
2070
2426
  const body = await safeJSON(resp);
2071
- console.error(color("red", "error: Trust Diff API quota exceeded for this month"));
2427
+ console.error(color("red", "error: Trust Diff API quota exceeded"));
2072
2428
  if (body?.message) console.error(color("dim", body.message));
2429
+ console.error(color("dim", "Higher quota via free API key (no card): https://cipherwake.io/account#api-keys"));
2073
2430
  process.exit(3);
2074
2431
  }
2432
+ // R74-confirm friction fix #2 (GPT 2026-05-22): 404 on first-deploy is
2433
+ // expected — the domain has never been scanned, so there's no baseline to
2434
+ // diff against. Instead of asking the user to run `pqcheck <domain>` first
2435
+ // (a friction step that breaks the AI-coder funnel), automatically fall
2436
+ // through to /api/scan, populate the cache, and emit ship_decision based
2437
+ // on the scan's current absolute state. Subsequent deploy-checks will
2438
+ // have a baseline and produce a real drift verdict.
2439
+ if (resp.status === 404 && parseAiMode(args)) {
2440
+ return await runScanBasedDeployCheck(domain, args);
2441
+ }
2075
2442
  if (!resp.ok) {
2076
2443
  const body = await safeJSON(resp);
2077
2444
  console.error(color("red", `error: /api/trust-diff returned ${resp.status}`));
@@ -2084,6 +2451,72 @@ async function runTrustDiffCommand(args) {
2084
2451
  const verdict = result.verdict || "pass";
2085
2452
  const deltas = Array.isArray(result.deltas) ? result.deltas : [];
2086
2453
 
2454
+ // AI Coder Mode — three-layer output (banner / body / structured block).
2455
+ if (parseAiMode(args)) {
2456
+ const maxSev = highestSeverity(deltas);
2457
+ const hasUnexpectedDiff = deltas.length > 0;
2458
+ const shipDecision = computeShipDecision({ maxSeverity: maxSev, hasUnexpectedDiff });
2459
+ const topDelta = [...deltas].sort((a, b) => severityRank(b.severity) - severityRank(a.severity))[0];
2460
+
2461
+ const topIssue = topDelta
2462
+ ? `[${String(topDelta.severity || "").toUpperCase()}] ${topDelta.title || topDelta.type}${topDelta.what_changed ? ` — ${topDelta.what_changed}` : ""}`
2463
+ : "No deltas observed since baseline.";
2464
+ const whyMatters = topDelta
2465
+ ? `This change happened between your baseline (${baseline}) and now. If it's an intentional change you shipped, accept it. If unexpected, your domain's posture drifted without an associated deploy — investigate before the diff compounds.`
2466
+ : `Your public trust posture is stable since ${baseline}. No CSP / HSTS / cert / SPKI / DMARC / vendor-script regressions.`;
2467
+ const nextActions = shipDecision === "pass"
2468
+ ? [`Posture stable. Safe to announce deploy.`]
2469
+ : [
2470
+ `Review each delta and decide if it was intentional.`,
2471
+ `If intentional: accept (no action needed in CI; quota tick recorded).`,
2472
+ `If not intentional: revert the deploy or investigate the drift source.`,
2473
+ ];
2474
+
2475
+ console.log("");
2476
+ console.log(formatAiBanner({
2477
+ domain,
2478
+ kind: "trust-diff",
2479
+ dbr: result.current_score,
2480
+ grade: result.current_grade,
2481
+ maxSeverity: maxSev,
2482
+ shipDecision,
2483
+ }));
2484
+ console.log(formatAiBody({ topIssue, whyMatters, nextActions }));
2485
+ console.log(formatAiFooterBlock({
2486
+ status: shipDecision,
2487
+ domain,
2488
+ kind: "trust-diff",
2489
+ baseline,
2490
+ verdict,
2491
+ delta_count: deltas.length,
2492
+ max_severity: maxSev,
2493
+ ship_decision: shipDecision,
2494
+ top_issue: topDelta?.id || topDelta?.type || "none",
2495
+ top_issue_title: topDelta?.title || "",
2496
+ dbr: typeof result.current_score === "number" ? result.current_score.toFixed(1) : "",
2497
+ grade: result.current_grade || "",
2498
+ quota_used: result.quota?.used_this_month ?? "",
2499
+ quota_limit: result.quota?.monthly_limit ?? "",
2500
+ scanned_at: new Date().toISOString(),
2501
+ advisory_only: "true",
2502
+ }));
2503
+ console.log("");
2504
+
2505
+ await writeLastScanFile({
2506
+ domain,
2507
+ kind: "trust-diff",
2508
+ score: typeof result.current_score === "number" ? result.current_score : null,
2509
+ grade: result.current_grade || null,
2510
+ max_severity: maxSev,
2511
+ ship_decision: shipDecision,
2512
+ baseline,
2513
+ delta_count: deltas.length,
2514
+ top_issue: topDelta?.id || topDelta?.title || null,
2515
+ });
2516
+
2517
+ process.exit(shipDecisionExitCode(shipDecision));
2518
+ }
2519
+
2087
2520
  // Format output
2088
2521
  if (format === "json") {
2089
2522
  console.log(JSON.stringify(result, null, 2));
@@ -2147,8 +2580,14 @@ async function runTrustDiffCommand(args) {
2147
2580
  *
2148
2581
  * Exit codes match trust-diff: 0 pass · 1 warn · 2 fail · 3 error.
2149
2582
  *
2150
- * Requires CIPHERWAKE_API_KEY env var (Free tier: 100 calls/repo/mo at
2151
- * /account#api-keys, or use the GitHub Action which fetches OIDC automatically).
2583
+ * Authentication paths (server's applyRepoQuota supports all three):
2584
+ * CIPHERWAKE_API_KEY=qpk_... (paid quota, higher cap)
2585
+ * • GITHUB_ACTIONS=true + id-token (OIDC, keyless, Free 100 calls/repo/mo)
2586
+ * • No auth (anonymous per-IP rate limit — frictionless first-use)
2587
+ *
2588
+ * R74-confirm friction fix (GPT 2026-05-22): the hard-gate on API key has
2589
+ * been removed; server applyRepoQuota handles all 3 auth paths, including
2590
+ * anonymous per-IP rate limit for frictionless first use.
2152
2591
  */
2153
2592
  async function runPreviewDiffCommand(args) {
2154
2593
  const previewUrl = parseFlag(args, "--preview");
@@ -2158,13 +2597,6 @@ async function runPreviewDiffCommand(args) {
2158
2597
  console.error(color("dim", "Usage: npx pqcheck preview-diff --preview https://preview-xyz.vercel.app --production https://example.com [--compare-transport] [--fail-on high] [--format pretty|json]"));
2159
2598
  process.exit(3);
2160
2599
  }
2161
- if (!QP_API_KEY) {
2162
- console.error(color("red", "error: pqcheck preview-diff requires CIPHERWAKE_API_KEY"));
2163
- console.error(color("dim", "Generate a free key (100 calls/repo/mo) at https://cipherwake.io/account#api-keys"));
2164
- console.error(color("dim", "Then: export CIPHERWAKE_API_KEY=qpk_<32-hex>"));
2165
- console.error(color("dim", "Or use the GitHub Action with 'permissions: id-token: write' — no key needed."));
2166
- process.exit(3);
2167
- }
2168
2600
 
2169
2601
  const compareTransport = args.includes("--compare-transport");
2170
2602
  const failOn = parseFlag(args, "--fail-on") || "high";
@@ -2178,15 +2610,19 @@ async function runPreviewDiffCommand(args) {
2178
2610
  ? "report"
2179
2611
  : "fail";
2180
2612
 
2613
+ const headers = {
2614
+ "Content-Type": "application/json",
2615
+ "User-Agent": `pqcheck-cli/${VERSION}`,
2616
+ };
2617
+ if (QP_API_KEY) {
2618
+ headers["Authorization"] = `Bearer ${QP_API_KEY}`;
2619
+ }
2620
+
2181
2621
  let resp;
2182
2622
  try {
2183
2623
  resp = await fetch(`${API_BASE}/api/preview-diff`, {
2184
2624
  method: "POST",
2185
- headers: {
2186
- "Content-Type": "application/json",
2187
- "Authorization": `Bearer ${QP_API_KEY}`,
2188
- "User-Agent": `pqcheck-cli/${VERSION}`,
2189
- },
2625
+ headers,
2190
2626
  body: JSON.stringify({
2191
2627
  preview_url: previewUrl,
2192
2628
  production_url: productionUrl,
@@ -2225,6 +2661,73 @@ async function runPreviewDiffCommand(args) {
2225
2661
  const verdict = result?.result?.verdict || "pass";
2226
2662
  const summaryLines = result?.result?.summary_lines || [];
2227
2663
 
2664
+ // AI Coder Mode — three-layer output (banner / body / structured block).
2665
+ if (parseAiMode(args)) {
2666
+ const maxSev = result?.result?.max_severity || "none";
2667
+ const hasUnexpectedDiff = summaryLines.some((l) => !/no meaningful/i.test(l));
2668
+ const prevScore = result?.preview?.score;
2669
+ const prodScore = result?.production?.score;
2670
+ const scoreDelta = (typeof prevScore === "number" && typeof prodScore === "number")
2671
+ ? Number((prevScore - prodScore).toFixed(2))
2672
+ : null;
2673
+ const shipDecision = computeShipDecision({ maxSeverity: maxSev, hasUnexpectedDiff, scoreDelta });
2674
+
2675
+ const firstDelta = summaryLines.find((l) => !/no meaningful/i.test(l));
2676
+ const topIssue = firstDelta || "No meaningful application-surface changes between preview and production.";
2677
+ const whyMatters = hasUnexpectedDiff
2678
+ ? `Preview URL introduces application-surface changes vs production. If you intentionally added (e.g.) a new third-party script or relaxed a CSP, accept and merge. If unexpected, the change is hidden in this PR's bundle — investigate before merge.`
2679
+ : `Your preview is application-equivalent to production. No new vendors, no header regressions, no DBR score drop.`;
2680
+ const nextActions = shipDecision === "pass"
2681
+ ? [`Preview matches production. Safe to merge.`]
2682
+ : [
2683
+ `Review the changes above in this PR before merging.`,
2684
+ `Each "+ New third-party script" is a real new third-party request your users will make on this deploy.`,
2685
+ `Each "- CSP weakened" or "~ HSTS weakened" reduces production safety.`,
2686
+ ];
2687
+
2688
+ console.log("");
2689
+ console.log(formatAiBanner({
2690
+ domain: result?.production?.domain || productionUrl,
2691
+ kind: "preview-diff",
2692
+ dbr: prevScore,
2693
+ grade: result?.preview?.grade,
2694
+ maxSeverity: maxSev,
2695
+ shipDecision,
2696
+ }));
2697
+ console.log(formatAiBody({ topIssue, whyMatters, nextActions }));
2698
+ console.log(formatAiFooterBlock({
2699
+ status: shipDecision,
2700
+ domain: result?.production?.domain || productionUrl,
2701
+ kind: "preview-diff",
2702
+ preview_url: previewUrl,
2703
+ production_url: productionUrl,
2704
+ verdict,
2705
+ max_severity: maxSev,
2706
+ delta_count: summaryLines.filter((l) => !/no meaningful/i.test(l)).length,
2707
+ ship_decision: shipDecision,
2708
+ preview_dbr: typeof prevScore === "number" ? prevScore.toFixed(1) : "",
2709
+ production_dbr: typeof prodScore === "number" ? prodScore.toFixed(1) : "",
2710
+ score_delta: scoreDelta !== null ? scoreDelta.toString() : "",
2711
+ top_issue: firstDelta || "none",
2712
+ scanned_at: new Date().toISOString(),
2713
+ advisory_only: "true",
2714
+ }));
2715
+ console.log("");
2716
+
2717
+ await writeLastScanFile({
2718
+ domain: result?.production?.domain || productionUrl,
2719
+ kind: "preview-diff",
2720
+ preview_url: previewUrl,
2721
+ production_url: productionUrl,
2722
+ score: typeof prevScore === "number" ? prevScore : null,
2723
+ max_severity: maxSev,
2724
+ ship_decision: shipDecision,
2725
+ delta_count: summaryLines.filter((l) => !/no meaningful/i.test(l)).length,
2726
+ });
2727
+
2728
+ process.exit(shipDecisionExitCode(shipDecision));
2729
+ }
2730
+
2228
2731
  if (format === "json") {
2229
2732
  console.log(JSON.stringify(result, null, 2));
2230
2733
  } else {
@@ -2414,7 +2917,7 @@ function computeLockDiff(oldLock, newLock) {
2414
2917
 
2415
2918
  // `pqcheck watch <domain>` — adds the given domain to the user's watched-
2416
2919
  // domain list via the authenticated /api/watched-domains POST. Requires
2417
- // QUANTAPACT_API_KEY env var. Closes the CLI ↔ account loop: developers
2920
+ // CIPHERWAKE_API_KEY env var. Closes the CLI ↔ account loop: developers
2418
2921
  // who use the CLI can now opt into persistent monitoring from the same
2419
2922
  // surface without leaving the terminal.
2420
2923
  async function runWatchCommand(args) {
@@ -2926,9 +3429,10 @@ async function runDeployCheckCommand(args) {
2926
3429
  if (!args.includes("--fail-on")) forwarded.push("--fail-on", "high");
2927
3430
 
2928
3431
  // Pre-print a deploy-context header (only in text mode — JSON/SARIF users
2929
- // are scripting and don't want our preamble polluting their pipe).
3432
+ // are scripting and don't want our preamble polluting their pipe; AI mode
3433
+ // emits its own banner so don't double-up).
2930
3434
  const format = parseFormat(forwarded);
2931
- if (format === "text") {
3435
+ if (format === "text" && !parseAiMode(forwarded)) {
2932
3436
  console.log("");
2933
3437
  console.log(` ${color("bold", "🚀 Deploy gate")} ${color("dim", "— checking public trust posture vs last scan")}`);
2934
3438
  console.log("");
@@ -3546,6 +4050,1183 @@ async function tryOpenBrowser(url) {
3546
4050
  });
3547
4051
  }
3548
4052
 
4053
+ // =============================================================================
4054
+ // `pqcheck guard --domain X -- <deploy command>` — wrapper command
4055
+ // =============================================================================
4056
+ // The strongest single artifact for terminal-first AI-coder workflows.
4057
+ // Runs deploy-check first, conditionally executes the wrapped deploy command
4058
+ // based on ship_decision. AI coders type ONE command; Cipherwake controls
4059
+ // whether the deploy actually runs.
4060
+ //
4061
+ // Usage:
4062
+ // npx pqcheck guard --domain example.com -- vercel deploy --prod
4063
+ // npx pqcheck guard --domain example.com --gate-mode strict -- bash deploy.sh
4064
+ // npx pqcheck guard --domain example.com --bypass "shipping despite review" -- ...
4065
+ //
4066
+ // Exit codes:
4067
+ // 0 = deploy ran and succeeded (pre-check passed or user confirmed review)
4068
+ // 1 = pre-check returned review and user chose not to proceed
4069
+ // 2 = pre-check returned block and deploy was refused (use --bypass to override)
4070
+ // 3 = wrapper / deploy command itself errored
4071
+ // =============================================================================
4072
+ async function runGuardCommand(args) {
4073
+ // Parse flags and the `--` separator.
4074
+ const sepIdx = args.indexOf("--");
4075
+ const ourArgs = sepIdx >= 0 ? args.slice(0, sepIdx) : args;
4076
+ const deployCmd = sepIdx >= 0 ? args.slice(sepIdx + 1) : [];
4077
+
4078
+ const domain = parseFlag(ourArgs, "--domain");
4079
+ const gateMode = parseFlag(ourArgs, "--gate-mode") || "balanced";
4080
+ const bypassReason = parseFlag(ourArgs, "--bypass");
4081
+ const noPostCheck = ourArgs.includes("--no-post-check");
4082
+
4083
+ if (!domain) {
4084
+ console.error(color("red", "error: pqcheck guard requires --domain"));
4085
+ console.error(color("dim", "Usage: npx pqcheck guard --domain example.com -- <deploy command>"));
4086
+ console.error(color("dim", "Example: npx pqcheck guard --domain example.com -- vercel deploy --prod"));
4087
+ process.exit(3);
4088
+ }
4089
+ if (deployCmd.length === 0) {
4090
+ console.error(color("red", "error: pqcheck guard requires a deploy command after `--`"));
4091
+ console.error(color("dim", "Usage: npx pqcheck guard --domain example.com -- vercel deploy --prod"));
4092
+ process.exit(3);
4093
+ }
4094
+ if (!["balanced", "advisory", "strict"].includes(gateMode)) {
4095
+ console.error(color("red", `error: --gate-mode must be one of: balanced, advisory, strict (got "${gateMode}")`));
4096
+ process.exit(3);
4097
+ }
4098
+
4099
+ const labelByMode = {
4100
+ balanced: "Balanced (default — review on HIGH, block on CRITICAL)",
4101
+ advisory: "Advisory (warnings only, deploy never blocked)",
4102
+ strict: "Strict (block on any finding ≥ medium)",
4103
+ };
4104
+
4105
+ console.log("");
4106
+ console.log(` ${color("bold", "◆ Cipherwake Deploy Guard")} ${color("dim", `· ${labelByMode[gateMode]}`)}`);
4107
+ console.log(` ${color("dim", `domain: ${domain}`)}`);
4108
+ console.log(` ${color("dim", `deploy: ${deployCmd.join(" ")}`)}`);
4109
+ console.log("");
4110
+
4111
+ if (bypassReason) {
4112
+ console.log(color("yellow", ` ⚠ --bypass set with reason: "${bypassReason}"`));
4113
+ console.log(color("dim", " The pre-deploy check will still run, but a review or block won't stop the deploy."));
4114
+ console.log("");
4115
+ }
4116
+
4117
+ // Run the pre-deploy check. We invoke ourselves (the CLI) as a subprocess
4118
+ // rather than calling internal functions because the deploy-check exit
4119
+ // code + ship_decision semantics are the contract; recreating that logic
4120
+ // inline would risk drift.
4121
+ const { spawn } = await import("node:child_process");
4122
+
4123
+ // Detect the right binary to invoke: if we're running via `npx pqcheck`
4124
+ // we want to re-invoke pqcheck (process.argv[1]); if we're installed
4125
+ // globally, same path.
4126
+ const selfPath = process.argv[1];
4127
+
4128
+ console.log(color("dim", " Running pre-deploy check ..."));
4129
+ const checkArgs = ["deploy-check", domain, "--ai"];
4130
+ // In advisory mode the deploy NEVER blocks — pass --fail-on none so the
4131
+ // server returns findings but the verdict downgrades to report.
4132
+ if (gateMode === "advisory") checkArgs.push("--fail-on", "none");
4133
+ // In strict mode block on any finding ≥ medium.
4134
+ if (gateMode === "strict") checkArgs.push("--fail-on", "medium");
4135
+
4136
+ let checkOutput = "";
4137
+ const checkExitCode = await new Promise((resolve) => {
4138
+ const child = spawn(process.execPath, [selfPath, ...checkArgs], {
4139
+ stdio: ["ignore", "pipe", "inherit"],
4140
+ });
4141
+ child.stdout.on("data", (chunk) => {
4142
+ const s = chunk.toString();
4143
+ checkOutput += s;
4144
+ process.stdout.write(s);
4145
+ });
4146
+ child.on("close", (code) => resolve(code ?? 3));
4147
+ child.on("error", () => resolve(3));
4148
+ });
4149
+
4150
+ // Parse the structured CIPHERWAKE_AI_GUARD_RESULT block to extract
4151
+ // ship_decision (the contract field). If we can't find it, fail safe.
4152
+ let shipDecision = "review";
4153
+ const blockMatch = checkOutput.match(/CIPHERWAKE_AI_GUARD_RESULT\n([\s\S]*?)\nEND_CIPHERWAKE_AI_GUARD_RESULT/);
4154
+ if (blockMatch) {
4155
+ const sdLine = blockMatch[1].split("\n").find((l) => l.startsWith("ship_decision="));
4156
+ if (sdLine) shipDecision = sdLine.split("=")[1].trim();
4157
+ }
4158
+
4159
+ console.log("");
4160
+ console.log(color("bold", ` Pre-deploy check returned: ship_decision=${shipDecision}`));
4161
+
4162
+ // Decide what to do.
4163
+ if (shipDecision === "pass") {
4164
+ console.log(color("green", " ✓ Posture stable — running deploy command."));
4165
+ console.log("");
4166
+ } else if (shipDecision === "review") {
4167
+ if (bypassReason) {
4168
+ console.log(color("yellow", " ⚠ Review-level finding(s) detected, but --bypass is set — proceeding."));
4169
+ } else if (process.stdin.isTTY) {
4170
+ const readline = await import("node:readline");
4171
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
4172
+ const answer = await new Promise((resolve) => {
4173
+ rl.question(color("yellow", "\n ⚠ Cipherwake flagged a review-level finding. Continue with deploy? [y/N]: "), (a) => {
4174
+ rl.close();
4175
+ resolve((a || "").trim().toLowerCase());
4176
+ });
4177
+ });
4178
+ if (answer !== "y" && answer !== "yes") {
4179
+ console.log(color("dim", " Deploy cancelled by user."));
4180
+ process.exit(1);
4181
+ }
4182
+ } else {
4183
+ console.error(color("red", " ✗ Review-level finding(s) and no interactive terminal — failing closed."));
4184
+ console.error(color("dim", " Re-run interactively, or pass --bypass \"<reason>\" to acknowledge."));
4185
+ process.exit(1);
4186
+ }
4187
+ } else if (shipDecision === "block") {
4188
+ if (bypassReason) {
4189
+ console.log(color("red", " ⚠ BLOCK-level finding, but --bypass is set with explicit reason — proceeding."));
4190
+ console.log(color("dim", ` Logged bypass reason: "${bypassReason}"`));
4191
+ } else {
4192
+ console.error(color("red", " ✗ Cipherwake returned BLOCK — refusing to run deploy command."));
4193
+ console.error(color("dim", " Investigate the finding above. To override, re-run with --bypass \"<your reason>\"."));
4194
+ process.exit(2);
4195
+ }
4196
+ }
4197
+
4198
+ // Execute the wrapped deploy command.
4199
+ console.log(color("dim", ` Executing: ${deployCmd.join(" ")}`));
4200
+ console.log("");
4201
+ const deployExitCode = await new Promise((resolve) => {
4202
+ const child = spawn(deployCmd[0], deployCmd.slice(1), { stdio: "inherit" });
4203
+ child.on("close", (code) => resolve(code ?? 3));
4204
+ child.on("error", (err) => {
4205
+ console.error(color("red", ` ✗ Deploy command failed to spawn: ${err.message}`));
4206
+ resolve(3);
4207
+ });
4208
+ });
4209
+
4210
+ if (deployExitCode !== 0) {
4211
+ console.error(color("red", `\n ✗ Deploy command exited with code ${deployExitCode}.`));
4212
+ process.exit(deployExitCode);
4213
+ }
4214
+
4215
+ // Post-deploy check — useful for verifying the live URL after deploy.
4216
+ if (!noPostCheck) {
4217
+ console.log("");
4218
+ console.log(color("dim", " Running post-deploy check ..."));
4219
+ const postExitCode = await new Promise((resolve) => {
4220
+ const child = spawn(process.execPath, [selfPath, domain, "--ai", "--fresh"], { stdio: "inherit" });
4221
+ child.on("close", (code) => resolve(code ?? 0));
4222
+ child.on("error", () => resolve(0));
4223
+ });
4224
+ if (postExitCode !== 0) {
4225
+ console.error(color("yellow", `\n ⚠ Post-deploy check returned non-zero (${postExitCode}) — investigate before announcing.`));
4226
+ }
4227
+ }
4228
+
4229
+ console.log("");
4230
+ console.log(color("green", " ◆ Deploy guard complete."));
4231
+ process.exit(0);
4232
+ }
4233
+
4234
+ // =============================================================================
4235
+ // `pqcheck protocol install` — Rule 17 consent-flow installer for the AI Coder
4236
+ // Protocol.
4237
+ // =============================================================================
4238
+ // Detects ~/.claude/CLAUDE.md and ./.cursorrules, then asks ONE upfront question
4239
+ // (auto / manual / no) per Rule 17. Never silently writes user config.
4240
+ // =============================================================================
4241
+ const AI_CODER_PROTOCOL_TEXT = `
4242
+ ## Pre-deploy verification with Cipherwake
4243
+
4244
+ Before announcing any deploy of a public-facing site, you MUST run a
4245
+ Cipherwake deploy check and route on the result.
4246
+
4247
+ 1. Run:
4248
+
4249
+ npx pqcheck deploy-check <your-domain> --ai
4250
+
4251
+ (Or wrap your deploy command with: npx pqcheck guard --domain <your-domain> -- <deploy-cmd>)
4252
+
4253
+ 2. Parse the CIPHERWAKE_AI_GUARD_RESULT block at the end of stdout.
4254
+ Extract the \`ship_decision\` field.
4255
+
4256
+ 3. Route on ship_decision:
4257
+
4258
+ • pass — Public trust surface is stable. Safe to announce.
4259
+
4260
+ • review — STOP. Surface the change to the user:
4261
+
4262
+ "Cipherwake flagged a change: <top_issue>. The public
4263
+ trust surface drifted between baseline and now. Want me to:
4264
+ 1) Ship anyway (you accept the change as intentional)
4265
+ 2) Revert the deploy
4266
+ 3) Investigate before deciding"
4267
+
4268
+ Wait for explicit user choice. Do NOT announce the deploy
4269
+ until they confirm.
4270
+
4271
+ • block — STOP. Surface to the user:
4272
+
4273
+ "Cipherwake flagged a CRITICAL change: <top_issue>.
4274
+ I recommend not announcing this deploy until we investigate.
4275
+ Want to revert and look, or override?"
4276
+
4277
+ Wait for explicit user override before announcing.
4278
+
4279
+ 4. Never claim a deploy is "done" or "shipped" until either:
4280
+ • ship_decision was pass, OR
4281
+ • the user explicitly confirmed they want to ship despite review/block.
4282
+
4283
+ Reference: https://cipherwake.io/methodology/ai-coder-protocol
4284
+ `;
4285
+
4286
+ async function runProtocolCommand(args) {
4287
+ const sub = args[0];
4288
+ if (sub !== "install") {
4289
+ console.error(color("red", `error: unknown protocol subcommand "${sub || ""}"`));
4290
+ console.error(color("dim", "Usage:"));
4291
+ console.error(color("dim", " Interactive: npx pqcheck protocol install"));
4292
+ console.error(color("dim", " Auto (incl AI): npx pqcheck protocol install --auto"));
4293
+ console.error(color("dim", " Print only: npx pqcheck protocol install --manual"));
4294
+ console.error(color("dim", " Add audit metadata: --invoked-by=\"<name>\" --consent-phrase=\"<user words>\""));
4295
+ process.exit(3);
4296
+ }
4297
+
4298
+ const os = await import("node:os");
4299
+ const path = await import("node:path");
4300
+ const fs = await import("node:fs/promises");
4301
+ const readline = await import("node:readline");
4302
+
4303
+ // -------------------------------------------------------------------------
4304
+ // Flag-driven setup mode (per CLAUDE.md Rule 17 — agent-invocation path).
4305
+ // --auto → install to all detected files, no prompts. AI-agent-friendly.
4306
+ // --manual → print only, no install. Same as choosing [m] interactively.
4307
+ // (no flag, with TTY) → interactive [a/m/n] prompt
4308
+ // (no flag, no TTY) → bail with helpful error pointing at --auto/--manual
4309
+ //
4310
+ // Optional audit metadata (richer trail in install-prefs.json):
4311
+ // --invoked-by="<name>" — who initiated the install (AI name+version, "human", etc.)
4312
+ // --consent-phrase="<words>" — the literal words that authorized this (free-form)
4313
+ //
4314
+ // The flag's presence IS the consent signal — Cipherwake doesn't second-guess
4315
+ // a flag that's explicitly typed. The audit trail file captures who, when,
4316
+ // and what for customer recourse if needed.
4317
+ //
4318
+ // Rule 17's spirit is "no surprises." A flag that was explicitly passed
4319
+ // (by a human or by an AI on the human's behalf) is the opposite of a
4320
+ // surprise — it's a recorded, intentional action.
4321
+ // -------------------------------------------------------------------------
4322
+ const autoFlag = args.includes("--auto");
4323
+ const manualFlag = args.includes("--manual");
4324
+ const invokedBy = parseFlag(args, "--invoked-by") || (autoFlag ? "unknown-agent-or-script" : null);
4325
+ const consentPhrase = parseFlag(args, "--consent-phrase") || null;
4326
+
4327
+ if (autoFlag && manualFlag) {
4328
+ console.error(color("red", "error: --auto and --manual are mutually exclusive"));
4329
+ process.exit(3);
4330
+ }
4331
+
4332
+ // Detect candidate files across major AI coders that:
4333
+ // (a) read an instructions file at session start, AND
4334
+ // (b) can run shell commands (so they can actually invoke pqcheck).
4335
+ //
4336
+ // Surfaces that ONLY do autocomplete (Copilot ghost text, basic Codeium,
4337
+ // tab-only IDEs) are intentionally not here — the protocol can't apply to
4338
+ // them; they need the GitHub Action PR-comment surface instead.
4339
+ const candidates = [
4340
+ { label: "Claude Code (global)", path: path.join(os.homedir(), ".claude", "CLAUDE.md") },
4341
+ { label: "Claude Code (project)", path: path.join(process.cwd(), "CLAUDE.md") },
4342
+ { label: "Cursor (project)", path: path.join(process.cwd(), ".cursorrules") },
4343
+ { label: "Aider conf (project)", path: path.join(process.cwd(), ".aider.conf.yml") },
4344
+ { label: "Aider conventions (project)", path: path.join(process.cwd(), "CONVENTIONS.md") },
4345
+ { label: "GitHub Copilot Chat / Workspace (project)", path: path.join(process.cwd(), ".github", "copilot-instructions.md") },
4346
+ { label: "Windsurf / Codeium (project)", path: path.join(process.cwd(), ".windsurfrules") },
4347
+ { label: "Continue.dev (project)", path: path.join(process.cwd(), ".continuerules") },
4348
+ { label: "Cline / Roo Cline (project)", path: path.join(process.cwd(), ".clinerules") },
4349
+ { label: "AGENTS.md (cross-tool standard)", path: path.join(process.cwd(), "AGENTS.md") },
4350
+ ];
4351
+
4352
+ const detected = [];
4353
+ for (const c of candidates) {
4354
+ try {
4355
+ await fs.access(c.path);
4356
+ detected.push(c);
4357
+ } catch { /* file doesn't exist; that's OK */ }
4358
+ }
4359
+
4360
+ console.log("");
4361
+ console.log(color("bold", "◆ Cipherwake AI Coder Protocol — install"));
4362
+ console.log("");
4363
+ if (autoFlag || manualFlag) {
4364
+ console.log(color("dim", `Mode: ${autoFlag ? "auto (--auto flag set)" : "print-only (--manual flag set)"}`));
4365
+ if (invokedBy) console.log(color("dim", `Invoked by: ${invokedBy}`));
4366
+ if (consentPhrase) console.log(color("dim", `Consent phrase: "${consentPhrase}"`));
4367
+ console.log("");
4368
+ }
4369
+ console.log("Here's what would be added:");
4370
+ console.log("");
4371
+ if (detected.length === 0) {
4372
+ console.log(color("dim", " No existing CLAUDE.md / .cursorrules / .aider.conf.yml found."));
4373
+ console.log(color("dim", " Creating ~/.claude/CLAUDE.md with the protocol."));
4374
+ detected.push({ label: "Claude Code (will create)", path: path.join(os.homedir(), ".claude", "CLAUDE.md") });
4375
+ }
4376
+ for (const d of detected) {
4377
+ console.log(` • Append a ~30-line "## Pre-deploy verification with Cipherwake" section to ${color("bold", d.path)}`);
4378
+ console.log(` ${color("dim", `(${d.label} — existing content preserved)`)}`);
4379
+ }
4380
+ console.log("");
4381
+
4382
+ // --manual → print only, no install
4383
+ if (manualFlag) {
4384
+ console.log(color("bold", "── Cipherwake AI Coder Protocol — paste this into your AI coder's instructions ──"));
4385
+ console.log(AI_CODER_PROTOCOL_TEXT);
4386
+ console.log(color("bold", "── End of protocol ──"));
4387
+ console.log("");
4388
+ console.log(color("dim", `For target files, see: ${detected.map(d => d.path).join(", ")}`));
4389
+ process.exit(0);
4390
+ }
4391
+
4392
+ // --auto → install to all detected files
4393
+ if (autoFlag) {
4394
+ console.log(color("dim", "Auto-install (--auto flag set). Writing audit trail to ~/.config/cipherwake/install-prefs.json."));
4395
+ console.log("");
4396
+ return await performAutoInstall(detected, {
4397
+ mode: "auto-flag",
4398
+ consent_phrase: consentPhrase,
4399
+ invoked_by: invokedBy,
4400
+ fs, path, os,
4401
+ });
4402
+ }
4403
+
4404
+ // Interactive (human-at-terminal) path.
4405
+ console.log("Per CLAUDE.md Rule 17, Cipherwake never modifies your config without asking.");
4406
+ console.log("");
4407
+ console.log(" [a]uto — I add the protocol to all detected files + show you a diff afterward");
4408
+ console.log(" [m]anual — Print the protocol so you can paste it yourself");
4409
+ console.log(" [n]o — Skip; you can re-run anytime with `npx pqcheck protocol install`");
4410
+ console.log("");
4411
+
4412
+ if (!process.stdin.isTTY) {
4413
+ console.error(color("red", "error: pqcheck protocol install requires an interactive terminal — OR an explicit --auto / --manual flag."));
4414
+ console.error(color("dim", "Re-run in an interactive shell, OR pass one of:"));
4415
+ console.error(color("dim", " --auto (install to all detected files; AI-friendly)"));
4416
+ console.error(color("dim", " --manual (print protocol so you can paste it yourself; no install)"));
4417
+ console.error(color("dim", "Optional audit metadata: --invoked-by=\"<name>\" --consent-phrase=\"<words>\""));
4418
+ process.exit(3);
4419
+ }
4420
+
4421
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
4422
+ const choice = await new Promise((resolve) => {
4423
+ rl.question("Choose [a/m/n]: ", (a) => {
4424
+ rl.close();
4425
+ resolve((a || "").trim().toLowerCase());
4426
+ });
4427
+ });
4428
+
4429
+ if (choice === "n" || choice === "no") {
4430
+ console.log("");
4431
+ console.log(color("dim", "Skipped. Re-run anytime: npx pqcheck protocol install"));
4432
+ process.exit(0);
4433
+ }
4434
+
4435
+ if (choice === "m" || choice === "manual") {
4436
+ console.log("");
4437
+ console.log(color("bold", "── Cipherwake AI Coder Protocol — paste this into your AI coder's instructions ──"));
4438
+ console.log(AI_CODER_PROTOCOL_TEXT);
4439
+ console.log(color("bold", "── End of protocol ──"));
4440
+ console.log("");
4441
+ console.log(color("dim", `For target files, see: ${detected.map(d => d.path).join(", ")}`));
4442
+ process.exit(0);
4443
+ }
4444
+
4445
+ if (choice === "a" || choice === "auto") {
4446
+ return await performAutoInstall(detected, {
4447
+ mode: "interactive",
4448
+ consent_phrase: "user typed [a] at the install prompt",
4449
+ invoked_by: "human-at-terminal",
4450
+ fs, path, os,
4451
+ });
4452
+ }
4453
+
4454
+ console.error(color("red", `Unknown choice "${choice}". Expected a / m / n.`));
4455
+ process.exit(3);
4456
+ }
4457
+
4458
+ // Shared install routine — used by both interactive and agent-invoked paths.
4459
+ // Writes the protocol to each detected file and records the audit trail.
4460
+ async function performAutoInstall(detected, opts) {
4461
+ const { fs, path, os } = opts;
4462
+ const results = [];
4463
+ for (const d of detected) {
4464
+ try {
4465
+ await fs.mkdir(path.dirname(d.path), { recursive: true });
4466
+ let existing = "";
4467
+ try { existing = await fs.readFile(d.path, "utf8"); } catch { /* file may not exist yet */ }
4468
+ if (existing.includes("## Pre-deploy verification with Cipherwake")) {
4469
+ console.log(color("dim", ` ⊝ ${d.path} — already contains the protocol, skipping.`));
4470
+ results.push({ path: d.path, label: d.label, status: "skipped-already-present" });
4471
+ continue;
4472
+ }
4473
+ const newContent = existing + "\n" + AI_CODER_PROTOCOL_TEXT + "\n";
4474
+ await fs.writeFile(d.path, newContent, "utf8");
4475
+ console.log(color("green", ` ✓ ${d.path} — protocol appended (${AI_CODER_PROTOCOL_TEXT.split("\n").length} lines)`));
4476
+ results.push({ path: d.path, label: d.label, status: "installed" });
4477
+ } catch (err) {
4478
+ console.log(color("red", ` ✗ ${d.path} — failed: ${err.message}`));
4479
+ results.push({ path: d.path, label: d.label, status: "failed", error: String(err?.message || err) });
4480
+ }
4481
+ }
4482
+
4483
+ // Persist the audit trail to ~/.config/cipherwake/install-prefs.json.
4484
+ // This is the customer's recourse if they ever dispute the install — the
4485
+ // file shows who invoked it, when, and the exact consent phrase used.
4486
+ try {
4487
+ const prefsDir = path.join(os.homedir(), ".config", "cipherwake");
4488
+ await fs.mkdir(prefsDir, { recursive: true });
4489
+ const prefsPath = path.join(prefsDir, "install-prefs.json");
4490
+ let prefs = { history: [] };
4491
+ try {
4492
+ const existing = await fs.readFile(prefsPath, "utf8");
4493
+ prefs = JSON.parse(existing);
4494
+ if (!Array.isArray(prefs.history)) prefs.history = [];
4495
+ } catch { /* first run, file doesn't exist or is malformed */ }
4496
+ prefs.history.push({
4497
+ timestamp: new Date().toISOString(),
4498
+ command: "protocol-install",
4499
+ mode: opts.mode,
4500
+ consent_phrase: opts.consent_phrase,
4501
+ invoked_by: opts.invoked_by,
4502
+ cwd: process.cwd(),
4503
+ hostname: os.hostname(),
4504
+ pqcheck_version: VERSION,
4505
+ results,
4506
+ });
4507
+ await fs.writeFile(prefsPath, JSON.stringify(prefs, null, 2), "utf8");
4508
+ console.log(color("dim", ` Audit trail: ${prefsPath}`));
4509
+ } catch (err) {
4510
+ console.log(color("dim", ` (audit trail write failed: ${err.message} — non-fatal)`));
4511
+ }
4512
+
4513
+ console.log("");
4514
+ console.log(color("green", "Done. Your AI coder will follow the protocol on its next deploy."));
4515
+ console.log(color("dim", "Reference: https://cipherwake.io/methodology/ai-coder-protocol"));
4516
+ process.exit(0);
4517
+ }
4518
+
4519
+ // =============================================================================
4520
+ // `pqcheck setup --auto --domain <D>` — consolidated installer
4521
+ // =============================================================================
4522
+ // One command installs everything an AI-coder workflow needs:
4523
+ // 1. GitHub Action workflow file (CI hard gate)
4524
+ // 2. AI Coder Protocol across all detected rules files (CLAUDE.md /
4525
+ // .cursorrules / .github/copilot-instructions.md / .aider.conf.yml /
4526
+ // AGENTS.md / etc.) — multi-platform, defense-in-depth
4527
+ // 3. Git pre-push hook (catches manual git push origin main bypasses)
4528
+ // 4. Claude Code statusLine config in ~/.claude/settings.json (per-flag
4529
+ // consent counts as Rule 17 consent; recorded in audit trail)
4530
+ // 5. VS Code extension via `code --install-extension` if `code` CLI on PATH
4531
+ // (skipped silently if not available)
4532
+ //
4533
+ // The pinnedai-equivalent for Cipherwake. Launch story: "One command, every
4534
+ // AI coder ready."
4535
+ //
4536
+ // Usage:
4537
+ // npx pqcheck setup --auto --domain cipherwake.io
4538
+ // npx pqcheck setup --auto --domain cipherwake.io \\
4539
+ // --invoked-by "Claude Code" --consent-phrase "the user said yes do B"
4540
+ // =============================================================================
4541
+
4542
+ const GIT_PREPUSH_HOOK_SCRIPT = `#!/usr/bin/env bash
4543
+ # Cipherwake pre-push hook — installed by \`pqcheck setup --auto\`
4544
+ #
4545
+ # Runs deploy-check before pushes to deploy-triggering branches. Catches the
4546
+ # case where a human (or an AI that forgot the protocol) pushes directly to
4547
+ # main without running deploy-check first. Defense-in-depth alongside the
4548
+ # GitHub Action + the AI Coder Protocol.
4549
+ #
4550
+ # To bypass for a single push: CIPHERWAKE_HOOK_SKIP=1 git push
4551
+ # To uninstall: rm .git/hooks/pre-push (or pqcheck setup --uninstall — TBD)
4552
+
4553
+ CIPHERWAKE_DOMAIN="\${CIPHERWAKE_DOMAIN:-DOMAIN_PLACEHOLDER}"
4554
+ DEPLOY_BRANCHES_REGEX="\${CIPHERWAKE_DEPLOY_BRANCHES:-^refs/heads/(main|master|production|deploy)$}"
4555
+
4556
+ if [ "\$CIPHERWAKE_HOOK_SKIP" = "1" ]; then
4557
+ echo "▶ Cipherwake hook: CIPHERWAKE_HOOK_SKIP=1 — bypassing"
4558
+ exit 0
4559
+ fi
4560
+
4561
+ # Read which refs are being pushed. We only act on pushes to deploy branches.
4562
+ SHOULD_RUN=0
4563
+ while read local_ref local_sha remote_ref remote_sha; do
4564
+ if [[ "\$remote_ref" =~ \$DEPLOY_BRANCHES_REGEX ]]; then
4565
+ SHOULD_RUN=1
4566
+ break
4567
+ fi
4568
+ done
4569
+
4570
+ if [ "\$SHOULD_RUN" = "0" ]; then
4571
+ exit 0
4572
+ fi
4573
+
4574
+ echo "▶ Cipherwake pre-push: running deploy-check on \$CIPHERWAKE_DOMAIN..."
4575
+
4576
+ # npx pqcheck deploy-check exits: 0=pass, 1=warn/review, 2=fail/block, 3=error
4577
+ npx --yes pqcheck deploy-check "\$CIPHERWAKE_DOMAIN" --ai
4578
+ RC=\$?
4579
+
4580
+ case "\$RC" in
4581
+ 0)
4582
+ echo "▶ Cipherwake: ship_decision=pass — proceeding with push"
4583
+ exit 0
4584
+ ;;
4585
+ 1)
4586
+ echo ""
4587
+ echo "▶ Cipherwake flagged a REVIEW-level finding (see output above)."
4588
+ echo "▶ Push allowed — but please review the change before announcing the deploy."
4589
+ echo "▶ To suppress this notice for one push: CIPHERWAKE_HOOK_SKIP=1 git push"
4590
+ exit 0
4591
+ ;;
4592
+ 2)
4593
+ echo ""
4594
+ echo "✗ Cipherwake returned BLOCK — refusing push."
4595
+ echo "✗ Investigate the finding above before deploying."
4596
+ echo "✗ To override (with explicit acknowledgement): CIPHERWAKE_HOOK_SKIP=1 git push"
4597
+ exit 1
4598
+ ;;
4599
+ *)
4600
+ echo "▶ Cipherwake pre-check errored (exit \$RC) — allowing push (fail-open)."
4601
+ echo "▶ Set CIPHERWAKE_API_KEY if deploy-check is failing on auth."
4602
+ exit 0
4603
+ ;;
4604
+ esac
4605
+ `;
4606
+
4607
+ const CLAUDE_STATUSLINE_CONFIG_SNIPPET = `
4608
+ "statusLine": {
4609
+ "type": "command",
4610
+ "command": "npx cipherwake-statusline"
4611
+ }`;
4612
+
4613
+ // R74-confirm SHIP #14-15 (GPT 2026-05-22): network connectivity diagnostic.
4614
+ // Customer runs this when "scan hung" or "command not found" to surface the
4615
+ // actual broken hop instead of guessing. Tests: DNS resolution → TCP → TLS
4616
+ // → HTTP for each upstream.
4617
+ async function runDebugNetworkCommand() {
4618
+ console.log("");
4619
+ console.log(color("bold", "◆ Cipherwake — network diagnostic"));
4620
+ console.log(color("dim", "Probes every upstream the CLI depends on. Run this when scans hang or fail."));
4621
+ console.log("");
4622
+
4623
+ const probes = [
4624
+ { name: "cipherwake.io (API)", url: "https://cipherwake.io/api/scan?domain=cipherwake.io" },
4625
+ { name: "cipherwake.io (homepage)", url: "https://cipherwake.io/" },
4626
+ { name: "crt.sh (CT log upstream)", url: "https://crt.sh/?q=%25.cipherwake.io&output=json" },
4627
+ { name: "Vercel direct (bypass Cloudflare)", url: "https://quantapact.vercel.app/" },
4628
+ ];
4629
+
4630
+ let anyFailed = false;
4631
+ for (const p of probes) {
4632
+ const t0 = Date.now();
4633
+ try {
4634
+ const ctrl = new AbortController();
4635
+ const tmr = setTimeout(() => ctrl.abort(), 10000);
4636
+ const resp = await fetch(p.url, { method: "HEAD", signal: ctrl.signal });
4637
+ clearTimeout(tmr);
4638
+ const elapsed = Date.now() - t0;
4639
+ const statusOk = resp.status >= 200 && resp.status < 500;
4640
+ const marker = statusOk ? color("green", "✓") : color("yellow", "⚠");
4641
+ console.log(` ${marker} ${p.name.padEnd(38)} HTTP ${resp.status} ${elapsed}ms`);
4642
+ if (!statusOk) anyFailed = true;
4643
+ } catch (err) {
4644
+ anyFailed = true;
4645
+ const elapsed = Date.now() - t0;
4646
+ const msg = (err && err.message) || String(err);
4647
+ const kind = err?.name === "AbortError" ? "timeout" : "error";
4648
+ console.log(` ${color("red", "✗")} ${p.name.padEnd(38)} ${kind.toUpperCase()} ${elapsed}ms ${msg.slice(0, 60)}`);
4649
+ }
4650
+ }
4651
+
4652
+ console.log("");
4653
+ if (anyFailed) {
4654
+ console.log(color("bold", "Possible causes (in order of likelihood):"));
4655
+ console.log(" 1. Corporate proxy / VPN blocking outbound HTTPS to public scanners.");
4656
+ console.log(" → Try setting HTTPS_PROXY env var, or run from a non-corporate network.");
4657
+ console.log(" 2. Cloudflare WAF rate-limited your IP. Wait 5-15min and retry.");
4658
+ console.log(" → Or switch networks (mobile hotspot bypasses your home IP).");
4659
+ console.log(" 3. DNS resolver doesn't resolve cipherwake.io.");
4660
+ console.log(" → Test with: `dig cipherwake.io`. If it fails, your resolver is offline.");
4661
+ console.log(" 4. crt.sh is intermittently slow — that's the upstream we use for CT logs.");
4662
+ console.log(" → Cipherwake degrades to a 14-day stale-cache fallback when crt.sh fails, so this");
4663
+ console.log(" only matters on first scan of brand-new domains.");
4664
+ console.log("");
4665
+ console.log(color("dim", "If you're still blocked, file a bug at https://cipherwake.io/feedback"));
4666
+ process.exit(1);
4667
+ } else {
4668
+ console.log(color("green", " ✓ All upstreams reachable — Cipherwake should work for you."));
4669
+ console.log("");
4670
+ }
4671
+ }
4672
+
4673
+ async function runSetupCommand(args) {
4674
+ const autoFlag = args.includes("--auto");
4675
+ const planFlag = args.includes("--plan"); // R74-confirm BLOCKING #19 (GPT 2026-05-22)
4676
+ const domain = parseFlag(args, "--domain");
4677
+ const failOn = parseFlag(args, "--fail-on") || "high";
4678
+ const baseline = parseFlag(args, "--baseline") || "last-scan";
4679
+ const invokedBy = parseFlag(args, "--invoked-by") || (autoFlag ? "unknown-agent-or-script" : null);
4680
+ const consentPhrase = parseFlag(args, "--consent-phrase") || null;
4681
+ const skipWorkflow = args.includes("--skip-workflow");
4682
+ const skipProtocol = args.includes("--skip-protocol");
4683
+ const skipHook = args.includes("--skip-hook");
4684
+ const skipStatusline = args.includes("--skip-statusline");
4685
+ const skipVscode = args.includes("--skip-vscode");
4686
+
4687
+ if (!domain) {
4688
+ console.error(color("red", "error: pqcheck setup requires --domain"));
4689
+ console.error(color("dim", "Usage: npx pqcheck setup --auto --domain example.com"));
4690
+ console.error(color("dim", " --plan Print the install plan without writing any files"));
4691
+ console.error(color("dim", " --invoked-by=\"<name>\" --consent-phrase=\"<words>\" (audit trail)"));
4692
+ console.error(color("dim", "Skip flags: --skip-workflow --skip-protocol --skip-hook --skip-statusline --skip-vscode"));
4693
+ process.exit(3);
4694
+ }
4695
+ if (!autoFlag && !planFlag && !process.stdin.isTTY) {
4696
+ console.error(color("red", "error: pqcheck setup requires --auto or --plan when stdin is not a TTY"));
4697
+ console.error(color("dim", "Pass --auto explicitly (the flag IS the consent signal per CLAUDE.md Rule 17),"));
4698
+ console.error(color("dim", "or --plan to see what would be installed without writing anything."));
4699
+ process.exit(3);
4700
+ }
4701
+
4702
+ // R74-confirm BLOCKING #19 (GPT 2026-05-22): --plan mode prints the
4703
+ // intended changes without writing. Addresses GPT's "init modifies too
4704
+ // much without a clear dry run" concern. Customer can run --plan first
4705
+ // to inspect, then run --auto when they're comfortable. AI agents are
4706
+ // encouraged (per the protocol page) to run --plan first when no recent
4707
+ // user consent for --auto exists in the conversation.
4708
+ if (planFlag) {
4709
+ const os = await import("node:os");
4710
+ const path = await import("node:path");
4711
+ const fs = await import("node:fs/promises");
4712
+ console.log("");
4713
+ console.log(color("bold", `◆ Cipherwake Setup — PLAN (dry run, no files will be written)`));
4714
+ console.log(color("dim", `Domain target: ${domain}`));
4715
+ console.log("");
4716
+ console.log(color("bold", "Files this install would touch:"));
4717
+ const planEntries = [];
4718
+ if (!skipWorkflow) planEntries.push({ what: "GitHub Action workflow", to: path.join(process.cwd(), ".github", "workflows", "cipherwake.yml"), op: "create" });
4719
+ if (!skipProtocol) {
4720
+ const candidates = [
4721
+ { label: "Claude Code (global)", path: path.join(os.homedir(), ".claude", "CLAUDE.md") },
4722
+ { label: "Claude Code (project)", path: path.join(process.cwd(), "CLAUDE.md") },
4723
+ { label: "Cursor", path: path.join(process.cwd(), ".cursorrules") },
4724
+ { label: "GitHub Copilot", path: path.join(process.cwd(), ".github", "copilot-instructions.md") },
4725
+ { label: "Aider", path: path.join(process.cwd(), ".aider.conf.yml") },
4726
+ { label: "AGENTS.md", path: path.join(process.cwd(), "AGENTS.md") },
4727
+ ];
4728
+ for (const c of candidates) {
4729
+ let exists = false;
4730
+ try { await fs.access(c.path); exists = true; } catch { /* */ }
4731
+ planEntries.push({ what: `AI Coder Protocol — ${c.label}`, to: c.path, op: exists ? "append-markered" : "skip (file not present)" });
4732
+ }
4733
+ }
4734
+ if (!skipHook) planEntries.push({ what: "git pre-push hook", to: path.join(process.cwd(), ".git", "hooks", "pre-push"), op: "create (if git repo)" });
4735
+ if (!skipStatusline) {
4736
+ const settingsPath = path.join(os.homedir(), ".claude", "settings.json");
4737
+ let exists = false;
4738
+ try { await fs.access(settingsPath); exists = true; } catch { /* */ }
4739
+ planEntries.push({ what: "Claude Code statusLine", to: settingsPath, op: exists ? "deep-merge (backup first)" : "create" });
4740
+ planEntries.push({ what: "Claude Code chat-hook (PostToolUse Bash)", to: settingsPath, op: exists ? "deep-merge into hooks.PostToolUse" : "create" });
4741
+ }
4742
+ if (!skipVscode) planEntries.push({ what: "VS Code / Cursor extension", to: "via `code --install-extension cipherwakelabs.cipherwake-statusbar`", op: "attempt (soft-fail if Marketplace listing missing)" });
4743
+ for (const e of planEntries) {
4744
+ console.log(` ${color("dim", e.op.padEnd(28))} ${color("bold", e.what)}`);
4745
+ console.log(` ${color("dim", "→")} ${e.to}`);
4746
+ }
4747
+ console.log("");
4748
+ console.log(color("bold", "To proceed:"));
4749
+ console.log(` npx pqcheck setup --auto --domain ${domain}${invokedBy ? ` --invoked-by "${invokedBy}"` : ""}${consentPhrase ? ` --consent-phrase "${consentPhrase}"` : ""}`);
4750
+ console.log("");
4751
+ console.log(color("dim", "Per Cipherwake Rule 17, --auto is the consent signal. Backups are taken before each settings.json write."));
4752
+ console.log("");
4753
+ return;
4754
+ }
4755
+
4756
+ const os = await import("node:os");
4757
+ const path = await import("node:path");
4758
+ const fs = await import("node:fs/promises");
4759
+ const { spawn } = await import("node:child_process");
4760
+
4761
+ console.log("");
4762
+ console.log(color("bold", `◆ Cipherwake Setup — ${domain}`));
4763
+ console.log("");
4764
+ if (autoFlag) {
4765
+ console.log(color("dim", "Auto mode (--auto flag set). Per CLAUDE.md Rule 17, the flag is the consent signal."));
4766
+ if (invokedBy) console.log(color("dim", `Invoked by: ${invokedBy}`));
4767
+ if (consentPhrase) console.log(color("dim", `Consent phrase: "${consentPhrase}"`));
4768
+ console.log("");
4769
+ }
4770
+
4771
+ const installSummary = [];
4772
+
4773
+ // -------------------------------------------------------------------------
4774
+ // Component 1: GitHub Action workflow (.github/workflows/cipherwake.yml)
4775
+ // -------------------------------------------------------------------------
4776
+ if (!skipWorkflow) {
4777
+ const workflowPath = path.join(process.cwd(), ".github", "workflows", "cipherwake.yml");
4778
+ try {
4779
+ try {
4780
+ await fs.access(workflowPath);
4781
+ console.log(color("dim", ` ⊝ workflow at .github/workflows/cipherwake.yml already exists — skipping`));
4782
+ installSummary.push({ component: "GitHub Action workflow", path: workflowPath, status: "skipped-already-present" });
4783
+ } catch {
4784
+ const workflowYaml = renderTrustDiffWorkflow({ domain, failOn, baseline });
4785
+ await fs.mkdir(path.dirname(workflowPath), { recursive: true });
4786
+ await fs.writeFile(workflowPath, workflowYaml, "utf8");
4787
+ console.log(color("green", ` ✓ wrote .github/workflows/cipherwake.yml (CI hard-gate layer)`));
4788
+ installSummary.push({ component: "GitHub Action workflow", path: workflowPath, status: "installed" });
4789
+ }
4790
+ } catch (err) {
4791
+ console.log(color("red", ` ✗ workflow install failed: ${err.message}`));
4792
+ installSummary.push({ component: "GitHub Action workflow", status: "failed", error: String(err?.message || err) });
4793
+ }
4794
+ }
4795
+
4796
+ // -------------------------------------------------------------------------
4797
+ // Component 2: AI Coder Protocol across detected rules files
4798
+ // -------------------------------------------------------------------------
4799
+ if (!skipProtocol) {
4800
+ const candidates = [
4801
+ { label: "Claude Code (global)", path: path.join(os.homedir(), ".claude", "CLAUDE.md") },
4802
+ { label: "Claude Code (project)", path: path.join(process.cwd(), "CLAUDE.md") },
4803
+ { label: "Cursor (project)", path: path.join(process.cwd(), ".cursorrules") },
4804
+ { label: "Aider conf (project)", path: path.join(process.cwd(), ".aider.conf.yml") },
4805
+ { label: "Aider conventions (project)", path: path.join(process.cwd(), "CONVENTIONS.md") },
4806
+ { label: "GitHub Copilot Chat / Workspace (project)", path: path.join(process.cwd(), ".github", "copilot-instructions.md") },
4807
+ { label: "Windsurf / Codeium (project)", path: path.join(process.cwd(), ".windsurfrules") },
4808
+ { label: "Continue.dev (project)", path: path.join(process.cwd(), ".continuerules") },
4809
+ { label: "Cline / Roo Cline (project)", path: path.join(process.cwd(), ".clinerules") },
4810
+ { label: "AGENTS.md (cross-tool standard)", path: path.join(process.cwd(), "AGENTS.md") },
4811
+ ];
4812
+ // Detect any existing files; auto-create the global Claude Code file so
4813
+ // every install yields at least one rules-file landing site.
4814
+ const protocolTargets = [];
4815
+ for (const c of candidates) {
4816
+ try {
4817
+ await fs.access(c.path);
4818
+ protocolTargets.push(c);
4819
+ } catch { /* skip */ }
4820
+ }
4821
+ if (protocolTargets.length === 0) {
4822
+ protocolTargets.push({ label: "Claude Code (created)", path: path.join(os.homedir(), ".claude", "CLAUDE.md") });
4823
+ }
4824
+ // R74-confirm BLOCKING #6 (GPT 2026-05-22): wrap the protocol section in
4825
+ // fenced markers so future installs / updates / removals can locate and
4826
+ // replace exactly this block without scanning for the heading. Idempotent:
4827
+ // if markers exist, the block between them is replaced; if neither markers
4828
+ // nor heading exist, the block is appended. Always preserves whatever the
4829
+ // user has written outside the markers.
4830
+ const START_MARKER = "<!-- CIPHERWAKE_AI_CODER_PROTOCOL_START — managed by pqcheck setup; safe to delete this section between markers but do not edit by hand -->";
4831
+ const END_MARKER = "<!-- CIPHERWAKE_AI_CODER_PROTOCOL_END -->";
4832
+ for (const t of protocolTargets) {
4833
+ try {
4834
+ await fs.mkdir(path.dirname(t.path), { recursive: true });
4835
+ let existing = "";
4836
+ try { existing = await fs.readFile(t.path, "utf8"); } catch { /* file may not exist */ }
4837
+
4838
+ const hasMarkers = existing.includes(START_MARKER) && existing.includes(END_MARKER);
4839
+ const hasLegacyHeading = existing.includes("## Pre-deploy verification with Cipherwake");
4840
+
4841
+ if (hasMarkers) {
4842
+ // Replace just the bounded section — leaves all other user content untouched.
4843
+ const startIdx = existing.indexOf(START_MARKER);
4844
+ const endIdx = existing.indexOf(END_MARKER) + END_MARKER.length;
4845
+ const before = existing.slice(0, startIdx);
4846
+ const after = existing.slice(endIdx);
4847
+ const next = `${before.replace(/\n+$/, "")}\n\n${START_MARKER}\n${AI_CODER_PROTOCOL_TEXT}\n${END_MARKER}\n${after.replace(/^\n+/, "")}`;
4848
+ if (next === existing) {
4849
+ console.log(color("dim", ` ⊝ ${path.basename(t.path)} (${t.label}) — protocol already current`));
4850
+ installSummary.push({ component: "AI Coder Protocol", path: t.path, label: t.label, status: "skipped-already-present" });
4851
+ continue;
4852
+ }
4853
+ await fs.writeFile(t.path, next, "utf8");
4854
+ console.log(color("green", ` ✓ refreshed protocol section → ${path.basename(t.path)} (${t.label})`));
4855
+ installSummary.push({ component: "AI Coder Protocol", path: t.path, label: t.label, status: "installed-updated" });
4856
+ } else if (hasLegacyHeading) {
4857
+ // Old install lacks markers. Don't double-append; treat as already present.
4858
+ // Note: customers can re-run `pqcheck protocol install --auto` to upgrade to the markered form.
4859
+ console.log(color("dim", ` ⊝ ${path.basename(t.path)} (${t.label}) — legacy unmarkered protocol present (run \`pqcheck protocol install --auto\` to upgrade to markered form)`));
4860
+ installSummary.push({ component: "AI Coder Protocol", path: t.path, label: t.label, status: "skipped-legacy-present" });
4861
+ } else {
4862
+ // Fresh install — append with fenced markers.
4863
+ const sep = existing.length > 0 ? "\n\n" : "";
4864
+ await fs.writeFile(t.path, `${existing}${sep}${START_MARKER}\n${AI_CODER_PROTOCOL_TEXT}\n${END_MARKER}\n`, "utf8");
4865
+ console.log(color("green", ` ✓ appended protocol (markered) → ${path.basename(t.path)} (${t.label})`));
4866
+ installSummary.push({ component: "AI Coder Protocol", path: t.path, label: t.label, status: "installed" });
4867
+ }
4868
+ } catch (err) {
4869
+ console.log(color("red", ` ✗ ${t.path} — failed: ${err.message}`));
4870
+ installSummary.push({ component: "AI Coder Protocol", path: t.path, status: "failed", error: String(err?.message || err) });
4871
+ }
4872
+ }
4873
+ }
4874
+
4875
+ // -------------------------------------------------------------------------
4876
+ // Component 3: Git pre-push hook
4877
+ // -------------------------------------------------------------------------
4878
+ if (!skipHook) {
4879
+ const gitDir = path.join(process.cwd(), ".git");
4880
+ let isGitRepo = true;
4881
+ try { await fs.access(gitDir); } catch { isGitRepo = false; }
4882
+ if (!isGitRepo) {
4883
+ console.log(color("dim", ` ⊝ git pre-push hook — skipped (not a git repo: no .git dir in ${process.cwd()})`));
4884
+ installSummary.push({ component: "git pre-push hook", status: "skipped-not-git-repo" });
4885
+ } else {
4886
+ const hookPath = path.join(gitDir, "hooks", "pre-push");
4887
+ try {
4888
+ let existing = "";
4889
+ try { existing = await fs.readFile(hookPath, "utf8"); } catch { /* file may not exist */ }
4890
+ if (existing.includes("Cipherwake pre-push hook")) {
4891
+ console.log(color("dim", ` ⊝ .git/hooks/pre-push already contains the Cipherwake hook — skipping`));
4892
+ installSummary.push({ component: "git pre-push hook", path: hookPath, status: "skipped-already-present" });
4893
+ } else if (existing.trim() && !existing.startsWith("#!/")) {
4894
+ console.log(color("yellow", ` ⊝ .git/hooks/pre-push exists but doesn't look like our hook — skipped to avoid overwriting your script`));
4895
+ installSummary.push({ component: "git pre-push hook", path: hookPath, status: "skipped-conflicts" });
4896
+ } else {
4897
+ const hookScript = GIT_PREPUSH_HOOK_SCRIPT.replace("DOMAIN_PLACEHOLDER", domain);
4898
+ await fs.mkdir(path.dirname(hookPath), { recursive: true });
4899
+ await fs.writeFile(hookPath, hookScript, { mode: 0o755 });
4900
+ // Ensure executable bit set (writeFile mode is platform-dependent)
4901
+ try { await fs.chmod(hookPath, 0o755); } catch { /* best effort */ }
4902
+ console.log(color("green", ` ✓ installed .git/hooks/pre-push (catches manual \`git push origin main\` bypasses)`));
4903
+ installSummary.push({ component: "git pre-push hook", path: hookPath, status: "installed" });
4904
+ }
4905
+ } catch (err) {
4906
+ console.log(color("red", ` ✗ pre-push hook install failed: ${err.message}`));
4907
+ installSummary.push({ component: "git pre-push hook", path: hookPath, status: "failed", error: String(err?.message || err) });
4908
+ }
4909
+ }
4910
+ }
4911
+
4912
+ // -------------------------------------------------------------------------
4913
+ // Component 4: Claude Code statusLine config
4914
+ // -------------------------------------------------------------------------
4915
+ // R74-confirm BLOCKING #10 (GPT 2026-05-22): backup before any settings.json
4916
+ // write. The user's ~/.claude/settings.json is theirs; we deep-merge our
4917
+ // entries but if the merge is ever wrong (existing key shape we didn't
4918
+ // anticipate), the backup gives them a one-second rollback path. The backup
4919
+ // is also surfaced in the install summary so the audit trail captures it.
4920
+ async function backupSettingsJson(settingsPath) {
4921
+ try {
4922
+ let raw;
4923
+ try { raw = await fs.readFile(settingsPath, "utf8"); } catch { return null; }
4924
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
4925
+ const backupPath = `${settingsPath}.bak.${ts}`;
4926
+ await fs.writeFile(backupPath, raw, "utf8");
4927
+ return backupPath;
4928
+ } catch (e) {
4929
+ console.log(color("yellow", ` ⚠ settings.json backup failed (${(e && e.message || e)}); proceeding anyway`));
4930
+ return null;
4931
+ }
4932
+ }
4933
+
4934
+ if (!skipStatusline) {
4935
+ const settingsPath = path.join(os.homedir(), ".claude", "settings.json");
4936
+ try {
4937
+ let settings = {};
4938
+ let existed = false;
4939
+ try {
4940
+ const raw = await fs.readFile(settingsPath, "utf8");
4941
+ settings = JSON.parse(raw);
4942
+ existed = true;
4943
+ } catch { /* will create */ }
4944
+ if (existed && settings.statusLine && typeof settings.statusLine === "object") {
4945
+ // Already has a statusLine config — don't overwrite.
4946
+ console.log(color("dim", ` ⊝ ~/.claude/settings.json already has a statusLine entry — leaving alone`));
4947
+ console.log(color("dim", ` To use the Cipherwake statusline instead, set: "command": "npx cipherwake-statusline"`));
4948
+ installSummary.push({ component: "Claude Code statusLine", path: settingsPath, status: "skipped-existing-config" });
4949
+ } else {
4950
+ const backupPath = existed ? await backupSettingsJson(settingsPath) : null;
4951
+ if (backupPath) console.log(color("dim", ` backup: ${backupPath}`));
4952
+ settings.statusLine = { type: "command", command: "npx cipherwake-statusline" };
4953
+ await fs.mkdir(path.dirname(settingsPath), { recursive: true });
4954
+ await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
4955
+ console.log(color("green", ` ✓ added statusLine config → ~/.claude/settings.json`));
4956
+ installSummary.push({ component: "Claude Code statusLine", path: settingsPath, status: existed ? "installed-updated" : "installed-created", backup: backupPath });
4957
+ }
4958
+ } catch (err) {
4959
+ console.log(color("red", ` ✗ statusLine config install failed: ${err.message}`));
4960
+ installSummary.push({ component: "Claude Code statusLine", path: settingsPath, status: "failed", error: String(err?.message || err) });
4961
+ }
4962
+ }
4963
+
4964
+ // -------------------------------------------------------------------------
4965
+ // Component 4b: Claude Code chat-hook (PostToolUse on Bash → cipherwake-chat-hook)
4966
+ // Pushes a live "◆ Cipherwake just caught X" message into the chat scrollback
4967
+ // every time a pqcheck command runs. Merges with existing hook configs;
4968
+ // doesn't clobber other hooks per CLAUDE.md Rule 17.
4969
+ // -------------------------------------------------------------------------
4970
+ if (!skipStatusline) {
4971
+ const settingsPath = path.join(os.homedir(), ".claude", "settings.json");
4972
+ try {
4973
+ let settings = {};
4974
+ let existed = false;
4975
+ try {
4976
+ const raw = await fs.readFile(settingsPath, "utf8");
4977
+ settings = JSON.parse(raw);
4978
+ existed = true;
4979
+ } catch { /* will create */ }
4980
+ settings.hooks = settings.hooks || {};
4981
+ settings.hooks.PostToolUse = settings.hooks.PostToolUse || [];
4982
+
4983
+ // Look for an existing matcher entry for Bash
4984
+ let bashEntry = settings.hooks.PostToolUse.find((e) => e?.matcher === "Bash");
4985
+ if (!bashEntry) {
4986
+ bashEntry = { matcher: "Bash", hooks: [] };
4987
+ settings.hooks.PostToolUse.push(bashEntry);
4988
+ }
4989
+ bashEntry.hooks = bashEntry.hooks || [];
4990
+
4991
+ const cipherwakeHookCmd = "npx cipherwake-chat-hook";
4992
+ const alreadyInstalled = bashEntry.hooks.some(
4993
+ (h) => h?.type === "command" && typeof h?.command === "string" && h.command.includes("cipherwake-chat-hook"),
4994
+ );
4995
+
4996
+ if (alreadyInstalled) {
4997
+ console.log(color("dim", ` ⊝ chat-hook already configured in ~/.claude/settings.json PostToolUse — skipping`));
4998
+ installSummary.push({ component: "Claude Code chat-hook", path: settingsPath, status: "skipped-already-present" });
4999
+ } else {
5000
+ const backupPath = existed ? await backupSettingsJson(settingsPath) : null;
5001
+ if (backupPath) console.log(color("dim", ` backup: ${backupPath}`));
5002
+ bashEntry.hooks.push({ type: "command", command: cipherwakeHookCmd });
5003
+ await fs.mkdir(path.dirname(settingsPath), { recursive: true });
5004
+ await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
5005
+ console.log(color("green", ` ✓ added chat-hook (PostToolUse Bash) → ~/.claude/settings.json`));
5006
+ console.log(color("dim", ` Every \`pqcheck\` run will now push a live status message into Claude Code chat`));
5007
+ installSummary.push({ component: "Claude Code chat-hook", path: settingsPath, status: existed ? "installed-updated" : "installed-created", backup: backupPath });
5008
+ }
5009
+ } catch (err) {
5010
+ console.log(color("red", ` ✗ chat-hook install failed: ${err.message}`));
5011
+ installSummary.push({ component: "Claude Code chat-hook", status: "failed", error: String(err?.message || err) });
5012
+ }
5013
+ }
5014
+
5015
+ // -------------------------------------------------------------------------
5016
+ // Component 4c: Claude Code prompt-hook (UserPromptSubmit → cipherwake-prompt-hook)
5017
+ // v0.15.1 — pinnedai-parity item. Injects ship_decision into Claude's
5018
+ // context BEFORE Claude responds to every user prompt. Different timing
5019
+ // from 4b: chat-hook fires AFTER a tool ran (reactive), prompt-hook fires
5020
+ // BEFORE Claude responds (proactive). Silent when state is missing / stale
5021
+ // / ship_decision=pass (no spam).
5022
+ // -------------------------------------------------------------------------
5023
+ if (!skipStatusline) {
5024
+ const settingsPath = path.join(os.homedir(), ".claude", "settings.json");
5025
+ try {
5026
+ let settings = {};
5027
+ let existed = false;
5028
+ try {
5029
+ const raw = await fs.readFile(settingsPath, "utf8");
5030
+ settings = JSON.parse(raw);
5031
+ existed = true;
5032
+ } catch { /* will create */ }
5033
+ settings.hooks = settings.hooks || {};
5034
+ settings.hooks.UserPromptSubmit = settings.hooks.UserPromptSubmit || [];
5035
+
5036
+ const cipherwakeHookCmd = "npx cipherwake-prompt-hook";
5037
+ const alreadyInstalled = settings.hooks.UserPromptSubmit.some(
5038
+ (entry) => Array.isArray(entry?.hooks) && entry.hooks.some(
5039
+ (h) => h?.type === "command" && typeof h?.command === "string" && h.command.includes("cipherwake-prompt-hook"),
5040
+ ),
5041
+ );
5042
+
5043
+ if (alreadyInstalled) {
5044
+ console.log(color("dim", ` ⊝ prompt-hook already configured in ~/.claude/settings.json UserPromptSubmit — skipping`));
5045
+ installSummary.push({ component: "Claude Code prompt-hook", path: settingsPath, status: "skipped-already-present" });
5046
+ } else {
5047
+ const backupPath = existed ? await backupSettingsJson(settingsPath) : null;
5048
+ if (backupPath) console.log(color("dim", ` backup: ${backupPath}`));
5049
+ settings.hooks.UserPromptSubmit.push({ hooks: [{ type: "command", command: cipherwakeHookCmd }] });
5050
+ await fs.mkdir(path.dirname(settingsPath), { recursive: true });
5051
+ await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
5052
+ console.log(color("green", ` ✓ added prompt-hook (UserPromptSubmit) → ~/.claude/settings.json`));
5053
+ console.log(color("dim", ` Claude will see latest ship_decision in context on every prompt (when REVIEW/BLOCK)`));
5054
+ installSummary.push({ component: "Claude Code prompt-hook", path: settingsPath, status: existed ? "installed-updated" : "installed-created", backup: backupPath });
5055
+ }
5056
+ } catch (err) {
5057
+ console.log(color("red", ` ✗ prompt-hook install failed: ${err.message}`));
5058
+ installSummary.push({ component: "Claude Code prompt-hook", status: "failed", error: String(err?.message || err) });
5059
+ }
5060
+ }
5061
+
5062
+ // -------------------------------------------------------------------------
5063
+ // Component 4d: Per-repo state directory (.cipherwake/) for Cursor / Copilot
5064
+ // v0.15.1 — pinnedai-parity item. Cursor/Copilot/Continue/Cline read repo
5065
+ // state for context. Creating .cipherwake/ in the repo gives them a
5066
+ // read-on-demand surface that subsequent `pqcheck` runs (via the
5067
+ // writeLastScanFile path) populate with the latest scan state. Also adds
5068
+ // .cipherwake/ to .gitignore if not already there — per-developer state,
5069
+ // not committable.
5070
+ // -------------------------------------------------------------------------
5071
+ if (!skipStatusline) {
5072
+ try {
5073
+ const repoStateDir = path.join(process.cwd(), ".cipherwake");
5074
+ await fs.mkdir(repoStateDir, { recursive: true });
5075
+ // Write an initial placeholder so the file exists immediately. Subsequent
5076
+ // scans via writeLastScanFile will overwrite with real data.
5077
+ const placeholderPath = path.join(repoStateDir, "last-status.json");
5078
+ try {
5079
+ await fs.access(placeholderPath);
5080
+ // Already exists — preserve it.
5081
+ } catch {
5082
+ await fs.writeFile(placeholderPath, JSON.stringify({
5083
+ domain,
5084
+ ship_decision: "unknown",
5085
+ note: "Initial placeholder — run `npx pqcheck deploy-check " + domain + " --ai` to populate",
5086
+ written_at: new Date().toISOString(),
5087
+ }, null, 2));
5088
+ }
5089
+ // Add .cipherwake/ to .gitignore if missing (don't commit per-developer state).
5090
+ const gitignorePath = path.join(process.cwd(), ".gitignore");
5091
+ let gitignore = "";
5092
+ try { gitignore = await fs.readFile(gitignorePath, "utf8"); } catch { /* may not exist */ }
5093
+ if (!/^\.cipherwake\/?\s*$/m.test(gitignore)) {
5094
+ const appended = gitignore + (gitignore.endsWith("\n") || gitignore.length === 0 ? "" : "\n") + "\n# Cipherwake per-developer scan state (read-on-demand by AI coders)\n.cipherwake/\n";
5095
+ await fs.writeFile(gitignorePath, appended);
5096
+ }
5097
+ console.log(color("green", ` ✓ created .cipherwake/last-status.json (Cursor/Copilot/Continue read this for context)`));
5098
+ installSummary.push({ component: "Per-repo state file", path: placeholderPath, status: "installed" });
5099
+ } catch (err) {
5100
+ console.log(color("red", ` ✗ per-repo state install failed: ${err.message}`));
5101
+ installSummary.push({ component: "Per-repo state file", status: "failed", error: String(err?.message || err) });
5102
+ }
5103
+ }
5104
+
5105
+ // -------------------------------------------------------------------------
5106
+ // Component 5: VS Code / Cursor extension (via `code` CLI if available)
5107
+ // -------------------------------------------------------------------------
5108
+ if (!skipVscode) {
5109
+ // Check if `code` CLI is on PATH
5110
+ const codeAvailable = await new Promise((resolve) => {
5111
+ const child = spawn("code", ["--version"], { stdio: ["ignore", "ignore", "ignore"] });
5112
+ child.on("error", () => resolve(false));
5113
+ child.on("close", (rc) => resolve(rc === 0));
5114
+ });
5115
+ if (!codeAvailable) {
5116
+ console.log(color("dim", ` ⊝ VS Code extension — \`code\` CLI not on PATH. Install from Marketplace: search "Cipherwake Trust Status Bar"`));
5117
+ installSummary.push({ component: "VS Code / Cursor extension", status: "skipped-code-cli-not-available" });
5118
+ } else {
5119
+ // Note: until the extension is published to Marketplace, `code --install-extension cipherwakelabs.cipherwake-statusbar`
5120
+ // won't resolve. We attempt the install and treat failure as a soft skip.
5121
+ const installResult = await new Promise((resolve) => {
5122
+ const child = spawn("code", ["--install-extension", "cipherwakelabs.cipherwake-statusbar"], { stdio: ["ignore", "pipe", "pipe"] });
5123
+ let out = "";
5124
+ child.stdout?.on("data", (d) => out += d.toString());
5125
+ child.stderr?.on("data", (d) => out += d.toString());
5126
+ child.on("close", (rc) => resolve({ rc, out }));
5127
+ child.on("error", (err) => resolve({ rc: -1, out: err.message }));
5128
+ });
5129
+ if (installResult.rc === 0) {
5130
+ console.log(color("green", ` ✓ installed VS Code extension: cipherwakelabs.cipherwake-statusbar`));
5131
+ installSummary.push({ component: "VS Code / Cursor extension", status: "installed" });
5132
+ } else {
5133
+ console.log(color("dim", ` ⊝ VS Code extension install attempted but not on Marketplace yet. Awaiting publish.`));
5134
+ installSummary.push({ component: "VS Code / Cursor extension", status: "skipped-not-on-marketplace-yet", attempted: true });
5135
+ }
5136
+ }
5137
+ }
5138
+
5139
+ // -------------------------------------------------------------------------
5140
+ // Audit trail
5141
+ // -------------------------------------------------------------------------
5142
+ try {
5143
+ const prefsDir = path.join(os.homedir(), ".config", "cipherwake");
5144
+ await fs.mkdir(prefsDir, { recursive: true });
5145
+ const prefsPath = path.join(prefsDir, "install-prefs.json");
5146
+ let prefs = { history: [] };
5147
+ try {
5148
+ const existing = await fs.readFile(prefsPath, "utf8");
5149
+ prefs = JSON.parse(existing);
5150
+ if (!Array.isArray(prefs.history)) prefs.history = [];
5151
+ } catch { /* first run */ }
5152
+ prefs.history.push({
5153
+ timestamp: new Date().toISOString(),
5154
+ command: "setup",
5155
+ mode: autoFlag ? "auto-flag" : "interactive",
5156
+ domain,
5157
+ consent_phrase: consentPhrase,
5158
+ invoked_by: invokedBy,
5159
+ cwd: process.cwd(),
5160
+ hostname: os.hostname(),
5161
+ pqcheck_version: VERSION,
5162
+ summary: installSummary,
5163
+ });
5164
+ await fs.writeFile(prefsPath, JSON.stringify(prefs, null, 2), "utf8");
5165
+ console.log("");
5166
+ console.log(color("dim", ` Audit trail: ${prefsPath}`));
5167
+ } catch (err) {
5168
+ console.log(color("dim", ` (audit trail write failed: ${err.message} — non-fatal)`));
5169
+ }
5170
+
5171
+ // R74-confirm BLOCKING #16 (GPT 2026-05-22): write an install manifest so
5172
+ // a Ctrl-C'd or partially-failed install can be diagnosed + resumed. The
5173
+ // manifest is the "what got done" record, separate from install-prefs.json
5174
+ // (which is the "who/why" audit trail). A future `pqcheck doctor --repair`
5175
+ // command will use this to skip already-completed steps. For now the file
5176
+ // is the artifact you can `cat` to see exactly what got written.
5177
+ try {
5178
+ const manifestPath = path.join(os.homedir(), ".config", "cipherwake", "install-manifest.json");
5179
+ let manifestList = [];
5180
+ try {
5181
+ manifestList = JSON.parse(await fs.readFile(manifestPath, "utf8"));
5182
+ if (!Array.isArray(manifestList)) manifestList = [];
5183
+ } catch { /* new file */ }
5184
+ manifestList.unshift({
5185
+ run_at: new Date().toISOString(),
5186
+ domain,
5187
+ auto_flag: autoFlag,
5188
+ invoked_by: invokedBy || null,
5189
+ consent_phrase: consentPhrase || null,
5190
+ cwd: process.cwd(),
5191
+ cli_version: VERSION,
5192
+ install_summary: installSummary,
5193
+ // Surface the backup paths separately so `pqcheck doctor --repair --rollback` can find them
5194
+ backups: installSummary.filter((s) => s.backup).map((s) => ({ component: s.component, backup: s.backup })),
5195
+ });
5196
+ // Keep last 20 install runs for forensics
5197
+ manifestList = manifestList.slice(0, 20);
5198
+ await fs.mkdir(path.dirname(manifestPath), { recursive: true });
5199
+ await fs.writeFile(manifestPath, JSON.stringify(manifestList, null, 2), "utf8");
5200
+ console.log(color("dim", ` Install manifest: ${manifestPath}`));
5201
+ } catch (err) {
5202
+ console.log(color("dim", ` (manifest write failed: ${err.message} — non-fatal)`));
5203
+ }
5204
+
5205
+ // -------------------------------------------------------------------------
5206
+ // Summary table
5207
+ // -------------------------------------------------------------------------
5208
+ console.log("");
5209
+ console.log(color("bold", "◆ Setup complete — summary:"));
5210
+ console.log("");
5211
+ for (const s of installSummary) {
5212
+ const icon = s.status === "installed" || s.status === "installed-created" || s.status === "installed-updated"
5213
+ ? color("green", "✓")
5214
+ : s.status?.startsWith("skipped")
5215
+ ? color("dim", "⊝")
5216
+ : color("red", "✗");
5217
+ const label = s.component;
5218
+ const detail = s.status === "installed" || s.status === "installed-created" || s.status === "installed-updated"
5219
+ ? color("dim", `installed${s.path ? " → " + s.path.replace(os.homedir(), "~") : ""}`)
5220
+ : color("dim", s.status?.replace(/-/g, " "));
5221
+ console.log(` ${icon} ${label.padEnd(34, " ")} ${detail}`);
5222
+ }
5223
+ console.log("");
5224
+ console.log(color("dim", `Reference: https://cipherwake.io/methodology/ai-coder-protocol`));
5225
+ console.log("");
5226
+
5227
+ process.exit(0);
5228
+ }
5229
+
3549
5230
  main().catch((err) => {
3550
5231
  console.error(color("red", `fatal: ${err.message}`));
3551
5232
  process.exit(2);