sproutkit-code 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +419 -18
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -30557,6 +30557,147 @@ var require_llm = __commonJS({
30557
30557
  }
30558
30558
  });
30559
30559
 
30560
+ // ../desktop/dist/main/lib/qa.js
30561
+ var require_qa = __commonJS({
30562
+ "../desktop/dist/main/lib/qa.js"(exports2) {
30563
+ "use strict";
30564
+ Object.defineProperty(exports2, "__esModule", { value: true });
30565
+ exports2.staticChecks = staticChecks;
30566
+ exports2.reviewWithClaude = reviewWithClaude;
30567
+ exports2.fixIssues = fixIssues;
30568
+ var llm_1 = require_llm();
30569
+ function staticChecks(args) {
30570
+ const { code, photoCount, phone, businessName } = args;
30571
+ const issues = [];
30572
+ const deadHrefs = code.match(/href\s*=\s*["'`]\s*#\s*["'`]/g)?.length ?? 0;
30573
+ if (deadHrefs > 0) {
30574
+ issues.push({
30575
+ severity: "warn",
30576
+ code: "dead_href",
30577
+ detail: `${deadHrefs} placeholder href="#" link(s) \u2014 nav/CTAs should point somewhere real (tel:, /book, or a section id).`
30578
+ });
30579
+ }
30580
+ if (/lorem ipsum|todo:|placeholder text/i.test(code)) {
30581
+ issues.push({
30582
+ severity: "error",
30583
+ code: "placeholder_copy",
30584
+ detail: "Placeholder copy (lorem ipsum / TODO) left in the page."
30585
+ });
30586
+ }
30587
+ if (businessName && !code.includes(businessName.split(" ")[0])) {
30588
+ issues.push({
30589
+ severity: "error",
30590
+ code: "missing_business_name",
30591
+ detail: `The business name ("${businessName}") doesn't appear in the page.`
30592
+ });
30593
+ }
30594
+ if (phone && !code.includes("tel:")) {
30595
+ issues.push({
30596
+ severity: "warn",
30597
+ code: "missing_phone_cta",
30598
+ detail: "A phone number is on file but there's no tel: link to call it."
30599
+ });
30600
+ }
30601
+ const referenced = new Set((code.match(/\/photo(\d+)\.jpg/g) ?? []).map((m) => Number(m.match(/\d+/)[0])));
30602
+ const overRef = [...referenced].filter((n) => n > photoCount);
30603
+ if (overRef.length > 0) {
30604
+ issues.push({
30605
+ severity: "error",
30606
+ code: "broken_image_ref",
30607
+ detail: `References /photo${overRef.join(", /photo")}.jpg but only ${photoCount} photo(s) were shipped \u2014 those images will be broken.`
30608
+ });
30609
+ }
30610
+ if (photoCount > 0 && referenced.size === 0 && !code.includes("PHOTOS")) {
30611
+ issues.push({
30612
+ severity: "warn",
30613
+ code: "unused_photos",
30614
+ detail: `${photoCount} real photo(s) were shipped but the page uses none of them.`
30615
+ });
30616
+ }
30617
+ if (/>\s*(Opening Hours|Hours)\s*<\/[a-z0-9]+>\s*<\/(div|section)>/i.test(code)) {
30618
+ issues.push({
30619
+ severity: "warn",
30620
+ code: "empty_section",
30621
+ detail: "An Opening Hours heading appears with no content under it \u2014 omit the section instead of rendering it empty."
30622
+ });
30623
+ }
30624
+ return issues;
30625
+ }
30626
+ async function reviewWithClaude(args) {
30627
+ const prompt = `You are reviewing a generated single-page React site (App.jsx) for a real local business before it's shown to the owner. Find only genuine DEFECTS \u2014 things that would look broken, unfinished, or wrong to that owner.
30628
+
30629
+ Business: "${args.businessName}" (${args.businessType})
30630
+
30631
+ Look for: sections that render empty or with placeholder content; copy that doesn't match this business/industry; links that go nowhere; text that's cut off mid-sentence; duplicated sections; contact details that contradict each other; obviously broken layout (e.g. a grid with one item, overlapping fixed elements).
30632
+ Do NOT report style preferences, colour choices, or "could be improved" suggestions. Only defects.
30633
+
30634
+ Respond with ONLY compact JSON, no prose:
30635
+ {"issues":[{"severity":"error"|"warn","code":"short_slug","detail":"one sentence"}]}
30636
+ If there are no real defects, return {"issues":[]}.
30637
+
30638
+ App.jsx:
30639
+ \`\`\`jsx
30640
+ ${args.code.slice(0, 45e3)}
30641
+ \`\`\``;
30642
+ try {
30643
+ const res = await (0, llm_1.generateText)({
30644
+ apiKey: args.apiKey,
30645
+ prompt,
30646
+ maxTokens: 2048,
30647
+ timeoutMs: 18e4
30648
+ });
30649
+ const raw = res.text.replace(/^```(?:json)?\s*/i, "").replace(/\s*```\s*$/, "").trim();
30650
+ const match = raw.match(/\{[\s\S]*\}/);
30651
+ if (!match)
30652
+ return [];
30653
+ const parsed = JSON.parse(match[0]);
30654
+ if (!Array.isArray(parsed.issues))
30655
+ return [];
30656
+ return parsed.issues.filter((i) => !!i && typeof i.detail === "string").slice(0, 12).map((i) => ({
30657
+ severity: i.severity === "error" ? "error" : "warn",
30658
+ code: String(i.code || "issue").slice(0, 40),
30659
+ detail: String(i.detail).slice(0, 300)
30660
+ }));
30661
+ } catch {
30662
+ return [];
30663
+ }
30664
+ }
30665
+ async function fixIssues(args) {
30666
+ if (args.issues.length === 0)
30667
+ return null;
30668
+ const list = args.issues.map((i, n) => `${n + 1}. [${i.severity}] ${i.detail}`).join("\n");
30669
+ const prompt = `Fix these specific defects in the React component below. Change ONLY what's needed to fix them \u2014 do not redesign, restyle, rewrite copy that isn't faulty, or remove working sections.
30670
+
30671
+ Defects:
30672
+ ${list}
30673
+
30674
+ Rules: keep the same design language and structure; if a section can't be filled with real content, remove the section entirely rather than leaving it empty; never invent image URLs (only use the existing PHOTOS entries); the file must remain a single component ending in \`export default App;\`.
30675
+
30676
+ Return ONLY the complete corrected App.jsx \u2014 no markdown fences, no commentary.
30677
+
30678
+ \`\`\`jsx
30679
+ ${args.code.slice(0, 45e3)}
30680
+ \`\`\``;
30681
+ try {
30682
+ const res = await (0, llm_1.generateText)({
30683
+ apiKey: args.apiKey,
30684
+ prompt,
30685
+ maxTokens: 64e3,
30686
+ timeoutMs: 3e5
30687
+ });
30688
+ const cleaned = res.text.replace(/^```[a-z]*\s*/i, "").replace(/\s*```$/, "").trim();
30689
+ if (!cleaned.includes("export default App"))
30690
+ return null;
30691
+ if (cleaned.length < args.code.length * 0.5)
30692
+ return null;
30693
+ return cleaned;
30694
+ } catch {
30695
+ return null;
30696
+ }
30697
+ }
30698
+ }
30699
+ });
30700
+
30560
30701
  // ../desktop/dist/main/lib/models.js
30561
30702
  var require_models = __commonJS({
30562
30703
  "../desktop/dist/main/lib/models.js"(exports2) {
@@ -35390,7 +35531,7 @@ var require_cross_spawn = __commonJS({
35390
35531
  enoent.hookChildProcess(spawned, parsed);
35391
35532
  return spawned;
35392
35533
  }
35393
- function spawnSync3(command, args, options) {
35534
+ function spawnSync4(command, args, options) {
35394
35535
  const parsed = parse(command, args, options);
35395
35536
  const result = cp.spawnSync(parsed.command, parsed.args, parsed.options);
35396
35537
  result.error = result.error || enoent.verifyENOENTSync(result.status, parsed);
@@ -35398,7 +35539,7 @@ var require_cross_spawn = __commonJS({
35398
35539
  }
35399
35540
  module2.exports = spawn2;
35400
35541
  module2.exports.spawn = spawn2;
35401
- module2.exports.sync = spawnSync3;
35542
+ module2.exports.sync = spawnSync4;
35402
35543
  module2.exports._parse = parse;
35403
35544
  module2.exports._enoent = enoent;
35404
35545
  }
@@ -79518,6 +79659,7 @@ var require_builder = __commonJS({
79518
79659
  var runtime_1 = require_runtime();
79519
79660
  var db_1 = require_db();
79520
79661
  var llm_1 = require_llm();
79662
+ var qa_1 = require_qa();
79521
79663
  var fs3 = __importStar(require("fs"));
79522
79664
  var path3 = __importStar(require("path"));
79523
79665
  var models_1 = require_models();
@@ -79760,7 +79902,7 @@ var require_builder = __commonJS({
79760
79902
  }
79761
79903
  }
79762
79904
  async function buildAndDeploySite(args) {
79763
- const { lead, anthropicKey, vercelToken, placesKey, customInstructions, onProgress, includeCms, blobToken, reuseAdminPassword, skipContentExtraction, multipage, pages } = args;
79905
+ const { lead, anthropicKey, vercelToken, placesKey, customInstructions, onProgress, includeCms, blobToken, reuseAdminPassword, skipContentExtraction, multipage, pages, skipQa } = args;
79764
79906
  const pageList = multipage && (!pages || pages.length === 0) ? ["Home", "Services", "About", "Contact"] : pages ?? [];
79765
79907
  if (includeCms && !blobToken) {
79766
79908
  throw new Error("CMS is enabled but no Vercel Blob token is set. Add it in Settings \u2192 Vercel Blob token.");
@@ -80073,6 +80215,35 @@ ${prompt}` : prompt;
80073
80215
  }
80074
80216
  }
80075
80217
  appCode = appCode.replace("REPLACE_PHOTOS", JSON.stringify(photoPaths));
80218
+ if (!skipQa) {
80219
+ onProgress("Reviewing the site for defects\u2026");
80220
+ const staticIssues = (0, qa_1.staticChecks)({
80221
+ code: appCode,
80222
+ photoCount: photoFiles.length,
80223
+ phone: lead.phone ?? "",
80224
+ businessName: lead.name
80225
+ });
80226
+ const modelIssues = await (0, qa_1.reviewWithClaude)({
80227
+ code: appCode,
80228
+ businessName: lead.name,
80229
+ businessType: lead.type ?? "local business",
80230
+ apiKey: anthropicKey
80231
+ });
80232
+ const issues = [...staticIssues, ...modelIssues];
80233
+ if (issues.length > 0) {
80234
+ const errors = issues.filter((i) => i.severity === "error").length;
80235
+ onProgress(`QA found ${issues.length} issue${issues.length === 1 ? "" : "s"}${errors ? ` (${errors} serious)` : ""} \u2014 fixing\u2026`);
80236
+ const fixed = await (0, qa_1.fixIssues)({ code: appCode, issues, apiKey: anthropicKey });
80237
+ if (fixed) {
80238
+ appCode = fixed;
80239
+ onProgress("QA fixes applied.");
80240
+ } else {
80241
+ onProgress("QA fix didn't come back clean \u2014 deploying the original.");
80242
+ }
80243
+ } else {
80244
+ onProgress("QA passed.");
80245
+ }
80246
+ }
80076
80247
  onProgress("Deploying to Vercel...");
80077
80248
  const indexHtml = `<!DOCTYPE html>
80078
80249
  <html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${lead.name}</title></head>
@@ -92713,6 +92884,77 @@ var require_email = __commonJS({
92713
92884
  }
92714
92885
  });
92715
92886
 
92887
+ // ../desktop/dist/main/lib/email-harvest.js
92888
+ var require_email_harvest = __commonJS({
92889
+ "../desktop/dist/main/lib/email-harvest.js"(exports2) {
92890
+ "use strict";
92891
+ Object.defineProperty(exports2, "__esModule", { value: true });
92892
+ exports2.harvestEmail = harvestEmail;
92893
+ var llm_1 = require_llm();
92894
+ var BAD_EMAIL_DOMAINS = [
92895
+ "yelp.com",
92896
+ "yell.com",
92897
+ "tripadvisor",
92898
+ "facebook.com",
92899
+ "instagram.com",
92900
+ "google.com",
92901
+ "justeat",
92902
+ "deliveroo",
92903
+ "ubereats",
92904
+ "treatwell",
92905
+ "fresha.com",
92906
+ "booking.com",
92907
+ "example.com",
92908
+ "sentry.io",
92909
+ "wixpress.com",
92910
+ "squarespace.com"
92911
+ ];
92912
+ function plausibleEmail(raw) {
92913
+ if (typeof raw !== "string")
92914
+ return null;
92915
+ const email = raw.trim().toLowerCase();
92916
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(email))
92917
+ return null;
92918
+ const domain = email.split("@")[1] ?? "";
92919
+ if (BAD_EMAIL_DOMAINS.some((d) => domain.includes(d)))
92920
+ return null;
92921
+ return email;
92922
+ }
92923
+ async function harvestEmail(args) {
92924
+ const prompt = `Search the web for the contact email address of this specific local business.
92925
+
92926
+ Business: "${args.name}"
92927
+ Address: ${args.address}${args.city ? `, ${args.city}` : ""}
92928
+
92929
+ Rules \u2014 be strict:
92930
+ - The email must clearly belong to THIS business at THIS address/city. A business with the same or similar name in a different town or country is NOT a match \u2014 return null.
92931
+ - Prefer an email on the business's own website or its official social profile.
92932
+ - Do NOT return an email belonging to a directory, aggregator or booking platform (Yelp, Yell, TripAdvisor, JustEat, Deliveroo, Treatwell, Fresha, etc.).
92933
+ - Do NOT guess, construct, or infer an address (e.g. never invent info@<name>.com).
92934
+ - If you cannot confirm a real email for this exact business, return null.
92935
+
92936
+ Respond with ONLY compact JSON, no prose: {"email": string|null}`;
92937
+ try {
92938
+ const res = await (0, llm_1.generateText)({
92939
+ apiKey: args.apiKey,
92940
+ prompt,
92941
+ allowWebSearch: true,
92942
+ maxTokens: 512,
92943
+ timeoutMs: 9e4
92944
+ });
92945
+ const raw = res.text.replace(/^```(?:json)?\s*/i, "").replace(/\s*```\s*$/, "").trim();
92946
+ const match = raw.match(/\{[\s\S]*\}/);
92947
+ if (!match)
92948
+ return null;
92949
+ const parsed = JSON.parse(match[0]);
92950
+ return plausibleEmail(parsed.email);
92951
+ } catch {
92952
+ return null;
92953
+ }
92954
+ }
92955
+ }
92956
+ });
92957
+
92716
92958
  // ../desktop/dist/main/lib/chat-tools.js
92717
92959
  var require_chat_tools = __commonJS({
92718
92960
  "../desktop/dist/main/lib/chat-tools.js"(exports2) {
@@ -92726,6 +92968,7 @@ var require_chat_tools = __commonJS({
92726
92968
  var lead_search_1 = require_lead_search();
92727
92969
  var places_1 = require_places();
92728
92970
  var email_1 = require_email();
92971
+ var email_harvest_1 = require_email_harvest();
92729
92972
  exports2.TOOLS = [
92730
92973
  {
92731
92974
  name: "find_leads",
@@ -92846,6 +93089,10 @@ var require_chat_tools = __commonJS({
92846
93089
  multipage: {
92847
93090
  type: "boolean",
92848
93091
  description: "When true, build a MULTI-PAGE site (Home/Services/About/Contact as real routed pages with a nav) instead of a single long-scroll page. Use when the user asks for a multipage site or a bigger site with separate pages."
93092
+ },
93093
+ skipQa: {
93094
+ type: "boolean",
93095
+ description: "Skip the post-build QA review (a review + fix pass that catches broken links, empty sections, broken image refs). Only set true when the user explicitly wants maximum speed/volume over quality."
92849
93096
  }
92850
93097
  },
92851
93098
  required: ["id"]
@@ -92975,6 +93222,7 @@ var require_chat_tools = __commonJS({
92975
93222
  customInstructions: args.instructions,
92976
93223
  includeCms: args.includeCms,
92977
93224
  multipage: args.multipage,
93225
+ skipQa: args.skipQa,
92978
93226
  blobToken,
92979
93227
  onProgress: (msg) => args.ctx.emitProgress?.({
92980
93228
  channel: "sites:progress",
@@ -93160,7 +93408,7 @@ Interested?`;
93160
93408
  }
93161
93409
  case "prepare_outreach": {
93162
93410
  const explicit = input.ids;
93163
- const rows = explicit && explicit.length > 0 ? db2.all(`SELECT id, name, phone, email, site_url FROM leads WHERE id IN (${explicit.map(() => "?").join(",")})`, explicit) : db2.all("SELECT id, name, phone, email, site_url FROM leads WHERE status = 'site_built' AND site_url IS NOT NULL AND site_url != '' ORDER BY score DESC NULLS LAST, id DESC", []);
93411
+ const rows = explicit && explicit.length > 0 ? db2.all(`SELECT id, name, phone, email, address, city, site_url FROM leads WHERE id IN (${explicit.map(() => "?").join(",")})`, explicit) : db2.all("SELECT id, name, phone, email, address, city, site_url FROM leads WHERE status = 'site_built' AND site_url IS NOT NULL AND site_url != '' ORDER BY score DESC NULLS LAST, id DESC", []);
93164
93412
  const eligible = rows.filter((r) => r.site_url);
93165
93413
  if (eligible.length === 0) {
93166
93414
  return {
@@ -93174,9 +93422,27 @@ Interested?`;
93174
93422
  const smtp = (0, email_1.smtpFromSettings)((k) => getDecryptedSetting(k));
93175
93423
  const emailSubject = getDecryptedSetting("outreach_email_subject") || "A free demo website for {name}";
93176
93424
  const prepared = [];
93425
+ let harvested = 0;
93177
93426
  for (const r of eligible) {
93178
93427
  const message = template.replace(/\{name\}/g, r.name).replace(/\{site_url\}/g, r.site_url ?? "");
93179
93428
  const e164 = toE164(r.phone ?? "");
93429
+ if (!e164 && !r.email && smtp) {
93430
+ ctx.emitProgress?.({
93431
+ channel: "outreach:progress",
93432
+ payload: { msg: `Looking up an email for ${r.name}\u2026` }
93433
+ });
93434
+ const found = await (0, email_harvest_1.harvestEmail)({
93435
+ name: r.name,
93436
+ address: r.address ?? "",
93437
+ city: r.city ?? "",
93438
+ apiKey: getDecryptedSetting("anthropic_key")
93439
+ });
93440
+ if (found) {
93441
+ db2.run("UPDATE leads SET email = ? WHERE id = ?", [found, r.id]);
93442
+ r.email = found;
93443
+ harvested++;
93444
+ }
93445
+ }
93180
93446
  if (e164) {
93181
93447
  prepared.push({
93182
93448
  id: r.id,
@@ -93227,6 +93493,7 @@ Interested?`;
93227
93493
  phoneLinks: prepared.filter((p) => p.via === "phone").length,
93228
93494
  emailsSent,
93229
93495
  emailsFailed,
93496
+ emailsFound: harvested,
93230
93497
  unreachable,
93231
93498
  // Guide the user if email-only leads were skipped for want of SMTP.
93232
93499
  note: smtpMissing ? "Some leads have only an email but SMTP isn't set up \u2014 run 'sproutkit setup' to add SMTP and email them." : void 0,
@@ -93248,6 +93515,7 @@ Interested?`;
93248
93515
  instructions: input.instructions,
93249
93516
  includeCms: !!input.includeCms,
93250
93517
  multipage: !!input.multipage,
93518
+ skipQa: input.skipQa === true,
93251
93519
  ctx
93252
93520
  });
93253
93521
  return { ...result, id };
@@ -93265,6 +93533,7 @@ Interested?`;
93265
93533
  id,
93266
93534
  instructions,
93267
93535
  multipage: !!input.multipage,
93536
+ skipQa: input.skipQa === true,
93268
93537
  ctx
93269
93538
  });
93270
93539
  return { ...result, id };
@@ -94328,7 +94597,7 @@ var require_package2 = __commonJS({
94328
94597
  "package.json"(exports2, module2) {
94329
94598
  module2.exports = {
94330
94599
  name: "sproutkit-code",
94331
- version: "0.15.0",
94600
+ version: "0.17.0",
94332
94601
  description: "Sproutkit in your terminal \u2014 find leads, build sites, run outreach, all via chat.",
94333
94602
  bin: {
94334
94603
  sproutkit: "./dist/index.js"
@@ -94589,10 +94858,10 @@ var Status = class {
94589
94858
  this.timer = null;
94590
94859
  }
94591
94860
  const label = finalLabel ?? this.label;
94592
- const mark = ok ? c.green("\u2713") : c.red("\u2717");
94593
- if (this.tty) process.stdout.write(`\r\x1B[2K ${mark} ${label}
94861
+ const mark2 = ok ? c.green("\u2713") : c.red("\u2717");
94862
+ if (this.tty) process.stdout.write(`\r\x1B[2K ${mark2} ${label}
94594
94863
  `);
94595
- else say(` ${mark} ${label}`);
94864
+ else say(` ${mark2} ${label}`);
94596
94865
  }
94597
94866
  };
94598
94867
  var STATE_MARK = {
@@ -94641,16 +94910,16 @@ var StatusBoard = class {
94641
94910
  else this.render();
94642
94911
  }
94643
94912
  line(r) {
94644
- const mark = r.state === "building" ? c.cyan(FRAMES[this.frame]) : STATE_MARK[r.state]();
94913
+ const mark2 = r.state === "building" ? c.cyan(FRAMES[this.frame]) : STATE_MARK[r.state]();
94645
94914
  const detail = r.detail ? c.dim(` \xB7 ${r.detail}`) : "";
94646
- return ` ${mark} ${r.label}${detail}`;
94915
+ return ` ${mark2} ${r.label}${detail}`;
94647
94916
  }
94648
94917
  logRowIfChanged(r) {
94649
94918
  const sig = `${r.state}:${r.detail ?? ""}`;
94650
94919
  if (this.lastSig.get(r.key) === sig) return;
94651
94920
  this.lastSig.set(r.key, sig);
94652
- const mark = r.state === "built" ? "\u2713" : r.state === "failed" ? "\u2717" : r.state === "skipped" ? "\u2013" : "\u2026";
94653
- say(` ${mark} ${r.label}${r.detail ? ` \xB7 ${r.detail}` : ""}`);
94921
+ const mark2 = r.state === "built" ? "\u2713" : r.state === "failed" ? "\u2717" : r.state === "skipped" ? "\u2013" : "\u2026";
94922
+ say(` ${mark2} ${r.label}${r.detail ? ` \xB7 ${r.detail}` : ""}`);
94654
94923
  }
94655
94924
  render() {
94656
94925
  if (this.lastLineCount > 0) process.stdout.write(`\x1B[${this.lastLineCount}A`);
@@ -95485,7 +95754,8 @@ async function cmdBuild(argv) {
95485
95754
  id,
95486
95755
  instructions: flags.instructions ? String(flags.instructions) : void 0,
95487
95756
  includeCms: !!flags.cms,
95488
- multipage: !!flags.multipage
95757
+ multipage: !!flags.multipage,
95758
+ skipQa: !!flags["skip-qa"]
95489
95759
  },
95490
95760
  makeCtx({ json, status })
95491
95761
  );
@@ -95540,6 +95810,7 @@ async function cmdOutreach(argv) {
95540
95810
  say();
95541
95811
  const parts = [];
95542
95812
  if (result.phoneLinks) parts.push(`${result.phoneLinks} message link${result.phoneLinks === 1 ? "" : "s"} to open`);
95813
+ if (result.emailsFound) parts.push(c.dim(`${result.emailsFound} email${result.emailsFound === 1 ? "" : "s"} found`));
95543
95814
  if (result.emailsSent) parts.push(`${result.emailsSent} emailed`);
95544
95815
  if (result.emailsFailed) parts.push(c.red(`${result.emailsFailed} email failed`));
95545
95816
  if (result.unreachable) parts.push(c.amber(`${result.unreachable} unreachable`));
@@ -95569,7 +95840,7 @@ async function cmdRegenerate(argv) {
95569
95840
  try {
95570
95841
  const result = await runTool(
95571
95842
  "regenerate_site",
95572
- { id, instructions, multipage: !!flags.multipage },
95843
+ { id, instructions, multipage: !!flags.multipage, skipQa: !!flags["skip-qa"] },
95573
95844
  makeCtx({ json, status })
95574
95845
  );
95575
95846
  if (result && result.ok === false) {
@@ -95845,13 +96116,136 @@ function runClaudeTurn(args) {
95845
96116
  });
95846
96117
  }
95847
96118
 
95848
- // src/index.ts
96119
+ // src/doctor.ts
95849
96120
  var import_child_process4 = require("child_process");
96121
+ function mark(l) {
96122
+ return l === "ok" ? c.green("\u2713") : l === "warn" ? c.amber("!") : c.red("\u2717");
96123
+ }
96124
+ async function runDoctor() {
96125
+ const rows = [];
96126
+ const backend = getBackend();
96127
+ const lic = await check();
96128
+ rows.push(
96129
+ lic.status === "active" ? { level: "ok", label: "Licence", detail: "active" } : {
96130
+ level: "fail",
96131
+ label: "Licence",
96132
+ detail: lic.status,
96133
+ fix: "Run 'sproutkit setup' and enter your key, or buy one at https://sproutkit.app"
96134
+ }
96135
+ );
96136
+ if (backend === "claude_code") {
96137
+ const present = (() => {
96138
+ try {
96139
+ return (0, import_child_process4.spawnSync)(claudeBin(), ["--version"], { stdio: "ignore", timeout: 1e4 }).status === 0;
96140
+ } catch {
96141
+ return false;
96142
+ }
96143
+ })();
96144
+ if (!present) {
96145
+ rows.push({
96146
+ level: "fail",
96147
+ label: "Claude Code",
96148
+ detail: "CLI not found",
96149
+ fix: "Install Claude Code, run 'claude /login', then 'sproutkit setup'."
96150
+ });
96151
+ } else {
96152
+ const auth = await checkClaudeCodeAuth();
96153
+ rows.push(
96154
+ auth.ok ? {
96155
+ level: "ok",
96156
+ label: "Claude (subscription)",
96157
+ detail: `logged in \xB7 model: ${getClaudeCodeModel() ?? "default"}`
96158
+ } : {
96159
+ level: "fail",
96160
+ label: "Claude (subscription)",
96161
+ detail: auth.reason,
96162
+ fix: "Run 'claude /login' in another terminal, then re-run doctor."
96163
+ }
96164
+ );
96165
+ }
96166
+ } else {
96167
+ rows.push(
96168
+ getSetting("anthropic_key") ? { level: "ok", label: "Anthropic API key", detail: "set" } : {
96169
+ level: "fail",
96170
+ label: "Anthropic API key",
96171
+ detail: "missing",
96172
+ fix: "Run 'sproutkit setup' (or switch to the Claude subscription backend)."
96173
+ }
96174
+ );
96175
+ }
96176
+ rows.push(
96177
+ getSetting("places_key") ? { level: "ok", label: "Google Places key", detail: "set \u2014 lead search works" } : {
96178
+ level: "fail",
96179
+ label: "Google Places key",
96180
+ detail: "missing",
96181
+ fix: "Run 'sproutkit setup'. Without it, 'find' can't search for leads."
96182
+ }
96183
+ );
96184
+ rows.push(
96185
+ getSetting("vercel_token") ? { level: "ok", label: "Vercel token", detail: "set \u2014 deploys work" } : {
96186
+ level: "fail",
96187
+ label: "Vercel token",
96188
+ detail: "missing",
96189
+ fix: "Run 'sproutkit setup'. Without it, sites can't deploy."
96190
+ }
96191
+ );
96192
+ rows.push(
96193
+ getSetting("blob_token") ? { level: "ok", label: "Vercel Blob token", detail: "set \u2014 booking & CMS work" } : {
96194
+ level: "warn",
96195
+ label: "Vercel Blob token",
96196
+ detail: "missing \u2014 booking sites & CMS will fail",
96197
+ fix: "Create a Blob store at https://vercel.com/dashboard/stores, copy BLOB_READ_WRITE_TOKEN, then 'sproutkit setup'. One token covers all your sites."
96198
+ }
96199
+ );
96200
+ const smtpReady = !!(getSetting("smtp_host") && getSetting("smtp_user") && getSetting("smtp_pass"));
96201
+ rows.push(
96202
+ smtpReady ? { level: "ok", label: "SMTP (email outreach)", detail: `via ${getSetting("smtp_host")}` } : {
96203
+ level: "warn",
96204
+ label: "SMTP (email outreach)",
96205
+ detail: "not set \u2014 leads without a phone can't be emailed",
96206
+ fix: "Run 'sproutkit setup' and add SMTP host/user/password."
96207
+ }
96208
+ );
96209
+ rows.push(
96210
+ getSetting("resend_key") ? { level: "ok", label: "Resend key", detail: "set \u2014 booking emails notify the owner" } : {
96211
+ level: "warn",
96212
+ label: "Resend key",
96213
+ detail: "not set \u2014 bookings are stored but the owner isn't emailed",
96214
+ fix: "Optional. Add it in 'sproutkit setup' if you want booking notifications."
96215
+ }
96216
+ );
96217
+ rows.push(
96218
+ skillInstalled() ? { level: "ok", label: "Claude Code skill", detail: skillTargetDir() } : {
96219
+ level: "warn",
96220
+ label: "Claude Code skill",
96221
+ detail: "not installed",
96222
+ fix: "Run 'sproutkit setup' and say yes to installing the skill, so `claude` can drive Sproutkit."
96223
+ }
96224
+ );
96225
+ say();
96226
+ say(c.greenBold(" Sproutkit doctor"));
96227
+ say(c.dim(` Backend: ${backend === "claude_code" ? "Claude subscription" : "Anthropic API key"}`));
96228
+ say();
96229
+ for (const r of rows) {
96230
+ say(` ${mark(r.level)} ${c.bold(r.label)}${r.detail ? c.dim(` \u2014 ${r.detail}`) : ""}`);
96231
+ if (r.fix && r.level !== "ok") say(c.dim(` \u2192 ${r.fix}`));
96232
+ }
96233
+ say();
96234
+ const fails = rows.filter((r) => r.level === "fail").length;
96235
+ const warns = rows.filter((r) => r.level === "warn").length;
96236
+ if (fails === 0 && warns === 0) say(c.green(" All good \u2014 you're ready to find leads and build sites."));
96237
+ else if (fails === 0) say(c.amber(` Ready to build, with ${warns} optional thing${warns === 1 ? "" : "s"} unset (see above).`));
96238
+ else say(c.red(` ${fails} blocking issue${fails === 1 ? "" : "s"} \u2014 fix those first.`));
96239
+ say();
96240
+ }
96241
+
96242
+ // src/index.ts
96243
+ var import_child_process5 = require("child_process");
95850
96244
  process.env.SPROUTKIT_HEADLESS = "1";
95851
96245
  function claudePresent() {
95852
96246
  try {
95853
96247
  const bin = process.env.SPROUTKIT_CLAUDE_BIN || "claude";
95854
- return (0, import_child_process4.spawnSync)(bin, ["--version"], { stdio: "ignore", timeout: 1e4 }).status === 0;
96248
+ return (0, import_child_process5.spawnSync)(bin, ["--version"], { stdio: "ignore", timeout: 1e4 }).status === 0;
95855
96249
  } catch {
95856
96250
  return false;
95857
96251
  }
@@ -95892,6 +96286,11 @@ async function main() {
95892
96286
  closeTty();
95893
96287
  return;
95894
96288
  }
96289
+ if (arg === "doctor") {
96290
+ await runDoctor();
96291
+ closeTty();
96292
+ return;
96293
+ }
95895
96294
  if (arg === "find" || arg === "list" || arg === "build" || arg === "build-all" || arg === "regenerate" || arg === "cms" || arg === "book" || arg === "outreach") {
95896
96295
  const subArgs = process.argv.slice(3);
95897
96296
  const lic2 = await check();
@@ -95929,6 +96328,7 @@ async function main() {
95929
96328
  say(` ${c.bold("sproutkit book <id>")} booking site (--standalone for own-URL)`);
95930
96329
  say(` ${c.bold("sproutkit outreach")} prep outreach links (--ids 3,5)`);
95931
96330
  say(` ${c.bold("sproutkit setup")} set or update keys, licence, backend`);
96331
+ say(` ${c.bold("sproutkit doctor")} check everything is wired up`);
95932
96332
  say(` ${c.bold("sproutkit models")} list Claude models and pick one`);
95933
96333
  say(` ${c.bold("sproutkit sync")} push your leads into the desktop app`);
95934
96334
  say(` ${c.bold("sproutkit update")} update to the latest version`);
@@ -95999,7 +96399,7 @@ async function main() {
95999
96399
  }
96000
96400
  const direct = line.replace(/^\//, "");
96001
96401
  const verb = direct.split(/\s+/)[0]?.toLowerCase();
96002
- const DIRECT_VERBS = ["find", "list", "build", "build-all", "regenerate", "cms", "book", "outreach"];
96402
+ const DIRECT_VERBS = ["find", "list", "build", "build-all", "regenerate", "cms", "book", "outreach", "doctor"];
96003
96403
  if (verb === "commands" || verb === "help" || line === "/help" || line === "/?") {
96004
96404
  say();
96005
96405
  say(c.bold(" Commands (type with or without a leading /):"));
@@ -96012,7 +96412,7 @@ async function main() {
96012
96412
  say(` ${c.greenBold("book")} <id> [--standalone] [--archetype slots|reservation|enquiry] [--email \u2026]`);
96013
96413
  say(` ${c.dim(" ")} booking on the site, or --standalone for its own URL`);
96014
96414
  say(` ${c.greenBold("outreach")} [--ids 3,5] prepare outreach links`);
96015
- say(c.dim(" /freemode \u2014 open conversation & custom tweaks \xB7 /taskmode \u2014 precise \xB7 exit \u2014 quit"));
96415
+ say(c.dim(" doctor \u2014 check your setup \xB7 /freemode \u2014 open chat \xB7 /taskmode \u2014 precise \xB7 exit"));
96016
96416
  say(c.dim(" Or just talk: \u201Cfind cafes in Leeds and build the best two\u201D."));
96017
96417
  say();
96018
96418
  continue;
@@ -96029,6 +96429,7 @@ async function main() {
96029
96429
  else if (verb === "cms") await cmdCms(argv);
96030
96430
  else if (verb === "book") await cmdBook(argv);
96031
96431
  else if (verb === "outreach") await cmdOutreach(argv);
96432
+ else if (verb === "doctor") await runDoctor();
96032
96433
  } catch (e) {
96033
96434
  say(c.red(` Error: ${e.message}`));
96034
96435
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sproutkit-code",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "description": "Sproutkit in your terminal — find leads, build sites, run outreach, all via chat.",
5
5
  "bin": {
6
6
  "sproutkit": "./dist/index.js"