clawmoney 0.17.46 → 0.17.47

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.
@@ -11,7 +11,7 @@ const LOG_FILE = join(homedir(), ".clawmoney", "relay.log");
11
11
  export async function relayRegisterCommand(options) {
12
12
  const config = requireConfig();
13
13
  // Validate CLI type
14
- const validClis = ["claude", "codex", "gemini", "antigravity"];
14
+ const validClis = ["claude", "codex", "gemini", "antigravity", "chatgpt-web"];
15
15
  if (!validClis.includes(options.cli)) {
16
16
  console.error(chalk.red(`Invalid CLI type "${options.cli}". Must be one of: ${validClis.join(", ")}`));
17
17
  process.exit(1);
@@ -36,14 +36,16 @@ export async function relayRegisterCommand(options) {
36
36
  }
37
37
  }
38
38
  else {
39
- const spinner = ora(`Checking if ${options.cli} is installed...`).start();
39
+ // chatgpt-web drives the browser via opencli — there's no "chatgpt-web" binary.
40
+ const probeBin = options.cli === "chatgpt-web" ? "opencli" : options.cli;
41
+ const spinner = ora(`Checking if ${probeBin} is installed...`).start();
40
42
  try {
41
- execSync(`which ${options.cli}`, { stdio: "pipe" });
42
- spinner.succeed(`${options.cli} is available`);
43
+ execSync(`which ${probeBin}`, { stdio: "pipe" });
44
+ spinner.succeed(`${probeBin} is available`);
43
45
  }
44
46
  catch {
45
- spinner.fail(chalk.red(`${options.cli} is not installed or not in PATH`));
46
- console.log(chalk.dim(` Make sure ${options.cli} CLI is installed and accessible.`));
47
+ spinner.fail(chalk.red(`${probeBin} is not installed or not in PATH`));
48
+ console.log(chalk.dim(` Make sure ${probeBin} CLI is installed and accessible.`));
47
49
  process.exit(1);
48
50
  }
49
51
  }
@@ -32,6 +32,13 @@ export const API_PRICES = {
32
32
  // 5 / 5.1 / 5.2-codex families were fully removed that day. Anything
33
33
  // below this comment that's deprecated was removed from the CLI-side
34
34
  // pricing table so `modelsForCli("codex")` no longer offers them.
35
+ //
36
+ // gpt-5.5 — current Codex CLI default (config.toml `model = "gpt-5.5"`)
37
+ // after the mid-2026 bump. Upgraded ChatGPT accounts now 404 the older
38
+ // 5.4/5.3-codex/5.2 ids with "not supported when using Codex with a
39
+ // ChatGPT account", so gpt-5.5 must be offered. Priced at the 5.4 tier
40
+ // pending an official LiteLLM entry.
41
+ "gpt-5.5": { input: 2.50, output: 15 },
35
42
  "gpt-5.4": { input: 2.50, output: 15 },
36
43
  "gpt-5.4-mini": { input: 0.75, output: 4.50 },
37
44
  "gpt-5.3-codex": { input: 1.75, output: 14 },
@@ -6,6 +6,7 @@ import { RelayWsClient } from "./ws-client.js";
6
6
  import { callClaudeApi, callClaudeApiPassthrough, preflightClaudeApi, getRateGuardSnapshot as getClaudeRateGuardSnapshot, } from "./upstream/claude-api.js";
7
7
  import { callCodexApi, callCodexApiPassthrough, preflightCodexApi, getRateGuardSnapshot as getCodexRateGuardSnapshot, } from "./upstream/codex-api.js";
8
8
  import { callGeminiApi, preflightGeminiApi, getGeminiRateGuardSnapshot, } from "./upstream/gemini-api.js";
9
+ import { callChatGPTWeb } from "./upstream/chatgpt-web.js";
9
10
  import { callAntigravityApi, preflightAntigravityApi, getAntigravityRateGuardSnapshot, } from "./upstream/antigravity-api.js";
10
11
  import { callMinimaxApi, preflightMinimaxApi, getMinimaxRateGuardSnapshot, } from "./upstream/minimax-api.js";
11
12
  import { callKimiCodingApi, preflightKimiCodingApi, getKimiCodingRateGuardSnapshot, } from "./upstream/kimi-coding-api.js";
@@ -221,6 +222,11 @@ function messagesToPrompt(messages) {
221
222
  const AUTH_ERROR_THRESHOLD = 3;
222
223
  const consecutiveAuthErrorsByCli = new Map();
223
224
  const cliAuthDisabled = new Set();
225
+ // chatgpt-web: buyer sessions we've already opened a ChatGPT tab for. First
226
+ // turn opens a temporary chat; later turns continue in the same tab so context
227
+ // accumulates. Keyed by cli_session_id (falls back to request_id for stateless
228
+ // single-shots, which just get a fresh temporary chat each time).
229
+ const chatgptWebSessions = new Set();
224
230
  const AUTH_BROKEN_PATTERNS = [
225
231
  // Anthropic 403: OAuth authentication is currently not allowed for
226
232
  // this organization. The new prod signal from 2026-04-15 incident.
@@ -313,11 +319,15 @@ async function executeRelayRequest(request, config, sendChunk) {
313
319
  // antigravity → daily-cloudcode-pa). Each handler has its own
314
320
  // fingerprint file and rate-guard instance.
315
321
  if (cliType === "codex") {
316
- // Same two-mode pattern as claude: passthrough when the Hub forwards
317
- // a real Responses API body (used by /v1/responses endpoint for
318
- // Codex CLI drop-in replacement), template mode otherwise (used by
319
- // the OpenAI-compat /v1/chat/completions classic endpoint).
320
- if (request.passthrough_body) {
322
+ // Passthrough ONLY when the Hub forwarded a real Responses API body —
323
+ // i.e. it carries an `input` array (the /v1/responses drop-in path).
324
+ // The OpenAI-compat /v1/chat/completions path forwards a body with
325
+ // `messages` (no `input`), so fall through to template mode, which
326
+ // builds a Responses request from the flattened `prompt`. Without this
327
+ // guard, chat/completions hit passthrough and 400'd with
328
+ // "Passthrough body missing `input` array".
329
+ const pb = request.passthrough_body;
330
+ if (pb && Array.isArray(pb.input) && pb.input.length > 0) {
321
331
  parsed = await callCodexApiPassthrough({
322
332
  clientBody: request.passthrough_body,
323
333
  model,
@@ -340,6 +350,26 @@ async function executeRelayRequest(request, config, sendChunk) {
340
350
  maxTokens: max_budget_usd ? undefined : 8192,
341
351
  });
342
352
  }
353
+ else if (cliType === "chatgpt-web") {
354
+ // Web send/read path — drive chatgpt.com via opencli (temporary chat)
355
+ // instead of the reverse-proxy API. Real-browser route, un-bannable.
356
+ // Per-buyer tab keyed by cli_session_id: first turn opens a temporary
357
+ // chat, later turns continue in the same tab so context accumulates.
358
+ const webKey = cliSessionId || request_id;
359
+ const seenBefore = chatgptWebSessions.has(webKey);
360
+ chatgptWebSessions.add(webKey);
361
+ // Image turn when the buyer asks for an image model (gpt-image-*) — that
362
+ // forces a regular chat + image grab. Text models stay on temporary chat.
363
+ const wantsImage = /image/i.test(model);
364
+ parsed = await callChatGPTWeb({
365
+ prompt,
366
+ model,
367
+ timeout: wantsImage ? 200 : 120,
368
+ sessionKey: webKey,
369
+ continueChat: seenBefore,
370
+ imageOut: wantsImage,
371
+ });
372
+ }
343
373
  else if (cliType === "antigravity") {
344
374
  parsed = await callAntigravityApi({
345
375
  prompt,
@@ -0,0 +1,51 @@
1
+ /**
2
+ * chatgpt-web upstream — serve a relay request by driving the ChatGPT *website*
3
+ * (not the reverse-proxy API). Sends the prompt into a ChatGPT temporary chat
4
+ * via opencli's CDP browser automation, waits for the reply, reads it back.
5
+ *
6
+ * Why this exists alongside the codex reverse-proxy path:
7
+ * - It's a real person typing in chatgpt.com → OpenAI can't tell it apart from
8
+ * normal use → effectively un-bannable (the reverse-proxy路径 mimics the
9
+ * Codex CLI fingerprint, which is safe but not bullet-proof at scale).
10
+ * - Temporary chat → no history pollution, clean per-session isolation, not
11
+ * used for training.
12
+ * - Trade-off: slower (seconds, UI render) and not token-streamed. Fine for
13
+ * conversation; the buyer still gets a standard reply.
14
+ *
15
+ * Requires the provider machine to have `@jackwener/opencli` installed (with the
16
+ * `chatgpt ask --temporary` support) and a logged-in ChatGPT session in the
17
+ * opencli-controlled Chrome. Point OPENCLI_BIN at a custom binary for local
18
+ * source runs.
19
+ */
20
+ import type { ParsedOutput } from "../types.js";
21
+ export interface ChatGPTWebOptions {
22
+ prompt: string;
23
+ model: string;
24
+ /** Max seconds to wait for the assistant reply. */
25
+ timeout?: number;
26
+ /** Continue an existing /c/<id> conversation (multi-turn, non-temporary). */
27
+ conversationId?: string;
28
+ /**
29
+ * Continue the CURRENT chat in place (no --new/--temporary). Used for
30
+ * stateful multi-turn inside a temporary chat: the first turn opens the
31
+ * temporary chat, later turns just send into the same tab so ChatGPT
32
+ * accumulates context. Temporary chats have no /c/<id> to resume by URL,
33
+ * so this in-place continuation is the only way to keep their context.
34
+ */
35
+ continueChat?: boolean;
36
+ /**
37
+ * Per-buyer browser session key → its own ChatGPT tab. Same key reuses the
38
+ * same tab (multi-turn context); different keys run concurrently in separate
39
+ * tabs. Maps to opencli `--session <key>`.
40
+ */
41
+ sessionKey?: string;
42
+ /**
43
+ * This turn is expected to produce an image (generate/edit). Forces a regular
44
+ * chat (ChatGPT temporary chat BLOCKS image generation) and waits for + grabs
45
+ * the image. Maps to opencli `--image-out` + `--new` (first turn).
46
+ */
47
+ imageOut?: boolean;
48
+ /** Local image paths to upload before the prompt (edit the buyer's own image). */
49
+ imagePaths?: string[];
50
+ }
51
+ export declare function callChatGPTWeb(opts: ChatGPTWebOptions): Promise<ParsedOutput>;
@@ -0,0 +1,114 @@
1
+ /**
2
+ * chatgpt-web upstream — serve a relay request by driving the ChatGPT *website*
3
+ * (not the reverse-proxy API). Sends the prompt into a ChatGPT temporary chat
4
+ * via opencli's CDP browser automation, waits for the reply, reads it back.
5
+ *
6
+ * Why this exists alongside the codex reverse-proxy path:
7
+ * - It's a real person typing in chatgpt.com → OpenAI can't tell it apart from
8
+ * normal use → effectively un-bannable (the reverse-proxy路径 mimics the
9
+ * Codex CLI fingerprint, which is safe but not bullet-proof at scale).
10
+ * - Temporary chat → no history pollution, clean per-session isolation, not
11
+ * used for training.
12
+ * - Trade-off: slower (seconds, UI render) and not token-streamed. Fine for
13
+ * conversation; the buyer still gets a standard reply.
14
+ *
15
+ * Requires the provider machine to have `@jackwener/opencli` installed (with the
16
+ * `chatgpt ask --temporary` support) and a logged-in ChatGPT session in the
17
+ * opencli-controlled Chrome. Point OPENCLI_BIN at a custom binary for local
18
+ * source runs.
19
+ */
20
+ import { execFile } from "node:child_process";
21
+ import { promisify } from "node:util";
22
+ const execFileP = promisify(execFile);
23
+ const OPENCLI_BIN = process.env.OPENCLI_BIN || "opencli";
24
+ /** Roughly 4 chars per token — web send/read has no real token counts, so we
25
+ * estimate for billing parity with the API paths. */
26
+ function estimateTokens(text) {
27
+ return Math.max(1, Math.ceil(text.length / 4));
28
+ }
29
+ export async function callChatGPTWeb(opts) {
30
+ const timeout = opts.timeout ?? 120;
31
+ const args = ["chatgpt", "ask", opts.prompt, "--timeout", String(timeout), "-f", "json"];
32
+ // Turn routing:
33
+ // - continueChat → no flag; opencli's ensureOnChatGPT stays on the current
34
+ // page, so a temporary chat continues in place and keeps its context.
35
+ // - conversationId → resume a saved /c/<id> conversation.
36
+ // - otherwise → open a fresh temporary chat (first turn / single-shot).
37
+ if (opts.continueChat) {
38
+ // intentionally no flag — continue the current chat in place.
39
+ }
40
+ else if (opts.conversationId) {
41
+ args.push("--conversation", opts.conversationId);
42
+ }
43
+ else if (opts.imageOut) {
44
+ // Image generation is blocked in temporary chats → use a regular new chat.
45
+ args.push("--new");
46
+ }
47
+ else {
48
+ args.push("--temporary");
49
+ }
50
+ if (opts.imageOut) {
51
+ args.push("--image-out");
52
+ }
53
+ if (opts.imagePaths?.length) {
54
+ args.push("--image", opts.imagePaths.join(","));
55
+ }
56
+ // Per-buyer tab isolation: same key → same tab (context kept), different
57
+ // keys → concurrent tabs.
58
+ if (opts.sessionKey) {
59
+ args.push("--session", opts.sessionKey);
60
+ }
61
+ let stdout = "";
62
+ try {
63
+ const result = await execFileP(OPENCLI_BIN, args, {
64
+ timeout: (timeout + 30) * 1000,
65
+ maxBuffer: 96 * 1024 * 1024, // base64 images can be several MB each
66
+ env: { ...process.env },
67
+ });
68
+ stdout = result.stdout;
69
+ }
70
+ catch (err) {
71
+ const e = err;
72
+ const tail = `${e.stdout ?? ""}${e.stderr ?? ""}`.slice(0, 400);
73
+ throw new Error(`opencli chatgpt ask failed: ${e.message ?? String(err)} ${tail}`);
74
+ }
75
+ // opencli `-f json` prints a JSON array; tolerate any leading banner lines.
76
+ const start = stdout.indexOf("[");
77
+ if (start < 0) {
78
+ throw new Error(`opencli chatgpt ask: no JSON in output: ${stdout.slice(0, 300)}`);
79
+ }
80
+ let rows;
81
+ try {
82
+ rows = JSON.parse(stdout.slice(start));
83
+ }
84
+ catch {
85
+ throw new Error(`opencli chatgpt ask: bad JSON: ${stdout.slice(start, start + 300)}`);
86
+ }
87
+ const response = String(rows?.[0]?.response ?? "").trim();
88
+ const images = Array.isArray(rows?.[0]?.images) ? rows[0].images : [];
89
+ if (!response && !images.length) {
90
+ throw new Error(`opencli chatgpt ask empty (stdout=${stdout.length}b, keys=[${Object.keys(rows?.[0] ?? {}).join(",")}], imagesType=${typeof rows?.[0]?.images})`);
91
+ }
92
+ // Carry images back inside the chat.completion content as markdown data
93
+ // URLs — the buyer's front-end renders them directly. (Image turns often
94
+ // have little/no text, so the image IS the answer.)
95
+ let text = response;
96
+ if (images.length) {
97
+ const md = images.map((img, i) => `![image${i + 1}](${img})`).join("\n");
98
+ text = text ? `${text}\n\n${md}` : md;
99
+ }
100
+ return {
101
+ text,
102
+ sessionId: String(rows?.[0]?.conversationId ?? ""),
103
+ usage: {
104
+ input_tokens: estimateTokens(opts.prompt),
105
+ // Don't token-count the base64 image (it'd be ~375k "tokens"); bill each
106
+ // generated image as a flat token block instead.
107
+ output_tokens: estimateTokens(response) + images.length * 800,
108
+ cache_creation_tokens: 0,
109
+ cache_read_tokens: 0,
110
+ },
111
+ model: opts.model,
112
+ costUsd: 0,
113
+ };
114
+ }
@@ -48,10 +48,12 @@ function installFileLogger() {
48
48
  console.error = (...args) => logToFile("ERROR", ...args);
49
49
  }
50
50
  import { TaskWsClient } from "./ws-client.js";
51
- import { getSkill, listSkills } from "./skills/index.js";
51
+ import { getSkill, listSkills, defaultAdvertiseSkills } from "./skills/index.js";
52
+ import { runPreflight, writePreflightReport } from "./preflight.js";
52
53
  const CONFIG_DIR = join(homedir(), ".clawmoney");
53
54
  const CONFIG_FILE = join(CONFIG_DIR, "config.yaml");
54
55
  const PID_FILE = join(CONFIG_DIR, "task.pid");
56
+ const TASK_STATE_FILE = join(CONFIG_DIR, "task-state.json");
55
57
  function loadYamlConfig() {
56
58
  if (!existsSync(CONFIG_FILE))
57
59
  return {};
@@ -76,7 +78,10 @@ function loadConfig() {
76
78
  .split(",")
77
79
  .map((s) => s.trim())
78
80
  .filter(Boolean);
79
- const skills = requested.length > 0 ? requested : listSkills();
81
+ // Default advertise set excludes skills the SpareAPI backend now fetches
82
+ // directly (YC / IndieHackers / Hacker News); an explicit SKILLS= can still
83
+ // opt into them since `supported` below is the full registry.
84
+ const skills = requested.length > 0 ? requested : defaultAdvertiseSkills();
80
85
  // Sanity-check: drop anything we can't actually serve so we don't
81
86
  // advertise dead skills to the hub.
82
87
  const supported = new Set(listSkills());
@@ -129,6 +134,26 @@ function removePid() {
129
134
  // ignore
130
135
  }
131
136
  }
137
+ // Lifecycle phase the desktop app reads to show "service running, checking
138
+ // logins…" during the (possibly slow first-run) preflight, vs "online" once
139
+ // the hub connection is up. Distinct from the pid file, which only says the
140
+ // process exists.
141
+ function writeTaskState(phase) {
142
+ try {
143
+ writeFileSync(TASK_STATE_FILE, JSON.stringify({ phase, pid: process.pid, ts: new Date().toISOString() }), "utf-8");
144
+ }
145
+ catch {
146
+ /* best effort */
147
+ }
148
+ }
149
+ function clearTaskState() {
150
+ try {
151
+ unlinkSync(TASK_STATE_FILE);
152
+ }
153
+ catch {
154
+ // ignore
155
+ }
156
+ }
132
157
  async function handleTaskRequest(ws, req) {
133
158
  const startedAtMs = Date.now();
134
159
  const handler = getSkill(req.skill_id);
@@ -223,7 +248,7 @@ function applySystemProxy() {
223
248
  console.error(`[task] system proxy detection skipped: ${err instanceof Error ? err.message : String(err)}`);
224
249
  }
225
250
  }
226
- function main() {
251
+ async function main() {
227
252
  // Daemon was started as a script (stdio:"ignore"); from here on, all
228
253
  // log lines should land in ~/.clawmoney/task.log.
229
254
  installFileLogger();
@@ -238,12 +263,32 @@ function main() {
238
263
  console.error("[task] api_key missing — set API_KEY env or run `clawmoney setup`");
239
264
  process.exit(1);
240
265
  }
266
+ // Mark the service up the moment config checks out — BEFORE the (possibly
267
+ // slow first-run) preflight. Two reasons: the app can show "service running,
268
+ // checking logins…" instead of "not started", and the app's self-heal keys
269
+ // off the pid file, so writing it now stops it from re-spawning us mid-probe.
270
+ writePid();
271
+ writeTaskState("probing");
272
+ // Preflight: probe each platform's external dependency and drop the ones
273
+ // that would fail every task (Codex not installed, X not logged in, the
274
+ // Chrome extension down, …). Writes ~/.clawmoney/preflight.json so the
275
+ // desktop app can surface a home-screen notice. Re-runs every start, so
276
+ // fixing the dependency recovers the platform on the next boot.
277
+ console.log(`[task] preflight on ${config.skills.length} skills…`);
278
+ const { skills: keptSkills, report } = await runPreflight(config.skills);
279
+ writePreflightReport(report);
280
+ config.skills = keptSkills;
281
+ if (report.summary.droppedSkills > 0) {
282
+ console.warn(`[task] preflight dropped ${report.summary.droppedSkills} skill(s) across ` +
283
+ `${report.summary.failed} platform(s): ${report.dropped.join(", ")}`);
284
+ }
241
285
  console.log(`[task] starting daemon hub=${config.hub_url} skills=[${config.skills.join(",")}]`);
242
286
  if (config.skills.length === 0) {
243
287
  console.error("[task] no skills to advertise — refusing to start");
288
+ removePid();
289
+ clearTaskState();
244
290
  process.exit(1);
245
291
  }
246
- writePid();
247
292
  // Belt-and-suspenders: even if every other handle (WS, heartbeat,
248
293
  // reconnect timer) somehow goes away simultaneously, this interval
249
294
  // is unref-less and ref-counted into the event loop, so the daemon
@@ -263,6 +308,7 @@ function main() {
263
308
  switch (frame.event) {
264
309
  case "connected":
265
310
  console.log(`[task] connected agent_id=${frame.agent_id} name="${frame.agent_name}"`);
311
+ writeTaskState("online");
266
312
  break;
267
313
  case "task_request":
268
314
  void handleTaskRequest(ws, frame);
@@ -279,6 +325,7 @@ function main() {
279
325
  console.log(`[task] ${signal} — shutting down`);
280
326
  ws.stop();
281
327
  removePid();
328
+ clearTaskState();
282
329
  setTimeout(() => process.exit(0), 200);
283
330
  };
284
331
  process.on("SIGINT", () => shutdown("SIGINT"));
@@ -289,5 +336,8 @@ function main() {
289
336
  // the module is imported (e.g. by src/commands/task.ts which only wants
290
337
  // to use readTaskPid / isPidAlive helpers).
291
338
  if (process.argv[1] === fileURLToPath(import.meta.url)) {
292
- main();
339
+ main().catch((err) => {
340
+ console.error(`[task] fatal: ${err instanceof Error ? err.stack : String(err)}`);
341
+ process.exit(1);
342
+ });
293
343
  }
@@ -0,0 +1,47 @@
1
+ export type PreflightStatus = "ok" | "failed" | "unknown";
2
+ export interface PlatformPreflight {
3
+ /** Display name surfaced in the app notice. */
4
+ label: string;
5
+ /** "local-app" | "web-login" | "social-login" — drives the app hint copy. */
6
+ category: string;
7
+ status: PreflightStatus;
8
+ /** How many advertised skills sit under this platform prefix. */
9
+ skills: number;
10
+ /** Human-readable failure reason (only when failed/unknown). */
11
+ reason?: string;
12
+ /** Actionable fix shown to the operator. */
13
+ hint?: string;
14
+ /** false = nothing the operator can do (e.g. upstream app dropped the
15
+ * capability); the app keeps it off the notice banner. Default true. */
16
+ actionable?: boolean;
17
+ /** Login page the app can open for the operator to fix a logged-out
18
+ * browser platform in one click. */
19
+ loginUrl?: string;
20
+ }
21
+ export interface PreflightReport {
22
+ ts: string;
23
+ /** false when any probed platform failed. */
24
+ ok: boolean;
25
+ summary: {
26
+ checked: number;
27
+ failed: number;
28
+ droppedSkills: number;
29
+ };
30
+ /** Keyed by platform prefix (the segment before the first dot in a skill_id). */
31
+ platforms: Record<string, PlatformPreflight>;
32
+ /** skill_ids removed from the advertise set. */
33
+ dropped: string[];
34
+ }
35
+ export interface PreflightOutcome {
36
+ /** Skills that passed (or were never probed) — the advertise set. */
37
+ skills: string[];
38
+ report: PreflightReport;
39
+ }
40
+ /**
41
+ * Probe every platform that has a registered dependency, drop the failures
42
+ * from the advertise set, and build the report. `unknown` verdicts are
43
+ * kept advertising (conservative) but recorded so the app can hint.
44
+ */
45
+ export declare function runPreflight(skills: string[]): Promise<PreflightOutcome>;
46
+ /** Persist the verdict for the desktop app to read on its next dashboard load. */
47
+ export declare function writePreflightReport(report: PreflightReport): void;
@@ -0,0 +1,515 @@
1
+ /**
2
+ * Provider preflight.
3
+ *
4
+ * Before advertising skills to the hub we probe each platform's external
5
+ * dependency — a local desktop app (Codex / ChatGPT), a logged-in browser
6
+ * session (X / 小红书 / 抖音 / ChatGPT Web / Gemini / Flow), or the BNBot
7
+ * Chrome extension link they all share. A skill whose dependency is
8
+ * missing will fail *every* task and burn the provider's reputation, so
9
+ * we drop those platforms from the advertise set instead of letting the
10
+ * hub route work to them.
11
+ *
12
+ * Design choices:
13
+ * - Probes are LIGHT. We never run a real scrape/generation — only the
14
+ * `status` / `whoami` introspection commands bnbot already ships, or a
15
+ * filesystem/env check. The operator explicitly asked us not to hammer
16
+ * the machine on every boot. One exception: Codex SELF-HEALS — a
17
+ * portless/stopped Codex is relaunched with the CDP flag right here
18
+ * (see probeCodex) instead of being reported for manual fixing.
19
+ * - Only platforms with a *deterministic* dependency are probed. Public
20
+ * read surfaces (wiki / google / hn / reddit / youtube …) have no local
21
+ * dependency beyond network, so they are never dropped here — that
22
+ * avoids false-positives taking good platforms offline.
23
+ * - The extension link (`bnbot status`) is the shared gate for every
24
+ * browser-driven platform; we probe it once and reuse the verdict.
25
+ * - Everything re-runs on each daemon start. KeepAlive (launchd) and the
26
+ * desktop app's self-heal both restart the daemon, so installing Codex
27
+ * or logging into x.com recovers the platform on the next boot — no
28
+ * persistent blacklist to clear.
29
+ *
30
+ * The verdict is written to ~/.clawmoney/preflight.json for the desktop
31
+ * app to surface as a home-screen notice + per-card status.
32
+ */
33
+ import { execFile } from "node:child_process";
34
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
35
+ import { join } from "node:path";
36
+ import { homedir } from "node:os";
37
+ const PREFLIGHT_FILE = join(homedir(), ".clawmoney", "preflight.json");
38
+ // A navigation login-probe costs ~15s and login state rarely flips, so reuse a
39
+ // recent verdict across daemon restarts (KeepAlive / app self-heal) instead of
40
+ // re-probing every browser platform each boot. Local-app probes (codex/chatgpt)
41
+ // are cheap + volatile, so they're always re-run, never cached.
42
+ const CACHE_FRESH_MS = 30 * 60 * 1000;
43
+ // Same resolution order as the skill handlers' bnbot lookup so the probe
44
+ // hits the exact binary the skills will use.
45
+ const BNBOT_CANDIDATES = [
46
+ process.env.BNBOT_CLI,
47
+ "bnbot",
48
+ "/opt/homebrew/bin/bnbot",
49
+ "/usr/local/bin/bnbot",
50
+ ].filter((value, index, values) => typeof value === "string" && value.length > 0 && values.indexOf(value) === index);
51
+ function runBnbot(args, timeoutMs) {
52
+ return new Promise((resolve) => {
53
+ const attempt = (idx) => {
54
+ const bin = BNBOT_CANDIDATES[idx];
55
+ if (!bin) {
56
+ resolve({ ok: false, stdout: "", stderr: "bnbot not found on PATH", missing: true });
57
+ return;
58
+ }
59
+ execFile(bin, args, { timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
60
+ const e = err;
61
+ // Binary not at this path → try the next candidate.
62
+ if (e && e.code === "ENOENT") {
63
+ attempt(idx + 1);
64
+ return;
65
+ }
66
+ resolve({
67
+ ok: !err,
68
+ stdout: stdout ?? "",
69
+ stderr: stderr ?? "",
70
+ missing: false,
71
+ });
72
+ });
73
+ };
74
+ attempt(0);
75
+ });
76
+ }
77
+ /** Pull the first JSON object out of bnbot stdout (it may prefix a banner). */
78
+ function parseJson(stdout) {
79
+ const trimmed = stdout.trim();
80
+ if (!trimmed)
81
+ return null;
82
+ try {
83
+ return JSON.parse(trimmed);
84
+ }
85
+ catch {
86
+ const start = trimmed.indexOf("{");
87
+ const end = trimmed.lastIndexOf("}");
88
+ if (start >= 0 && end > start) {
89
+ try {
90
+ return JSON.parse(trimmed.slice(start, end + 1));
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ }
96
+ return null;
97
+ }
98
+ }
99
+ // ── Shared extension gate ────────────────────────────────────────────
100
+ /** `bnbot status` — is the Chrome extension connected to `bnbot serve`?
101
+ * This is the common dependency for every browser-driven platform. */
102
+ async function probeExtensionLink() {
103
+ const r = await runBnbot(["status"], 8000);
104
+ if (r.missing) {
105
+ return {
106
+ status: "failed",
107
+ reason: "未找到 bnbot 命令",
108
+ hint: "安装 @bnbot/cli(provider 机器需要 bnbot)",
109
+ };
110
+ }
111
+ // `bnbot status` prints a banner; "connected" only appears when the
112
+ // extension handshake succeeded.
113
+ if (/extension[\s\S]*connected/i.test(r.stdout) || /connected/i.test(r.stdout)) {
114
+ return { status: "ok" };
115
+ }
116
+ return {
117
+ status: "failed",
118
+ reason: "BNBot 浏览器扩展未连接",
119
+ hint: "打开 Chrome,确认已安装 BNBot 扩展且 `bnbot serve` 在运行",
120
+ };
121
+ }
122
+ async function codexCdpStatus(extraArgs, timeoutMs) {
123
+ const r = await runBnbot(["codex", "status", ...extraArgs], timeoutMs);
124
+ if (r.missing)
125
+ return { missing: true, connected: false, processRunning: false };
126
+ const j = parseJson(r.stdout);
127
+ return {
128
+ missing: false,
129
+ connected: j?.connected === true,
130
+ processRunning: j?.processRunning === true,
131
+ };
132
+ }
133
+ /**
134
+ * Quit Codex the way Cmd+Q does (Apple Event). NEVER signal it instead:
135
+ * SIGTERM reads as a crash to macOS, which reopens the app via launchd a
136
+ * few seconds later — without our debug flag — and that zombie then owns
137
+ * the single-instance lock, defeating the relaunch (verified live).
138
+ */
139
+ function quitCodexGracefully() {
140
+ return new Promise((resolve) => {
141
+ execFile("osascript", ["-e", 'tell application id "com.openai.codex" to quit'], { timeout: 8000 }, (err) => resolve(!err));
142
+ });
143
+ }
144
+ function sleep(ms) {
145
+ return new Promise((r) => setTimeout(r, ms));
146
+ }
147
+ function codexProcessAlive() {
148
+ return new Promise((resolve) => {
149
+ execFile("pgrep", ["-x", "Codex"], (err) => resolve(!err));
150
+ });
151
+ }
152
+ /** A CDP-style failure means the app launches fine but never opens the
153
+ * debug port (current Codex builds dropped CDP support entirely), so a
154
+ * fresh quit+relaunch cycle can't fix it — don't churn the operator's
155
+ * Codex again until the backoff expires or the failure mode changes. */
156
+ const HEAL_BACKOFF_MS = 6 * 60 * 60 * 1000;
157
+ function codexHealRecentlyFailed() {
158
+ const prev = readPreviousReport();
159
+ const last = prev?.platforms?.codex;
160
+ if (!last || last.status !== "failed")
161
+ return false;
162
+ // Matches both the post-heal verdict ("…CDP 未就绪") and the backoff
163
+ // verdict itself ("不支持 CDP 调试…") so the backoff renews each boot.
164
+ if (!last.reason?.includes("CDP"))
165
+ return false;
166
+ const ts = Date.parse(prev?.ts ?? "");
167
+ return Number.isFinite(ts) && Date.now() - ts < HEAL_BACKOFF_MS;
168
+ }
169
+ /**
170
+ * Codex is the one dependency we try to SELF-HEAL instead of just reporting:
171
+ * CDP can only be enabled at launch (Chromium limitation), and bnbot already
172
+ * knows how to launch Codex with the debug port (`status --launch`). So a
173
+ * portless instance gets a graceful quit + relaunch here, and "Codex not
174
+ * running" gets a plain launch — the operator only sees a notice when that
175
+ * automation genuinely failed (e.g. current Codex builds ignore the CDP
176
+ * flag altogether — Chromium-148 shell, no remote debugging).
177
+ */
178
+ async function probeCodex() {
179
+ const first = await codexCdpStatus([], 8000);
180
+ if (first.missing) {
181
+ return { status: "failed", reason: "未找到 bnbot 命令", hint: "安装 @bnbot/cli" };
182
+ }
183
+ if (first.connected)
184
+ return { status: "ok" };
185
+ if (codexHealRecentlyFailed()) {
186
+ return {
187
+ status: "failed",
188
+ reason: "Codex 不支持 CDP 调试(端口 9238),自动修复已暂停重试",
189
+ hint: "当前版本的 Codex 无法开启远程调试,绘图技能暂不可用;等待 bnbot 适配新版 Codex",
190
+ actionable: false,
191
+ };
192
+ }
193
+ if (first.processRunning) {
194
+ // Quit the portless instance first: its single-instance lock would
195
+ // swallow a relaunch (args forwarded to the old process, port ignored).
196
+ console.log("[preflight] codex running without CDP — restarting with debug port…");
197
+ await quitCodexGracefully();
198
+ let gone = false;
199
+ for (let i = 0; i < 8; i++) {
200
+ await sleep(1000);
201
+ if (!(await codexProcessAlive())) {
202
+ gone = true;
203
+ break;
204
+ }
205
+ }
206
+ if (!gone) {
207
+ return {
208
+ status: "failed",
209
+ reason: "Codex 已运行但 CDP 未就绪(端口 9238),自动重启未成功",
210
+ hint: "请手动退出 Codex(Cmd+Q),接单守护会自动以调试端口重新拉起",
211
+ };
212
+ }
213
+ }
214
+ // Not running (or just quit) — launch with the CDP port and re-verify,
215
+ // allowing a slow cold start some runway.
216
+ const launched = await codexCdpStatus(["--launch"], 30000);
217
+ if (launched.connected)
218
+ return { status: "ok" };
219
+ for (let i = 0; i < 6; i++) {
220
+ await sleep(2000);
221
+ const s = await codexCdpStatus([], 8000);
222
+ if (s.connected)
223
+ return { status: "ok" };
224
+ }
225
+ const stillRunning = launched.processRunning || (await codexProcessAlive());
226
+ return {
227
+ status: "failed",
228
+ reason: stillRunning
229
+ ? "Codex 已自动拉起但 CDP 未就绪(端口 9238)"
230
+ : "Codex Desktop 自动启动未成功",
231
+ hint: stillRunning
232
+ ? "当前版本的 Codex 可能不支持远程调试;绘图技能暂不可用"
233
+ : "安装并打开 Codex.app(运行 `codex app` 可自动下载)",
234
+ // App launches fine but never opens the port → nothing the operator
235
+ // can click to fix. A missing install IS fixable, keep that one loud.
236
+ actionable: !stillRunning,
237
+ };
238
+ }
239
+ async function probeChatGpt() {
240
+ const r = await runBnbot(["chatgpt", "status"], 8000);
241
+ if (r.missing) {
242
+ return { status: "failed", reason: "未找到 bnbot 命令", hint: "安装 @bnbot/cli" };
243
+ }
244
+ const j = parseJson(r.stdout);
245
+ if (j && j.running === true)
246
+ return { status: "ok" };
247
+ return {
248
+ status: "failed",
249
+ reason: "ChatGPT Desktop 未运行",
250
+ hint: "安装并打开 ChatGPT.app 并保持登录",
251
+ };
252
+ }
253
+ /** A landed URL matching this means the navigation bounced to a login wall. */
254
+ const DEFAULT_LOGGED_OUT = /login|signin|sign[_-]?in|passport|\/sso\b|accounts\.google\.com|auth\.openai\.com/i;
255
+ /**
256
+ * Real login-state probe — the product's edge. Instead of guessing, drive the
257
+ * extension to the platform's must-login page and read where it actually
258
+ * landed: a logged-out session gets bounced to a login wall, a logged-in one
259
+ * stays on the page. No business request is sent — same signal bnbot's own
260
+ * `checkLoginRedirect` uses, just surfaced as a standalone check.
261
+ */
262
+ async function probeLoginByNavigation(def) {
263
+ const nav = await runBnbot(["navigate", def.url], def.navTimeoutMs ?? 18000);
264
+ if (nav.missing) {
265
+ return { status: "failed", reason: "未找到 bnbot 命令", hint: "安装 @bnbot/cli" };
266
+ }
267
+ // `debug eval` prints the live tab's URL as `.url` regardless of the
268
+ // expression result — that's the signal we want.
269
+ const ev = await runBnbot(["debug", "eval", "location.href"], 8000);
270
+ const landed = parseJson(ev.stdout);
271
+ const finalUrl = typeof landed?.url === "string" ? landed.url : "";
272
+ if (!finalUrl) {
273
+ return {
274
+ status: "unknown",
275
+ reason: `${def.label} 登录态探测超时`,
276
+ hint: `若 ${def.label} 接单失败,请确认 Chrome 已登录`,
277
+ loginUrl: def.url,
278
+ };
279
+ }
280
+ const loggedOut = def.loggedOut || DEFAULT_LOGGED_OUT;
281
+ if (loggedOut.test(finalUrl)) {
282
+ return {
283
+ status: "failed",
284
+ reason: `${def.label} 未登录`,
285
+ hint: `在 Chrome 登录 ${def.label} 后,接单守护重启即自动恢复`,
286
+ loginUrl: def.url,
287
+ };
288
+ }
289
+ return { status: "ok" };
290
+ }
291
+ /** Wrap a login-by-navigation probe, gated on the extension link being up
292
+ * (no extension → no browser session to read, so it's a hard fail). */
293
+ function navProbe(def) {
294
+ return async (extensionOk) => {
295
+ if (!extensionOk) {
296
+ return {
297
+ status: "failed",
298
+ reason: "BNBot 浏览器扩展未连接",
299
+ hint: "打开 Chrome 并确认 BNBot 扩展已连接",
300
+ };
301
+ }
302
+ return probeLoginByNavigation(def);
303
+ };
304
+ }
305
+ /** X: extension link is the gate; on top of it we read the active handle
306
+ * via `bnbot x whoami` (no real request — the extension reads the pool
307
+ * window's session). whoami needs a cold pool window so it's slow; on
308
+ * timeout we stay conservative ("unknown", keep advertising) rather than
309
+ * drop a platform that may well be logged in. */
310
+ async function probeX(extensionOk) {
311
+ if (!extensionOk) {
312
+ return {
313
+ status: "failed",
314
+ reason: "BNBot 浏览器扩展未连接",
315
+ hint: "打开 Chrome 并确认 BNBot 扩展已连接",
316
+ };
317
+ }
318
+ const r = await runBnbot(["x", "whoami"], 10000);
319
+ const j = parseJson(r.stdout);
320
+ const handle = j && (j.username || j.handle || j.screen_name || j.screenName || j.name);
321
+ if (typeof handle === "string" && handle.trim()) {
322
+ return { status: "ok" };
323
+ }
324
+ // Explicit "not logged in" signal → drop it.
325
+ if (/not\s*logged|no\s*account|logged\s*out|null/i.test(`${r.stdout}\n${r.stderr}`)) {
326
+ return {
327
+ status: "failed",
328
+ reason: "X(Twitter)未登录",
329
+ hint: "在 Chrome 登录 x.com 后重启接单",
330
+ loginUrl: "https://x.com/login",
331
+ };
332
+ }
333
+ // whoami timed out / unparseable — fall back to the navigation probe. The
334
+ // x.com/home page bounces to a login wall when logged out, which is more
335
+ // robust than whoami's cold-pool-window read.
336
+ return probeLoginByNavigation({ label: "X / Twitter", url: "https://x.com/home" });
337
+ }
338
+ // ── Registry ─────────────────────────────────────────────────────────
339
+ /**
340
+ * Platforms with a deterministic external dependency. Anything not listed
341
+ * here is assumed ready (public read surfaces) and never dropped.
342
+ *
343
+ * Keyed by platform prefix — the segment before the first dot in a
344
+ * skill_id (e.g. "codex.image_generate" → "codex").
345
+ *
346
+ * Login-walled sites use a real navigation probe: open the must-login page,
347
+ * read whether it bounced to a login wall. The `url` must be a page that
348
+ * REQUIRES login (a creator dashboard / app home), so a logged-out session
349
+ * redirects. Add a platform by dropping one line here.
350
+ */
351
+ const PLATFORM_PROBES = {
352
+ // Local desktop apps — exact, fast probes.
353
+ codex: { label: "Codex 绘图", category: "local-app", probe: probeCodex },
354
+ chatgpt: { label: "ChatGPT", category: "local-app", probe: probeChatGpt },
355
+ // X keeps its dedicated handle read (cheaper, and names the account).
356
+ x: { label: "X / Twitter", category: "social-login", probe: probeX },
357
+ // Login-walled sites — real login-state probe by navigation.
358
+ xhs: {
359
+ label: "小红书", category: "social-login",
360
+ probe: navProbe({ label: "小红书", url: "https://creator.xiaohongshu.com/" }),
361
+ },
362
+ dy: {
363
+ label: "抖音", category: "social-login",
364
+ probe: navProbe({ label: "抖音", url: "https://creator.douyin.com/" }),
365
+ },
366
+ gemini: {
367
+ label: "Gemini 绘图", category: "web-login",
368
+ // Google properties are slow from CN networks — same runway as Flow.
369
+ probe: navProbe({ label: "Gemini", url: "https://gemini.google.com/app", navTimeoutMs: 28000 }),
370
+ },
371
+ flow: {
372
+ label: "Google Flow", category: "web-login",
373
+ probe: navProbe({ label: "Flow", url: "https://labs.google/fx/tools/flow", navTimeoutMs: 28000 }),
374
+ },
375
+ chatgpt_web: {
376
+ label: "ChatGPT 网页", category: "web-login",
377
+ probe: navProbe({ label: "ChatGPT 网页", url: "https://chatgpt.com/" }),
378
+ },
379
+ };
380
+ /** Whether any probed platform depends on the Chrome extension. */
381
+ function needsExtension(prefixes) {
382
+ for (const p of prefixes) {
383
+ const probe = PLATFORM_PROBES[p];
384
+ if (probe && probe.category !== "local-app")
385
+ return true;
386
+ }
387
+ return false;
388
+ }
389
+ function prefixOf(skillId) {
390
+ const dot = skillId.indexOf(".");
391
+ return dot < 0 ? skillId : skillId.slice(0, dot);
392
+ }
393
+ /**
394
+ * Probe every platform that has a registered dependency, drop the failures
395
+ * from the advertise set, and build the report. `unknown` verdicts are
396
+ * kept advertising (conservative) but recorded so the app can hint.
397
+ */
398
+ export async function runPreflight(skills) {
399
+ // Count skills per platform prefix so the report can show "3 skills off".
400
+ const skillsByPrefix = new Map();
401
+ for (const s of skills) {
402
+ const p = prefixOf(s);
403
+ const list = skillsByPrefix.get(p);
404
+ if (list)
405
+ list.push(s);
406
+ else
407
+ skillsByPrefix.set(p, [s]);
408
+ }
409
+ const probedPrefixes = new Set([...skillsByPrefix.keys()].filter((p) => PLATFORM_PROBES[p]));
410
+ // Probe the shared extension link once if anything needs it.
411
+ let extensionOk = true;
412
+ if (needsExtension(probedPrefixes)) {
413
+ const ext = await probeExtensionLink();
414
+ extensionOk = ext.status === "ok";
415
+ if (!extensionOk) {
416
+ console.warn(`[preflight] extension link down: ${ext.reason}`);
417
+ }
418
+ }
419
+ const platforms = {};
420
+ const dropped = [];
421
+ // Reuse a recent verdict for browser-login platforms (their nav probe is
422
+ // slow). Only when the extension is up — a down extension invalidates every
423
+ // cached "ok". `unknown` is never cached (it means "retry next time").
424
+ const prev = readPreviousReport();
425
+ const cacheFresh = !!prev &&
426
+ Number.isFinite(Date.parse(prev.ts)) &&
427
+ Date.now() - Date.parse(prev.ts) < CACHE_FRESH_MS;
428
+ // Serial — these talk to one Chrome / one desktop app; parallel probes
429
+ // would contend on the same UI and skew results.
430
+ for (const prefix of probedPrefixes) {
431
+ const def = PLATFORM_PROBES[prefix];
432
+ const prefixSkills = skillsByPrefix.get(prefix) ?? [];
433
+ const cached = prev?.platforms?.[prefix];
434
+ const useCache = def.category !== "local-app" &&
435
+ extensionOk &&
436
+ cacheFresh &&
437
+ !!cached &&
438
+ cached.status !== "unknown";
439
+ let result;
440
+ if (useCache && cached) {
441
+ result = {
442
+ status: cached.status,
443
+ reason: cached.reason,
444
+ hint: cached.hint,
445
+ actionable: cached.actionable,
446
+ loginUrl: cached.loginUrl,
447
+ };
448
+ }
449
+ else {
450
+ try {
451
+ result = await def.probe(extensionOk);
452
+ }
453
+ catch (err) {
454
+ result = {
455
+ status: "unknown",
456
+ reason: `预演异常: ${err instanceof Error ? err.message : String(err)}`,
457
+ hint: "查看 ~/.clawmoney/task.log",
458
+ };
459
+ }
460
+ }
461
+ platforms[prefix] = {
462
+ label: def.label,
463
+ category: def.category,
464
+ status: result.status,
465
+ skills: prefixSkills.length,
466
+ reason: result.reason,
467
+ hint: result.hint,
468
+ actionable: result.actionable,
469
+ loginUrl: result.loginUrl,
470
+ };
471
+ console.log(`[preflight] ${prefix} (${def.label}): ${result.status}` +
472
+ (useCache ? " (cached)" : "") +
473
+ (result.reason ? ` — ${result.reason}` : ""));
474
+ if (result.status === "failed") {
475
+ dropped.push(...prefixSkills);
476
+ }
477
+ }
478
+ const droppedSet = new Set(dropped);
479
+ const keptSkills = skills.filter((s) => !droppedSet.has(s));
480
+ const failed = Object.values(platforms).filter((p) => p.status === "failed").length;
481
+ const report = {
482
+ ts: new Date().toISOString(),
483
+ ok: failed === 0,
484
+ summary: {
485
+ checked: probedPrefixes.size,
486
+ failed,
487
+ droppedSkills: dropped.length,
488
+ },
489
+ platforms,
490
+ dropped,
491
+ };
492
+ return { skills: keptSkills, report };
493
+ }
494
+ /** Persist the verdict for the desktop app to read on its next dashboard load. */
495
+ export function writePreflightReport(report) {
496
+ try {
497
+ mkdirSync(join(homedir(), ".clawmoney"), { recursive: true });
498
+ writeFileSync(PREFLIGHT_FILE, JSON.stringify(report, null, 2), "utf-8");
499
+ }
500
+ catch (err) {
501
+ console.error(`[preflight] failed to write report: ${err instanceof Error ? err.message : String(err)}`);
502
+ }
503
+ }
504
+ /** Read the last written report so fresh login-state verdicts can be reused
505
+ * across restarts (see CACHE_FRESH_MS). Null when absent/corrupt. */
506
+ function readPreviousReport() {
507
+ try {
508
+ if (!existsSync(PREFLIGHT_FILE))
509
+ return null;
510
+ return JSON.parse(readFileSync(PREFLIGHT_FILE, "utf-8"));
511
+ }
512
+ catch {
513
+ return null;
514
+ }
515
+ }
@@ -19,6 +19,9 @@ export async function bnbotCodexImageGenerate(input) {
19
19
  String(timeoutS),
20
20
  "--response-format",
21
21
  input.response_format ?? "b64_json",
22
+ // Self-heal: only acts when Codex is running WITHOUT CDP (quit+relaunch
23
+ // with the debug port); a healthy instance is untouched.
24
+ "--restart",
22
25
  ];
23
26
  if (input.size)
24
27
  args.push("--size", input.size);
@@ -15,4 +15,20 @@ import type { SkillHandler } from "../types.js";
15
15
  */
16
16
  export declare const SKILL_REGISTRY: Record<string, SkillHandler>;
17
17
  export declare function listSkills(): string[];
18
+ /**
19
+ * Skills now served directly by the SpareAPI backend — plain-HTTP public APIs
20
+ * (Y Combinator / IndieHackers / Hacker News) that the gateway fetches itself
21
+ * instead of dispatching to operators. The handlers stay registered, so an
22
+ * explicit `SKILLS=yc.companies,...` can still opt in, but operators DON'T
23
+ * advertise them by default: the hub no longer routes these to operators, and
24
+ * advertising them would just invite redundant single-fetch jobs.
25
+ *
26
+ * Browser-walled platforms (Kickstarter `ks.*` / Indiegogo `igg.*`, behind
27
+ * Cloudflare) are NOT here — they still need a real operator Chrome, so they
28
+ * keep being advertised. Other public read surfaces (wiki / bbc / bloomberg /
29
+ * stackoverflow / v2ex …) also stay, since the backend doesn't fetch them yet.
30
+ */
31
+ export declare const DIRECT_SERVED_SKILLS: Set<string>;
32
+ /** Skills an operator advertises when the `SKILLS` env var is unset. */
33
+ export declare function defaultAdvertiseSkills(): string[];
18
34
  export declare function getSkill(skillId: string): SkillHandler | undefined;
@@ -143,7 +143,7 @@ import { biliFeedSkill } from "./bilibili/feed.js";
143
143
  import { biliFeedDetailSkill } from "./bilibili/feed-detail.js";
144
144
  import { biliDownloadSkill } from "./bilibili/download.js";
145
145
  // OpenCLI public/browser read skills (Google, HN, Wikipedia, etc.)
146
- import { ggSearchSkill, ggSuggestSkill, ggNewsSkill, ggTrendsSkill, wxmpArticleSearchSkill, wxmpArticleSkill, hnTopSkill, hnNewSkill, hnBestSkill, hnAskSkill, hnShowSkill, hnJobsSkill, hnSearchSkill, hnUserSkill, hnReadSkill, wikiSearchSkill, wikiSummarySkill, wikiRandomSkill, wikiTrendingSkill, wikiPageSkill, yfQuoteSkill, zhSearchSkill, zhHotSkill, zhRecommendSkill, zhQuestionSkill, zhAnswerDetailSkill, zhAnswerCommentsSkill, bbcNewsSkill, bbcTopicSkill, bbgMainSkill, bbgMarketsSkill, bbgEconomicsSkill, bbgIndustriesSkill, bbgTechSkill, bbgPoliticsSkill, bbgBusinessweekSkill, bbgOpinionsSkill, bbgFeedsSkill, bbgArticleSkill, medSearchSkill, medTagSkill, medFeedSkill, medUserSkill, subSearchSkill, subPublicationSkill, subFeedSkill, wbHotSkill, wbSearchSkill, wbFeedSkill, wbUserSkill, wbPostSkill, wbCommentsSkill, kr36NewsSkill, kr36HotSkill, kr36SearchSkill, kr36ArticleSkill, dbSearchSkill, dbMovieHotSkill, dbBookHotSkill, dbTop250Skill, dbPhotosSkill, sfNewsSkill, sfRollingNewsSkill, sfStockSkill, jkFeedSkill, jkSearchSkill, xqSearchSkill, xqHotSkill, xqHotStockSkill, xqStockSkill, xqCommentsSkill, xqKlineSkill, xqEarningsDateSkill, xyzPodcastSkill, xyzPodcastEpisodesSkill, xyzEpisodeSkill, fbSearchSkill, fbProfileSkill, fbEventsSkill, wrSearchSkill, wrRankingSkill, wrBookSkill, ctSearchSkill, ctHotelSuggestSkill, ctHotelSearchSkill, ctFlightSkill, } from "./opencli/platforms.js";
146
+ import { ggSearchSkill, ggSuggestSkill, ggNewsSkill, ggTrendsSkill, wxmpArticleSearchSkill, wxmpArticleSkill, hnTopSkill, hnNewSkill, hnBestSkill, hnAskSkill, hnShowSkill, hnJobsSkill, hnSearchSkill, hnUserSkill, hnReadSkill, wikiSearchSkill, wikiSummarySkill, wikiRandomSkill, wikiTrendingSkill, wikiPageSkill, yfQuoteSkill, zhSearchSkill, zhHotSkill, zhRecommendSkill, zhQuestionSkill, zhAnswerDetailSkill, zhAnswerCommentsSkill, bbcNewsSkill, bbcTopicSkill, bbgMainSkill, bbgMarketsSkill, bbgEconomicsSkill, bbgIndustriesSkill, bbgTechSkill, bbgPoliticsSkill, bbgBusinessweekSkill, bbgOpinionsSkill, bbgFeedsSkill, bbgArticleSkill, medSearchSkill, medTagSkill, medFeedSkill, medUserSkill, subSearchSkill, subPublicationSkill, subFeedSkill, wbHotSkill, wbSearchSkill, wbFeedSkill, wbUserSkill, wbPostSkill, wbCommentsSkill, kr36NewsSkill, kr36HotSkill, kr36SearchSkill, kr36ArticleSkill, dbSearchSkill, dbMovieHotSkill, dbBookHotSkill, dbTop250Skill, dbPhotosSkill, sfNewsSkill, sfRollingNewsSkill, sfStockSkill, jkFeedSkill, jkSearchSkill, xqSearchSkill, xqHotSkill, xqHotStockSkill, xqStockSkill, xqCommentsSkill, xqKlineSkill, xqEarningsDateSkill, xyzPodcastSkill, xyzPodcastEpisodesSkill, xyzEpisodeSkill, fbSearchSkill, fbProfileSkill, fbEventsSkill, wrSearchSkill, wrRankingSkill, wrBookSkill, ctSearchSkill, ctHotelSuggestSkill, ctHotelSearchSkill, ctFlightSkill, ycCompaniesSkill, ihProductsSkill, ksDiscoverSkill, igExploreSkill, } from "./opencli/platforms.js";
147
147
  // Codex Desktop generation skills
148
148
  import { codexImageGenerateSkill } from "./codex/image-generate.js";
149
149
  // ChatGPT Desktop skills
@@ -155,6 +155,10 @@ import { geminiImageGenerateSkill } from "./gemini/image-generate.js";
155
155
  // Google Labs Flow skills
156
156
  import { flowVideoGenerateSkill } from "./flow/video-generate.js";
157
157
  import { flowImageGenerateSkill } from "./flow/image-generate.js";
158
+ // Apify-equivalent scrapers — Amazon / Google Maps / Web Scraper / web read.
159
+ import { amazonProductSkill, amazonOfferSkill, amazonSearchSkill, ggMapsSkill, webScrapeSkill, webReadSkill, } from "./opencli/scrapers.js";
160
+ // LinkedIn lead-gen / Sales Navigator (read-only, queries others' public data).
161
+ import { liPeopleSearchSkill, liSalesnavSearchSkill, liProfileSkill, liProfileExperienceSkill, liProfileProjectsSkill, liPostsSkill, liJobDetailSkill, } from "./opencli/linkedin-salesnav.js";
158
162
  // `_unimplemented.ts` kept for future stub skills; not used in the
159
163
  // registry today.
160
164
  /**
@@ -356,6 +360,10 @@ export const SKILL_REGISTRY = {
356
360
  "hn.search": hnSearchSkill,
357
361
  "hn.user": hnUserSkill,
358
362
  "hn.read": hnReadSkill,
363
+ "yc.companies": ycCompaniesSkill,
364
+ "ih.products": ihProductsSkill,
365
+ "ks.discover": ksDiscoverSkill,
366
+ "igg.explore": igExploreSkill,
359
367
  "wiki.search": wikiSearchSkill,
360
368
  "wiki.summary": wikiSummarySkill,
361
369
  "wiki.random": wikiRandomSkill,
@@ -443,10 +451,60 @@ export const SKILL_REGISTRY = {
443
451
  // Google Labs Flow — provider drives labs.google/fx/tools/flow via bnbot.
444
452
  "flow.video_generate": flowVideoGenerateSkill,
445
453
  "flow.image_generate": flowImageGenerateSkill,
454
+ // Apify-equivalent scrapers — run in the operator's real logged-in Chrome
455
+ // (residential IP + login state). Skill ids match hub routing:
456
+ // /amazon/product → amazon.product, /web/scrape → web.scrape,
457
+ // /web/read → web.read, /google/maps → gg.maps.
458
+ "amazon.product": amazonProductSkill,
459
+ "amazon.offer": amazonOfferSkill,
460
+ "amazon.search": amazonSearchSkill,
461
+ "gg.maps": ggMapsSkill,
462
+ "web.scrape": webScrapeSkill,
463
+ "web.read": webReadSkill,
464
+ // LinkedIn lead-gen / Sales Navigator — read-only, queries others' public
465
+ // data. /linkedin/{action} → li.{action} via the hub catalog router.
466
+ // salesnav_search needs a Sales Navigator subscription on the provider.
467
+ "li.people_search": liPeopleSearchSkill,
468
+ "li.salesnav_search": liSalesnavSearchSkill,
469
+ "li.profile": liProfileSkill,
470
+ "li.profile_experience": liProfileExperienceSkill,
471
+ "li.profile_projects": liProfileProjectsSkill,
472
+ "li.posts": liPostsSkill,
473
+ "li.job_detail": liJobDetailSkill,
446
474
  };
447
475
  export function listSkills() {
448
476
  return Object.keys(SKILL_REGISTRY);
449
477
  }
478
+ /**
479
+ * Skills now served directly by the SpareAPI backend — plain-HTTP public APIs
480
+ * (Y Combinator / IndieHackers / Hacker News) that the gateway fetches itself
481
+ * instead of dispatching to operators. The handlers stay registered, so an
482
+ * explicit `SKILLS=yc.companies,...` can still opt in, but operators DON'T
483
+ * advertise them by default: the hub no longer routes these to operators, and
484
+ * advertising them would just invite redundant single-fetch jobs.
485
+ *
486
+ * Browser-walled platforms (Kickstarter `ks.*` / Indiegogo `igg.*`, behind
487
+ * Cloudflare) are NOT here — they still need a real operator Chrome, so they
488
+ * keep being advertised. Other public read surfaces (wiki / bbc / bloomberg /
489
+ * stackoverflow / v2ex …) also stay, since the backend doesn't fetch them yet.
490
+ */
491
+ export const DIRECT_SERVED_SKILLS = new Set([
492
+ "yc.companies",
493
+ "ih.products",
494
+ "hn.top",
495
+ "hn.new",
496
+ "hn.best",
497
+ "hn.ask",
498
+ "hn.show",
499
+ "hn.jobs",
500
+ "hn.search",
501
+ "hn.user",
502
+ "hn.read",
503
+ ]);
504
+ /** Skills an operator advertises when the `SKILLS` env var is unset. */
505
+ export function defaultAdvertiseSkills() {
506
+ return Object.keys(SKILL_REGISTRY).filter((s) => !DIRECT_SERVED_SKILLS.has(s));
507
+ }
450
508
  export function getSkill(skillId) {
451
509
  return SKILL_REGISTRY[skillId];
452
510
  }
@@ -1,3 +1,8 @@
1
1
  export type FlagValue = string | number | boolean | undefined;
2
2
  export declare function bnbotCommand(base: string[], positional?: string[], flags?: Record<string, FlagValue>): Promise<unknown>;
3
+ /**
4
+ * Run an opencli command that emits raw text (e.g. `web read --stdout true`
5
+ * prints Markdown, not JSON). Returns stdout verbatim — no JSON.parse.
6
+ */
7
+ export declare function opencliText(args: string[]): Promise<string>;
3
8
  export declare function opencliCommand(base: string[], positional?: string[], flags?: Record<string, FlagValue>): Promise<unknown>;
@@ -37,6 +37,27 @@ export async function bnbotCommand(base, positional = [], flags = {}) {
37
37
  throw err;
38
38
  }
39
39
  }
40
+ /**
41
+ * Run an opencli command that emits raw text (e.g. `web read --stdout true`
42
+ * prints Markdown, not JSON). Returns stdout verbatim — no JSON.parse.
43
+ */
44
+ export async function opencliText(args) {
45
+ const rawBin = process.env.OPENCLI_CLI || "opencli";
46
+ const bin = rawBin.endsWith(".js") ? process.execPath : rawBin;
47
+ const finalArgs = rawBin.endsWith(".js") ? [rawBin, ...args] : args;
48
+ try {
49
+ const { stdout } = await exec(bin, finalArgs, { maxBuffer: MAX_BUFFER, timeout: TIMEOUT_MS });
50
+ if (!stdout.trim())
51
+ throw new Error("opencli returned empty stdout");
52
+ return stdout;
53
+ }
54
+ catch (err) {
55
+ const e = err;
56
+ if (e.stderr && e.stderr.trim())
57
+ throw new Error(`opencli failed: ${e.stderr.trim()}`);
58
+ throw err;
59
+ }
60
+ }
40
61
  export async function opencliCommand(base, positional = [], flags = {}) {
41
62
  const args = [...base, ...positional];
42
63
  for (const [name, value] of Object.entries(flags)) {
@@ -0,0 +1,7 @@
1
+ export declare const liPeopleSearchSkill: import("../../types.js").SkillHandler;
2
+ export declare const liSalesnavSearchSkill: import("../../types.js").SkillHandler;
3
+ export declare const liProfileSkill: import("../../types.js").SkillHandler;
4
+ export declare const liProfileExperienceSkill: import("../../types.js").SkillHandler;
5
+ export declare const liProfileProjectsSkill: import("../../types.js").SkillHandler;
6
+ export declare const liPostsSkill: import("../../types.js").SkillHandler;
7
+ export declare const liJobDetailSkill: import("../../types.js").SkillHandler;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * LinkedIn lead-gen / Sales Navigator skills — the highest-value, hardest-to-
3
+ * scrape surface (Apify charges $3-10/1K and data-center IPs get blocked fast).
4
+ * These run in the operator's real logged-in Chrome via **bnbot** (extension),
5
+ * so they carry a genuine session and residential IP.
6
+ *
7
+ * Migrated from opencli → bnbot (2026-06-07). bnbot takes url/keywords as a
8
+ * positional arg (not a --profile-url flag), and the profile command is
9
+ * `linkedin profile` (opencli called it `profile-read`).
10
+ *
11
+ * SAFETY: only commands that query OTHER people / public data are exposed.
12
+ * The operator's OWN private surface and all write ops are NOT registered.
13
+ *
14
+ * Skill ids are `li.{action}`; the hub's catalog router maps
15
+ * /linkedin/{action} → li.{action} automatically (no hub change needed).
16
+ */
17
+ import { bnbotCommand } from "./_bnbot.js";
18
+ import { makeOpenCliSkill, num, reqStr } from "./_skill.js";
19
+ const limit = (i, fb) => num(i, ["limit", "count"]) ?? fb;
20
+ const profileUrl = (i) => reqStr(i, ["profile_url", "profileUrl", "url"], "profile_url");
21
+ const keywords = (i) => reqStr(i, ["query", "keywords", "keyword", "q"], "query");
22
+ // People search — B2B lead-gen core (standard LinkedIn search).
23
+ export const liPeopleSearchSkill = makeOpenCliSkill("linkedin people-search", (i) => bnbotCommand(["linkedin", "people-search"], [keywords(i)], { limit: limit(i) }));
24
+ // Sales Navigator search — premium lead-gen. Requires the provider account to
25
+ // have a Sales Navigator subscription.
26
+ export const liSalesnavSearchSkill = makeOpenCliSkill("linkedin salesnav-search", (i) => bnbotCommand(["linkedin", "salesnav-search"], [keywords(i)], { limit: limit(i) }));
27
+ // Profile read — full profile of a target person (bnbot: `linkedin profile <url>`).
28
+ export const liProfileSkill = makeOpenCliSkill("linkedin profile", (i) => bnbotCommand(["linkedin", "profile"], [profileUrl(i)]));
29
+ // A target's work experience.
30
+ export const liProfileExperienceSkill = makeOpenCliSkill("linkedin profile-experience", (i) => bnbotCommand(["linkedin", "profile-experience"], [profileUrl(i)]));
31
+ // A target's projects.
32
+ export const liProfileProjectsSkill = makeOpenCliSkill("linkedin profile-projects", (i) => bnbotCommand(["linkedin", "profile-projects"], [profileUrl(i)]));
33
+ // A target's recent posts.
34
+ export const liPostsSkill = makeOpenCliSkill("linkedin posts", (i) => bnbotCommand(["linkedin", "posts"], [profileUrl(i)], { limit: limit(i) }));
35
+ // Job posting detail.
36
+ export const liJobDetailSkill = makeOpenCliSkill("linkedin job-detail", (i) => bnbotCommand(["linkedin", "job-detail"], [reqStr(i, ["job_url", "jobUrl", "url"], "job_url")]));
@@ -13,6 +13,10 @@ export declare const hnJobsSkill: import("../../types.js").SkillHandler;
13
13
  export declare const hnSearchSkill: import("../../types.js").SkillHandler;
14
14
  export declare const hnUserSkill: import("../../types.js").SkillHandler;
15
15
  export declare const hnReadSkill: import("../../types.js").SkillHandler;
16
+ export declare const ycCompaniesSkill: import("../../types.js").SkillHandler;
17
+ export declare const ihProductsSkill: import("../../types.js").SkillHandler;
18
+ export declare const ksDiscoverSkill: import("../../types.js").SkillHandler;
19
+ export declare const igExploreSkill: import("../../types.js").SkillHandler;
16
20
  export declare const wikiSearchSkill: import("../../types.js").SkillHandler;
17
21
  export declare const wikiSummarySkill: import("../../types.js").SkillHandler;
18
22
  export declare const wikiRandomSkill: import("../../types.js").SkillHandler;
@@ -41,6 +41,11 @@ export const hnReadSkill = makeOpenCliSkill("hackernews read", (i) => bnbotComma
41
41
  replies: num(i, ["replies"]),
42
42
  "max-length": num(i, ["maxLength", "max_length"]),
43
43
  }));
44
+ // ── Lead-gen sources (SpareAPI customer acquisition: YC / IH / KS / IGG) ──
45
+ export const ycCompaniesSkill = makeOpenCliSkill("ycombinator companies", (i) => bnbotCommand(["yc", "companies"], [], { limit: num(i, ["limit", "count"]), batches: str(i, ["batches"]) }));
46
+ export const ihProductsSkill = makeOpenCliSkill("indiehackers products", (i) => bnbotCommand(["indiehackers", "products"], [], { limit: num(i, ["limit", "count"]) }));
47
+ export const ksDiscoverSkill = makeOpenCliSkill("kickstarter discover", (i) => bnbotCommand(["kickstarter", "discover"], [], { limit: num(i, ["limit", "count"]) }));
48
+ export const igExploreSkill = makeOpenCliSkill("indiegogo explore", (i) => bnbotCommand(["indiegogo", "explore"], [], { limit: num(i, ["limit", "count"]) }));
44
49
  export const wikiSearchSkill = makeOpenCliSkill("wikipedia search", (i) => bnbotCommand(["wikipedia", "search"], [query(i)], { limit: limit(i), lang: str(i, ["lang", "language"]) }));
45
50
  export const wikiSummarySkill = makeOpenCliSkill("wikipedia summary", (i) => bnbotCommand(["wikipedia", "summary"], [reqStr(i, ["title", "page"], "title")], { lang: str(i, ["lang", "language"]) }));
46
51
  export const wikiRandomSkill = makeOpenCliSkill("wikipedia random", (i) => bnbotCommand(["wikipedia", "random"], [], { lang: str(i, ["lang", "language"]) }));
@@ -0,0 +1,6 @@
1
+ export declare const amazonProductSkill: import("../../types.js").SkillHandler;
2
+ export declare const amazonOfferSkill: import("../../types.js").SkillHandler;
3
+ export declare const amazonSearchSkill: import("../../types.js").SkillHandler;
4
+ export declare const ggMapsSkill: import("../../types.js").SkillHandler;
5
+ export declare const webScrapeSkill: import("../../types.js").SkillHandler;
6
+ export declare const webReadSkill: import("../../types.js").SkillHandler;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Apify-equivalent scraper skills — Amazon, Google Maps, generic Web Scraper,
3
+ * and Website Content Crawler. These bridge the operator's **bnbot** commands
4
+ * (which run in the operator's real logged-in Chrome via the extension, so they
5
+ * see residential IP + login state) to the SpareAPI skill surface, letting
6
+ * buyers call them like any RapidAPI actor.
7
+ *
8
+ * Migrated from opencli → bnbot (2026-06-07). Note bnbot takes url/input as a
9
+ * positional arg, not a --url flag like opencli did.
10
+ *
11
+ * Skill ids match the hub's routing:
12
+ * /amazon/product → amazon.product, /amazon/search → amazon.search,
13
+ * /web/scrape → web.scrape, /web/read → web.read, /google/maps → gg.maps.
14
+ */
15
+ import { bnbotCommand, opencliCommand } from "./_bnbot.js";
16
+ import { makeOpenCliSkill, num, reqStr, str } from "./_skill.js";
17
+ const limit = (i, fallback) => num(i, ["limit", "count"]) ?? fallback;
18
+ // --- Amazon (Apify Amazon Scraper equivalent) ---
19
+ export const amazonProductSkill = makeOpenCliSkill("amazon product", (i) => bnbotCommand(["amazon", "product"], [reqStr(i, ["input", "asin", "url", "query"], "input")]));
20
+ // bnbot has no `amazon offer` yet — keep this one on opencli until migrated.
21
+ export const amazonOfferSkill = makeOpenCliSkill("amazon offer", (i) => opencliCommand(["amazon", "offer"], [reqStr(i, ["input", "asin", "url"], "input")]));
22
+ export const amazonSearchSkill = makeOpenCliSkill("amazon search", (i) => bnbotCommand(["amazon", "search"], [reqStr(i, ["query", "keyword", "q"], "query")], { limit: limit(i) }));
23
+ // --- Google Maps (Apify Google Maps Scraper equivalent — their largest, 361K) ---
24
+ export const ggMapsSkill = makeOpenCliSkill("google maps", (i) => bnbotCommand(["google", "maps"], [reqStr(i, ["query", "keyword", "q"], "query")], { limit: limit(i) }));
25
+ // --- Web Scraper (Apify Web Scraper equivalent — URL + CSS selectors → JSON) ---
26
+ // bnbot: `web scrape <url> --selectors <json> --container <sel>`.
27
+ export const webScrapeSkill = makeOpenCliSkill("web scrape", (i) => {
28
+ const selectors = i.selectors;
29
+ return bnbotCommand(["web", "scrape"], [reqStr(i, ["url"], "url")], {
30
+ selectors: typeof selectors === "string" ? selectors : JSON.stringify(selectors ?? {}),
31
+ container: str(i, ["container"]),
32
+ wait: num(i, ["wait"]),
33
+ });
34
+ });
35
+ // --- Website Content Crawler (Apify equivalent — any page → main content) ---
36
+ // bnbot: `web read <url>` returns { url, title, text } JSON directly.
37
+ export const webReadSkill = makeOpenCliSkill("web read", (i) => bnbotCommand(["web", "read"], [reqStr(i, ["url"], "url")]));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawmoney",
3
- "version": "0.17.46",
3
+ "version": "0.17.47",
4
4
  "description": "ClawMoney CLI -- Earn rewards with your AI agent",
5
5
  "type": "module",
6
6
  "bin": {