sproutkit-code 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,18 @@
1
+ ---
2
+ description: Build a booking site (for a lead, or any business by name)
3
+ argument-hint: "<leadId> | \"Business Name, Town\" [--standalone]"
4
+ allowed-tools: Bash(sproutkit *)
5
+ ---
6
+ Build a booking site. The operator asked: $ARGUMENTS
7
+
8
+ Use `sproutkit book`. Notes:
9
+ - A lead id adds booking onto a site Sproutkit already built (same domain, /book).
10
+ - `--standalone` gives a booking site on its OWN url — for a client who already
11
+ has their own website and just needs bookings.
12
+ - A business NAME instead of an id works even if they aren't a lead yet: it looks
13
+ them up, reads their site, and builds a booking page in one shot.
14
+ - `--archetype slots|reservation|enquiry`, `--instructions "…"`, `--email owner@x`.
15
+
16
+ Booking sites include a client `/admin` panel. The result carries `adminUrl` and
17
+ `adminPassword` — ALWAYS pass both on to the operator. The password is shown only
18
+ at build time, so if you swallow it the client can't log in.
@@ -0,0 +1,18 @@
1
+ ---
2
+ description: Build a demo website for a lead
3
+ argument-hint: "<leadId> [--multipage] [instructions]"
4
+ allowed-tools: Bash(sproutkit *)
5
+ ---
6
+ Build a site. The operator asked: $ARGUMENTS
7
+
8
+ Use `sproutkit build <leadId>`. Notes:
9
+ - `--multipage` gives real separate pages instead of one long scroll.
10
+ - Put any styling or content steer in `--instructions "…"`.
11
+ - `--skip-qa` only if they want speed over polish.
12
+ - No lead id given? Show `sproutkit list --status discovered` and ask which one.
13
+
14
+ Every generated site carries a DEMO banner by design — that's what stops clients
15
+ using the free preview as their real site. Don't try to remove it here; that's
16
+ what `/sproutkit-handover` does once they've paid.
17
+
18
+ When it's done, give the operator the live URL.
@@ -0,0 +1,15 @@
1
+ ---
2
+ description: Find local businesses with no website (or no online booking)
3
+ argument-hint: "[area] e.g. Leeds LS1, or \"Swansea --no-booking\""
4
+ allowed-tools: Bash(sproutkit *)
5
+ ---
6
+ Find new leads. The operator asked for: $ARGUMENTS
7
+
8
+ Run `sproutkit find` with that area. Notes:
9
+ - Default finds businesses with NO website. If they mention booking, WhatsApp
10
+ bookings, or "has a site already", add `--mode no_booking` instead.
11
+ - Pass `--categories` only if they named specific trades.
12
+ - Results stream in as each category returns, so let it finish before summarising.
13
+
14
+ Then report how many were found and the most promising few, with a one-line
15
+ reason each. Don't build anything yet.
@@ -0,0 +1,22 @@
1
+ ---
2
+ description: Hand a finished site to a paying client (removes the DEMO banner)
3
+ argument-hint: "<leadId> [--domain thesalon.co.uk]"
4
+ allowed-tools: Bash(sproutkit *)
5
+ ---
6
+ Hand over a site. The operator asked: $ARGUMENTS
7
+
8
+ This is the PAID-DELIVERABLE step. It removes the DEMO banner from a live site,
9
+ attaches the client's domain, and marks the lead won. The banner is what stops a
10
+ client using a free preview as their real site, so:
11
+
12
+ 1. Confirm the operator is telling you this client has PAID or signed off. If
13
+ that isn't explicit, ask — don't infer it from the site looking finished.
14
+ 2. Then run `sproutkit handover <leadId> --yes` (add `--domain x.co.uk` if given).
15
+ The command refuses without `--yes` by design.
16
+
17
+ Afterwards, relay the whole credentials sheet verbatim: site URL, /admin panel
18
+ and password, booking page and its password, and any DNS records the client has
19
+ to set at their registrar. That output is the client handover — don't summarise
20
+ the passwords away.
21
+
22
+ To re-check whether a domain's DNS has propagated, run the same command again.
@@ -0,0 +1,16 @@
1
+ ---
2
+ description: Show current leads and their stage
3
+ argument-hint: "[filter] e.g. site_built, or Leeds"
4
+ allowed-tools: Bash(sproutkit *)
5
+ ---
6
+ Leads:
7
+
8
+ !`sproutkit list --compact 2>/dev/null`
9
+
10
+ The operator asked to see: $ARGUMENTS
11
+
12
+ Every lead is listed above (one JSON object per line, nothing truncated).
13
+
14
+ Show the matching leads as a compact table — id, name, town, stage, and whether
15
+ they have a site / booking page. If they didn't narrow it down, group by stage.
16
+ Read-only: don't build, contact, or change anything.
@@ -0,0 +1,17 @@
1
+ ---
2
+ description: Prepare outreach messages for leads with a built site
3
+ argument-hint: "[--ids 3,5] [--channel sms|whatsapp|email]"
4
+ allowed-tools: Bash(sproutkit *)
5
+ ---
6
+ Prepare outreach. The operator asked: $ARGUMENTS
7
+
8
+ This CONTACTS REAL BUSINESSES and moves leads to Contacted, so:
9
+
10
+ 1. First run `sproutkit list --status site_built --json` and show exactly who is
11
+ about to be contacted, with a count.
12
+ 2. Ask the operator to confirm before running `sproutkit outreach`.
13
+ 3. Only then run it. Never add `--ids` you invented — use the ones they gave.
14
+
15
+ Routing is per-lead automatically: mobile number → SMS/WhatsApp, otherwise email
16
+ (harvesting the address if needed). Only pass `--channel` if they asked for a
17
+ specific one.
@@ -0,0 +1,20 @@
1
+ ---
2
+ description: Run the full pipeline — build then prepare outreach, in parallel
3
+ argument-hint: "[x3] [--book] [--limit 10]"
4
+ allowed-tools: Bash(sproutkit *)
5
+ ---
6
+ Run the pipeline. The operator asked: $ARGUMENTS
7
+
8
+ This builds sites AND prepares outreach to real businesses, for many leads at
9
+ once. Before running:
10
+
11
+ 1. Show which leads are queued (`sproutkit list --status discovered --json`) and
12
+ the count.
13
+ 2. Confirm with the operator.
14
+ 3. Run `sproutkit pipeline x<N>` where N is the concurrency they asked for
15
+ (default 3). Add `--book` to include a booking page, `--limit`/`--ids` to
16
+ scope it.
17
+
18
+ It prints a live board and can be stopped with the stop key. When it finishes,
19
+ report how many succeeded, and pass on any client admin panel credentials it
20
+ printed.
@@ -0,0 +1,15 @@
1
+ ---
2
+ description: Sproutkit — show the pipeline and what to do next
3
+ allowed-tools: Bash(sproutkit *)
4
+ ---
5
+ Current pipeline:
6
+
7
+ !`sproutkit list --compact 2>/dev/null`
8
+
9
+ Setup check:
10
+
11
+ !`sproutkit doctor 2>&1 | tail -20`
12
+
13
+ Summarise the pipeline for the operator in a few lines: how many leads at each
14
+ stage, and the single most useful next action. Mention any setup problem the
15
+ doctor output reveals. Don't run anything else unless asked.
package/dist/index.js CHANGED
@@ -183,6 +183,17 @@ var require_db = __commonJS({
183
183
  } catch {
184
184
  }
185
185
  }
