@sellable/install 0.1.219 → 0.1.221

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 CHANGED
@@ -35,8 +35,9 @@ Campaign creation, foundation memory, content capture/ideation, and post
35
35
  drafting run inside Claude Code or Codex, where the Sellable MCP tools and
36
36
  approval flows are available.
37
37
 
38
- Install is auth-free by default. If you do not pass a token, the agent handles
39
- Sellable sign-in on the first campaign run with a magic-link handoff.
38
+ Install is auth-free by default. The normal path is first-run login: launch a
39
+ Sellable workflow in Claude Code or Codex and the agent handles Sellable
40
+ sign-in with a browser magic-link handoff.
40
41
 
41
42
  The installer uses package stdio MCP by default:
42
43
 
@@ -64,14 +65,15 @@ verification in one path:
64
65
  curl -fsSL "https://app.sellable.dev/api/v2/cli/install" | sh
65
66
  ```
66
67
 
67
- For CI/scripted installs, get a Sellable API token from:
68
+ For scripted fallback after browser login, paste the command shown by Sellable:
68
69
 
69
- ```text
70
- https://app.sellable.dev/settings
70
+ ```bash
71
+ sellable auth set <token> --workspace-id <workspace_id>
71
72
  ```
72
73
 
73
- Then pass it with `--token` / `SELLABLE_TOKEN` plus `--workspace-id` /
74
- `SELLABLE_WORKSPACE_ID`.
74
+ For CI/env-only installs, operators can still pass `--token` / `SELLABLE_TOKEN`
75
+ plus `--workspace-id` / `SELLABLE_WORKSPACE_ID`. Do not use env vars as the
76
+ primary human setup path.
75
77
 
76
78
  Auth is stored once at:
77
79
 
