sproutkit-code 0.19.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +354 -569
- package/dist/skill/CLAUDE.md +46 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -69072,9 +69072,9 @@ var require_mock_utils = __commonJS({
|
|
|
69072
69072
|
if (mockDispatch2.data.callback) {
|
|
69073
69073
|
mockDispatch2.data = { ...mockDispatch2.data, ...mockDispatch2.data.callback(opts) };
|
|
69074
69074
|
}
|
|
69075
|
-
const { data: { statusCode, data, headers, trailers, error }, delay, persist
|
|
69075
|
+
const { data: { statusCode, data, headers, trailers, error }, delay, persist } = mockDispatch2;
|
|
69076
69076
|
const { timesInvoked, times } = mockDispatch2;
|
|
69077
|
-
mockDispatch2.consumed = !
|
|
69077
|
+
mockDispatch2.consumed = !persist && timesInvoked >= times;
|
|
69078
69078
|
mockDispatch2.pending = timesInvoked < times;
|
|
69079
69079
|
if (error !== null) {
|
|
69080
69080
|
deleteMockDispatch(this[kDispatches], key);
|
|
@@ -69494,14 +69494,14 @@ var require_pending_interceptors_formatter = __commonJS({
|
|
|
69494
69494
|
}
|
|
69495
69495
|
format(pendingInterceptors) {
|
|
69496
69496
|
const withPrettyHeaders = pendingInterceptors.map(
|
|
69497
|
-
({ method, path: path3, data: { statusCode }, persist
|
|
69497
|
+
({ method, path: path3, data: { statusCode }, persist, times, timesInvoked, origin }) => ({
|
|
69498
69498
|
Method: method,
|
|
69499
69499
|
Origin: origin,
|
|
69500
69500
|
Path: path3,
|
|
69501
69501
|
"Status code": statusCode,
|
|
69502
|
-
Persistent:
|
|
69502
|
+
Persistent: persist ? PERSISTENT : NOT_PERSISTENT,
|
|
69503
69503
|
Invocations: timesInvoked,
|
|
69504
|
-
Remaining:
|
|
69504
|
+
Remaining: persist ? Infinity : times - timesInvoked
|
|
69505
69505
|
})
|
|
69506
69506
|
);
|
|
69507
69507
|
this.logger.table(withPrettyHeaders);
|
|
@@ -80049,28 +80049,84 @@ Whichever you pick, make it FEEL premium and intentional \u2014 not a template.
|
|
|
80049
80049
|
- Subtle scroll-revealed details (not heavy animations)
|
|
80050
80050
|
- One (and only one) accent colour, chosen from the allowed palette above. Use it sparingly \u2014 buttons, key hover states, one accent element per section.
|
|
80051
80051
|
|
|
80052
|
+
The direction governs LOOK; the archetype below governs STRUCTURE. Vary both. Also
|
|
80053
|
+
vary the mechanics between sites so they don't feel stamped from one mould \u2014 pick
|
|
80054
|
+
what suits this business rather than defaulting:
|
|
80055
|
+
- Section rhythm: alternating full-bleed bands vs a contained column vs a
|
|
80056
|
+
two-column split that runs down the page.
|
|
80057
|
+
- Content shape: cards vs a plain itemised list vs a table vs numbered steps vs a
|
|
80058
|
+
single long editorial column.
|
|
80059
|
+
- Where the accent lands, whether section backgrounds alternate, whether headings
|
|
80060
|
+
are centred or hard-left, whether the page is wide or narrow.
|
|
80061
|
+
|
|
80052
80062
|
\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
80053
|
-
|
|
80063
|
+
STRUCTURE \u2014 design the page around THIS business, do not follow a fixed template
|
|
80054
80064
|
\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
80055
|
-
|
|
80056
|
-
|
|
80057
|
-
|
|
80058
|
-
|
|
80059
|
-
|
|
80060
|
-
|
|
80061
|
-
|
|
80062
|
-
|
|
80065
|
+
CRITICAL: sites for different kinds of business must not share a skeleton. What a
|
|
80066
|
+
customer needs from a takeaway is not what they need from a plumber. Decide the
|
|
80067
|
+
section set, their ORDER, and how much space each gets based on what actually
|
|
80068
|
+
drives a decision for a "${lead.type}" \u2014 then commit to it.
|
|
80069
|
+
|
|
80070
|
+
Pick the LAYOUT ARCHETYPE that fits this business (or compose your own if none fits):
|
|
80071
|
+
|
|
80072
|
+
A. MENU / PRODUCT-FORWARD \u2014 food & drink (restaurants, cafes, takeaways, bakeries).
|
|
80073
|
+
The menu IS the page. Lead with it high up, itemised with prices, grouped by
|
|
80074
|
+
course/category. Hero can be compact (a band, not full-screen) to get the menu
|
|
80075
|
+
above the fold. Photos of food used large. Hours + "how to order/book" near top.
|
|
80076
|
+
|
|
80077
|
+
B. BOOKING-FORWARD \u2014 appointment trades (barbers, salons, nails, clinics, dentists).
|
|
80078
|
+
The job is "when can I get in": services + prices as a scannable price list,
|
|
80079
|
+
opening hours prominent, a persistent Book/Call action. Staff or before/after
|
|
80080
|
+
imagery if available. Reviews used as reassurance near the booking action.
|
|
80081
|
+
|
|
80082
|
+
C. TRUST & COVERAGE-FORWARD \u2014 home/field trades (plumbers, electricians, builders,
|
|
80083
|
+
roofers, cleaners, car washes, removals). Buyers need confidence + "do you cover
|
|
80084
|
+
me?". Lead with the problem you solve and credentials (years, insured, callout
|
|
80085
|
+
time), then areas covered, then services, then proof (reviews). Phone must be
|
|
80086
|
+
impossible to miss \u2014 a sticky call bar is appropriate. Photos are of work done.
|
|
80087
|
+
|
|
80088
|
+
D. SHOWCASE / PORTFOLIO-FORWARD \u2014 visual businesses (florists, photographers,
|
|
80089
|
+
tattoo, interiors, boutiques). Imagery dominates: full-bleed gallery early and
|
|
80090
|
+
large, minimal copy, the work sells itself. Contact is quiet but easy.
|
|
80091
|
+
|
|
80092
|
+
E. CREDIBILITY-FORWARD \u2014 professional services (accountants, solicitors, agencies,
|
|
80093
|
+
consultants, tutors). Lead with clarity of offer and who it's for, then process
|
|
80094
|
+
or specialisms, then credentials/testimonials. Restrained, text-led, confident.
|
|
80095
|
+
|
|
80096
|
+
RULES for structure:
|
|
80097
|
+
- Use the archetype's priorities to choose and ORDER sections. Genuinely differ:
|
|
80098
|
+
a takeaway should NOT open with a full-screen hero then "About"; a plumber should
|
|
80099
|
+
NOT bury the phone number under a gallery.
|
|
80100
|
+
- OMIT any section you have no real content for. Never render an empty or padded
|
|
80101
|
+
section \u2014 fewer, fuller sections always beat a complete-looking skeleton.
|
|
80102
|
+
- Vary the hero treatment to suit: full-viewport image, compact banner, split
|
|
80103
|
+
text/image, or type-only. Not every site gets the same big centred hero.
|
|
80104
|
+
- Include a NAV appropriate to the site (sticky if the page is long) with the
|
|
80105
|
+
business name and the single most useful action for THIS business type
|
|
80106
|
+
(tel:${lead.phone} for trades, book for appointments, menu/order for food).
|
|
80107
|
+
- Non-negotiable floor, however you arrange it: the business name, what they do,
|
|
80108
|
+
where they are (${lead.city}), the phone as a tel: link, and a footer ending with
|
|
80109
|
+
"Built with Sproutkit \xB7 sproutkit.app" in muted text.
|
|
80110
|
+
- Use the real reviews and hours given above where they earn their place \u2014 not in a
|
|
80111
|
+
fixed slot. Photos: only from PHOTOS, sized per the archetype (see gallery rule:
|
|
80112
|
+
if PHOTOS.length < 2 there is no gallery section at all).
|
|
80063
80113
|
${multipage ? `
|
|
80064
80114
|
\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
80065
80115
|
MULTIPAGE (REQUIRED) \u2014 real separate pages, not one long scroll
|
|
80066
80116
|
\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
80067
80117
|
Build a MULTI-PAGE site using react-router-dom's HashRouter (import { HashRouter, Routes, Route, Link, useNavigate } from "react-router-dom"). HashRouter is REQUIRED (not BrowserRouter) so routes work on static hosting without server config.
|
|
80068
80118
|
|
|
80069
|
-
Pages (one <Route> each), with a persistent
|
|
80070
|
-
${pageList.map((p, i) => ` ${i + 1}. ${p}`).join("\n")}
|
|
80071
|
-
|
|
80072
|
-
|
|
80073
|
-
|
|
80119
|
+
${pages && pages.length > 0 ? `Pages (one <Route> each), with a persistent NAV linking between them via <Link>:
|
|
80120
|
+
${pageList.map((p, i) => ` ${i + 1}. ${p}`).join("\n")}` : `Choose the PAGES yourself from what this business actually needs \u2014 do not default
|
|
80121
|
+
to a generic Home/Services/About/Contact set. Base them on the layout archetype
|
|
80122
|
+
below. For example: a takeaway needs Menu (the big one) + Order/Find us; a plumber
|
|
80123
|
+
needs Services + Areas covered + Get a quote; a salon needs Treatments & prices +
|
|
80124
|
+
Book + Gallery; a florist needs Gallery/Shop + Occasions + Contact. 3-5 pages,
|
|
80125
|
+
each with a real reason to exist. Put them in one <Route> each with a persistent
|
|
80126
|
+
NAV linking between them via <Link>.`}
|
|
80127
|
+
|
|
80128
|
+
- Home: a focused landing \u2014 the single most compelling thing about this business plus the primary action, then short teasers that link onward. Not a dump of every section.
|
|
80129
|
+
- Give each page enough real content to justify itself; if a page would be thin, merge it rather than padding it.
|
|
80074
80130
|
- The whole thing is STILL one App.jsx exporting a single default App component that renders <HashRouter>\u2026</HashRouter>. Define each page as a component in the same file.
|
|
80075
80131
|
- Nav must be present on every page and highlight the active route. Include the phone/Book CTA in the nav.
|
|
80076
80132
|
` : ""}
|
|
@@ -80095,6 +80151,14 @@ CMS INTEGRATION (MANDATORY when this brief mentions it)
|
|
|
80095
80151
|
\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
80096
80152
|
This site is editable by the business owner via a CMS. You MUST wire it up:
|
|
80097
80153
|
|
|
80154
|
+
IMPORTANT \u2014 this schema is a CONTENT contract, not a LAYOUT one. The keys below
|
|
80155
|
+
exist so the /admin editor knows what it can edit; they do NOT dictate the page's
|
|
80156
|
+
structure or order. Still follow the layout archetype above: render these fields
|
|
80157
|
+
wherever that structure puts them, give them the weight that business needs (e.g.
|
|
80158
|
+
services as a priced menu high on the page for a takeaway), and omit anything you
|
|
80159
|
+
have no content for. Do not let this schema flatten every CMS site into the same
|
|
80160
|
+
hero \u2192 about \u2192 services \u2192 contact page.
|
|
80161
|
+
|
|
80098
80162
|
1. At the top of App.jsx, declare a FALLBACK object with all the copy you'd
|
|
80099
80163
|
normally hardcode:
|
|
80100
80164
|
const FALLBACK = {
|
|
@@ -94597,7 +94661,7 @@ var require_package2 = __commonJS({
|
|
|
94597
94661
|
"package.json"(exports2, module2) {
|
|
94598
94662
|
module2.exports = {
|
|
94599
94663
|
name: "sproutkit-code",
|
|
94600
|
-
version: "0.
|
|
94664
|
+
version: "1.0.0",
|
|
94601
94665
|
description: "Sproutkit in your terminal \u2014 find leads, build sites, run outreach, all via chat.",
|
|
94602
94666
|
bin: {
|
|
94603
94667
|
sproutkit: "./dist/index.js"
|
|
@@ -95159,6 +95223,235 @@ async function runOnboarding(force = false) {
|
|
|
95159
95223
|
return true;
|
|
95160
95224
|
}
|
|
95161
95225
|
|
|
95226
|
+
// src/update.ts
|
|
95227
|
+
var import_child_process2 = require("child_process");
|
|
95228
|
+
var import_util = require("util");
|
|
95229
|
+
var execFileP = (0, import_util.promisify)(import_child_process2.execFile);
|
|
95230
|
+
var PKG = "sproutkit-code";
|
|
95231
|
+
var CURRENT_VERSION = require_package2().version;
|
|
95232
|
+
async function latestOnRegistry() {
|
|
95233
|
+
try {
|
|
95234
|
+
const r = await fetch(`https://registry.npmjs.org/${PKG}/latest`, {
|
|
95235
|
+
signal: AbortSignal.timeout(5e3)
|
|
95236
|
+
});
|
|
95237
|
+
if (!r.ok) return null;
|
|
95238
|
+
const j = await r.json();
|
|
95239
|
+
return j.version ?? null;
|
|
95240
|
+
} catch {
|
|
95241
|
+
return null;
|
|
95242
|
+
}
|
|
95243
|
+
}
|
|
95244
|
+
function isNewer(a, b) {
|
|
95245
|
+
const pa = a.split(".").map(Number);
|
|
95246
|
+
const pb = b.split(".").map(Number);
|
|
95247
|
+
for (let i = 0; i < 3; i++) {
|
|
95248
|
+
if ((pa[i] ?? 0) > (pb[i] ?? 0)) return true;
|
|
95249
|
+
if ((pa[i] ?? 0) < (pb[i] ?? 0)) return false;
|
|
95250
|
+
}
|
|
95251
|
+
return false;
|
|
95252
|
+
}
|
|
95253
|
+
async function runUpdate() {
|
|
95254
|
+
say(c.dim(` Current version ${CURRENT_VERSION}. Checking npm\u2026`));
|
|
95255
|
+
const latest = await latestOnRegistry();
|
|
95256
|
+
if (!latest) {
|
|
95257
|
+
say(c.amber(" Couldn't reach npm. Check your connection and try again."));
|
|
95258
|
+
return;
|
|
95259
|
+
}
|
|
95260
|
+
if (!isNewer(latest, CURRENT_VERSION)) {
|
|
95261
|
+
say(c.green(` \u2713 You're on the latest version (${CURRENT_VERSION}).`));
|
|
95262
|
+
return;
|
|
95263
|
+
}
|
|
95264
|
+
say(c.dim(` Updating ${CURRENT_VERSION} \u2192 ${latest}\u2026`));
|
|
95265
|
+
try {
|
|
95266
|
+
await execFileP("npm", ["install", "-g", `${PKG}@latest`], { timeout: 12e4 });
|
|
95267
|
+
say(c.green(` \u2713 Updated to ${latest}. Restart sproutkit to use it.`));
|
|
95268
|
+
} catch (e) {
|
|
95269
|
+
say(c.red(` Update failed: ${e.message}`));
|
|
95270
|
+
say(c.dim(` Try manually: npm install -g ${PKG}@latest`));
|
|
95271
|
+
}
|
|
95272
|
+
}
|
|
95273
|
+
async function notifyIfOutdated() {
|
|
95274
|
+
try {
|
|
95275
|
+
const last = Number(getSetting("update_checked_at") ?? 0);
|
|
95276
|
+
if (Date.now() - last < 24 * 60 * 60 * 1e3) return;
|
|
95277
|
+
setSetting("update_checked_at", String(Date.now()));
|
|
95278
|
+
const latest = await latestOnRegistry();
|
|
95279
|
+
if (latest && isNewer(latest, CURRENT_VERSION)) {
|
|
95280
|
+
say(
|
|
95281
|
+
c.amber(
|
|
95282
|
+
` \u2191 Update available: ${CURRENT_VERSION} \u2192 ${latest}. Run 'sproutkit update'.`
|
|
95283
|
+
)
|
|
95284
|
+
);
|
|
95285
|
+
say();
|
|
95286
|
+
}
|
|
95287
|
+
} catch {
|
|
95288
|
+
}
|
|
95289
|
+
}
|
|
95290
|
+
|
|
95291
|
+
// src/sync.ts
|
|
95292
|
+
var import_os3 = __toESM(require("os"));
|
|
95293
|
+
var import_path2 = __toESM(require("path"));
|
|
95294
|
+
var import_fs2 = __toESM(require("fs"));
|
|
95295
|
+
var COLS = [
|
|
95296
|
+
"place_id",
|
|
95297
|
+
"name",
|
|
95298
|
+
"type",
|
|
95299
|
+
"city",
|
|
95300
|
+
"address",
|
|
95301
|
+
"phone",
|
|
95302
|
+
"website",
|
|
95303
|
+
"rating",
|
|
95304
|
+
"review_count",
|
|
95305
|
+
"lat",
|
|
95306
|
+
"lng",
|
|
95307
|
+
"status",
|
|
95308
|
+
"site_url",
|
|
95309
|
+
"site_built_at",
|
|
95310
|
+
"contacted_at",
|
|
95311
|
+
"notes",
|
|
95312
|
+
"score",
|
|
95313
|
+
"price_level",
|
|
95314
|
+
"business_status"
|
|
95315
|
+
];
|
|
95316
|
+
function desktopDbPath() {
|
|
95317
|
+
const home = import_os3.default.homedir();
|
|
95318
|
+
const candidates = [];
|
|
95319
|
+
if (process.platform === "darwin") {
|
|
95320
|
+
candidates.push(import_path2.default.join(home, "Library", "Application Support", "Sproutkit", "data", "sproutio.db"));
|
|
95321
|
+
} else if (process.platform === "win32") {
|
|
95322
|
+
const appData = process.env.APPDATA || import_path2.default.join(home, "AppData", "Roaming");
|
|
95323
|
+
candidates.push(import_path2.default.join(appData, "Sproutkit", "data", "sproutio.db"));
|
|
95324
|
+
} else {
|
|
95325
|
+
const cfg = process.env.XDG_CONFIG_HOME || import_path2.default.join(home, ".config");
|
|
95326
|
+
candidates.push(import_path2.default.join(cfg, "Sproutkit", "data", "sproutio.db"));
|
|
95327
|
+
}
|
|
95328
|
+
return candidates.find((p) => import_fs2.default.existsSync(p)) ?? null;
|
|
95329
|
+
}
|
|
95330
|
+
function syncToDesktop(opts = {}) {
|
|
95331
|
+
const dest = desktopDbPath();
|
|
95332
|
+
if (!dest) return { ok: false, reason: "no_desktop_db", inserted: 0, updated: 0 };
|
|
95333
|
+
const cliLeads = getDb().all(
|
|
95334
|
+
`SELECT ${COLS.join(", ")} FROM leads WHERE place_id IS NOT NULL AND place_id != ''`,
|
|
95335
|
+
[]
|
|
95336
|
+
);
|
|
95337
|
+
if (cliLeads.length === 0) return { ok: true, inserted: 0, updated: 0 };
|
|
95338
|
+
const { Database } = require("node-sqlite3-wasm");
|
|
95339
|
+
const ddb = new Database(dest);
|
|
95340
|
+
let inserted = 0;
|
|
95341
|
+
let updated = 0;
|
|
95342
|
+
try {
|
|
95343
|
+
for (const lead of cliLeads) {
|
|
95344
|
+
const existing = ddb.get("SELECT id FROM leads WHERE place_id = ?", [lead.place_id]);
|
|
95345
|
+
if (existing) {
|
|
95346
|
+
ddb.run(
|
|
95347
|
+
"UPDATE leads SET status = ?, site_url = ?, site_built_at = ?, score = ? WHERE place_id = ?",
|
|
95348
|
+
[lead.status, lead.site_url, lead.site_built_at, lead.score, lead.place_id].map(
|
|
95349
|
+
(v) => v ?? null
|
|
95350
|
+
)
|
|
95351
|
+
);
|
|
95352
|
+
updated++;
|
|
95353
|
+
} else {
|
|
95354
|
+
const placeholders = COLS.map(() => "?").join(", ");
|
|
95355
|
+
ddb.run(
|
|
95356
|
+
`INSERT INTO leads (${COLS.join(", ")}) VALUES (${placeholders})`,
|
|
95357
|
+
COLS.map((k) => lead[k] ?? null)
|
|
95358
|
+
);
|
|
95359
|
+
inserted++;
|
|
95360
|
+
}
|
|
95361
|
+
}
|
|
95362
|
+
} finally {
|
|
95363
|
+
ddb.close();
|
|
95364
|
+
}
|
|
95365
|
+
if (!opts.quiet) {
|
|
95366
|
+
say(
|
|
95367
|
+
c.green(` \u2713 Synced to desktop: ${inserted} new, ${updated} updated.`)
|
|
95368
|
+
);
|
|
95369
|
+
}
|
|
95370
|
+
return { ok: true, inserted, updated };
|
|
95371
|
+
}
|
|
95372
|
+
function autoSync() {
|
|
95373
|
+
try {
|
|
95374
|
+
const r = syncToDesktop({ quiet: true });
|
|
95375
|
+
if (r.ok && (r.inserted || r.updated)) {
|
|
95376
|
+
say(c.dim(` \u2194 synced to desktop (${r.inserted} new, ${r.updated} updated)`));
|
|
95377
|
+
}
|
|
95378
|
+
} catch {
|
|
95379
|
+
}
|
|
95380
|
+
}
|
|
95381
|
+
|
|
95382
|
+
// src/models-cmd.ts
|
|
95383
|
+
async function runModelsClaudeCode() {
|
|
95384
|
+
const current = getClaudeCodeModel();
|
|
95385
|
+
say();
|
|
95386
|
+
say(c.bold(" Model (Claude subscription):"));
|
|
95387
|
+
say(c.dim(" Uses your Claude Code login \u2014 pick which model builds run on."));
|
|
95388
|
+
CLAUDE_CODE_MODELS.forEach((m, i) => {
|
|
95389
|
+
const active = m.alias === current ? c.green(" \u2190 current") : "";
|
|
95390
|
+
const isDefault = !current && m.alias === "sonnet" ? c.dim(" (CLI default)") : "";
|
|
95391
|
+
say(` ${c.greenBold(String(i + 1).padStart(2))} ${m.label} ${c.dim(m.alias)}${active}${isDefault}`);
|
|
95392
|
+
});
|
|
95393
|
+
say();
|
|
95394
|
+
const pick = await ask(c.green(" Pick a number to switch (or Enter to keep current): "));
|
|
95395
|
+
if (!pick) {
|
|
95396
|
+
say(c.dim(" No change."));
|
|
95397
|
+
return;
|
|
95398
|
+
}
|
|
95399
|
+
const idx = Number(pick) - 1;
|
|
95400
|
+
if (Number.isNaN(idx) || idx < 0 || idx >= CLAUDE_CODE_MODELS.length) {
|
|
95401
|
+
say(c.amber(" Not a valid choice \u2014 no change made."));
|
|
95402
|
+
return;
|
|
95403
|
+
}
|
|
95404
|
+
const chosen = CLAUDE_CODE_MODELS[idx];
|
|
95405
|
+
setSetting("claude_code_model", chosen.alias);
|
|
95406
|
+
say(c.green(` \u2713 Builds will now use ${chosen.alias}.`));
|
|
95407
|
+
if (chosen.alias === "fable") {
|
|
95408
|
+
say(c.dim(" Note: Fable needs 30-day data retention on your Claude account, or it'll error."));
|
|
95409
|
+
}
|
|
95410
|
+
}
|
|
95411
|
+
async function runModels() {
|
|
95412
|
+
if (getBackend() === "claude_code") {
|
|
95413
|
+
await runModelsClaudeCode();
|
|
95414
|
+
return;
|
|
95415
|
+
}
|
|
95416
|
+
const apiKey = getSetting("anthropic_key");
|
|
95417
|
+
if (!apiKey) {
|
|
95418
|
+
say(c.red(" No Anthropic key set. Run 'sproutkit setup' first."));
|
|
95419
|
+
return;
|
|
95420
|
+
}
|
|
95421
|
+
say(c.dim(" Fetching your available models from Anthropic\u2026"));
|
|
95422
|
+
let models2 = listCachedModels();
|
|
95423
|
+
try {
|
|
95424
|
+
models2 = await refreshModels(apiKey);
|
|
95425
|
+
} catch (e) {
|
|
95426
|
+
say(c.amber(` Couldn't refresh from the API (${e.message}).`));
|
|
95427
|
+
say(c.dim(" Showing the last known list instead."));
|
|
95428
|
+
}
|
|
95429
|
+
if (models2.length === 0) {
|
|
95430
|
+
say(c.red(" No models returned. Check your Anthropic key and connection."));
|
|
95431
|
+
return;
|
|
95432
|
+
}
|
|
95433
|
+
const current = getPreferredModel();
|
|
95434
|
+
say();
|
|
95435
|
+
say(c.bold(" Available models:"));
|
|
95436
|
+
models2.forEach((m, i) => {
|
|
95437
|
+
const active = m.id === current ? c.green(" \u2190 current") : "";
|
|
95438
|
+
say(` ${c.greenBold(String(i + 1).padStart(2))} ${m.display_name ?? m.id} ${c.dim(m.id)}${active}`);
|
|
95439
|
+
});
|
|
95440
|
+
say();
|
|
95441
|
+
const pick = await ask(c.green(" Pick a number to switch (or Enter to keep current): "));
|
|
95442
|
+
if (!pick) {
|
|
95443
|
+
say(c.dim(" Kept current model."));
|
|
95444
|
+
return;
|
|
95445
|
+
}
|
|
95446
|
+
const idx = Number(pick) - 1;
|
|
95447
|
+
if (Number.isNaN(idx) || idx < 0 || idx >= models2.length) {
|
|
95448
|
+
say(c.amber(" Not a valid choice \u2014 no change made."));
|
|
95449
|
+
return;
|
|
95450
|
+
}
|
|
95451
|
+
setSetting("claude_model", models2[idx].id);
|
|
95452
|
+
say(c.green(` \u2713 Now using ${models2[idx].display_name ?? models2[idx].id}.`));
|
|
95453
|
+
}
|
|
95454
|
+
|
|
95162
95455
|
// src/invoke-ipc.ts
|
|
95163
95456
|
async function requireBlobToken(vercelToken, emit, leadId) {
|
|
95164
95457
|
const existing = getSetting("blob_token");
|
|
@@ -95308,357 +95601,6 @@ function makeInvokeIpc(emit) {
|
|
|
95308
95601
|
};
|
|
95309
95602
|
}
|
|
95310
95603
|
|
|
95311
|
-
// src/sync.ts
|
|
95312
|
-
var import_os3 = __toESM(require("os"));
|
|
95313
|
-
var import_path2 = __toESM(require("path"));
|
|
95314
|
-
var import_fs2 = __toESM(require("fs"));
|
|
95315
|
-
var COLS = [
|
|
95316
|
-
"place_id",
|
|
95317
|
-
"name",
|
|
95318
|
-
"type",
|
|
95319
|
-
"city",
|
|
95320
|
-
"address",
|
|
95321
|
-
"phone",
|
|
95322
|
-
"website",
|
|
95323
|
-
"rating",
|
|
95324
|
-
"review_count",
|
|
95325
|
-
"lat",
|
|
95326
|
-
"lng",
|
|
95327
|
-
"status",
|
|
95328
|
-
"site_url",
|
|
95329
|
-
"site_built_at",
|
|
95330
|
-
"contacted_at",
|
|
95331
|
-
"notes",
|
|
95332
|
-
"score",
|
|
95333
|
-
"price_level",
|
|
95334
|
-
"business_status"
|
|
95335
|
-
];
|
|
95336
|
-
function desktopDbPath() {
|
|
95337
|
-
const home = import_os3.default.homedir();
|
|
95338
|
-
const candidates = [];
|
|
95339
|
-
if (process.platform === "darwin") {
|
|
95340
|
-
candidates.push(import_path2.default.join(home, "Library", "Application Support", "Sproutkit", "data", "sproutio.db"));
|
|
95341
|
-
} else if (process.platform === "win32") {
|
|
95342
|
-
const appData = process.env.APPDATA || import_path2.default.join(home, "AppData", "Roaming");
|
|
95343
|
-
candidates.push(import_path2.default.join(appData, "Sproutkit", "data", "sproutio.db"));
|
|
95344
|
-
} else {
|
|
95345
|
-
const cfg = process.env.XDG_CONFIG_HOME || import_path2.default.join(home, ".config");
|
|
95346
|
-
candidates.push(import_path2.default.join(cfg, "Sproutkit", "data", "sproutio.db"));
|
|
95347
|
-
}
|
|
95348
|
-
return candidates.find((p) => import_fs2.default.existsSync(p)) ?? null;
|
|
95349
|
-
}
|
|
95350
|
-
function syncToDesktop(opts = {}) {
|
|
95351
|
-
const dest = desktopDbPath();
|
|
95352
|
-
if (!dest) return { ok: false, reason: "no_desktop_db", inserted: 0, updated: 0 };
|
|
95353
|
-
const cliLeads = getDb().all(
|
|
95354
|
-
`SELECT ${COLS.join(", ")} FROM leads WHERE place_id IS NOT NULL AND place_id != ''`,
|
|
95355
|
-
[]
|
|
95356
|
-
);
|
|
95357
|
-
if (cliLeads.length === 0) return { ok: true, inserted: 0, updated: 0 };
|
|
95358
|
-
const { Database } = require("node-sqlite3-wasm");
|
|
95359
|
-
const ddb = new Database(dest);
|
|
95360
|
-
let inserted = 0;
|
|
95361
|
-
let updated = 0;
|
|
95362
|
-
try {
|
|
95363
|
-
for (const lead of cliLeads) {
|
|
95364
|
-
const existing = ddb.get("SELECT id FROM leads WHERE place_id = ?", [lead.place_id]);
|
|
95365
|
-
if (existing) {
|
|
95366
|
-
ddb.run(
|
|
95367
|
-
"UPDATE leads SET status = ?, site_url = ?, site_built_at = ?, score = ? WHERE place_id = ?",
|
|
95368
|
-
[lead.status, lead.site_url, lead.site_built_at, lead.score, lead.place_id].map(
|
|
95369
|
-
(v) => v ?? null
|
|
95370
|
-
)
|
|
95371
|
-
);
|
|
95372
|
-
updated++;
|
|
95373
|
-
} else {
|
|
95374
|
-
const placeholders = COLS.map(() => "?").join(", ");
|
|
95375
|
-
ddb.run(
|
|
95376
|
-
`INSERT INTO leads (${COLS.join(", ")}) VALUES (${placeholders})`,
|
|
95377
|
-
COLS.map((k) => lead[k] ?? null)
|
|
95378
|
-
);
|
|
95379
|
-
inserted++;
|
|
95380
|
-
}
|
|
95381
|
-
}
|
|
95382
|
-
} finally {
|
|
95383
|
-
ddb.close();
|
|
95384
|
-
}
|
|
95385
|
-
if (!opts.quiet) {
|
|
95386
|
-
say(
|
|
95387
|
-
c.green(` \u2713 Synced to desktop: ${inserted} new, ${updated} updated.`)
|
|
95388
|
-
);
|
|
95389
|
-
}
|
|
95390
|
-
return { ok: true, inserted, updated };
|
|
95391
|
-
}
|
|
95392
|
-
function autoSync() {
|
|
95393
|
-
try {
|
|
95394
|
-
const r = syncToDesktop({ quiet: true });
|
|
95395
|
-
if (r.ok && (r.inserted || r.updated)) {
|
|
95396
|
-
say(c.dim(` \u2194 synced to desktop (${r.inserted} new, ${r.updated} updated)`));
|
|
95397
|
-
}
|
|
95398
|
-
} catch {
|
|
95399
|
-
}
|
|
95400
|
-
}
|
|
95401
|
-
|
|
95402
|
-
// src/chat-repl.ts
|
|
95403
|
-
function loadHistory() {
|
|
95404
|
-
const rows = getDb().all(
|
|
95405
|
-
"SELECT role, content FROM chat_messages WHERE role IN ('user','assistant') ORDER BY id DESC LIMIT 20",
|
|
95406
|
-
[]
|
|
95407
|
-
);
|
|
95408
|
-
return rows.reverse();
|
|
95409
|
-
}
|
|
95410
|
-
function persist(role, content, toolCalls) {
|
|
95411
|
-
getDb().run("INSERT INTO chat_messages (role, content, tool_calls) VALUES (?, ?, ?)", [
|
|
95412
|
-
role,
|
|
95413
|
-
content,
|
|
95414
|
-
toolCalls ? JSON.stringify(toolCalls) : null
|
|
95415
|
-
]);
|
|
95416
|
-
}
|
|
95417
|
-
function labelFor(t) {
|
|
95418
|
-
const i = t.input ?? {};
|
|
95419
|
-
switch (t.name) {
|
|
95420
|
-
case "find_leads":
|
|
95421
|
-
return `Find ${i.categories?.join(", ") ?? "businesses"}${i.location ? ` in ${i.location}` : ""}`;
|
|
95422
|
-
case "build_site":
|
|
95423
|
-
return `Build website for lead #${i.id}`;
|
|
95424
|
-
case "build_all_sites":
|
|
95425
|
-
return i.leadIds?.length ? `Build ${i.leadIds.length} websites` : "Build all unbuilt sites";
|
|
95426
|
-
case "regenerate_site":
|
|
95427
|
-
return `Rebuild website for lead #${i.id}`;
|
|
95428
|
-
case "add_booking_site":
|
|
95429
|
-
return `Add booking site to lead #${i.id}`;
|
|
95430
|
-
case "provision_cms":
|
|
95431
|
-
return `Set up CMS for lead #${i.id}`;
|
|
95432
|
-
case "prepare_outreach":
|
|
95433
|
-
return "Prepare outreach";
|
|
95434
|
-
case "delete_lead":
|
|
95435
|
-
return `Delete lead #${i.id}`;
|
|
95436
|
-
default:
|
|
95437
|
-
return t.name;
|
|
95438
|
-
}
|
|
95439
|
-
}
|
|
95440
|
-
async function runTurn(userMessage) {
|
|
95441
|
-
const apiKey = getSetting("anthropic_key");
|
|
95442
|
-
if (!apiKey) {
|
|
95443
|
-
if (getBackend() === "claude_code") {
|
|
95444
|
-
say(c.amber(" You're on the Claude subscription backend, which powers site building."));
|
|
95445
|
-
say(c.dim(" The chat assistant (lead-finding & outreach dispatch) additionally needs an"));
|
|
95446
|
-
say(c.dim(" Anthropic API key. Add one with 'sproutkit setup', or build sites directly:"));
|
|
95447
|
-
say(c.dim(' sproutkit "build a site for <business>"'));
|
|
95448
|
-
} else {
|
|
95449
|
-
say(c.red(" No Anthropic key set. Run 'sproutkit setup'."));
|
|
95450
|
-
}
|
|
95451
|
-
return;
|
|
95452
|
-
}
|
|
95453
|
-
persist("user", userMessage);
|
|
95454
|
-
const history = loadHistory().slice(0, -1);
|
|
95455
|
-
const confirmations = {};
|
|
95456
|
-
const completedResults = {};
|
|
95457
|
-
const status = new Status();
|
|
95458
|
-
let statusActive = false;
|
|
95459
|
-
let lastProgress = "";
|
|
95460
|
-
const emit = (_channel, payload) => {
|
|
95461
|
-
const p = payload;
|
|
95462
|
-
if (p?.msg && p.msg !== lastProgress) {
|
|
95463
|
-
lastProgress = p.msg;
|
|
95464
|
-
if (statusActive) status.update(p.msg);
|
|
95465
|
-
}
|
|
95466
|
-
};
|
|
95467
|
-
const invokeIpc = makeInvokeIpc(emit);
|
|
95468
|
-
while (true) {
|
|
95469
|
-
const seenRunning = /* @__PURE__ */ new Set();
|
|
95470
|
-
const result = await chatWithTools({
|
|
95471
|
-
apiKey,
|
|
95472
|
-
ctx: {
|
|
95473
|
-
invokeIpc,
|
|
95474
|
-
history,
|
|
95475
|
-
userMessage,
|
|
95476
|
-
confirmations,
|
|
95477
|
-
completedResults,
|
|
95478
|
-
emit: (e) => {
|
|
95479
|
-
if (e.type === "tool_call") {
|
|
95480
|
-
const t = e.data;
|
|
95481
|
-
if (t.status === "pending" && !seenRunning.has(t.id)) {
|
|
95482
|
-
seenRunning.add(t.id);
|
|
95483
|
-
lastProgress = "";
|
|
95484
|
-
status.start(labelFor(t));
|
|
95485
|
-
statusActive = true;
|
|
95486
|
-
} else if (t.status === "completed") {
|
|
95487
|
-
status.done(true, labelFor(t));
|
|
95488
|
-
statusActive = false;
|
|
95489
|
-
} else if (t.status === "error") {
|
|
95490
|
-
status.done(false, `${labelFor(t)} \u2014 ${t.error ?? "failed"}`);
|
|
95491
|
-
statusActive = false;
|
|
95492
|
-
}
|
|
95493
|
-
}
|
|
95494
|
-
},
|
|
95495
|
-
emitIpc: emit
|
|
95496
|
-
}
|
|
95497
|
-
});
|
|
95498
|
-
const pending = result.toolCalls.filter((t) => t.status === "awaiting_confirmation");
|
|
95499
|
-
if (pending.length === 0) {
|
|
95500
|
-
if (result.text) {
|
|
95501
|
-
say();
|
|
95502
|
-
say(` ${result.text.replace(/\n/g, "\n ")}`);
|
|
95503
|
-
say();
|
|
95504
|
-
persist("assistant", result.text, result.toolCalls);
|
|
95505
|
-
}
|
|
95506
|
-
const ranSomething = result.toolCalls.some((t) => t.status === "completed");
|
|
95507
|
-
if (ranSomething) autoSync();
|
|
95508
|
-
return;
|
|
95509
|
-
}
|
|
95510
|
-
if (statusActive) {
|
|
95511
|
-
status.done(false, "paused for confirmation");
|
|
95512
|
-
statusActive = false;
|
|
95513
|
-
}
|
|
95514
|
-
say();
|
|
95515
|
-
say(c.amber(` ${pending.length} action${pending.length === 1 ? "" : "s"} need confirmation:`));
|
|
95516
|
-
for (const t of pending) {
|
|
95517
|
-
const ans = (await ask(c.amber(` ${labelFor(t)} \u2014 run it? [y/N] `))).toLowerCase();
|
|
95518
|
-
confirmations[t.confirmKey ?? t.id] = ans === "y" || ans === "yes" ? "confirmed" : "rejected";
|
|
95519
|
-
}
|
|
95520
|
-
say();
|
|
95521
|
-
}
|
|
95522
|
-
}
|
|
95523
|
-
|
|
95524
|
-
// src/update.ts
|
|
95525
|
-
var import_child_process2 = require("child_process");
|
|
95526
|
-
var import_util = require("util");
|
|
95527
|
-
var execFileP = (0, import_util.promisify)(import_child_process2.execFile);
|
|
95528
|
-
var PKG = "sproutkit-code";
|
|
95529
|
-
var CURRENT_VERSION = require_package2().version;
|
|
95530
|
-
async function latestOnRegistry() {
|
|
95531
|
-
try {
|
|
95532
|
-
const r = await fetch(`https://registry.npmjs.org/${PKG}/latest`, {
|
|
95533
|
-
signal: AbortSignal.timeout(5e3)
|
|
95534
|
-
});
|
|
95535
|
-
if (!r.ok) return null;
|
|
95536
|
-
const j = await r.json();
|
|
95537
|
-
return j.version ?? null;
|
|
95538
|
-
} catch {
|
|
95539
|
-
return null;
|
|
95540
|
-
}
|
|
95541
|
-
}
|
|
95542
|
-
function isNewer(a, b) {
|
|
95543
|
-
const pa = a.split(".").map(Number);
|
|
95544
|
-
const pb = b.split(".").map(Number);
|
|
95545
|
-
for (let i = 0; i < 3; i++) {
|
|
95546
|
-
if ((pa[i] ?? 0) > (pb[i] ?? 0)) return true;
|
|
95547
|
-
if ((pa[i] ?? 0) < (pb[i] ?? 0)) return false;
|
|
95548
|
-
}
|
|
95549
|
-
return false;
|
|
95550
|
-
}
|
|
95551
|
-
async function runUpdate() {
|
|
95552
|
-
say(c.dim(` Current version ${CURRENT_VERSION}. Checking npm\u2026`));
|
|
95553
|
-
const latest = await latestOnRegistry();
|
|
95554
|
-
if (!latest) {
|
|
95555
|
-
say(c.amber(" Couldn't reach npm. Check your connection and try again."));
|
|
95556
|
-
return;
|
|
95557
|
-
}
|
|
95558
|
-
if (!isNewer(latest, CURRENT_VERSION)) {
|
|
95559
|
-
say(c.green(` \u2713 You're on the latest version (${CURRENT_VERSION}).`));
|
|
95560
|
-
return;
|
|
95561
|
-
}
|
|
95562
|
-
say(c.dim(` Updating ${CURRENT_VERSION} \u2192 ${latest}\u2026`));
|
|
95563
|
-
try {
|
|
95564
|
-
await execFileP("npm", ["install", "-g", `${PKG}@latest`], { timeout: 12e4 });
|
|
95565
|
-
say(c.green(` \u2713 Updated to ${latest}. Restart sproutkit to use it.`));
|
|
95566
|
-
} catch (e) {
|
|
95567
|
-
say(c.red(` Update failed: ${e.message}`));
|
|
95568
|
-
say(c.dim(` Try manually: npm install -g ${PKG}@latest`));
|
|
95569
|
-
}
|
|
95570
|
-
}
|
|
95571
|
-
async function notifyIfOutdated() {
|
|
95572
|
-
try {
|
|
95573
|
-
const last = Number(getSetting("update_checked_at") ?? 0);
|
|
95574
|
-
if (Date.now() - last < 24 * 60 * 60 * 1e3) return;
|
|
95575
|
-
setSetting("update_checked_at", String(Date.now()));
|
|
95576
|
-
const latest = await latestOnRegistry();
|
|
95577
|
-
if (latest && isNewer(latest, CURRENT_VERSION)) {
|
|
95578
|
-
say(
|
|
95579
|
-
c.amber(
|
|
95580
|
-
` \u2191 Update available: ${CURRENT_VERSION} \u2192 ${latest}. Run 'sproutkit update'.`
|
|
95581
|
-
)
|
|
95582
|
-
);
|
|
95583
|
-
say();
|
|
95584
|
-
}
|
|
95585
|
-
} catch {
|
|
95586
|
-
}
|
|
95587
|
-
}
|
|
95588
|
-
|
|
95589
|
-
// src/models-cmd.ts
|
|
95590
|
-
async function runModelsClaudeCode() {
|
|
95591
|
-
const current = getClaudeCodeModel();
|
|
95592
|
-
say();
|
|
95593
|
-
say(c.bold(" Model (Claude subscription):"));
|
|
95594
|
-
say(c.dim(" Uses your Claude Code login \u2014 pick which model builds run on."));
|
|
95595
|
-
CLAUDE_CODE_MODELS.forEach((m, i) => {
|
|
95596
|
-
const active = m.alias === current ? c.green(" \u2190 current") : "";
|
|
95597
|
-
const isDefault = !current && m.alias === "sonnet" ? c.dim(" (CLI default)") : "";
|
|
95598
|
-
say(` ${c.greenBold(String(i + 1).padStart(2))} ${m.label} ${c.dim(m.alias)}${active}${isDefault}`);
|
|
95599
|
-
});
|
|
95600
|
-
say();
|
|
95601
|
-
const pick = await ask(c.green(" Pick a number to switch (or Enter to keep current): "));
|
|
95602
|
-
if (!pick) {
|
|
95603
|
-
say(c.dim(" No change."));
|
|
95604
|
-
return;
|
|
95605
|
-
}
|
|
95606
|
-
const idx = Number(pick) - 1;
|
|
95607
|
-
if (Number.isNaN(idx) || idx < 0 || idx >= CLAUDE_CODE_MODELS.length) {
|
|
95608
|
-
say(c.amber(" Not a valid choice \u2014 no change made."));
|
|
95609
|
-
return;
|
|
95610
|
-
}
|
|
95611
|
-
const chosen = CLAUDE_CODE_MODELS[idx];
|
|
95612
|
-
setSetting("claude_code_model", chosen.alias);
|
|
95613
|
-
say(c.green(` \u2713 Builds will now use ${chosen.alias}.`));
|
|
95614
|
-
if (chosen.alias === "fable") {
|
|
95615
|
-
say(c.dim(" Note: Fable needs 30-day data retention on your Claude account, or it'll error."));
|
|
95616
|
-
}
|
|
95617
|
-
}
|
|
95618
|
-
async function runModels() {
|
|
95619
|
-
if (getBackend() === "claude_code") {
|
|
95620
|
-
await runModelsClaudeCode();
|
|
95621
|
-
return;
|
|
95622
|
-
}
|
|
95623
|
-
const apiKey = getSetting("anthropic_key");
|
|
95624
|
-
if (!apiKey) {
|
|
95625
|
-
say(c.red(" No Anthropic key set. Run 'sproutkit setup' first."));
|
|
95626
|
-
return;
|
|
95627
|
-
}
|
|
95628
|
-
say(c.dim(" Fetching your available models from Anthropic\u2026"));
|
|
95629
|
-
let models2 = listCachedModels();
|
|
95630
|
-
try {
|
|
95631
|
-
models2 = await refreshModels(apiKey);
|
|
95632
|
-
} catch (e) {
|
|
95633
|
-
say(c.amber(` Couldn't refresh from the API (${e.message}).`));
|
|
95634
|
-
say(c.dim(" Showing the last known list instead."));
|
|
95635
|
-
}
|
|
95636
|
-
if (models2.length === 0) {
|
|
95637
|
-
say(c.red(" No models returned. Check your Anthropic key and connection."));
|
|
95638
|
-
return;
|
|
95639
|
-
}
|
|
95640
|
-
const current = getPreferredModel();
|
|
95641
|
-
say();
|
|
95642
|
-
say(c.bold(" Available models:"));
|
|
95643
|
-
models2.forEach((m, i) => {
|
|
95644
|
-
const active = m.id === current ? c.green(" \u2190 current") : "";
|
|
95645
|
-
say(` ${c.greenBold(String(i + 1).padStart(2))} ${m.display_name ?? m.id} ${c.dim(m.id)}${active}`);
|
|
95646
|
-
});
|
|
95647
|
-
say();
|
|
95648
|
-
const pick = await ask(c.green(" Pick a number to switch (or Enter to keep current): "));
|
|
95649
|
-
if (!pick) {
|
|
95650
|
-
say(c.dim(" Kept current model."));
|
|
95651
|
-
return;
|
|
95652
|
-
}
|
|
95653
|
-
const idx = Number(pick) - 1;
|
|
95654
|
-
if (Number.isNaN(idx) || idx < 0 || idx >= models2.length) {
|
|
95655
|
-
say(c.amber(" Not a valid choice \u2014 no change made."));
|
|
95656
|
-
return;
|
|
95657
|
-
}
|
|
95658
|
-
setSetting("claude_model", models2[idx].id);
|
|
95659
|
-
say(c.green(` \u2713 Now using ${models2[idx].display_name ?? models2[idx].id}.`));
|
|
95660
|
-
}
|
|
95661
|
-
|
|
95662
95604
|
// src/commands.ts
|
|
95663
95605
|
function makeCtx(opts) {
|
|
95664
95606
|
const emit = (channel, payload) => {
|
|
@@ -96128,8 +96070,7 @@ async function cmdBuildAll(argv) {
|
|
|
96128
96070
|
});
|
|
96129
96071
|
}
|
|
96130
96072
|
|
|
96131
|
-
// src/
|
|
96132
|
-
var import_child_process3 = require("child_process");
|
|
96073
|
+
// src/operating-brief.ts
|
|
96133
96074
|
var TASK_SYSTEM_PROMPT = `You operate the "sproutkit" CLI on the user's behalf. Sproutkit finds local businesses without websites, builds them demo sites, adds booking, and prepares outreach.
|
|
96134
96075
|
|
|
96135
96076
|
HOW TO ACT \u2014 this is strict:
|
|
@@ -96166,99 +96107,9 @@ COMMANDS:
|
|
|
96166
96107
|
- sproutkit pipeline x3 --json [--book] [--limit 10] \u2014 full pipeline (build\u2192booking\u2192outreach) for many clients concurrently
|
|
96167
96108
|
|
|
96168
96109
|
If no command fits the request, say so in one line and name the closest thing you can do. Never invent results.`;
|
|
96169
|
-
var FREE_SYSTEM_PROMPT = `You are Sproutkit's assistant, talking with the user about their lead-generation and website work. You can run the sproutkit CLI (find, list, build, build-all, book, outreach \u2014 all support --json) via Bash, and you can help with small custom changes and questions. Be conversational and helpful, but stay within Sproutkit's domain (leads, demo sites, booking, outreach). You don't need to collapse everything to a single command here \u2014 it's fine to discuss, then act.`;
|
|
96170
|
-
function runClaudeTurn(args) {
|
|
96171
|
-
const { message, sessionId, write, freeMode } = args;
|
|
96172
|
-
const timeoutMs = args.timeoutMs ?? 6e5;
|
|
96173
|
-
const cliArgs = [
|
|
96174
|
-
"-p",
|
|
96175
|
-
"--output-format",
|
|
96176
|
-
"stream-json",
|
|
96177
|
-
"--include-partial-messages",
|
|
96178
|
-
"--verbose",
|
|
96179
|
-
// required for stream_event emission
|
|
96180
|
-
// Inject the operating instructions directly so behaviour is precise and
|
|
96181
|
-
// doesn't depend on flaky skill discovery. Task mode pins exact commands;
|
|
96182
|
-
// free mode allows open conversation.
|
|
96183
|
-
"--append-system-prompt",
|
|
96184
|
-
freeMode ? FREE_SYSTEM_PROMPT : TASK_SYSTEM_PROMPT,
|
|
96185
|
-
// Let Claude drive the sproutkit CLI without hanging on a permission
|
|
96186
|
-
// prompt, but ONLY the sproutkit command — nothing else runs unattended.
|
|
96187
|
-
"--allowedTools",
|
|
96188
|
-
"Bash(sproutkit *)"
|
|
96189
|
-
];
|
|
96190
|
-
if (sessionId) cliArgs.push("--resume", sessionId);
|
|
96191
|
-
return new Promise((resolve) => {
|
|
96192
|
-
let child;
|
|
96193
|
-
try {
|
|
96194
|
-
child = (0, import_child_process3.spawn)(claudeBin(), cliArgs, { stdio: ["pipe", "pipe", "pipe"] });
|
|
96195
|
-
} catch (e) {
|
|
96196
|
-
resolve({ sessionId, ok: false, error: e.message });
|
|
96197
|
-
return;
|
|
96198
|
-
}
|
|
96199
|
-
let buf = "";
|
|
96200
|
-
let newSessionId = sessionId;
|
|
96201
|
-
let sawText = false;
|
|
96202
|
-
let settled = false;
|
|
96203
|
-
let stderr = "";
|
|
96204
|
-
const timer = setTimeout(() => {
|
|
96205
|
-
if (settled) return;
|
|
96206
|
-
settled = true;
|
|
96207
|
-
child.kill("SIGKILL");
|
|
96208
|
-
resolve({ sessionId: newSessionId, ok: false, error: `Timed out after ${Math.round(timeoutMs / 1e3)}s.` });
|
|
96209
|
-
}, timeoutMs);
|
|
96210
|
-
child.stdout.on("data", (d) => {
|
|
96211
|
-
buf += d.toString();
|
|
96212
|
-
let nl;
|
|
96213
|
-
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
96214
|
-
const line = buf.slice(0, nl).trim();
|
|
96215
|
-
buf = buf.slice(nl + 1);
|
|
96216
|
-
if (!line) continue;
|
|
96217
|
-
let ev;
|
|
96218
|
-
try {
|
|
96219
|
-
ev = JSON.parse(line);
|
|
96220
|
-
} catch {
|
|
96221
|
-
continue;
|
|
96222
|
-
}
|
|
96223
|
-
if (ev.session_id) newSessionId = ev.session_id;
|
|
96224
|
-
if (ev.type === "stream_event" && ev.event?.delta?.type === "text_delta" && typeof ev.event.delta.text === "string") {
|
|
96225
|
-
sawText = true;
|
|
96226
|
-
write(ev.event.delta.text);
|
|
96227
|
-
}
|
|
96228
|
-
if (ev.type === "result" && !sawText && typeof ev.result === "string") {
|
|
96229
|
-
write(ev.result);
|
|
96230
|
-
}
|
|
96231
|
-
}
|
|
96232
|
-
});
|
|
96233
|
-
child.stderr.on("data", (d) => stderr += d.toString());
|
|
96234
|
-
child.on("error", (e) => {
|
|
96235
|
-
if (settled) return;
|
|
96236
|
-
settled = true;
|
|
96237
|
-
clearTimeout(timer);
|
|
96238
|
-
const msg = e.code === "ENOENT" ? `Claude Code CLI not found. Install it and run 'claude /login'.` : `Failed to run Claude Code: ${e.message}`;
|
|
96239
|
-
resolve({ sessionId: newSessionId, ok: false, error: msg });
|
|
96240
|
-
});
|
|
96241
|
-
child.on("close", (code) => {
|
|
96242
|
-
if (settled) return;
|
|
96243
|
-
settled = true;
|
|
96244
|
-
clearTimeout(timer);
|
|
96245
|
-
if (code !== 0 && !sawText) {
|
|
96246
|
-
resolve({
|
|
96247
|
-
sessionId: newSessionId,
|
|
96248
|
-
ok: false,
|
|
96249
|
-
error: stderr.trim() || `Claude Code exited with code ${code}.`
|
|
96250
|
-
});
|
|
96251
|
-
} else {
|
|
96252
|
-
resolve({ sessionId: newSessionId, ok: true });
|
|
96253
|
-
}
|
|
96254
|
-
});
|
|
96255
|
-
child.stdin.write(message);
|
|
96256
|
-
child.stdin.end();
|
|
96257
|
-
});
|
|
96258
|
-
}
|
|
96259
96110
|
|
|
96260
96111
|
// src/doctor.ts
|
|
96261
|
-
var
|
|
96112
|
+
var import_child_process3 = require("child_process");
|
|
96262
96113
|
function mark(l) {
|
|
96263
96114
|
return l === "ok" ? c.green("\u2713") : l === "warn" ? c.amber("!") : c.red("\u2717");
|
|
96264
96115
|
}
|
|
@@ -96277,7 +96128,7 @@ async function runDoctor() {
|
|
|
96277
96128
|
if (backend === "claude_code") {
|
|
96278
96129
|
const present = (() => {
|
|
96279
96130
|
try {
|
|
96280
|
-
return (0,
|
|
96131
|
+
return (0, import_child_process3.spawnSync)(claudeBin(), ["--version"], { stdio: "ignore", timeout: 1e4 }).status === 0;
|
|
96281
96132
|
} catch {
|
|
96282
96133
|
return false;
|
|
96283
96134
|
}
|
|
@@ -96381,20 +96232,16 @@ async function runDoctor() {
|
|
|
96381
96232
|
}
|
|
96382
96233
|
|
|
96383
96234
|
// src/index.ts
|
|
96384
|
-
var
|
|
96235
|
+
var import_child_process4 = require("child_process");
|
|
96385
96236
|
process.env.SPROUTKIT_HEADLESS = "1";
|
|
96386
96237
|
function claudePresent() {
|
|
96387
96238
|
try {
|
|
96388
96239
|
const bin = process.env.SPROUTKIT_CLAUDE_BIN || "claude";
|
|
96389
|
-
return (0,
|
|
96240
|
+
return (0, import_child_process4.spawnSync)(bin, ["--version"], { stdio: "ignore", timeout: 1e4 }).status === 0;
|
|
96390
96241
|
} catch {
|
|
96391
96242
|
return false;
|
|
96392
96243
|
}
|
|
96393
96244
|
}
|
|
96394
|
-
function useClaudeBridge() {
|
|
96395
|
-
if (getBackend() === "claude_code") return true;
|
|
96396
|
-
return claudePresent() && !getSetting("anthropic_key");
|
|
96397
|
-
}
|
|
96398
96245
|
async function main() {
|
|
96399
96246
|
const arg = process.argv[2];
|
|
96400
96247
|
initDatabase();
|
|
@@ -96460,7 +96307,7 @@ async function main() {
|
|
|
96460
96307
|
say(c.greenBold(" Sproutkit Code"));
|
|
96461
96308
|
say(" Find leads, build sites and run outreach from your terminal.");
|
|
96462
96309
|
say();
|
|
96463
|
-
say(` ${c.bold("sproutkit")} start
|
|
96310
|
+
say(` ${c.bold("sproutkit")} start Claude Code with Sproutkit loaded`);
|
|
96464
96311
|
say(` ${c.bold('sproutkit find "<area>"')} find leads (--categories "barber shop,cafe")`);
|
|
96465
96312
|
say(` ${c.bold("sproutkit list")} list leads (--status, --city, --json)`);
|
|
96466
96313
|
say(` ${c.bold("sproutkit build <id>")} build a website (--instructions, --cms)`);
|
|
@@ -96479,7 +96326,8 @@ async function main() {
|
|
|
96479
96326
|
say(` ${c.bold('sproutkit "..."')} run a single message and exit`);
|
|
96480
96327
|
say();
|
|
96481
96328
|
say(c.dim(" Add --json to find/list/build/outreach for machine-readable output."));
|
|
96482
|
-
say(c.dim("
|
|
96329
|
+
say(c.dim(" Bare 'sproutkit' hands you to Claude Code \u2014 just ask for what you want."));
|
|
96330
|
+
say(c.dim(" Long runs (build-all, pipeline): press q to stop after in-flight work finishes."));
|
|
96483
96331
|
say();
|
|
96484
96332
|
closeTty();
|
|
96485
96333
|
return;
|
|
@@ -96496,109 +96344,46 @@ async function main() {
|
|
|
96496
96344
|
closeTty();
|
|
96497
96345
|
return;
|
|
96498
96346
|
}
|
|
96499
|
-
const bridged = useClaudeBridge();
|
|
96500
96347
|
const oneShot = process.argv.slice(2).join(" ").trim();
|
|
96501
|
-
if (
|
|
96502
|
-
|
|
96503
|
-
|
|
96504
|
-
|
|
96505
|
-
|
|
96506
|
-
|
|
96507
|
-
|
|
96508
|
-
|
|
96348
|
+
if (!claudePresent()) {
|
|
96349
|
+
say();
|
|
96350
|
+
say(c.amber(" Claude Code isn't installed \u2014 that's what powers the conversational mode."));
|
|
96351
|
+
say(c.dim(" Install it from https://claude.com/claude-code, then run 'claude /login'."));
|
|
96352
|
+
say(c.dim(" Meanwhile every Sproutkit command still works directly:"));
|
|
96353
|
+
say(` ${c.bold('sproutkit find "Leeds" --categories "barber shop"')}`);
|
|
96354
|
+
say(` ${c.bold("sproutkit list")} \xB7 ${c.bold("sproutkit build <id>")} \xB7 ${c.bold("sproutkit pipeline x3")}`);
|
|
96355
|
+
say(c.dim(" Run 'sproutkit --help' for the full list, or 'sproutkit doctor' to check setup."));
|
|
96356
|
+
say();
|
|
96509
96357
|
closeTty();
|
|
96510
96358
|
return;
|
|
96511
96359
|
}
|
|
96512
|
-
|
|
96513
|
-
|
|
96514
|
-
|
|
96515
|
-
|
|
96516
|
-
say(c.amber(" Tip: run 'sproutkit setup' to install the Sproutkit skill so Claude can find/build/book for you."));
|
|
96517
|
-
}
|
|
96518
|
-
if (bridged) {
|
|
96519
|
-
say(c.dim(" Talk to Sproutkit (powered by your Claude subscription). Type a request, or 'exit'."));
|
|
96520
|
-
say(c.dim(" It runs the right action precisely. Type '/freemode' for open conversation + custom tweaks."));
|
|
96521
|
-
} else {
|
|
96522
|
-
say(c.dim(" Chat with Sproutkit. Type a request, or 'exit' to quit."));
|
|
96360
|
+
if (!skillInstalled()) {
|
|
96361
|
+
const res = installSkill();
|
|
96362
|
+
if (res.ok) say(c.dim(" Installed the Sproutkit skill for Claude Code."));
|
|
96363
|
+
else say(c.amber(` Couldn't install the Sproutkit skill: ${res.error}`));
|
|
96523
96364
|
}
|
|
96365
|
+
await notifyIfOutdated();
|
|
96524
96366
|
say();
|
|
96525
|
-
|
|
96526
|
-
|
|
96527
|
-
|
|
96528
|
-
|
|
96529
|
-
|
|
96530
|
-
|
|
96531
|
-
|
|
96532
|
-
|
|
96533
|
-
|
|
96534
|
-
|
|
96535
|
-
|
|
96536
|
-
}
|
|
96537
|
-
if (bridged && (line === "/taskmode" || line === "/task")) {
|
|
96538
|
-
freeMode = false;
|
|
96539
|
-
say(c.green(" Task mode ON \u2014 precise actions."));
|
|
96540
|
-
say();
|
|
96541
|
-
continue;
|
|
96542
|
-
}
|
|
96543
|
-
const direct = line.replace(/^\//, "");
|
|
96544
|
-
const verb = direct.split(/\s+/)[0]?.toLowerCase();
|
|
96545
|
-
const DIRECT_VERBS = ["find", "list", "build", "build-all", "regenerate", "cms", "book", "outreach", "pipeline", "doctor"];
|
|
96546
|
-
if (verb === "commands" || verb === "help" || line === "/help" || line === "/?") {
|
|
96547
|
-
say();
|
|
96548
|
-
say(c.bold(" Commands (type with or without a leading /):"));
|
|
96549
|
-
say(` ${c.greenBold("find")} "<area>" --categories "cafe,barber" find website-less leads`);
|
|
96550
|
-
say(` ${c.greenBold("list")} [--status <s>] [--city <c>] list leads (get ids)`);
|
|
96551
|
-
say(` ${c.greenBold("build")} <id> [--instructions "\u2026"] [--cms] [--multipage] build a demo site`);
|
|
96552
|
-
say(` ${c.greenBold("build-all")} [--ids 3,5] [--concurrency 3] build many at once`);
|
|
96553
|
-
say(` ${c.greenBold("regenerate")} <id> --instructions "\u2026" rebuild a site with changes`);
|
|
96554
|
-
say(` ${c.greenBold("cms")} <id> add the editable CMS to a built site`);
|
|
96555
|
-
say(` ${c.greenBold("book")} <id> [--standalone] [--archetype slots|reservation|enquiry] [--email \u2026]`);
|
|
96556
|
-
say(` ${c.dim(" ")} booking on the site, or --standalone for its own URL`);
|
|
96557
|
-
say(` ${c.greenBold("outreach")} [--ids 3,5] prepare outreach links`);
|
|
96558
|
-
say(` ${c.greenBold("pipeline")} x3 [--book] [--limit 10] build + outreach many, concurrently`);
|
|
96559
|
-
say(c.dim(" doctor \u2014 check your setup \xB7 /freemode \u2014 open chat \xB7 /taskmode \u2014 precise \xB7 exit"));
|
|
96560
|
-
say(c.dim(" Or just talk: \u201Cfind cafes in Leeds and build the best two\u201D."));
|
|
96561
|
-
say();
|
|
96562
|
-
continue;
|
|
96563
|
-
}
|
|
96564
|
-
const isSlash = line.startsWith("/");
|
|
96565
|
-
if (DIRECT_VERBS.includes(verb) && (isSlash || !freeMode)) {
|
|
96566
|
-
const argv = (direct.match(/"[^"]*"|'[^']*'|\S+/g) ?? []).slice(1).map((a) => a.replace(/^['"]|['"]$/g, ""));
|
|
96567
|
-
try {
|
|
96568
|
-
if (verb === "find") await cmdFind(argv);
|
|
96569
|
-
else if (verb === "list") await cmdList(argv);
|
|
96570
|
-
else if (verb === "build") await cmdBuild(argv);
|
|
96571
|
-
else if (verb === "build-all") await cmdBuildAll(argv);
|
|
96572
|
-
else if (verb === "regenerate") await cmdRegenerate(argv);
|
|
96573
|
-
else if (verb === "cms") await cmdCms(argv);
|
|
96574
|
-
else if (verb === "book") await cmdBook(argv);
|
|
96575
|
-
else if (verb === "outreach") await cmdOutreach(argv);
|
|
96576
|
-
else if (verb === "pipeline") await cmdPipeline(argv);
|
|
96577
|
-
else if (verb === "doctor") await runDoctor();
|
|
96578
|
-
} catch (e) {
|
|
96579
|
-
say(c.red(` Error: ${e.message}`));
|
|
96580
|
-
}
|
|
96581
|
-
continue;
|
|
96582
|
-
}
|
|
96583
|
-
try {
|
|
96584
|
-
if (bridged) {
|
|
96585
|
-
const r = await runClaudeTurn({
|
|
96586
|
-
message: line,
|
|
96587
|
-
sessionId: claudeSession,
|
|
96588
|
-
freeMode,
|
|
96589
|
-
write: (t) => process.stdout.write(t)
|
|
96590
|
-
});
|
|
96591
|
-
say();
|
|
96592
|
-
if (r.sessionId) claudeSession = r.sessionId;
|
|
96593
|
-
if (!r.ok) say(c.red(` ${r.error ?? "Claude Code failed."}`));
|
|
96594
|
-
} else {
|
|
96595
|
-
await runTurn(line);
|
|
96596
|
-
}
|
|
96597
|
-
} catch (e) {
|
|
96598
|
-
say(c.red(` Error: ${e.message}`));
|
|
96599
|
-
}
|
|
96600
|
-
}
|
|
96367
|
+
say(c.greenBold(" Sproutkit") + c.dim(" \u2014 starting Claude Code"));
|
|
96368
|
+
say(c.dim(" Ask for what you want: \u201Cfind barbers in Leeds and build the best two\u201D."));
|
|
96369
|
+
say(c.dim(" Commands still work directly too: sproutkit list \xB7 build <id> \xB7 pipeline x3"));
|
|
96370
|
+
say();
|
|
96371
|
+
const claudeArgs = [
|
|
96372
|
+
"--append-system-prompt",
|
|
96373
|
+
TASK_SYSTEM_PROMPT,
|
|
96374
|
+
"--allowedTools",
|
|
96375
|
+
"Bash(sproutkit *)"
|
|
96376
|
+
];
|
|
96377
|
+
if (oneShot) claudeArgs.push(oneShot);
|
|
96601
96378
|
closeTty();
|
|
96379
|
+
const child = (0, import_child_process4.spawn)(claudeBin(), claudeArgs, { stdio: "inherit" });
|
|
96380
|
+
child.on("error", (e) => {
|
|
96381
|
+
say(c.red(` Couldn't start Claude Code: ${e.message}`));
|
|
96382
|
+
process.exitCode = 1;
|
|
96383
|
+
});
|
|
96384
|
+
child.on("exit", (code) => {
|
|
96385
|
+
process.exitCode = code ?? 0;
|
|
96386
|
+
});
|
|
96602
96387
|
}
|
|
96603
96388
|
main().catch((e) => {
|
|
96604
96389
|
say(c.red(`Fatal: ${e.message}`));
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Sproutkit
|
|
2
|
+
|
|
3
|
+
You're working with Sproutkit: a licensed CLI that finds local businesses with no
|
|
4
|
+
website, builds them demo sites, adds booking/CMS, and runs outreach. The operator
|
|
5
|
+
talks to you; you drive the `sproutkit` command.
|
|
6
|
+
|
|
7
|
+
Generation runs on the operator's own Claude subscription (or their API key), so
|
|
8
|
+
you don't need any key — just run the commands.
|
|
9
|
+
|
|
10
|
+
## Commands
|
|
11
|
+
|
|
12
|
+
Always pass `--json` and parse it, then report tersely.
|
|
13
|
+
|
|
14
|
+
| Command | What it does |
|
|
15
|
+
|---|---|
|
|
16
|
+
| `sproutkit find "<area>" --categories "cafe,barber"` | Find businesses with no website. `--max`, `--radius` (default 10km). |
|
|
17
|
+
| `sproutkit list` | List leads with ids. `--status`, `--city`. Get ids here — never guess. |
|
|
18
|
+
| `sproutkit build <id>` | Build + deploy a demo site. `--instructions "…"`, `--cms`, `--multipage`, `--skip-qa`. |
|
|
19
|
+
| `sproutkit build-all` | Build many concurrently. `--ids`, `--concurrency` (max 4). |
|
|
20
|
+
| `sproutkit regenerate <id> --instructions "…"` | Rebuild an existing site with changes. |
|
|
21
|
+
| `sproutkit cms <id>` | Add the editable CMS on top of a site we built. |
|
|
22
|
+
| `sproutkit book <id>` | Booking form on the site. `--standalone` = its own URL (client already has a site). `--archetype slots\|reservation\|enquiry`. |
|
|
23
|
+
| `sproutkit outreach` | Per lead: phone → a messaging link to open; no phone → finds an email and sends it. |
|
|
24
|
+
| `sproutkit pipeline x3` | Full pipeline (build → booking → outreach) for many clients concurrently. `--book`, `--limit`. |
|
|
25
|
+
| `sproutkit doctor` | Check keys/licence/backend are wired up. Run this first when something fails. |
|
|
26
|
+
|
|
27
|
+
## How to work
|
|
28
|
+
|
|
29
|
+
- Run the command; don't narrate a plan or explore the repo first.
|
|
30
|
+
- Report results as terse lines — `#<id> <name> · <type>` — not prose. Don't
|
|
31
|
+
speculate about why a number is low unless asked.
|
|
32
|
+
- When a request is missing something the command needs, ask one short question
|
|
33
|
+
with the options listed. For a booking site you must establish the type
|
|
34
|
+
(slot-based / reservation / enquiry) and whether it's standalone.
|
|
35
|
+
- Bulk work: use `build-all` or `pipeline`, not many parallel `build` calls, and
|
|
36
|
+
don't spawn subagents for it — those commands own the concurrency and keep it
|
|
37
|
+
inside the operator's rate limits.
|
|
38
|
+
- Long runs can be stopped with `q`; in-flight builds finish, queued ones don't start.
|
|
39
|
+
- `{"ok":false,"error":…}` → read the error and act: "already has a website" →
|
|
40
|
+
offer `regenerate`; missing Blob token → tell them to run `sproutkit setup`.
|
|
41
|
+
|
|
42
|
+
## Boundaries
|
|
43
|
+
|
|
44
|
+
Sproutkit covers leads, sites (with CMS/booking) and outreach. It can't run ads,
|
|
45
|
+
post to social, phone people or take payment. Only do what was asked — building
|
|
46
|
+
sites and sending outreach spend the operator's money and act in their name.
|