186
+ for (const col of [
187
+ "booking_admin_url TEXT",
188
+ "booking_admin_password TEXT",
189
+ "handed_over_at TEXT",
190
+ "custom_domain TEXT"
191
+ ]) {
192
+ try {
193
+ db2.exec(`ALTER TABLE leads ADD COLUMN ${col}`);
194
+ } catch {
195
+ }
196
+ }
186
197
  try {
187
198
  db2.exec("ALTER TABLE leads ADD COLUMN dead_at TEXT");
188
199
  } catch {
@@ -31050,6 +31061,121 @@ export default function App() {
31050
31061
  }
31051
31062
  });
31052
31063
 
31064
+ // ../desktop/dist/main/lib/vercel-domains.js
31065
+ var require_vercel_domains = __commonJS({
31066
+ "../desktop/dist/main/lib/vercel-domains.js"(exports2) {
31067
+ "use strict";
31068
+ var __importDefault = exports2 && exports2.__importDefault || function(mod) {
31069
+ return mod && mod.__esModule ? mod : { "default": mod };
31070
+ };
31071
+ Object.defineProperty(exports2, "__esModule", { value: true });
31072
+ exports2.attachDomain = attachDomain;
31073
+ exports2.domainStatus = domainStatus;
31074
+ var axios_1 = __importDefault(require_axios());
31075
+ function qs(teamId) {
31076
+ return teamId ? `?teamId=${encodeURIComponent(teamId)}` : "";
31077
+ }
31078
+ function isApex(domain) {
31079
+ const parts = domain.split(".");
31080
+ if (parts.length <= 2)
31081
+ return true;
31082
+ const twoPartSuffixes = ["co.uk", "org.uk", "me.uk", "ac.uk", "com.au", "co.nz", "co.za", "com.br"];
31083
+ return parts.length === 3 && twoPartSuffixes.includes(parts.slice(-2).join("."));
31084
+ }
31085
+ async function fetchConfig(domain, projectId, vercelToken, teamId) {
31086
+ try {
31087
+ const params = new URLSearchParams({ projectIdOrName: projectId });
31088
+ if (teamId)
31089
+ params.set("teamId", teamId);
31090
+ const res = await axios_1.default.get(`https://api.vercel.com/v6/domains/${encodeURIComponent(domain)}/config?${params.toString()}`, { headers: { Authorization: `Bearer ${vercelToken}` }, timeout: 2e4 });
31091
+ return res.data;
31092
+ } catch {
31093
+ return null;
31094
+ }
31095
+ }
31096
+ function recordsFrom(domain, cfg) {
31097
+ const apex = isApex(domain);
31098
+ if (!cfg)
31099
+ return [];
31100
+ if (apex) {
31101
+ const ip = cfg.recommendedIPv4?.find((r) => r.rank === 1)?.value?.[0] ?? cfg.recommendedIPv4?.[0]?.value?.[0];
31102
+ if (!ip)
31103
+ return [];
31104
+ return [{ type: "A", name: "@", value: ip, note: "root domain" }];
31105
+ }
31106
+ const target = cfg.recommendedCNAME?.find((r) => r.rank === 1)?.value ?? cfg.recommendedCNAME?.[0]?.value;
31107
+ if (!target)
31108
+ return [];
31109
+ const sub = domain.split(".")[0];
31110
+ return [{ type: "CNAME", name: sub, value: target }];
31111
+ }
31112
+ async function attachDomain(args) {
31113
+ const { vercelToken, projectId, teamId } = args;
31114
+ const domain = args.domain.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
31115
+ if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/.test(domain)) {
31116
+ throw new Error(`"${args.domain}" doesn't look like a domain name (expected e.g. thesalon.co.uk).`);
31117
+ }
31118
+ let verified = false;
31119
+ let alreadyAttached = false;
31120
+ let apexName = domain;
31121
+ let ownershipChallenges = [];
31122
+ let warning;
31123
+ try {
31124
+ const res = await axios_1.default.post(`https://api.vercel.com/v10/projects/${encodeURIComponent(projectId)}/domains${qs(teamId)}`, { name: domain }, { headers: { Authorization: `Bearer ${vercelToken}` }, timeout: 2e4 });
31125
+ verified = !!res.data.verified;
31126
+ apexName = res.data.apexName ?? domain;
31127
+ ownershipChallenges = (res.data.verification ?? []).filter((v) => v.type?.toUpperCase() === "TXT").map((v) => ({ type: "TXT", name: v.domain, value: v.value, note: v.reason }));
31128
+ } catch (e) {
31129
+ const err = e;
31130
+ const status = err.response?.status;
31131
+ const msg = err.response?.data?.error?.message ?? e.message;
31132
+ if (status === 400) {
31133
+ alreadyAttached = true;
31134
+ } else if (status === 409) {
31135
+ throw new Error(`${domain} is already attached to a different Vercel project or account. Remove it there first, then retry. (${msg})`);
31136
+ } else if (status === 402) {
31137
+ throw new Error(`Vercel needs a payment method on the account before adding a custom domain. (${msg})`);
31138
+ } else if (status === 403) {
31139
+ throw new Error(`Your Vercel token doesn't have permission to add ${domain}. (${msg})`);
31140
+ } else {
31141
+ throw new Error(`Couldn't attach ${domain}: ${msg}`);
31142
+ }
31143
+ }
31144
+ const cfg = await fetchConfig(domain, projectId, vercelToken, teamId);
31145
+ const records = recordsFrom(domain, cfg);
31146
+ if (!records.length) {
31147
+ warning = "Couldn't read the DNS records from Vercel. Open the project's Domains tab in the Vercel dashboard to see the exact A/CNAME values.";
31148
+ }
31149
+ return {
31150
+ domain,
31151
+ apexDomain: apexName,
31152
+ verified,
31153
+ dnsConfigured: cfg ? cfg.misconfigured === false : false,
31154
+ alreadyAttached,
31155
+ records,
31156
+ ownershipChallenges,
31157
+ warning
31158
+ };
31159
+ }
31160
+ async function domainStatus(args) {
31161
+ const { domain, projectId, vercelToken, teamId } = args;
31162
+ let verified = false;
31163
+ try {
31164
+ const res = await axios_1.default.get(`https://api.vercel.com/v9/projects/${encodeURIComponent(projectId)}/domains/${encodeURIComponent(domain)}${qs(teamId)}`, { headers: { Authorization: `Bearer ${vercelToken}` }, timeout: 2e4 });
31165
+ verified = !!res.data.verified;
31166
+ } catch {
31167
+ }
31168
+ const cfg = await fetchConfig(domain, projectId, vercelToken, teamId);
31169
+ return {
31170
+ domain,
31171
+ verified,
31172
+ dnsConfigured: cfg ? cfg.misconfigured === false : false,
31173
+ records: recordsFrom(domain, cfg)
31174
+ };
31175
+ }
31176
+ }
31177
+ });
31178
+
31053
31179
  // ../../node_modules/bcryptjs/umd/index.js