@@ -13,6 +13,7 @@ import {
13
13
  import { homedir } from "node:os";
14
14
  import { dirname, join, relative } from "node:path";
15
15
  import { stdout as output } from "node:process";
16
+ import { createInterface } from "node:readline/promises";
16
17
  import { fileURLToPath } from "node:url";
17
18
  import {
18
19
  REQUIRED_SELLABLE_MCP_TOOLS,
@@ -22,6 +23,7 @@ import {
22
23
  const DEFAULT_API_URL = "https://app.sellable.dev";
23
24
  const DEFAULT_SERVER_PACKAGE =
24
25
  process.env.SELLABLE_MCP_PACKAGE || "@sellable/mcp@latest";
26
+ const CODEX_INSTALL_URL = "https://chatgpt.com/codex/install";
25
27
 
26
28
  function getInstallVersion() {
27
29
  try {
@@ -175,6 +177,8 @@ Options:
175
177
  --hosted-url <url> Hosted MCP URL for --server hosted.
176
178
  --verbose Print every file write and shell command.
177
179
  --dry-run Print actions without writing or running host commands.
180
+ --install-codex-cli Install Codex CLI without prompting if it is missing or blocked.
181
+ --no-install-codex-cli Do not prompt to install Codex CLI; install Desktop config only when possible.
178
182
  --verify-only Verify installed host config where possible.
179
183
  --json Print machine-readable verification JSON.
180
184
  --artifact <path> Write verification JSON to a file.
@@ -247,6 +251,7 @@ function parseArgs(argv) {
247
251
  json: false,
248
252
  artifactPath: process.env.SELLABLE_VERIFY_ARTIFACT || "",
249
253
  verbose: false,
254
+ codexCliInstall: codexCliInstallPreference(),
250
255
  };
251
256
 
252
257
  for (let i = 0; i < argv.length; i += 1) {
@@ -280,6 +285,10 @@ function parseArgs(argv) {
280
285
  opts.hostedUrl = next();
281
286
  } else if (arg === "--dry-run") {
282
287
  opts.dryRun = true;
288
+ } else if (arg === "--install-codex-cli") {
289
+ opts.codexCliInstall = "install";
290
+ } else if (arg === "--no-install-codex-cli") {
291
+ opts.codexCliInstall = "skip";
283
292
  } else if (arg === "--verify-only") {
284
293
  opts.verifyOnly = true;
285
294
  } else if (arg === "--json") {
@@ -303,6 +312,14 @@ function parseArgs(argv) {
303
312
  return opts;
304
313
  }
305
314
 
315
+ function codexCliInstallPreference() {
316
+ const raw = process.env.SELLABLE_INSTALL_CODEX_CLI || "";
317
+ const value = raw.trim().toLowerCase();
318
+ if (["1", "true", "yes", "y", "install"].includes(value)) return "install";
319
+ if (["0", "false", "no", "n", "skip"].includes(value)) return "skip";
320
+ return "ask";
321
+ }
322
+
306
323
  function redact(value) {
307
324
  if (!value) return "";
308
325
  if (value.length <= 10) return "[redacted]";
@@ -360,6 +377,175 @@ function commandExistsDetails(command, platform = process.platform) {
360
377
  };
361
378
  }
362
379
 
380
+ function summarizeSpawnFailure(result) {
381
+ const errorMessage = result.error?.message || "";
382
+ const stderr = (result.stderr || "").trim();
383
+ const stdout = (result.stdout || "").trim();
384
+ return (
385
+ errorMessage ||
386
+ stderr ||
387
+ stdout ||
388
+ (Number.isInteger(result.status) ? `exit ${result.status}` : "unknown failure")
389
+ );
390
+ }
391
+
392
+ function codexCliStatus(opts = {}) {
393
+ const details = commandExistsDetails("codex");
394
+ if (!details.exists) {
395
+ return {
396
+ exists: false,
397
+ usable: false,
398
+ reason: "Codex CLI not found on PATH",
399
+ details,
400
+ };
401
+ }
402
+ if (opts.dryRun) {
403
+ return {
404
+ exists: true,
405
+ usable: true,
406
+ reason: "dry-run",
407
+ details,
408
+ };
409
+ }
410
+
411
+ const result = spawnSync("codex", ["mcp", "--help"], {
412
+ encoding: "utf8",
413
+ stdio: "pipe",
414
+ timeout: 10000,
415
+ });
416
+ if (result.status === 0) {
417
+ return {
418
+ exists: true,
419
+ usable: true,
420
+ reason: "Codex CLI responded to `codex mcp --help`",
421
+ details,
422
+ };
423
+ }
424
+
425
+ return {
426
+ exists: true,
427
+ usable: false,
428
+ reason: `Codex CLI found but not usable: ${summarizeSpawnFailure(result)}`,
429
+ details,
430
+ };
431
+ }
432
+
433
+ function codexDesktopLikelyInstalled(status = null) {
434
+ if (process.env.SELLABLE_INSTALL_ASSUME_CODEX_DESKTOP === "1") return true;
435
+ const home = codexHome();
436
+ const probes = [
437
+ status?.details?.stdout || "",
438
+ process.env.PATH || "",
439
+ ].join("\n");
440
+
441
+ if (/WindowsApps[\\/]+codex\.exe/i.test(probes)) return true;
442
+ if (existsSync(join(home, "config.toml"))) return true;
443
+ if (existsSync(join(home, "plugins", ".plugin-appserver"))) return true;
444
+ if (existsSync(join(home, "plugins", "cache"))) return true;
445
+ if (process.platform === "darwin" && existsSync("/Applications/Codex.app")) {
446
+ return true;
447
+ }
448
+ return false;
449
+ }
450
+
451
+ function isNonInteractiveInstall() {
452
+ return (
453
+ process.env.CI === "true" ||
454
+ process.env.SELLABLE_NON_INTERACTIVE === "1" ||
455
+ process.env.SELLABLE_NON_INTERACTIVE === "true" ||
456
+ process.env.SELLABLE_NON_INTERACTIVE === "yes"
457
+ );
458
+ }
459
+
460
+ async function askYesNo(question, defaultAnswer = false) {
461
+ if (!process.stdin.isTTY || !output.isTTY || isNonInteractiveInstall()) {
462
+ return null;
463
+ }
464
+ const suffix = defaultAnswer ? " [Y/n] " : " [y/N] ";
465
+ const rl = createInterface({
466
+ input: process.stdin,
467
+ output: process.stdout,
468
+ });
469
+ try {
470
+ const answer = (await rl.question(`${question}${suffix}`)).trim().toLowerCase();
471
+ if (!answer) return defaultAnswer;
472
+ return ["y", "yes"].includes(answer);
473
+ } finally {
474
+ rl.close();
475
+ }
476
+ }
477
+
478
+ function codexInstallCommand(platform = process.platform) {
479
+ const override = process.env.SELLABLE_CODEX_INSTALL_COMMAND;
480
+ if (override) {
481
+ return isWindowsPlatform(platform)
482
+ ? ["cmd.exe", ["/d", "/s", "/c", override]]
483
+ : ["sh", ["-c", override]];
484
+ }
485
+ if (isWindowsPlatform(platform)) {
486
+ return [
487
+ "powershell.exe",
488
+ [
489
+ "-NoProfile",
490
+ "-ExecutionPolicy",
491
+ "Bypass",
492
+ "-Command",
493
+ `$env:CODEX_NON_INTERACTIVE='1'; irm ${CODEX_INSTALL_URL}.ps1 | iex`,
494
+ ],
495
+ ];
496
+ }
497
+ return [
498
+ "sh",
499
+ ["-c", `curl -fsSL ${CODEX_INSTALL_URL}.sh | CODEX_NON_INTERACTIVE=1 sh`],
500
+ ];
501
+ }
502
+
503
+ function codexManualInstallCommand(platform = process.platform) {
504
+ return isWindowsPlatform(platform)
505
+ ? `$env:CODEX_NON_INTERACTIVE=1; irm ${CODEX_INSTALL_URL}.ps1 | iex`
506
+ : `curl -fsSL ${CODEX_INSTALL_URL}.sh | sh`;
507
+ }
508
+
509
+ async function maybeInstallCodexCli(status, opts) {
510
+ if (opts.dryRun) return false;
511
+ if (opts.codexCliInstall === "skip") {
512
+ logWarn(`${status.reason}. Skipping Codex CLI install by request.`);
513
+ return false;
514
+ }
515
+
516
+ let shouldInstall = opts.codexCliInstall === "install";
517
+ if (!shouldInstall) {
518
+ const answer = await askYesNo(
519
+ `${status.reason}. Install the Codex CLI now?`,
520
+ false
521
+ );
522
+ if (answer === null) {
523
+ logWarn(
524
+ `${status.reason}. Non-interactive shell; skipping Codex CLI install. Re-run with --install-codex-cli to install it.`
525
+ );
526
+ return false;
527
+ }
528
+ shouldInstall = answer;
529
+ }
530
+
531
+ if (!shouldInstall) {
532
+ logWarn(`${status.reason}. Continuing with Codex Desktop plugin setup only.`);
533
+ return false;
534
+ }
535
+
536
+ const [command, args] = codexInstallCommand();
537
+ logStep("Installing Codex CLI using the official Codex installer...");
538
+ try {
539
+ run(command, args, opts);
540
+ return true;
541
+ } catch (err) {
542
+ logWarn(
543
+ `Codex CLI install failed: ${err instanceof Error ? err.message : String(err)}`
544
+ );
545
+ return false;
546
+ }
547
+ }
548
+
363
549
  function shellQuote(value) {
364
550
  return `'${String(value).replace(/'/g, `'\\''`)}'`;
365
551
  }
@@ -419,13 +605,26 @@ async function loadAuthIfPresent(opts) {
419
605
  return opts;
420
606
  }
421
607
 
608
+ function redactTokensForLog(value) {
609
+ if (Array.isArray(value)) {
610
+ return value.map((item) => redactTokensForLog(item));
611
+ }
612
+ if (value && typeof value === "object") {
613
+ return Object.fromEntries(
614
+ Object.entries(value).map(([key, item]) => [
615
+ key,
616
+ key.toLowerCase().includes("token") && typeof item === "string"
617
+ ? redact(item)
618
+ : redactTokensForLog(item),
619
+ ])
620
+ );
621
+ }
622
+ return value;
623
+ }
624
+
422
625
  function writeJson(path, data, opts) {
423
626
  if (VERBOSE) {
424
- const redacted = JSON.stringify(
425
- { ...data, token: redact(data.token) },
426
- null,
427
- 2
428
- );
627
+ const redacted = JSON.stringify(redactTokensForLog(data), null, 2);
429
628
  logVerbose(`${C.grey}Writing ${path}: ${redacted}${C.reset}`);
430
629
  }
431
630
  if (opts.dryRun) return;
@@ -442,6 +641,56 @@ function readExisting(path) {
442
641
  }
443
642
  }
444
643
 
644
+ function mergeAuthConfig(raw, auth) {
645
+ const existing =
646
+ raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
647
+ const authFields = {
648
+ token: auth.token,
649
+ activeWorkspaceId: auth.activeWorkspaceId,
650
+ apiUrl: auth.apiUrl,
651
+ };
652
+
653
+ if (existing.activeEnv && existing.environments) {
654
+ const activeEnv = existing.activeEnv;
655
+ const envConfig = existing.environments[activeEnv];
656
+ if (
657
+ !envConfig ||
658
+ typeof envConfig !== "object" ||
659
+ Array.isArray(envConfig)
660
+ ) {
661
+ throw new Error(
662
+ `Unknown active environment '${activeEnv}' in ${authPath()}`
663
+ );
664
+ }
665
+ return {
666
+ ...existing,
667
+ environments: {
668
+ ...existing.environments,
669
+ [activeEnv]: {
670
+ ...envConfig,
671
+ ...authFields,
672
+ },
673
+ },
674
+ };
675
+ }
676
+
677
+ return {
678
+ ...existing,
679
+ ...authFields,
680
+ };
681
+ }
682
+
683
+ function writeAuthSetConfig({ token, workspaceId, apiUrl, dryRun }) {
684
+ const configPath = authPath();
685
+ const raw = readExisting(configPath) || {};
686
+ const config = mergeAuthConfig(raw, {
687
+ token,
688
+ activeWorkspaceId: workspaceId || null,
689
+ apiUrl,
690
+ });
691
+ writeJson(configPath, config, { dryRun });
692
+ }
693
+
445
694
  function sellableHostEnvPath() {
446
695
  return join(homedir(), ".local", "sellable", "app-sellable-dev", ".env");
447
696
  }
@@ -742,6 +991,9 @@ const CREATE_CAMPAIGN_ALLOWED_TOOLS = [
742
991
  "mcp__sellable__record_campaign_review_batch",
743
992
  "mcp__sellable__queue_campaign_cells",
744
993
  "mcp__sellable__wait_for_campaign_processing",
994
+ "mcp__sellable__resolve_campaign_fill_route",
995
+ "mcp__sellable__get_campaign_refill_state",
996
+ "mcp__sellable__fill_campaign_horizon",
745
997
  "mcp__sellable__start_campaign_message_preparation",
746
998
  "mcp__sellable__get_campaign_message_preparation_status",
747
999
  "mcp__sellable__cancel_campaign_message_preparation",
@@ -2455,6 +2707,27 @@ function writeAuth(opts) {
2455
2707
  return { written: true, reused: false };
2456
2708
  }
2457
2709
 
2710
+ function resolveWindowsNpmCommand() {
2711
+ const explicit =
2712
+ process.env.SELLABLE_INSTALL_NPM_CMD_COMMAND ||
2713
+ process.env.SELLABLE_INSTALL_NPM_COMMAND;
2714
+ if (explicit) return explicit;
2715
+
2716
+ const nodeBin = process.env.SELLABLE_INSTALL_NODE_BIN || "";
2717
+ for (const name of ["npm.cmd", "npm"]) {
2718
+ const candidate = nodeBin ? join(nodeBin, name) : "";
2719
+ if (candidate && existsSync(candidate)) return candidate;
2720
+ }
2721
+
2722
+ if (isWindowsPlatform()) {
2723
+ const details = commandExistsDetails("npm.cmd");
2724
+ const first = details.stdout.split(/\r?\n/).find((line) => line.trim());
2725
+ if (details.exists && first) return first.trim();
2726
+ }
2727
+
2728
+ return packageManagerCommand("win32");
2729
+ }
2730
+
2458
2731
  function installSelfShim(opts) {
2459
2732
  const installDir =
2460
2733
  process.env.SELLABLE_INSTALL_DIR || join(homedir(), ".local", "bin");
@@ -2475,12 +2748,11 @@ exec ${shellQuote(npmCommand)} exec --yes --package ${shellQuote(INSTALL_PACKAGE
2475
2748
  isWindowsPlatform() || process.env.SELLABLE_INSTALL_WRITE_CMD_SHIM === "1";
2476
2749
  if (shouldWriteCmdShim) {
2477
2750
  const cmdPath = `${binPath}.cmd`;
2478
- const npmCmdCommand =
2479
- process.env.SELLABLE_INSTALL_NPM_CMD_COMMAND ||
2480
- process.env.SELLABLE_INSTALL_NPM_COMMAND ||
2481
- packageManagerCommand("win32");
2751
+ const npmCmdCommand = resolveWindowsNpmCommand();
2482
2752
  const cmdPathPrefix = nodeBin ? `set "PATH=${nodeBin};%PATH%"\r\n` : "";
2483
- const cmdShim = `@echo off\r\n${cmdPathPrefix}"${npmCmdCommand}" exec --yes --package ${INSTALL_PACKAGE_SPEC} -- sellable %*\r\n`;
2753
+ const cmdShim =
2754
+ `@echo off\r\n${cmdPathPrefix}` +
2755
+ `call "${npmCmdCommand}" exec --yes --package ${INSTALL_PACKAGE_SPEC} -- sellable %*\r\n`;
2484
2756
  writeFile(cmdPath, cmdShim, opts, 0o755);
2485
2757
  }
2486
2758
  }
@@ -2820,32 +3092,70 @@ function patchClaudeAlwaysLoad(opts) {
2820
3092
  }
2821
3093
  }
2822
3094
 
2823
- function installCodex(opts) {
2824
- if (!opts.dryRun && !commandExists("codex")) {
3095
+ function registerCodexCliMcp(opts) {
3096
+ try {
3097
+ if (opts.server !== "hosted") {
3098
+ run("codex", ["mcp", "remove", "sellable"], {
3099
+ ...opts,
3100
+ dryRun: opts.dryRun,
3101
+ allowFail: true,
3102
+ });
3103
+ }
3104
+ run("codex", codexMcpAddArgs(opts), opts);
3105
+ return true;
3106
+ } catch (err) {
3107
+ logWarn(
3108
+ `Codex CLI MCP registration skipped: ${err instanceof Error ? err.message : String(err)}`
3109
+ );
3110
+ return false;
3111
+ }
3112
+ }
3113
+
3114
+ async function installCodex(opts) {
3115
+ let cliStatus = codexCliStatus(opts);
3116
+ let desktopLikely = codexDesktopLikelyInstalled(cliStatus);
3117
+ let attemptedCliInstall = false;
3118
+
3119
+ if (!opts.dryRun && !cliStatus.usable) {
3120
+ attemptedCliInstall = await maybeInstallCodexCli(cliStatus, opts);
3121
+ if (attemptedCliInstall) {
3122
+ cliStatus = codexCliStatus(opts);
3123
+ desktopLikely = codexDesktopLikelyInstalled(cliStatus) || desktopLikely;
3124
+ if (!cliStatus.usable) {
3125
+ logWarn(
3126
+ `${cliStatus.reason}. Continuing with Codex Desktop plugin setup where possible.`
3127
+ );
3128
+ }
3129
+ }
3130
+ }
3131
+
3132
+ const shouldInstallDesktop =
3133
+ opts.host === "codex" ||
3134
+ cliStatus.usable ||
3135
+ desktopLikely ||
3136
+ attemptedCliInstall;
3137
+ if (!shouldInstallDesktop) {
2825
3138
  const message =
2826
- "Codex CLI not found. Install/login to Codex, then rerun: sellable --host codex";
3139
+ "Codex CLI not found and Codex Desktop was not detected. Install/login to Codex, then rerun: sellable --host codex";
2827
3140
  if (opts.host === "all") {
2828
3141
  logWarn(`Skipping Codex: ${message}`);
2829
3142
  return { installed: false };
2830
3143
  }
2831
3144
  throw new Error(message);
2832
3145
  }
3146
+
2833
3147
  if (!opts.dryRun) {
2834
3148
  mkdirSync(codexHome(), { recursive: true, mode: 0o700 });
2835
3149
  }
2836
- if (opts.server === "hosted") {
2837
- run("codex", codexMcpAddArgs(opts), opts);
2838
- const info = installCodexDesktopPlugin(opts);
2839
- return { installed: true, ...info };
3150
+
3151
+ const cliRegistered = cliStatus.usable ? registerCodexCliMcp(opts) : false;
3152
+ if (!cliRegistered && !opts.dryRun) {
3153
+ logWarn(
3154
+ "Codex CLI MCP registration did not run; writing Codex Desktop plugin and config directly."
3155
+ );
2840
3156
  }
2841
- run("codex", ["mcp", "remove", "sellable"], {
2842
- ...opts,
2843
- dryRun: opts.dryRun,
2844
- allowFail: true,
2845
- });
2846
- run("codex", codexMcpAddArgs(opts), opts);
2847
3157
  const info = installCodexDesktopPlugin(opts);
2848
- return { installed: true, ...info };
3158
+ return { installed: true, cliRegistered, ...info };
2849
3159
  }
2850
3160
 
2851
3161
  function requiredCheck(ok, label) {
@@ -2996,11 +3306,11 @@ async function verify(opts) {
2996
3306
  );
2997
3307
  }
2998
3308
  if (opts.host === "codex" || opts.host === "all") {
2999
- const hasCodexCli = commandExists("codex");
3309
+ const codexStatus = codexCliStatus(opts);
3000
3310
  checks.push(
3001
3311
  warningCheck(
3002
- hasCodexCli,
3003
- hasCodexCli ? "Codex CLI present" : "Codex CLI missing"
3312
+ codexStatus.usable,
3313
+ codexStatus.usable ? "Codex CLI usable" : codexStatus.reason
3004
3314
  )
3005
3315
  );
3006
3316
  const pluginPath = join(
@@ -3358,8 +3668,8 @@ function printNextSteps(installedHosts, authReused) {
3358
3668
  console.log("");
3359
3669
  printInstallAgentBox(
3360
3670
  "Install Codex",
3361
- "npm install -g @openai/codex",
3362
- "https://github.com/openai/codex"
3671
+ codexManualInstallCommand(),
3672
+ "https://developers.openai.com/codex/quickstart"
3363
3673
  );
3364
3674
  console.log("");
3365
3675
  console.log("");
@@ -3665,18 +3975,31 @@ async function main() {
3665
3975
  }
3666
3976
  const token = rawArgs[2];
3667
3977
  const authSetFlags = rawArgs.slice(3);
3668
- const dryRun = authSetFlags.includes("--dry-run");
3669
- const unknownAuthSetFlag = authSetFlags.find(
3670
- (arg) => arg !== "--dry-run"
3671
- );
3672
- if (unknownAuthSetFlag) {
3673
- console.error(`Unknown auth set option: ${unknownAuthSetFlag}`);
3978
+ let dryRun = false;
3979
+ let workspaceId = "";
3980
+ for (let i = 0; i < authSetFlags.length; i += 1) {
3981
+ const flag = authSetFlags[i];
3982
+ if (flag === "--dry-run") {
3983
+ dryRun = true;
3984
+ continue;
3985
+ }
3986
+ if (flag === "--workspace-id") {
3987
+ const value = authSetFlags[i + 1];
3988
+ if (!value || value.startsWith("--")) {
3989
+ console.error("Missing value for --workspace-id");
3990
+ process.exit(2);
3991
+ }
3992
+ workspaceId = value;
3993
+ i += 1;
3994
+ continue;
3995
+ }
3996
+ console.error(`Unknown auth set option: ${flag}`);
3674
3997
  process.exit(2);
3675
3998
  }
3676
3999
  if (!token) {
3677
4000
  console.error(
3678
- "Usage: sellable auth set <token>\n" +
3679
- "Get the token from the Sellable browser confirm page (after clicking the magic link)."
4001
+ "Usage: sellable auth set <token> [--workspace-id <id>]\n" +
4002
+ "Use this only when the Sellable browser login page shows a manual fallback command."
3680
4003
  );
3681
4004
  process.exit(2);
3682
4005
  }
@@ -3688,23 +4011,22 @@ async function main() {
3688
4011
  );
3689
4012
  process.exit(2);
3690
4013
  }
3691
- // Bypass writeAuth() its workspaceId guard early-returns on missing
3692
- // workspaceId, but on the auth-set path we don't have one yet (next MCP
3693
- // call hydrates it). Write the same shape directly.
4014
+ // Bypass writeAuth() because auth-set is a first-run browser fallback:
4015
+ // the workspace id is optional for legacy fallback pages, and existing
4016
+ // config metadata must be preserved.
3694
4017
  // Skip installSelfShim() — by definition the user has the shim already
3695
4018
  // (they invoked `sellable auth set` from it).
3696
4019
  const apiUrl = process.env.SELLABLE_API_URL || DEFAULT_API_URL;
3697
- writeJson(
3698
- authPath(),
3699
- { token, activeWorkspaceId: null, apiUrl },
3700
- { dryRun }
3701
- );
4020
+ writeAuthSetConfig({ token, workspaceId, apiUrl, dryRun });
3702
4021
  if (dryRun) {
3703
4022
  console.log(`Dry run: token would be saved to ${authPath()}`);
3704
4023
  } else {
3705
4024
  console.log(`✓ Token saved to ${authPath()}`);
3706
4025
  }
3707
4026
  console.log(` apiUrl: ${apiUrl}`);
4027
+ if (workspaceId) {
4028
+ console.log(` activeWorkspaceId: ${workspaceId}`);
4029
+ }
3708
4030
  console.log(` Continue in your agent:`);
3709
4031
  console.log(` Claude Code: /sellable:create-campaign`);
3710
4032
  console.log(` Claude Code: /sellable:foundation`);
@@ -3805,7 +4127,7 @@ async function main() {
3805
4127
  }
3806
4128
  }
3807
4129
  if (opts.host === "codex" || opts.host === "all") {
3808
- const result = installCodex(opts);
4130
+ const result = await installCodex(opts);
3809
4131
  if (result.installed) {
3810
4132
  installedHosts.push("Codex");
3811
4133
  }
@@ -28,12 +28,16 @@ export const REQUIRED_SELLABLE_MCP_TOOLS = [
28
28
  "get_campaign_messages_preview",
29
29
  "attach_sequence",
30
30
  "attach_recommended_sequence",
31
+ "resolve_campaign_fill_route",
32
+ "get_campaign_refill_state",
31
33
  "start_campaign_message_preparation",
32
34
  "get_campaign_message_preparation_status",
33
35
  "cancel_campaign_message_preparation",
34
36
  "start_campaign",
35
37
  ];
36
38
 
39
+ export const FORBIDDEN_SELLABLE_MCP_TOOLS = ["refill_campaign_sends"];
40
+
37
41
  export const CREATE_CAMPAIGN_SMOKE_CALLS = [
38
42
  {
39
43
  name: "get_auth_status",
@@ -57,6 +61,14 @@ export const CREATE_CAMPAIGN_SMOKE_CALLS = [
57
61
  limit: 1200,
58
62
  },
59
63
  },
64
+ {
65
+ name: "get_subskill_prompt",
66
+ arguments: {
67
+ subskillName: "refill-sends-workflow",
68
+ offset: 0,
69
+ limit: 1200,
70
+ },
71
+ },
60
72
  {
61
73
  name: "get_subskill_asset",
62
74
  arguments: {
@@ -308,6 +320,7 @@ function summarizeAttempt(result, attempt) {
308
320
  ok: result.ok === true,
309
321
  availableToolCount: result.availableTools?.length || 0,
310
322
  missingToolCount: result.missingTools?.length || 0,
323
+ forbiddenToolCount: result.forbiddenTools?.length || 0,
311
324
  error: result.error || null,
312
325
  createCampaignSmoke: result.createCampaignSmoke
313
326
  ? {
@@ -377,6 +390,7 @@ async function verifySellableMcpRuntimeOnce({
377
390
 
378
391
  let availableTools = [];
379
392
  let missingTools = [...requiredTools];
393
+ let forbiddenTools = [];
380
394
  let createCampaignSmoke = null;
381
395
  let error = null;
382
396
 
@@ -388,7 +402,19 @@ async function verifySellableMcpRuntimeOnce({
388
402
  });
389
403
  availableTools = toolList.tools.map((tool) => tool.name).sort();
390
404
  missingTools = missingRequiredTools(toolList.tools, requiredTools);
391
- if (missingTools.length === 0) {
405
+ forbiddenTools = availableTools.filter((tool) =>
406
+ FORBIDDEN_SELLABLE_MCP_TOOLS.includes(tool)
407
+ );
408
+ if (forbiddenTools.length > 0) {
409
+ createCampaignSmoke = {
410
+ ok: false,
411
+ missingTools: [],
412
+ calls: [],
413
+ error: `Forbidden MCP tool(s) exposed: ${forbiddenTools.join(", ")}`,
414
+ startedAt: new Date().toISOString(),
415
+ completedAt: new Date().toISOString(),
416
+ };
417
+ } else if (missingTools.length === 0) {
392
418
  createCampaignSmoke = await verifyCreateCampaignSmoke({
393
419
  client,
394
420
  availableTools,
@@ -418,13 +444,18 @@ async function verifySellableMcpRuntimeOnce({
418
444
  }
419
445
 
420
446
  return {
421
- ok: !error && missingTools.length === 0 && createCampaignSmoke?.ok === true,
447
+ ok:
448
+ !error &&
449
+ missingTools.length === 0 &&
450
+ forbiddenTools.length === 0 &&
451
+ createCampaignSmoke?.ok === true,
422
452
  skipped: false,
423
453
  command,
424
454
  args,
425
455
  requiredTools,
426
456
  availableTools,
427
457
  missingTools,
458
+ forbiddenTools,
428
459
  createCampaignSmoke,
429
460
  stderrTail: stderrLines,
430
461
  error: error ? redact(error) : null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sellable/install",
3
- "version": "0.1.219",
3
+ "version": "0.1.221",
4
4
  "type": "module",
5
5
  "description": "One-command installer for Sellable MCP in Claude Code and Codex",
6
6
  "bin": {
@@ -47,6 +47,9 @@ allowed-tools:
47
47
  - mcp__sellable__record_campaign_review_batch
48
48
  - mcp__sellable__queue_campaign_cells
49
49
  - mcp__sellable__wait_for_campaign_processing
50
+ - mcp__sellable__resolve_campaign_fill_route
51
+ - mcp__sellable__get_campaign_refill_state
52
+ - mcp__sellable__fill_campaign_horizon
50
53
  - mcp__sellable__start_campaign_message_preparation
51
54
  - mcp__sellable__get_campaign_message_preparation_status
52
55
  - mcp__sellable__cancel_campaign_message_preparation
@@ -113,27 +116,37 @@ source only when safe relaxation and same-source sampling would still fail or
113
116
  would pass bad-fit rows.
114
117
  The default path stays the existing first campaign-table execution slice:
115
118
  review the normal `reviewBatchLimit:15`, approve reviewed draft rows, then move
116
- to Settings/sequence/final greenlight. Only call
117
- `start_campaign_message_preparation` when the user explicitly asks for more
118
- prepared messages, a send count, or language like "fill up/load sends for these
119
- senders." Treat those requests as capacity-fill preparation: calculate the
120
- bounded target from sender capacity when needed, then let the preparation job
121
- queue pending `Enrich Prospect` cells, wait for ICP/rubric and Generate Message
122
- cells to cascade, and mark ready or approve only the target cohort. Do not
123
- count `checkedRows` as enriched rows; it is only the table cursor. Use
124
- `progress.enrichedRows`, `progress.needsEnrichRows`, `activeCellCount`,
125
- `preparedMessages`, `approvedRows`, target, estimated row budget remaining, and
126
- `stopReason` to explain progress. If the user says "prepare/generate X
127
- messages", set `targetPreparedMessages:X`, omit `maxRowsToCheck`, and keep
128
- `approvalMode:"mark_ready"`. The backend calibrates on at least 100 actually
129
- enriched rows, estimates the row budget from observed rubric/pass yield, caps
130
- `maxRowsToCheck` at 2500, then adapts later batches up to 250 rows while
131
- recalculating yield. If the user says "approve X messages", use
132
- `approvalMode:"approve"` but still do not launch. If the user says "schedule X
133
- sends" or asks to fill sender sends, use `approvalMode:"approve"` to approve
134
- exactly the bounded X-message cohort during preparation, then continue through
135
- sender, sequence, and final launch greenlight; the launch path must verify that
136
- bounded cohort and must not broad approve-all.
119
+ to Settings/sequence/final greenlight. For any plain post-mint fill request
120
+ such as "fill campaigns", "fill up", "refill sends", "max out sends", or
121
+ "load sends", hand off to the skill-led refill workflow:
122
+
123
+ ```text
124
+ get_subskill_prompt({ subskillName: "refill-sends-workflow" })
125
+ resolve_campaign_fill_route
126
+ get_campaign_refill_state
127
+ ```
128
+
129
+ Plain fill is not an alias for `fill_campaign_horizon` or campaign creation.
130
+ `fill_campaign_horizon` is evergreen-only and must not be used for regular
131
+ campaign refill. If route resolution returns `route:"ask_create"`, ask whether
132
+ to create a normal campaign or evergreen campaigns; campaign creation is never
133
+ the default response to plain fill. When more leads are needed, the refill
134
+ workflow must recommend same-campaign source-ladder replenishment; do not
135
+ create warm-post-engager side campaigns, on-demand campaigns, or unrelated
136
+ campaigns.
137
+
138
+ Treat active fills as capacity-fill preparation: calculate the bounded target
139
+ from sender capacity when needed, then use the refill workflow to decide source
140
+ replenishment, enrichment/prep, approval policy, and scheduler proof. Mutation
141
+ requires exact visible approval and a fresh `get_campaign_refill_state` reread.
142
+ If the user says "prepare/generate X messages", use message-prep primitives with
143
+ `targetPreparedMessages:X` and default `approvalMode:"mark_ready"`. If the user
144
+ says "approve X messages", use `approvalMode:"approve"` only for the bounded
145
+ cohort and still do not launch. If the user says "schedule X sends", approve
146
+ only when explicitly requested, then reread scheduled counts; if
147
+ scheduler-owned cells are not present, report prepared/approved/ready awaiting
148
+ scheduler instead of success. Final launch remains a separate explicit user
149
+ greenlight and must not broad approve-all.
137
150
  When approving reviewed draft rows in the campaign table, resolve the actual
138
151
  visible `Approved` cells with `select_campaign_cells({ columnRole: "approved",
139
152
  rowSelector: { type: "rowIds", rowIds } })` and `update_cell` those returned
@@ -891,7 +904,11 @@ updates.
891
904
 
892
905
  1. Call `mcp__sellable__get_auth_status({})`.
893
906
  2. If auth is not OK with `error.type === "config"` or `error.type === "auth"`,
894
- the user has not signed in yet. Run the FTUX magic-link handoff:
907
+ the user has not signed in yet. Run first-run login through the FTUX
908
+ magic-link handoff. If a browser page or tool guidance gives the user a
909
+ manual fallback, it must be
910
+ `sellable auth set <token> --workspace-id <workspace_id>`. Do not instruct
911
+ the user to hand-edit JSON auth config.
895
912
 
896
913
  a. Say to the user verbatim:
897
914
 
@@ -0,0 +1,54 @@
1
+ ---
2
+ name: refill-sends
3
+ description: Plan regular campaign and evergreen campaign send refill work through the skill-led refill workflow.
4
+ visibility: public
5
+ allowed-tools:
6
+ - mcp__sellable__get_subskill_prompt
7
+ - mcp__sellable__search_subskill_prompts
8
+ - mcp__sellable__resolve_campaign_fill_route
9
+ - mcp__sellable__get_campaign_refill_state
10
+ - mcp__sellable__fill_campaign_horizon
11
+ - mcp__sellable__get_campaign
12
+ - mcp__sellable__get_campaign_message_preparation_status
13
+ - mcp__sellable__start_campaign_message_preparation
14
+ - mcp__sellable__cancel_campaign_message_preparation
15
+ - mcp__sellable__import_leads
16
+ - mcp__sellable__wait_for_lead_list_ready
17
+ - mcp__sellable__confirm_lead_list
18
+ - mcp__sellable__search_signals
19
+ - mcp__sellable__fetch_post_engagers
20
+ - mcp__sellable__search_sales_nav
21
+ - mcp__sellable__lookup_sales_nav_filter
22
+ - mcp__sellable__search_prospeo
23
+ - mcp__sellable__search_prospeo_companies
24
+ - mcp__sellable__confirm_prospeo_company_accounts
25
+ - mcp__sellable__load_csv_linkedin_leads
26
+ - mcp__sellable__load_csv_domains
27
+ - mcp__sellable__list_dnc_entries
28
+ - mcp__sellable__load_csv_dnc_entries
29
+ - mcp__sellable__get_rows
30
+ - mcp__sellable__get_rows_minimal
31
+ - mcp__sellable__get_table_rows
32
+ ---
33
+
34
+ # Refill Sends
35
+
36
+ Use this public wrapper for plain operator requests such as "fill", "refill sends",
37
+ "max out sends", "load everyone up", or "fill horizon sends".
38
+
39
+ Load the internal workflow prompt before taking any operational step:
40
+
41
+ ```text
42
+ get_subskill_prompt({ subskillName: "refill-sends-workflow" })
43
+ ```
44
+
45
+ Then follow that workflow exactly. The default path is read-only research:
46
+ resolve the route, read target refill state, report the next safe step, and stop
47
+ before mutation unless the user has explicitly approved the exact workspace,
48
+ campaign/table/source ids, caps/dates, approval mode, expected side effects,
49
+ and stop/rollback condition.
50
+
51
+ Public concepts are regular campaign and evergreen campaign. Internal direct
52
+ campaign types are unsupported refill targets. `fill_campaign_horizon` is only a
53
+ legacy evergreen-only lower-level primitive after route and refill-state
54
+ evidence proves an evergreen target.