brand-manager-worker 0.1.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/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # brand-manager-worker
2
+
3
+ The Goose Tools brand worker — one daemon serving **Brand Manager** (inbound: reads your brand
4
+ email, drafts replies in your voice) and **Brand Outreach** (outbound: researches brands you want
5
+ to work with and drafts the pitch). It runs on **your** computer using **your own Claude account** — so there's no API key and nothing to pay
6
+ for beyond the Claude subscription you already have.
7
+
8
+ **It never sends anything.** Every reply is created as a Gmail draft for you to review. The Gmail
9
+ permission granted doesn't include sending, and no code in this package calls Gmail's send endpoint.
10
+
11
+ ## Setup
12
+
13
+ You don't normally run these by hand — <https://goosetools.com/dashboard/brand-manager> gives you a
14
+ one-line command with your token in it. For reference:
15
+
16
+ ```bash
17
+ npm install -g @anthropic-ai/claude-code # the thing that writes your replies
18
+ claude # once, to sign in — then type /exit
19
+
20
+ npx --yes brand-manager-worker install --token gt_…
21
+ ```
22
+
23
+ `install` registers a background service that starts with your computer. You don't need to leave a
24
+ terminal open.
25
+
26
+ ```bash
27
+ npx --yes brand-manager-worker@latest status # what's running, recent logs
28
+ npx --yes brand-manager-worker@latest update # pull the newest version, restart
29
+ npx --yes brand-manager-worker uninstall # remove it
30
+ ```
31
+
32
+ One token connects your computer to every Goose Tools worker. If you've already set up the Carousel
33
+ or Caption worker, `install` here needs no `--token`.
34
+
35
+ ## Where your stuff lives
36
+
37
+ Everything personal is on your machine, in `~/.goosetools/brand-manager/`:
38
+
39
+ ```
40
+ base/ prompt files synced down from Goose Tools — don't hand-edit, they get overwritten
41
+ voice/ how you write to brands
42
+ playbook/ rate-card.md and your negotiation moves
43
+ deals/ one markdown ledger per brand: terms, dates, what's outstanding
44
+ stats/ your media-kit numbers and screenshots
45
+ drafts.md every reply it has drafted
46
+ state.json which threads it has already handled
47
+ ```
48
+
49
+ These are ordinary files. You can open the folder in Claude Code and talk to the agent directly —
50
+ "bump my Reel rate", "what did this brand agree to?" — exactly as you would in the standalone
51
+ brand-manager repo. The website is a view onto these files, not the other way round.
52
+
53
+ Your email bodies and contracts are never uploaded. What the site stores is thread metadata (who,
54
+ subject, when), your ledgers and knowledge files, and the drafts the agent wrote.
55
+
56
+ ## Development
57
+
58
+ ```bash
59
+ node worker/index.js --url http://localhost:3000 --token gt_…
60
+ ```
61
+
62
+ Config precedence: command-line flags → environment → `worker/.env` → `~/.goosetools/env`.
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "brand-manager-worker",
3
+ "version": "0.1.0",
4
+ "description": "The Goose Tools brand-deal worker — your computer reads your brand email and drafts replies in your voice for goosetools.com, using your own Claude account. Drafts only; it never sends.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/ernkerr/brand-manager-worker.git"
8
+ },
9
+ "files": [
10
+ "worker",
11
+ "README.md"
12
+ ],
13
+ "type": "module",
14
+ "bin": {
15
+ "brand-manager-worker": "worker/cli.js"
16
+ },
17
+ "engines": {
18
+ "node": ">=20"
19
+ },
20
+ "scripts": {
21
+ "worker": "node worker/index.js",
22
+ "test": "node --test \"test/*.test.js\""
23
+ },
24
+ "dependencies": {
25
+ "mammoth": "^1.8.0",
26
+ "pdf-parse": "^1.1.1"
27
+ }
28
+ }
@@ -0,0 +1,212 @@
1
+ // The inbound drafting brain: classification, the drafting prompt, decision
2
+ // parsing, and the learn-from-sent-edits diff. Ported from brand-manager's
3
+ // src/core/agent.ts + the classify/pre-filter half of src/core/pipeline.ts.
4
+ //
5
+ // decide() is deliberately single-shot with tools OFF — all context inlined
6
+ // by loadContext() — per the ~36s-vs-10min lesson recorded in claude.js.
7
+
8
+ import { oneShot, parseJson, session } from "./claude.js";
9
+ import { loadContext, invalidateContext } from "./context.js";
10
+ import { BRAND_DIR } from "./paths.js";
11
+
12
+ // Automated / no-reply senders never send brand deals — skip them WITHOUT
13
+ // spending a Claude call. Conservative on purpose: real brands/agencies use
14
+ // human addresses.
15
+ const AUTOMATED_SENDER =
16
+ /no-?reply|do-?not-?reply|noreply|mailer-daemon|postmaster|notifications?@|accounts\.google\.com|@.*\.(beehiiv|substack)\.com|@e\.|@email\.|@mail\./i;
17
+
18
+ export const isAutomated = (from) => AUTOMATED_SENDER.test(from);
19
+
20
+ const DAY_MS = 24 * 60 * 60 * 1000;
21
+
22
+ /**
23
+ * Decide whether a thread needs the agent, and why. Pure: state lookups are
24
+ * injected so this is unit-testable and reusable from any handler.
25
+ * Returns 'new-or-reply' | 'followup-due' | null.
26
+ */
27
+ export function classify(thread, { followupDays, isHandled, lastFollowupAt }) {
28
+ const last = thread.last;
29
+ if (!last.fromMe && isAutomated(last.from)) return null;
30
+ const ageDays = (Date.now() - Date.parse(last.date)) / DAY_MS;
31
+
32
+ if (last.fromMe) {
33
+ // Ball is in the brand's court. Nudge only once per followupDays window.
34
+ if (ageDays < followupDays) return null;
35
+ const lastNudge = lastFollowupAt(thread.id);
36
+ if (lastNudge && (Date.now() - Date.parse(lastNudge)) / DAY_MS < followupDays) return null;
37
+ return "followup-due";
38
+ }
39
+
40
+ // Brand sent last — draft a reply, unless we already drafted against this message.
41
+ if (isHandled(thread.id, last.id)) return null;
42
+ return "new-or-reply";
43
+ }
44
+
45
+ function renderThread(thread) {
46
+ const n = thread.messages.length;
47
+ return thread.messages
48
+ .map((m, i) => {
49
+ const isLast = i === n - 1;
50
+ const text = (m.body || m.snippet).slice(0, isLast ? 6000 : 1800);
51
+ const who = m.fromMe ? "YOU (the creator)" : "BRAND";
52
+ const tag = isLast ? " <<<<< LATEST MESSAGE — your reply responds to THIS one" : "";
53
+ return `--- ${who} | ${m.from} | ${m.date}${tag}\nSubject: ${m.subject}\n${text}`;
54
+ })
55
+ .join("\n\n");
56
+ }
57
+
58
+ function buildPrompt({ thread, reason, instruction, contracts, context }) {
59
+ const contractBlock = contracts.length
60
+ ? `\n\nATTACHED DOCUMENTS (extracted text — verify per the contract mode above):\n${contracts
61
+ .map((d) => `### ${d.filename}\n${d.text.slice(0, 8000) || "(could not extract text)"}`)
62
+ .join("\n\n")}`
63
+ : "";
64
+
65
+ // A user instruction steers THIS draft only. It sits above the mode
66
+ // guidance so it wins for the turn without becoming a learned rule.
67
+ const steeringBlock = instruction
68
+ ? [
69
+ ``,
70
+ `===== CREATOR'S INSTRUCTION FOR THIS REPLY (overrides mode guidance for this draft only) =====`,
71
+ instruction,
72
+ ].join("\n")
73
+ : "";
74
+
75
+ return [
76
+ `You are brand-manager, drafting a brand-deal email reply for the creator.`,
77
+ `EVERYTHING you need is provided below. DO NOT use any tools and DO NOT read files —`,
78
+ `all voice, playbook, rate card, stats, deal ledgers, and mode guidance are inline here.`,
79
+ `Respond with ONLY the JSON described at the end. Work from the provided context alone.`,
80
+ ``,
81
+ `===== CONTEXT (voice · playbook · rates · stats · deal ledgers · modes) =====`,
82
+ context,
83
+ `===== END CONTEXT =====`,
84
+ steeringBlock,
85
+ ``,
86
+ `===== TASK =====`,
87
+ `REASON: ${reason}`,
88
+ `ACCOUNT: ${thread.account}`,
89
+ `THREAD ID: ${thread.id}`,
90
+ `REPLY TO MESSAGE ID: ${thread.last.id}`,
91
+ `BRAND CONTACT: ${thread.last.from}`,
92
+ `EXISTING UNSENT DRAFT IN THREAD: ${thread.hasUnsentDraft ? "yes — produce a distinct SECOND option" : "no"}`,
93
+ ``,
94
+ `THREAD (oldest first — read ALL of it before drafting):`,
95
+ renderThread(thread),
96
+ contractBlock,
97
+ ``,
98
+ `===== HOW TO READ THE THREAD =====`,
99
+ `- Your reply responds to the LATEST message (marked above). Address that message and its sender.`,
100
+ `- But READ THE WHOLE THREAD first and treat it all as established context. The brand has often`,
101
+ ` already told you things earlier in the thread (number of videos, deliverables, channel, budget,`,
102
+ ` timeline, usage). NEVER ask for something they already stated anywhere in the thread. If they said`,
103
+ ` "2 reels," acknowledge 2 reels and move on — do not ask "how many reels?". Only ask what is`,
104
+ ` genuinely still missing, and skip the scoping questions entirely if it's all already answered.`,
105
+ `- Don't re-introduce yourself or repeat points you already made earlier in the thread.`,
106
+ ``,
107
+ `===== TONE =====`,
108
+ `Warm and friendly first, professional underneath. Sound like a real person who is glad to hear from`,
109
+ `them, not a terms-and-conditions bot. Open with genuine warmth, keep pushback collaborative and kind`,
110
+ `(questions and requests, never demands or accusations), and close on a friendly note. Firm on the`,
111
+ `substance, soft in the delivery.`,
112
+ ``,
113
+ `===== OUTPUT =====`,
114
+ `Output ONLY a single fenced \`\`\`json block with this shape:`,
115
+ `{"action":"draft"|"skip","to":"<email>","subject":"Re: ...","body":"<full reply, warm + professional, no em dashes, never the phrase 'happy to explore this'>","attachments":["stats/screenshots/..."],"summary":"<one line>","flags":["<any contract/exclusivity discrepancy>"]}`,
116
+ `Rules: draft only (never send); honor the voice (no em dashes; banned phrases); reply to the latest`,
117
+ `message using the whole thread as context (never re-ask answered questions); if an unsent draft`,
118
+ `exists, produce a distinct second option; gifted-only -> Story mention, not a free Reel; verify any`,
119
+ `attached contract and FLAG specific discrepancies; check the deal ledgers for exclusivity conflicts.`,
120
+ `If no reply is warranted, use action "skip" with a reason in summary.`,
121
+ ].join("\n");
122
+ }
123
+
124
+ /**
125
+ * Run the drafting agent over one thread. Returns the Decision or null on
126
+ * failure — and null MUST mean "retry later", never "skip this thread".
127
+ */
128
+ export async function decide({ thread, reason, instruction = null, contracts = [] }) {
129
+ const context = loadContext();
130
+ const prompt = buildPrompt({ thread, reason, instruction, contracts, context });
131
+ const stdout = await oneShot(prompt, { cwd: BRAND_DIR });
132
+ const decision = parseJson(stdout);
133
+ if (!decision || (decision.action !== "draft" && decision.action !== "skip")) return null;
134
+ return decision;
135
+ }
136
+
137
+ const normalizeWs = (s) => s.replace(/\s+/g, " ").trim();
138
+
139
+ /**
140
+ * Word-level LCS diff -> list of `- "DRAFT text" -> "SENT text"` hunks.
141
+ * Ignores whitespace so plain-text re-wrapping on send doesn't register.
142
+ */
143
+ export function wordDiffHunks(a, b) {
144
+ const A = normalizeWs(a).split(" ");
145
+ const B = normalizeWs(b).split(" ");
146
+ const n = A.length;
147
+ const m = B.length;
148
+ const dp = Array.from({ length: n + 1 }, () => new Int32Array(m + 1));
149
+ for (let i = n - 1; i >= 0; i--)
150
+ for (let j = m - 1; j >= 0; j--)
151
+ dp[i][j] = A[i] === B[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
152
+ const hunks = [];
153
+ let i = 0;
154
+ let j = 0;
155
+ let del = [];
156
+ let ins = [];
157
+ const flush = () => {
158
+ if (del.length || ins.length) hunks.push(`- "${del.join(" ")}" -> "${ins.join(" ")}"`);
159
+ del = [];
160
+ ins = [];
161
+ };
162
+ while (i < n && j < m) {
163
+ if (A[i] === B[j]) {
164
+ flush();
165
+ i++;
166
+ j++;
167
+ } else if (dp[i + 1][j] >= dp[i][j + 1]) del.push(A[i++]);
168
+ else ins.push(B[j++]);
169
+ }
170
+ while (i < n) del.push(A[i++]);
171
+ while (j < m) ins.push(B[j++]);
172
+ flush();
173
+ return hunks;
174
+ }
175
+
176
+ /**
177
+ * Diff what the creator actually sent against the body we drafted, and let
178
+ * the agent save any DURABLE preference to the personal layer. Edits files,
179
+ * so it runs as a tool-enabled session scoped to the brand directory.
180
+ * Returns a one-line summary ('' = nothing learned).
181
+ */
182
+ export async function learnFromEdit({ subject, brandContact, original, sent }) {
183
+ const hunks = wordDiffHunks(original, sent);
184
+ if (hunks.length === 0) return ""; // sent verbatim
185
+ // A huge diff means a rewrite, not a tweak — too noisy to mine reliably.
186
+ if (hunks.length > 40) return "";
187
+
188
+ const prompt = [
189
+ `You are brand-manager. The creator edited your drafted reply before sending it. Learn from the`,
190
+ `edits per base/rules/feedback-loop.md — this IS the "diff the sent draft against the original`,
191
+ `and learn" step.`,
192
+ ``,
193
+ `Thread subject: ${subject}`,
194
+ `Brand contact: ${brandContact}`,
195
+ ``,
196
+ `The exact changes they made (DRAFT wording -> what they SENT):`,
197
+ ...hunks,
198
+ ``,
199
+ `For each change, decide whether it reflects a DURABLE preference worth saving:`,
200
+ `- Wording / tone / phrasing -> append to voice/voice.md (or voice/never-words.md if they removed`,
201
+ ` a word/phrase they dislike).`,
202
+ `- A rate or number -> playbook/rate-card.md. A negotiation move -> playbook/negotiation.md.`,
203
+ `- A fact specific to this brand -> deals/<brand>.md.`,
204
+ `Rules: APPEND, never overwrite. Choose the MOST SPECIFIC file. Skip typos and one-off phrasings`,
205
+ `that carry no reusable lesson. If nothing is durable, save nothing.`,
206
+ `Finally, output ONE line summarizing what you saved, or exactly "no durable lesson" if you saved nothing.`,
207
+ ].join("\n");
208
+
209
+ const { text } = await session(prompt, { cwd: BRAND_DIR, timeoutMs: 4 * 60 * 1000 });
210
+ invalidateContext();
211
+ return text.trim().split("\n").filter(Boolean).pop() ?? "";
212
+ }
package/worker/api.js ADDED
@@ -0,0 +1,104 @@
1
+ // Talking to Goose Tools: config resolution, the job endpoints, and the base
2
+ // asset sync.
3
+ //
4
+ // Config precedence matches the other workers exactly — flags beat env beats
5
+ // worker/.env beats ~/.goosetools/env — so a machine already connected for
6
+ // carousels or captions is already connected for this, no second token.
7
+
8
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { mkdirSync } from "node:fs";
12
+ import { GOOSE_DIR, BASE_DIR, ASSETS_VERSION_FILE } from "./paths.js";
13
+
14
+ const HERE = dirname(fileURLToPath(import.meta.url));
15
+
16
+ function parseEnvFile(file) {
17
+ if (!existsSync(file)) return {};
18
+ return Object.fromEntries(
19
+ readFileSync(file, "utf8")
20
+ .split("\n")
21
+ .filter((l) => l.includes("=") && !l.trim().startsWith("#"))
22
+ .map((l) => [l.slice(0, l.indexOf("=")).trim(), l.slice(l.indexOf("=") + 1).trim()]),
23
+ );
24
+ }
25
+
26
+ function flag(name) {
27
+ const i = process.argv.indexOf(name);
28
+ return i !== -1 ? process.argv[i + 1] : undefined;
29
+ }
30
+
31
+ const localEnv = parseEnvFile(join(HERE, ".env"));
32
+ const sharedEnv = parseEnvFile(join(GOOSE_DIR, "env"));
33
+
34
+ export const BASE_URL = (
35
+ flag("--url") ??
36
+ process.env.GOOSETOOLS_URL ??
37
+ localEnv.GOOSETOOLS_URL ??
38
+ sharedEnv.GOOSETOOLS_URL ??
39
+ "https://goosetools.com"
40
+ ).replace(/\/$/, "");
41
+
42
+ export const TOKEN =
43
+ flag("--token") ??
44
+ process.env.WORKER_TOKEN ??
45
+ localEnv.WORKER_TOKEN ??
46
+ sharedEnv.WORKER_TOKEN;
47
+
48
+ export const POLL_MS = 30_000;
49
+
50
+ /** POST to /api/brand/worker/<path>. Returns null on 204 (no work). */
51
+ export async function api(path, body) {
52
+ const res = await fetch(`${BASE_URL}/api/brand/worker/${path}`, {
53
+ method: "POST",
54
+ headers: {
55
+ authorization: `Bearer ${TOKEN}`,
56
+ "content-type": "application/json",
57
+ },
58
+ body: body ? JSON.stringify(body) : undefined,
59
+ });
60
+ if (res.status === 204) return null;
61
+ if (res.status === 401) {
62
+ console.error(
63
+ "\nThis computer's token was rejected.\n" +
64
+ "Reconnect it from https://goosetools.com/dashboard/brand-manager\n",
65
+ );
66
+ process.exit(1);
67
+ }
68
+ if (!res.ok) {
69
+ throw new Error(`${path} failed: ${res.status} ${(await res.text()).slice(0, 300)}`);
70
+ }
71
+ return res.json();
72
+ }
73
+
74
+ /**
75
+ * Sync the base prompt layer if the server's version differs from ours.
76
+ *
77
+ * The base layer is served rather than bundled so a prompt fix reaches every
78
+ * connected machine on the next poll. Local personal files (voice/, playbook/,
79
+ * deals/) are never touched by this — only base/.
80
+ */
81
+ export async function syncAssets(serverVersion) {
82
+ const local = existsSync(ASSETS_VERSION_FILE)
83
+ ? readFileSync(ASSETS_VERSION_FILE, "utf8").trim()
84
+ : null;
85
+ if (serverVersion && local === serverVersion) return false;
86
+
87
+ const res = await fetch(
88
+ `${BASE_URL}/api/brand/assets${local ? `?version=${encodeURIComponent(local)}` : ""}`,
89
+ { headers: { authorization: `Bearer ${TOKEN}` } },
90
+ );
91
+ if (!res.ok) throw new Error(`asset sync failed: ${res.status}`);
92
+ const data = await res.json();
93
+ if (data.unchanged) return false;
94
+
95
+ for (const [rel, body] of Object.entries(data.files ?? {})) {
96
+ // Defensive: never let a server response write outside base/.
97
+ if (rel.includes("..") || rel.startsWith("/")) continue;
98
+ const dest = join(BASE_DIR, rel);
99
+ mkdirSync(dirname(dest), { recursive: true });
100
+ writeFileSync(dest, body);
101
+ }
102
+ writeFileSync(ASSETS_VERSION_FILE, data.version);
103
+ return data.version;
104
+ }
@@ -0,0 +1,50 @@
1
+ // Contract text extraction — download contract-like attachments and pull
2
+ // their text so the drafting prompt can verify terms against the thread.
3
+ // Port of brand-manager's src/actions/attachment.ts.
4
+ //
5
+ // Extracted text is inlined into ONE prompt and discarded; nothing here
6
+ // persists contract contents. The durable record is the deal ledger the
7
+ // agent writes after reading it.
8
+
9
+ import { createRequire } from "node:module";
10
+ import mammoth from "mammoth";
11
+ import { getAttachment } from "./gmail.js";
12
+
13
+ // pdf-parse ships as CommonJS with a debug side-effect on default import.
14
+ const require = createRequire(import.meta.url);
15
+
16
+ const CONTRACT_EXT = /\.(pdf|docx)$/i;
17
+
18
+ export function isContractLike(a) {
19
+ return (
20
+ CONTRACT_EXT.test(a.filename) ||
21
+ a.mimeType === "application/pdf" ||
22
+ a.mimeType === "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
23
+ );
24
+ }
25
+
26
+ async function extractText(buf, filename) {
27
+ try {
28
+ if (/\.pdf$/i.test(filename)) {
29
+ const pdfParse = require("pdf-parse");
30
+ return (await pdfParse(buf)).text.trim();
31
+ }
32
+ if (/\.docx$/i.test(filename)) {
33
+ return (await mammoth.extractRawText({ buffer: buf })).value.trim();
34
+ }
35
+ } catch {
36
+ // Unsupported/corrupt file → '' and the prompt says so explicitly.
37
+ }
38
+ return "";
39
+ }
40
+
41
+ /** Download + extract text from every contract-like attachment on a message. */
42
+ export async function extractContracts(accessToken, messageId, attachments) {
43
+ const docs = [];
44
+ for (const a of attachments) {
45
+ if (!isContractLike(a)) continue;
46
+ const buf = await getAttachment(accessToken, messageId, a.id);
47
+ docs.push({ filename: a.filename, mimeType: a.mimeType, text: await extractText(buf, a.filename) });
48
+ }
49
+ return docs;
50
+ }
@@ -0,0 +1,136 @@
1
+ // Running Claude on this machine.
2
+ //
3
+ // Two modes, and the split matters for speed. brand-manager learned this the
4
+ // hard way (commit c0c1b33): letting Claude loop on tools to gather its own
5
+ // context took 5–10 minutes per draft versus ~36 seconds when everything was
6
+ // inlined up front. So:
7
+ //
8
+ // oneShot() — drafting. Tools OFF, all context inlined by the caller. Fast.
9
+ // session() — chat, voice-audit, stats, learn. Tools ON, scoped to the
10
+ // brand directory, and resumable so a follow-up message
11
+ // remembers the previous turn.
12
+ //
13
+ // There is no API key anywhere here. This runs on the user's own Claude
14
+ // subscription via the `claude` CLI, same as every other Goose Tools worker.
15
+
16
+ import { spawn } from "node:child_process";
17
+
18
+ const CLAUDE_BIN = process.env.CLAUDE_BIN ?? "claude";
19
+
20
+ // stdio[0] MUST be "ignore". Inherited stdin makes every invocation hang
21
+ // forever under launchd — brand-manager hit this exact bug (fixed in 3b1f955)
22
+ // and it presents as the worker silently doing nothing after a reboot.
23
+ function run(args, { timeoutMs, cwd }) {
24
+ return new Promise((resolve, reject) => {
25
+ const child = spawn(CLAUDE_BIN, args, {
26
+ cwd,
27
+ stdio: ["ignore", "pipe", "pipe"],
28
+ });
29
+ let stdout = "";
30
+ let stderr = "";
31
+ child.stdout.on("data", (d) => (stdout += d));
32
+ child.stderr.on("data", (d) => (stderr += d));
33
+ const timer = setTimeout(() => {
34
+ child.kill("SIGKILL");
35
+ reject(new Error(`claude timed out after ${timeoutMs}ms`));
36
+ }, timeoutMs);
37
+ child.on("error", (err) => {
38
+ clearTimeout(timer);
39
+ reject(
40
+ err.code === "ENOENT"
41
+ ? new Error("Claude Code not found. Install: npm install -g @anthropic-ai/claude-code")
42
+ : err,
43
+ );
44
+ });
45
+ child.on("close", (code) => {
46
+ clearTimeout(timer);
47
+ if (code === 0) resolve(stdout);
48
+ else reject(new Error(`claude exited ${code}: ${stderr.slice(0, 600)}`));
49
+ });
50
+ });
51
+ }
52
+
53
+ /** Single-shot, no tools. For drafting, where the caller inlines all context. */
54
+ export async function oneShot(prompt, { timeoutMs = 4 * 60 * 1000, cwd } = {}) {
55
+ return run(
56
+ [
57
+ "-p",
58
+ prompt,
59
+ "--output-format",
60
+ "text",
61
+ "--disallowedTools",
62
+ "Read,Edit,Write,Bash,Glob,Grep,WebFetch,WebSearch",
63
+ ],
64
+ { timeoutMs, cwd },
65
+ );
66
+ }
67
+
68
+ /**
69
+ * A real, resumable Claude Code session with tools, scoped to the brand
70
+ * directory. This is what makes the website chat behave like talking to the
71
+ * agent in a terminal: it can read the ledgers, edit the rate card, search
72
+ * Gmail through the worker's own commands, and remember the last turn.
73
+ *
74
+ * Returns { text, sessionId } — pass sessionId back in to continue.
75
+ */
76
+ export async function session(
77
+ prompt,
78
+ {
79
+ cwd,
80
+ sessionId,
81
+ timeoutMs = 10 * 60 * 1000,
82
+ // Outreach research adds WebSearch/WebFetch — the one place minutes-long
83
+ // tool use is the point, not the failure mode the oneShot split avoids.
84
+ tools = "Read,Edit,Write,Glob,Grep",
85
+ } = {},
86
+ ) {
87
+ const args = [
88
+ "-p",
89
+ prompt,
90
+ "--output-format",
91
+ "json",
92
+ "--permission-mode",
93
+ "acceptEdits",
94
+ "--allowedTools",
95
+ tools,
96
+ ];
97
+ if (sessionId) args.push("--resume", sessionId);
98
+
99
+ const raw = await run(args, { timeoutMs, cwd });
100
+
101
+ // --output-format json gives a result envelope carrying the session id.
102
+ // Be forgiving: a shape change shouldn't lose the user's answer, so fall
103
+ // back to treating the output as plain text.
104
+ try {
105
+ const parsed = JSON.parse(raw);
106
+ return {
107
+ text: parsed.result ?? parsed.text ?? raw,
108
+ sessionId: parsed.session_id ?? parsed.sessionId ?? sessionId ?? null,
109
+ };
110
+ } catch {
111
+ return { text: raw, sessionId: sessionId ?? null };
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Pull a JSON object out of Claude's text output. Ported verbatim in spirit
117
+ * from brand-manager's parseDecision: prefer a fenced block, else take the
118
+ * first {...last }. Returns null on failure — the caller must treat that as
119
+ * "retry later", never as "skip this thread".
120
+ */
121
+ export function parseJson(text) {
122
+ const fenced = text.match(/```json\s*([\s\S]*?)```/);
123
+ const candidate = fenced
124
+ ? fenced[1]
125
+ : (() => {
126
+ const start = text.indexOf("{");
127
+ const end = text.lastIndexOf("}");
128
+ return start !== -1 && end > start ? text.slice(start, end + 1) : null;
129
+ })();
130
+ if (!candidate) return null;
131
+ try {
132
+ return JSON.parse(candidate);
133
+ } catch {
134
+ return null;
135
+ }
136
+ }
package/worker/cli.js ADDED
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env node
2
+ // brand-manager-worker entrypoint. Like the caption worker, the only
3
+ // prerequisite is Claude Code — it does the drafting, on this machine, with
4
+ // the user's own subscription. Nothing here needs an API key.
5
+ //
6
+ // install save token + register a login service (worker runs forever)
7
+ // update pull the newest code into the background copy + restart
8
+ // status what's installed, whether it's running, recent log lines
9
+ // uninstall remove the service + saved token
10
+ // run run the worker loop in this window (default)
11
+
12
+ import { execSync } from "node:child_process";
13
+ import { dirname, resolve } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
17
+
18
+ const command = ["install", "update", "status", "uninstall", "run"].includes(process.argv[2])
19
+ ? process.argv[2]
20
+ : "run";
21
+
22
+ if (command === "uninstall") {
23
+ const { uninstall } = await import("./service.js");
24
+ uninstall();
25
+ process.exit(0);
26
+ }
27
+
28
+ if (command === "status") {
29
+ const { status } = await import("./service.js");
30
+ status();
31
+ process.exit(0);
32
+ }
33
+
34
+ function flag(name) {
35
+ const i = process.argv.indexOf(name);
36
+ return i !== -1 ? process.argv[i + 1] : undefined;
37
+ }
38
+
39
+ function has(cmd) {
40
+ try {
41
+ execSync(process.platform === "win32" ? `where ${cmd}` : `command -v ${cmd}`, {
42
+ stdio: "ignore",
43
+ shell: true,
44
+ });
45
+ return true;
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ if (!has("claude")) {
52
+ console.error(
53
+ "\nClaude Code isn't installed yet (it writes your replies).\n" +
54
+ "Install it with: npm install -g @anthropic-ai/claude-code\n" +
55
+ "Then run: claude (once, to sign in — type /exit to leave)\n" +
56
+ "Then run this command again.\n",
57
+ );
58
+ process.exit(1);
59
+ }
60
+
61
+ if (command === "install") {
62
+ const { install } = await import("./service.js");
63
+ install({
64
+ url: (flag("--url") ?? "https://goosetools.com").replace(/\/$/, ""),
65
+ token: flag("--token"),
66
+ root: ROOT,
67
+ });
68
+ } else if (command === "update") {
69
+ const { update } = await import("./service.js");
70
+ update({ root: ROOT });
71
+ } else {
72
+ await import("./index.js");
73
+ }