31054
31180
  var require_umd = __commonJS({
31055
31181
  "../../node_modules/bcryptjs/umd/index.js"(exports2, module2) {
@@ -34354,6 +34480,7 @@ export default async function handler(req, res) {
34354
34480
  </style>
34355
34481
  </head>
34356
34482
  <body>
34483
+ <!--SPROUTKIT_BANNER-->
34357
34484
  <div id="root"></div>
34358
34485
  <script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
34359
34486
  <script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
@@ -79674,6 +79801,7 @@ var require_builder = __commonJS({
79674
79801
  exports2.redeployCmsOnly = redeployCmsOnly;
79675
79802
  exports2.deployBookingOnly = deployBookingOnly2;
79676
79803
  exports2.deployStandaloneBooking = deployStandaloneBooking2;
79804
+ exports2.handoverSite = handoverSite2;
79677
79805
  var sdk_1 = __importDefault(require_sdk());
79678
79806
  var axios_1 = __importDefault(require_axios());
79679
79807
  var crypto_1 = require("crypto");
@@ -79685,6 +79813,7 @@ var require_builder = __commonJS({
79685
79813
  var path3 = __importStar(require("path"));
79686
79814
  var models_1 = require_models();
79687
79815
  var cms_template_1 = require_cms_template();
79816
+ var vercel_domains_1 = require_vercel_domains();
79688
79817
  var cms_runtime_1 = require_cms_runtime();
79689
79818
  var booking_runtime_1 = require_booking_runtime();
79690
79819
  function siteUidFor(leadId) {
@@ -79759,7 +79888,18 @@ var require_builder = __commonJS({
79759
79888
  const pattern = new RegExp(`https?:\\/\\/[^\\s"'\`)]*(${STOCK_HOSTS.map((h) => h.replace(/\./g, "\\.")).join("|")})[^\\s"'\`)]*`, "gi");
79760
79889
  return code.replace(pattern, "");
79761
79890
  }
79762
- function demoBanner(businessName) {
79891
+ function bannerFor(lead) {
79892
+ let handedOver = false;
79893
+ try {
79894
+ const row = (0, db_1.getDb)().get("SELECT handed_over_at FROM leads WHERE id = ?", [lead.id]);
79895
+ handedOver = !!row?.handed_over_at;
79896
+ } catch {
79897
+ }
79898
+ return demoBanner(lead.name, handedOver);
79899
+ }
79900
+ function demoBanner(businessName, handedOver) {
79901
+ if (handedOver)
79902
+ return "";
79763
79903
  const esc = (v) => v.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
79764
79904
  const label = `Preview site for ${esc(businessName)}`;
79765
79905
  return `<div id="sproutkit-demo-banner" role="note" style="all:initial;display:block;box-sizing:border-box;width:100%;background:#1f2937;color:#f9fafb;font-family:ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;font-size:13px;line-height:1.4;padding:10px 16px;text-align:center;letter-spacing:.01em">
@@ -80339,7 +80479,7 @@ ${prompt}` : prompt;
80339
80479
  onProgress("Deploying to Vercel...");
80340
80480
  const indexHtml = `<!DOCTYPE html>
80341
80481
  <html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${lead.name}</title></head>
80342
- <body style="margin:0">${demoBanner(lead.name)}<div id="root"></div><script type="module" src="/src/main.jsx"></script></body></html>`;
80482
+ <body style="margin:0">${bannerFor(lead)}<div id="root"></div><script type="module" src="/src/main.jsx"></script></body></html>`;
80343
80483
  const mainJsx = `import { StrictMode } from 'react';
80344
80484
  import { createRoot } from 'react-dom/client';
80345
80485
  import App from './App.jsx';
@@ -80534,7 +80674,7 @@ export default defineConfig({ plugins: [react()] });`;
80534
80674
  { source: "/book/", destination: "/book/index.html" }
80535
80675
  ]
80536
80676
  });
80537
- const indexHtmlCms = `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${lead.name}</title></head><body style="margin:0">${demoBanner(lead.name)}<div id="root"></div><script type="module" src="/src/main.jsx"></script></body></html>`;
80677
+ const indexHtmlCms = `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${lead.name}</title></head><body style="margin:0">${bannerFor(lead)}<div id="root"></div><script type="module" src="/src/main.jsx"></script></body></html>`;
80538
80678
  const mainJsxCms = `import { StrictMode } from 'react';
80539
80679
  import { createRoot } from 'react-dom/client';
80540
80680
  import App from './App.jsx';
@@ -80611,7 +80751,7 @@ export default defineConfig({ plugins: [react()] });`;
80611
80751
  snapshot[appJsxIndex] = { ...snapshot[appJsxIndex], data: modifiedApp };
80612
80752
  saveSnapshot(lead.id, snapshot);
80613
80753
  const files = snapshot.map((f) => ({ file: f.file, data: f.data, encoding: f.encoding }));
80614
- const indexHtml = `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${lead.name}</title></head><body style="margin:0">${demoBanner(lead.name)}<div id="root"></div><script type="module" src="/src/main.jsx"></script></body></html>`;
80754
+ const indexHtml = `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${lead.name}</title></head><body style="margin:0">${bannerFor(lead)}<div id="root"></div><script type="module" src="/src/main.jsx"></script></body></html>`;
80615
80755
  const mainJsx = `import { StrictMode } from 'react';
80616
80756
  import { createRoot } from 'react-dom/client';
80617
80757
  import App from './App.jsx';
@@ -80683,7 +80823,13 @@ export default defineConfig({ plugins: [react()] });`;
80683
80823
  { file: "vercel.json", data: vercelJson, encoding: "utf-8" },
80684
80824
  { file: "api/bookings.js", data: booking_runtime_1.API_BOOKINGS_JS, encoding: "utf-8" },
80685
80825
  { file: "api/booking-config.js", data: booking_runtime_1.API_BOOKING_CONFIG_JS, encoding: "utf-8" },
80686
- { file: "public/book/index.html", data: booking_runtime_1.BOOK_INDEX_HTML, encoding: "utf-8" },
80826
+ {
80827
+ file: "public/book/index.html",
80828
+ // The parent site's index.html already carries the banner; this page
80829
+ // just needs the placeholder removed.
80830
+ data: booking_runtime_1.BOOK_INDEX_HTML.replace("<!--SPROUTKIT_BANNER-->", ""),
80831
+ encoding: "utf-8"
80832
+ },
80687
80833
  { file: "public/book/app.js", data: booking_runtime_1.BOOK_APP_JS, encoding: "utf-8" },
80688
80834
  // Refresh the CMS admin app (contains the Bookings tab now)
80689
80835
  { file: "public/admin/index.html", data: cms_runtime_1.ADMIN_INDEX_HTML, encoding: "utf-8" },
@@ -80711,8 +80857,7 @@ export default defineConfig({ plugins: [react()] });`;
80711
80857
  async function deployStandaloneBooking2(args) {
80712
80858
  const { lead, vercelToken, blobToken, resendKey, ownerEmail, bookingConfig, onProgress } = args;
80713
80859
  const adminPassword = args.reuseAdminPassword ?? (0, cms_runtime_1.generateFriendlyPassword)();
80714
- const slug = lead.name.toLowerCase().replace(/[^a-z0-9]/g, "-").replace(/-+/g, "-").slice(0, 26);
80715
- const projectName = `sprout-book-${lead.id}-${slug}`;
80860
+ const projectName = standaloneBookingProjectName(lead);
80716
80861
  onProgress("Seeding booking config\u2026");
80717
80862
  const standaloneSiteUid = siteUidFor(lead.id);
80718
80863
  try {
@@ -80776,7 +80921,7 @@ export default defineConfig({ plugins: [react()] });`;
80776
80921
  ];
80777
80922
  files[0] = {
80778
80923
  file: "index.html",
80779
- data: booking_runtime_1.BOOK_INDEX_HTML.replace(/\/book\/app\.js/g, "/app.js"),
80924
+ data: booking_runtime_1.BOOK_INDEX_HTML.replace(/\/book\/app\.js/g, "/app.js").replace("<!--SPROUTKIT_BANNER-->", bannerFor(lead)),
80780
80925
  encoding: "utf-8"
80781
80926
  };
80782
80927
  onProgress("Deploying booking site\u2026");
@@ -80809,6 +80954,149 @@ export default defineConfig({ plugins: [react()] });`;
80809
80954
  admin: { url: `${publicUrl}/admin`, password: adminPassword }
80810
80955
  };
80811
80956
  }
80957
+ async function handoverSite2(args) {
80958
+ const { lead, vercelToken, blobToken, domain, teamId, onProgress } = args;
80959
+ const db2 = (0, db_1.getDb)();
80960
+ const row = db2.get(`SELECT site_url, booking_url, admin_url, admin_password,
80961
+ booking_admin_url, booking_admin_password, handed_over_at, site_uid
80962
+ FROM leads WHERE id = ?`, [lead.id]);
80963
+ if (!row)
80964
+ throw new Error("Lead not found");
80965
+ if (!row.site_url && !row.booking_url) {
80966
+ throw new Error("Nothing to hand over \u2014 this lead has no site and no booking page yet. Build one first.");
80967
+ }
80968
+ const alreadyHandedOver = !!row.handed_over_at;
80969
+ db2.run("UPDATE leads SET handed_over_at = COALESCE(handed_over_at, datetime('now')) WHERE id = ?", [
80970
+ lead.id
80971
+ ]);
80972
+ let url = row.site_url ?? row.booking_url ?? "";
80973
+ let bannerRemoved = false;
80974
+ if (row.site_url) {
80975
+ try {
80976
+ onProgress("Rebuilding site without the demo banner\u2026");
80977
+ const result = await redeployWithoutBanner({
80978
+ lead,
80979
+ vercelToken,
80980
+ blobToken: blobToken ?? null,
80981
+ hasCms: !!row.admin_url,
80982
+ adminPassword: row.admin_password,
80983
+ siteUid: row.site_uid,
80984
+ onProgress
80985
+ });
80986
+ url = result.url;
80987
+ bannerRemoved = true;
80988
+ } catch (e) {
80989
+ db2.run("UPDATE leads SET handed_over_at = ? WHERE id = ?", [row.handed_over_at, lead.id]);
80990
+ throw new Error(`Couldn't rebuild the site without the banner: ${e.message}`);
80991
+ }
80992
+ }
80993
+ let domainInfo;
80994
+ if (domain) {
80995
+ onProgress(`Attaching ${domain}\u2026`);
80996
+ const projectId = row.site_url ? projectNameFor(lead) : standaloneBookingProjectName(lead);
80997
+ const attached = await (0, vercel_domains_1.attachDomain)({ domain, projectId, vercelToken, teamId });
80998
+ domainInfo = {
80999
+ domain: attached.domain,
81000
+ verified: attached.verified,
81001
+ dnsConfigured: attached.dnsConfigured,
81002
+ records: attached.records,
81003
+ ownershipChallenges: attached.ownershipChallenges,
81004
+ warning: attached.warning
81005
+ };
81006
+ db2.run("UPDATE leads SET custom_domain = ? WHERE id = ?", [attached.domain, lead.id]);
81007
+ }
81008
+ return {
81009
+ url,
81010
+ admin: row.admin_url && row.admin_password ? { url: row.admin_url, password: row.admin_password } : void 0,
81011
+ bookingUrl: row.booking_url ?? void 0,
81012
+ bookingAdmin: row.booking_admin_url && row.booking_admin_password ? { url: row.booking_admin_url, password: row.booking_admin_password } : void 0,
81013
+ domain: domainInfo,
81014
+ bannerRemoved,
81015
+ alreadyHandedOver
81016
+ };
81017
+ }
81018
+ async function redeployWithoutBanner(args) {
81019
+ const { lead, vercelToken, blobToken, hasCms, adminPassword, siteUid, onProgress } = args;
81020
+ const projectName = projectNameFor(lead);
81021
+ onProgress("Loading site source\u2026");
81022
+ const snapshot = loadSnapshot(lead.id);
81023
+ if (!snapshot || !snapshot.some((f) => f.file === "src/App.jsx")) {
81024
+ throw new Error("No local snapshot of this site. Run `sproutkit regenerate` once to save one, then hand over again.");
81025
+ }
81026
+ const files = snapshot.map((f) => ({
81027
+ file: f.file,
81028
+ data: f.data,
81029
+ encoding: f.encoding
81030
+ }));
81031
+ const multipage = /react-router-dom/.test(snapshot.find((f) => f.file === "src/App.jsx")?.data ?? "");
81032
+ const deps = { react: "^18.3.1", "react-dom": "^18.3.1" };
81033
+ if (multipage)
81034
+ deps["react-router-dom"] = "^6.26.2";
81035
+ if (hasCms) {
81036
+ deps["@vercel/blob"] = "^0.23.4";
81037
+ deps.bcryptjs = "^2.4.3";
81038
+ }
81039
+ const indexHtml = `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${lead.name}</title></head><body style="margin:0">${bannerFor(lead)}<div id="root"></div><script type="module" src="/src/main.jsx"></script></body></html>`;
81040
+ files.push({ file: "index.html", data: indexHtml, encoding: "utf-8" }, {
81041
+ file: "src/main.jsx",
81042
+ data: `import { StrictMode } from 'react';
81043
+ import { createRoot } from 'react-dom/client';
81044
+ import App from './App.jsx';
81045
+ createRoot(document.getElementById('root')).render(<StrictMode><App /></StrictMode>);`,
81046
+ encoding: "utf-8"
81047
+ }, {
81048
+ file: "vite.config.js",
81049
+ data: `import { defineConfig } from 'vite';
81050
+ import react from '@vitejs/plugin-react';
81051
+ export default defineConfig({ plugins: [react()] });`,
81052
+ encoding: "utf-8"
81053
+ }, {
81054
+ file: "package.json",
81055
+ data: JSON.stringify({
81056
+ name: "sprout-site",
81057
+ private: true,
81058
+ version: "0.1.0",
81059
+ type: "module",
81060
+ scripts: { build: "vite build", dev: "vite" },
81061
+ dependencies: deps,
81062
+ devDependencies: { "@vitejs/plugin-react": "^4.3.1", vite: "^5.4.2" }
81063
+ }),
81064
+ encoding: "utf-8"
81065
+ });
81066
+ if (hasCms && adminPassword && blobToken) {
81067
+ onProgress("Preserving the admin panel\u2026");
81068
+ files.push({
81069
+ file: "vercel.json",
81070
+ data: JSON.stringify({
81071
+ rewrites: [
81072
+ { source: "/admin", destination: "/admin/index.html" },
81073
+ { source: "/admin/", destination: "/admin/index.html" },
81074
+ { source: "/book", destination: "/book/index.html" },
81075
+ { source: "/book/", destination: "/book/index.html" }
81076
+ ]
81077
+ }),
81078
+ encoding: "utf-8"
81079
+ }, { file: "api/content.js", data: cms_runtime_1.API_CONTENT_JS, encoding: "utf-8" }, { file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" }, { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" }, { file: "api/admin/upload.js", data: cms_runtime_1.API_ADMIN_UPLOAD_JS, encoding: "utf-8" }, { file: "public/admin/index.html", data: cms_runtime_1.ADMIN_INDEX_HTML, encoding: "utf-8" }, { file: "public/admin/app.js", data: cms_runtime_1.ADMIN_APP_JS, encoding: "utf-8" });
81080
+ const env = {
81081
+ BLOB_READ_WRITE_TOKEN: blobToken,
81082
+ ADMIN_PASSWORD_HASH: await (0, cms_runtime_1.hashPassword)(adminPassword),
81083
+ SESSION_SECRET: (0, cms_runtime_1.generateSessionSecret)(),
81084
+ BUSINESS_NAME: lead.name
81085
+ };
81086
+ if (siteUid)
81087
+ env.SITE_ID = siteUid;
81088
+ await setProjectEnv(projectName, vercelToken, env);
81089
+ }
81090
+ onProgress("Deploying\u2026");
81091
+ const res = await postVercelDeployment({ name: projectName, files, projectSettings: { framework: "vite" }, target: "production" }, vercelToken, onProgress);
81092
+ const url = await stableProjectUrl({
81093
+ projectName,
81094
+ vercelToken,
81095
+ fallbackUrl: `https://${res.data.url}`
81096
+ });
81097
+ onProgress(`Live at ${url}`);
81098
+ return { url };
81099
+ }
80812
81100
  function hasBookLink(code) {
80813
81101
  return /(href\s*=\s*["'`{]*\s*["'`]?\/book\/?["'`}]?)|(location(?:\.href)?\s*=\s*["'`]\/book["'`])/i.test(code);
80814
81102
  }
@@ -80950,6 +81238,10 @@ Return this exact shape:
80950
81238
  return null;
80951
81239
  }
80952
81240
  }
81241
+ function standaloneBookingProjectName(lead) {
81242
+ const slug = lead.name.toLowerCase().replace(/[^a-z0-9]/g, "-").replace(/-+/g, "-").slice(0, 26);
81243
+ return `sprout-book-${lead.id}-${slug}`;
81244
+ }
80953
81245
  function projectNameFor(lead) {
80954
81246
  const slug = lead.name.toLowerCase().replace(/[^a-z0-9]/g, "-").replace(/-+/g, "-").slice(0, 30);
80955
81247
  return `sprout-${lead.id}-${slug}`;
@@ -93359,6 +93651,24 @@ var require_chat_tools = __commonJS({
93359
93651
  },
93360
93652
  requiresConfirmation: true
93361
93653
  },
93654
+ {
93655
+ name: "hand_over_site",
93656
+ description: "HAND OVER a finished site to the paying client: removes the DEMO/preview banner, optionally attaches the client's own domain, and returns every credential in one place (site URL, /admin panel + password, booking page + its password, and the DNS records the client must set at their registrar). This is the paid-deliverable step \u2014 the banner exists to stop clients using a free demo as their real site, so only run this when the user says the client has paid / signed off / is going live. Requires the lead to already have a built site or booking page.",
93657
+ input_schema: {
93658
+ type: "object",
93659
+ properties: {
93660
+ id: { type: "number", description: "Lead id to hand over." },
93661
+ domain: {
93662
+ type: "string",
93663
+ description: "Optional: the client's own domain, e.g. 'thesalon.co.uk' or 'www.thesalon.co.uk'. Attaches it to the project and returns the exact DNS records to configure. Omit to keep the site on its .vercel.app URL."
93664
+ }
93665
+ },
93666
+ required: ["id"]
93667
+ },
93668
+ // Commercial + hard to walk back: it unbrands a live client site and marks
93669
+ // the lead won. Always ask first.
93670
+ requiresConfirmation: true
93671
+ },
93362
93672
  {
93363
93673
  name: "booking_site_for_business",
93364
93674
  description: "ONE-SHOT: build a standalone booking site for a business the user NAMES, even if it isn't a lead yet. Give it the business name (include the town/area if known, e.g. 'Kings Barbers, Leeds'). It looks the business up on Google Places, reads their existing website to understand the business and check whether they already take bookings online, saves them as a lead, and deploys a booking page on its own URL for them to link from their site. Use this for requests like 'build a booking site for The Oak Barbers' when that business isn't in the pipeline. Costs a Places lookup + a Vercel deploy.",
@@ -94013,6 +94323,25 @@ Interested?`;
94013
94323
  });
94014
94324
  return { ok: true, id, ...result };
94015
94325
  }
94326
+ case "hand_over_site": {
94327
+ const id = Number(input.id);
94328
+ const lead = db2.get("SELECT id, name, site_url, booking_url FROM leads WHERE id = ?", [id]);
94329
+ if (!lead)
94330
+ throw new Error(`Lead #${id} not found`);
94331
+ if (!lead.site_url && !lead.booking_url) {
94332
+ return {
94333
+ ok: false,
94334
+ error: `#${id} ${lead.name} has no site or booking page yet \u2014 build one before handing it over.`
94335
+ };
94336
+ }
94337
+ const result = await ctx.invokeIpc("site:handover", {
94338
+ leadId: id,
94339
+ domain: input.domain
94340
+ });
94341
+ db2.run("UPDATE leads SET status = 'won' WHERE id = ?", [id]);
94342
+ ctx.emitProgress?.({ channel: "leads:changed", payload: { reason: "handover" } });
94343
+ return { ok: true, id, business: lead.name, ...result };
94344
+ }
94016
94345
  case "provision_cms": {
94017
94346
  const id = Number(input.id);
94018
94347
  const result = await ctx.invokeIpc("cms:provision", id);
@@ -95027,7 +95356,7 @@ var require_package2 = __commonJS({
95027
95356
  "package.json"(exports2, module2) {
95028
95357
  module2.exports = {
95029
95358
  name: "sproutkit-code",
95030
- version: "1.3.0",
95359
+ version: "1.4.0",
95031
95360
  description: "Sproutkit in your terminal \u2014 find leads, build sites, run outreach, all via chat.",
95032
95361
  bin: {
95033
95362
  sproutkit: "./dist/index.js"
@@ -95087,6 +95416,7 @@ var provisionCmsForLead = cms.provisionCmsForLead;
95087
95416
  var generatePreset = bookingPreset.generatePreset;
95088
95417
  var deployBookingOnly = builder.deployBookingOnly;
95089
95418
  var deployStandaloneBooking = builder.deployStandaloneBooking;
95419
+ var handoverSite = builder.handoverSite;
95090
95420
  var createBlobStore = vercelStore.createBlobStore;
95091
95421
  var refreshModels = models.refreshModels;
95092
95422
  var refreshIfStale = models.refreshIfStale;
@@ -95405,6 +95735,43 @@ var StatusBoard = class {
95405
95735
  var import_fs = __toESM(require("fs"));
95406
95736
  var import_path = __toESM(require("path"));
95407
95737
  var import_os2 = __toESM(require("os"));
95738
+ function bundledCommandsDir() {
95739
+ const candidates = [
95740
+ import_path.default.join(__dirname, "commands"),
95741
+ import_path.default.join(__dirname, "..", "dist", "commands"),
95742
+ import_path.default.join(__dirname, "..", "commands")
95743
+ ];
95744
+ for (const c2 of candidates) {
95745
+ if (import_fs.default.existsSync(import_path.default.join(c2, "sproutkit.md"))) return c2;
95746
+ }
95747
+ return candidates[0];
95748
+ }
95749
+ function commandsTargetDir() {
95750
+ return import_path.default.join(import_os2.default.homedir(), ".claude", "commands");
95751
+ }
95752
+ function installCommands() {
95753
+ try {
95754
+ const src = bundledCommandsDir();
95755
+ if (!import_fs.default.existsSync(src)) {
95756
+ return { ok: false, error: `Command source not found (looked in ${src}).` };
95757
+ }
95758
+ const dest = commandsTargetDir();
95759
+ import_fs.default.mkdirSync(dest, { recursive: true });
95760
+ let count = 0;
95761
+ for (const f of import_fs.default.readdirSync(src)) {
95762
+ if (!f.endsWith(".md")) continue;
95763
+ if (f !== "sproutkit.md" && !f.startsWith("sproutkit-")) continue;
95764
+ import_fs.default.copyFileSync(import_path.default.join(src, f), import_path.default.join(dest, f));
95765
+ count++;
95766
+ }
95767
+ return { ok: true, count, path: dest };
95768
+ } catch (e) {
95769
+ return { ok: false, error: e.message };
95770
+ }
95771
+ }
95772
+ function commandsInstalled() {
95773
+ return import_fs.default.existsSync(import_path.default.join(commandsTargetDir(), "sproutkit.md"));
95774
+ }
95408
95775
  function bundledSkillDir() {
95409
95776
  const candidates = [
95410
95777
  import_path.default.join(__dirname, "skill"),
@@ -95514,10 +95881,16 @@ async function offerSkillInstall(force) {
95514
95881
  const res = installSkill();
95515
95882
  if (res.ok) {
95516
95883
  say(c.green(` \u2713 Skill installed to ${res.path}`));
95517
- say(c.dim(" Now run `claude` in any terminal and ask it to find or build leads."));
95518
95884
  } else {
95519
95885
  say(c.amber(` Couldn't install the skill: ${res.error}`));
95520
95886
  }
95887
+ const cmds = installCommands();
95888
+ if (cmds.ok) {
95889
+ say(c.green(` \u2713 ${cmds.count} slash commands installed \u2014 type / in Claude Code`));
95890
+ say(c.dim(" /sproutkit, /sproutkit-find, /sproutkit-build, /sproutkit-handover \u2026"));
95891
+ } else {
95892
+ say(c.amber(` Couldn't install the slash commands: ${cmds.error}`));
95893
+ }
95521
95894
  say();
95522
95895
  }
95523
95896
  async function onboardLicense(force) {
@@ -95948,12 +96321,12 @@ function makeInvokeIpc(emit) {
95948
96321
  resendKey: resendKey || "",
95949
96322
  ownerEmail: p.ownerEmail ?? "",
95950
96323
  bookingConfig: config,
95951
- reuseAdminPassword: lead.admin_password ?? null,
96324
+ reuseAdminPassword: lead.booking_admin_password ?? null,
95952
96325
  onProgress: (msg) => emit("bookings:progress", { leadId: p.leadId, msg })
95953
96326
  });
95954
96327
  db2.run(
95955
96328
  `UPDATE leads SET booking_url = ?, owner_email = ?, booking_archetype = ?, booking_config = ?,
95956
- admin_url = ?, admin_password = ?,
96329
+ booking_admin_url = ?, booking_admin_password = ?,
95957
96330
  status = CASE WHEN status = 'discovered' THEN 'site_built' ELSE status END
95958
96331
  WHERE id = ?`,
95959
96332
  [
@@ -95969,6 +96342,21 @@ function makeInvokeIpc(emit) {
95969
96342
  emit("bookings:done", { leadId: p.leadId, ...result });
95970
96343
  return result;
95971
96344
  }
96345
+ if (channel === "site:handover") {
96346
+ const p = payload;
96347
+ const db2 = getDb();
96348
+ const lead = db2.get("SELECT * FROM leads WHERE id = ?", [p.leadId]);
96349
+ if (!lead) throw new Error("Lead not found");
96350
+ const vercelToken = getSetting("vercel_token");
96351
+ if (!vercelToken) throw new Error("Vercel token not set");
96352
+ return await handoverSite({
96353
+ lead,
96354
+ vercelToken,
96355
+ blobToken: getSetting("blob_token"),
96356
+ domain: p.domain ?? null,
96357
+ onProgress: (msg) => emit("sites:progress", { leadId: p.leadId, msg })
96358
+ });
96359
+ }
95972
96360
  throw new Error(`channel not supported in CLI: ${channel}`);
95973
96361
  };
95974
96362
  }
@@ -96076,6 +96464,24 @@ async function cmdList(argv) {
96076
96464
  emitJson(rows);
96077
96465
  return;
96078
96466
  }
96467
+ if (flags.compact) {
96468
+ for (const r of rows) {
96469
+ process.stdout.write(
96470
+ JSON.stringify({
96471
+ id: r.id,
96472
+ name: r.name,
96473
+ city: r.city,
96474
+ type: r.type,
96475
+ status: r.status,
96476
+ phone: r.phone || void 0,
96477
+ site_url: r.site_url || void 0,
96478
+ booking_url: r.booking_url || void 0,
96479
+ lead_kind: r.lead_kind || void 0
96480
+ }) + "\n"
96481
+ );
96482
+ }
96483
+ return;
96484
+ }
96079
96485
  if (rows.length === 0) {
96080
96486
  say(c.dim(' No leads yet. Try: sproutkit find "barbers in <area>"'));
96081
96487
  return;
@@ -96307,6 +96713,89 @@ async function cmdPipeline(argv) {
96307
96713
  }
96308
96714
  say();
96309
96715
  }
96716
+ async function cmdHandover(argv) {
96717
+ const { flags, rest } = parseArgs(argv);
96718
+ const json = !!flags.json;
96719
+ const id = Number(flags.id ?? rest[0]);
96720
+ if (!id || Number.isNaN(id)) {
96721
+ const err = "Usage: sproutkit handover <leadId> [--domain thesalon.co.uk]";
96722
+ json ? emitJson({ ok: false, error: err }) : say(c.red(" " + err));
96723
+ process.exitCode = 1;
96724
+ return;
96725
+ }
96726
+ const domain = flags.domain ? String(flags.domain) : void 0;
96727
+ if (!flags.yes && !flags.y) {
96728
+ const lead = await runTool("get_lead", { id }, makeCtx({ json: true }));
96729
+ const label = lead?.name ? `${lead.name} (#${id})` : `lead #${id}`;
96730
+ const msg = `This removes the DEMO banner from ${label} and marks them won \u2014 only do it once the client has paid. Re-run with --yes to confirm: sproutkit handover ${id}${domain ? ` --domain ${domain}` : ""} --yes`;
96731
+ json ? emitJson({ ok: false, error: msg, needsConfirmation: true }) : say(c.amber(" " + msg));
96732
+ process.exitCode = 1;
96733
+ return;
96734
+ }
96735
+ const status = json ? void 0 : new Status();
96736
+ if (status) status.start(`Handing over lead #${id}`);
96737
+ try {
96738
+ const r = await runTool("hand_over_site", { id, domain }, makeCtx({ json, status }));
96739
+ if (r && r.ok === false) {
96740
+ if (status) status.done(false, r.error ?? "Handover failed");
96741
+ if (json) emitJson(r);
96742
+ process.exitCode = 1;
96743
+ return;
96744
+ }
96745
+ if (status) status.done(true, r.bannerRemoved ? "Demo banner removed" : "Handed over");
96746
+ autoSync();
96747
+ if (json) {
96748
+ emitJson(r);
96749
+ return;
96750
+ }
96751
+ say();
96752
+ say(c.bold(` Handover \u2014 ${r.business ?? `lead #${id}`}`));
96753
+ if (r.alreadyHandedOver) say(c.dim(" (already handed over previously \u2014 re-running is safe)"));
96754
+ say();
96755
+ if (r.url) say(` Website: ${r.url}`);
96756
+ if (r.admin) {
96757
+ say(` Admin panel: ${r.admin.url}`);
96758
+ say(` Password: ${c.bold(r.admin.password)}`);
96759
+ }
96760
+ if (r.bookingUrl) say(` Booking page: ${r.bookingUrl}`);
96761
+ if (r.bookingAdmin) {
96762
+ say(` Booking admin: ${r.bookingAdmin.url}`);
96763
+ say(` Password: ${c.bold(r.bookingAdmin.password)}`);
96764
+ }
96765
+ if (r.domain) {
96766
+ const d = r.domain;
96767
+ say();
96768
+ say(c.bold(` Domain \u2014 ${d.domain}`));
96769
+ if (d.dnsConfigured) {
96770
+ say(c.green(" DNS is already pointing at us. Nothing to do."));
96771
+ } else {
96772
+ say(c.dim(" Ask the client to add these records at their domain registrar:"));
96773
+ if (d.records.length) {
96774
+ for (const rec of d.records) {
96775
+ say(` ${rec.type.padEnd(6)} ${rec.name.padEnd(6)} ${rec.value}${rec.note ? c.dim(` (${rec.note})`) : ""}`);
96776
+ }
96777
+ }
96778
+ if (d.ownershipChallenges.length) {
96779
+ say(c.dim(" Plus this record to prove ownership:"));
96780
+ for (const rec of d.ownershipChallenges) {
96781
+ say(` ${rec.type.padEnd(6)} ${rec.name} ${rec.value}`);
96782
+ }
96783
+ }
96784
+ if (d.warning) say(c.amber(" " + d.warning));
96785
+ say(c.dim(" DNS can take minutes to 48h. Re-check with: sproutkit handover " + id + " --domain " + d.domain));
96786
+ }
96787
+ }
96788
+ if (!r.bannerRemoved && r.bookingUrl && !r.url) {
96789
+ say();
96790
+ say(c.dim(" Booking-only client: the demo banner comes off next time the booking site deploys."));
96791
+ }
96792
+ say();
96793
+ } catch (e) {
96794
+ if (status) status.done(false, "Handover failed");
96795
+ json ? emitJson({ ok: false, error: e.message }) : say(c.red(" " + e.message));
96796
+ process.exitCode = 1;
96797
+ }
96798
+ }
96310
96799
  async function cmdRegenerate(argv) {
96311
96800
  const { flags, rest } = parseArgs(argv);
96312
96801
  const json = !!flags.json;
@@ -96364,6 +96853,7 @@ async function cmdCms(argv) {
96364
96853
  if (status) status.done(true, result.publicUrl ? `CMS live at ${result.publicUrl}` : `CMS set up for #${id}`);
96365
96854
  autoSync();
96366
96855
  if (json) emitJson(result);
96856
+ else sayAdminHandover(result.adminUrl, result.adminPassword);
96367
96857
  } catch (e) {
96368
96858
  if (status) status.done(false, "CMS setup failed");
96369
96859
  json ? emitJson({ ok: false, error: e.message }) : say(c.red(" " + e.message));
@@ -96676,6 +97166,14 @@ async function runDoctor() {
96676
97166
  fix: "Run 'sproutkit setup' and say yes to installing the skill, so `claude` can drive Sproutkit."
96677
97167
  }
96678
97168
  );
97169
+ rows.push(
97170
+ commandsInstalled() ? { level: "ok", label: "Slash commands", detail: commandsTargetDir() } : {
97171
+ level: "warn",
97172
+ label: "Slash commands",
97173
+ detail: "not installed",
97174
+ fix: "Run 'sproutkit setup' so /sproutkit-find, /sproutkit-build etc. appear when you type / in Claude Code."
97175
+ }
97176
+ );
96679
97177
  say();
96680
97178
  say(c.greenBold(" Sproutkit doctor"));
96681
97179
  say(c.dim(` Backend: ${backend === "claude_code" ? "Claude subscription" : "Anthropic API key"}`));
@@ -96741,7 +97239,7 @@ async function main() {
96741
97239
  closeTty();
96742
97240
  return;
96743
97241
  }
96744
- if (arg === "find" || arg === "list" || arg === "build" || arg === "build-all" || arg === "regenerate" || arg === "cms" || arg === "book" || arg === "outreach" || arg === "pipeline") {
97242
+ if (arg === "find" || arg === "list" || arg === "build" || arg === "build-all" || arg === "regenerate" || arg === "cms" || arg === "book" || arg === "handover" || arg === "outreach" || arg === "pipeline") {
96745
97243
  const subArgs = process.argv.slice(3);
96746
97244
  if (subArgs.includes("--help") || subArgs.includes("-h")) {
96747
97245
  const USAGE = {
@@ -96753,7 +97251,7 @@ async function main() {
96753
97251
  " --json machine-readable output"
96754
97252
  ],
96755
97253
  list: [
96756
- "sproutkit list [--status <s>] [--city <c>] [--limit <n>] [--json]",
97254
+ "sproutkit list [--status <s>] [--city <c>] [--limit <n>] [--json|--compact]",
96757
97255
  " statuses: discovered, site_built, contacted, replied, won, dead"
96758
97256
  ],
96759
97257
  build: [
@@ -96781,6 +97279,13 @@ async function main() {
96781
97279
  ' --instructions "\u2026" e.g. "45-min slots, add beard trim \xA312"',
96782
97280
  " --email <addr> owner email for booking notifications"
96783
97281
  ],
97282
+ handover: [
97283
+ "sproutkit handover <leadId> [--domain thesalon.co.uk]",
97284
+ " the paid-deliverable step: removes the DEMO banner, attaches the",
97285
+ " client's own domain, and prints every credential in one place",
97286
+ " --domain <domain> client's domain; prints the DNS records to set",
97287
+ " --yes required \u2014 confirms the client has paid"
97288
+ ],
96784
97289
  outreach: [
96785
97290
  "sproutkit outreach [--ids 3,5] [--channel sms|whatsapp] [--json]",
96786
97291
  " Per lead: phone -> a messaging link you open; no phone -> emailed (needs SMTP).",
@@ -96815,6 +97320,7 @@ async function main() {
96815
97320
  else if (arg === "regenerate") await cmdRegenerate(subArgs);
96816
97321
  else if (arg === "cms") await cmdCms(subArgs);
96817
97322
  else if (arg === "book") await cmdBook(subArgs);
97323
+ else if (arg === "handover") await cmdHandover(subArgs);
96818
97324
  else if (arg === "pipeline") await cmdPipeline(subArgs);
96819
97325
  else await cmdOutreach(subArgs);
96820
97326
  closeTty();
@@ -96880,6 +97386,10 @@ async function main() {
96880
97386
  if (res.ok) say(c.dim(" Installed the Sproutkit skill for Claude Code."));
96881
97387
  else say(c.amber(` Couldn't install the Sproutkit skill: ${res.error}`));
96882
97388
  }
97389
+ if (!commandsInstalled()) {
97390
+ const res = installCommands();
97391
+ if (res.ok) say(c.dim(` Installed ${res.count} /sproutkit slash commands.`));
97392
+ }
96883
97393
  await notifyIfOutdated();
96884
97394
  say();
96885
97395
  say(c.greenBold(" Sproutkit") + c.dim(" \u2014 starting Claude Code"));
@@ -74,6 +74,20 @@ you can parse results reliably, then summarise them for the user in prose.
74
74
  `sproutkit book "Kings Barbers, Leeds" --json` looks them up, reads their
75
75
  site, and builds a booking page in one shot.
76
76
 
77
+ - **Hand a finished site to a paying client** — `sproutkit handover <leadId> --yes --json`
78
+ - THE PAID-DELIVERABLE STEP. Removes the DEMO banner, optionally attaches the
79
+ client's own domain, marks the lead won, and prints one sheet with the site
80
+ URL, /admin panel + password, booking page + password, and the DNS records
81
+ the client must set at their registrar.
82
+ - `--domain thesalon.co.uk` attaches their domain and returns the exact A or
83
+ CNAME record. DNS is out of our control, so re-run the same command later to
84
+ re-check whether it has propagated.
85
+ - `--yes` is REQUIRED. Without it the command refuses and explains why. Only
86
+ pass it when the user has clearly said the client has paid / signed off /
87
+ is going live — never infer it.
88
+ - The banner exists to stop clients using a free demo as their real site, so
89
+ do NOT run this just because a site looks finished.
90
+
77
91
  - **Run the full pipeline for many clients** — `sproutkit pipeline x3 --json` (optional `--book`, `--limit 10`, `--ids 3,5`)
78
92
  - Builds → (optionally adds booking) → prepares outreach for several clients
79
93
  CONCURRENTLY (capped at 4), refilling as each finishes. Use for "run the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sproutkit-code",
3
- "version": "1.3.0",
3
+ "version": "1.4.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"