@sellable/install 0.1.303 → 0.1.305

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.
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env node
2
+ import { createHash } from "node:crypto";
2
3
  import { spawnSync } from "node:child_process";
3
4
  import {
5
+ chmodSync,
4
6
  cpSync,
5
7
  existsSync,
6
8
  mkdirSync,
@@ -11,10 +13,11 @@ import {
11
13
  writeFileSync,
12
14
  } from "node:fs";
13
15
  import { homedir } from "node:os";
14
- import { dirname, join, relative } from "node:path";
16
+ import { dirname, join, relative, resolve } from "node:path";
15
17
  import { stdout as output } from "node:process";
16
18
  import { createInterface } from "node:readline/promises";
17
19
  import { fileURLToPath } from "node:url";
20
+ import { parseDocument } from "yaml";
18
21
  import {
19
22
  REQUIRED_SELLABLE_MCP_TOOLS,
20
23
  verifySellableMcpRuntime,
@@ -167,14 +170,20 @@ Commands:
167
170
  Save the ask-first prompt-sharing preference.
168
171
  setup claude-permissions --allow-common-setup
169
172
  Add common Sellable Bash permissions to Claude settings.
173
+ hermes profile bootstrap Create a profile-local Hermes config, Sellable auth config,
174
+ configs dir, skills, and optional Slack .env.
170
175
  uninstall Remove Sellable host config and installed artifacts.
171
176
 
172
177
  Options:
173
- --host <host> claude, codex, or all. Default: all
178
+ --host <host> claude, codex, hermes, or all. Default: all
174
179
  --server <mode> package, local, or hosted. Default: package
175
180
  --token <token> Sellable API token. Also reads SELLABLE_TOKEN.
176
181
  --workspace-id <id> Sellable workspace id. Also reads SELLABLE_WORKSPACE_ID.
177
182
  --api-url <url> Sellable API URL. Default: ${DEFAULT_API_URL}
183
+ --sellable-config-path <path>
184
+ Profile-scoped Sellable config path. Also reads SELLABLE_CONFIG_PATH.
185
+ --sellable-configs-dir <path>
186
+ Profile-scoped Sellable configs/memory dir. Also reads SELLABLE_CONFIGS_DIR.
178
187
  --mcp-package <pkg> MCP server package. Default: ${DEFAULT_SERVER_PACKAGE}
179
188
  --local-command <cmd> Local MCP command for --server local.
180
189
  --hosted-url <url> Hosted MCP URL for --server hosted.
@@ -190,20 +199,33 @@ Options:
190
199
  Auth:
191
200
  Install is auth-free by default. Sign in happens on the first run of
192
201
  /sellable:create-campaign, /sellable:create-evergreen-campaigns,
193
- /sellable:foundation, /sellable:content, /sellable:create-post, or
194
- /sellable:refresh-sender-engagement, or /sellable:refill-sends in Claude Code or Codex,
202
+ /sellable:foundation, /sellable:content, /sellable:create-post,
203
+ /sellable:refresh-sender-engagement, or /sellable:refill-sends in Claude Code,
204
+ Codex, or Hermes,
195
205
  where the agent walks you through signup or sign-in and stores credentials
196
206
  in ~/.sellable/config.json.
197
207
 
198
208
  For CI/scripted installs, pass --token + --workspace-id or set
199
209
  SELLABLE_TOKEN + SELLABLE_WORKSPACE_ID and the installer will write the
200
- config file directly without prompting.
210
+ config file directly without prompting. For Hermes customer/admin profiles,
211
+ also pass --sellable-config-path or set SELLABLE_CONFIG_PATH so auth and MCP
212
+ runtime use the same profile-local config file.
213
+
214
+ Hermes profile bootstrap:
215
+ sellable hermes profile bootstrap --profile acme --profiles-root /srv/hermes/profiles --workspace-id ws_acme --token-file /run/secrets/sellable-token --json
216
+ sellable hermes profile bootstrap --profile-root "$HOME/.hermes/profiles/acme" --workspace-id ws_acme --json
217
+
218
+ Bootstrap writes <profileRoot>/config.yaml, <profileRoot>/sellable/config.json,
219
+ <profileRoot>/sellable/configs, Sellable Hermes skills, and optional
220
+ <profileRoot>/.env Slack token keys. Use customer-scoped Sellable credentials
221
+ for customer profiles; a shared admin token is weaker isolation and should
222
+ remain internal-only.
201
223
  `;
202
224
  }
203
225
 
204
226
  function printCreateCommandHint() {
205
227
  printBanner();
206
- console.log(` ${C.bold}Run Sellable from Claude Code or Codex.${C.reset}`);
228
+ console.log(` ${C.bold}Run Sellable from Claude Code, Codex, or Hermes.${C.reset}`);
207
229
  console.log("");
208
230
  console.log(
209
231
  ` ${C.grey}The terminal command installs and verifies Sellable. Campaign creation runs inside your agent session so the workflow can use MCP tools, live state, and approval gates.${C.reset}`
@@ -237,6 +259,18 @@ function printCreateCommandHint() {
237
259
  { label: "Refill", command: "$sellable:refill-sends" },
238
260
  ]);
239
261
  console.log("");
262
+ console.log("");
263
+ printAgentBox("Using Hermes?", "hermes", [
264
+ { label: "Campaign", command: "/sellable-create-campaign" },
265
+ { label: "A/B Test", command: "/sellable-create-ab-test" },
266
+ { label: "Evergreen", command: "/sellable-create-evergreen-campaigns" },
267
+ { label: "Foundation", command: "/sellable-foundation" },
268
+ { label: "Content", command: "/sellable-content" },
269
+ { label: "Post", command: "/sellable-create-post" },
270
+ { label: "Refresh", command: "/sellable-refresh-sender-engagement" },
271
+ { label: "Refill", command: "/sellable-refill-sends" },
272
+ ]);
273
+ console.log("");
240
274
  console.log(` ${"─".repeat(63)}`);
241
275
  console.log(` ${C.grey}If those commands are missing, run:${C.reset}`);
242
276
  console.log("");
@@ -249,11 +283,16 @@ function printCreateCommandHint() {
249
283
 
250
284
  function parseArgs(argv) {
251
285
  const opts = {
252
- host: process.env.SELLABLE_INSTALL_HOST || "all",
286
+ host:
287
+ process.env.SELLABLE_INSTALLER_HOST ||
288
+ process.env.SELLABLE_INSTALL_HOST ||
289
+ "all",
253
290
  server: process.env.SELLABLE_INSTALL_SERVER || "package",
254
291
  token: process.env.SELLABLE_TOKEN || "",
255
292
  workspaceId: process.env.SELLABLE_WORKSPACE_ID || "",
256
293
  apiUrl: process.env.SELLABLE_API_URL || DEFAULT_API_URL,
294
+ sellableConfigPath: process.env.SELLABLE_CONFIG_PATH || "",
295
+ sellableConfigsDir: process.env.SELLABLE_CONFIGS_DIR || "",
257
296
  mcpPackage: DEFAULT_SERVER_PACKAGE,
258
297
  localCommand: process.env.SELLABLE_MCP_LOCAL_COMMAND || "",
259
298
  hostedUrl: process.env.SELLABLE_MCP_HOSTED_URL || "",
@@ -288,6 +327,10 @@ function parseArgs(argv) {
288
327
  opts.workspaceId = next();
289
328
  } else if (arg === "--api-url") {
290
329
  opts.apiUrl = next();
330
+ } else if (arg === "--sellable-config-path") {
331
+ opts.sellableConfigPath = next();
332
+ } else if (arg === "--sellable-configs-dir") {
333
+ opts.sellableConfigsDir = next();
291
334
  } else if (arg === "--mcp-package") {
292
335
  opts.mcpPackage = normalizeWindowsPackageSpec(next());
293
336
  } else if (arg === "--local-command") {
@@ -313,12 +356,17 @@ function parseArgs(argv) {
313
356
  }
314
357
  }
315
358
 
316
- if (!["claude", "codex", "all"].includes(opts.host)) {
317
- throw new Error("--host must be claude, codex, or all");
359
+ if (!["claude", "codex", "hermes", "all"].includes(opts.host)) {
360
+ throw new Error("--host must be claude, codex, hermes, or all");
318
361
  }
319
362
  if (!["package", "local", "hosted"].includes(opts.server)) {
320
363
  throw new Error("--server must be package, local, or hosted");
321
364
  }
365
+ opts.sellableConfigPath = normalizeOptionalPath(opts.sellableConfigPath);
366
+ opts.sellableConfigsDir = normalizeOptionalPath(opts.sellableConfigsDir);
367
+ if (opts.sellableConfigPath && !opts.sellableConfigsDir) {
368
+ opts.sellableConfigsDir = join(dirname(opts.sellableConfigPath), "configs");
369
+ }
322
370
 
323
371
  return opts;
324
372
  }
@@ -576,8 +624,17 @@ function getMcpVersion() {
576
624
  return "latest";
577
625
  }
578
626
 
579
- function authPath() {
580
- return join(homedir(), ".sellable", "config.json");
627
+ function normalizeOptionalPath(value) {
628
+ const trimmed = String(value || "").trim();
629
+ return trimmed ? resolve(trimmed) : "";
630
+ }
631
+
632
+ function authPath(opts = {}) {
633
+ return (
634
+ normalizeOptionalPath(opts.sellableConfigPath) ||
635
+ normalizeOptionalPath(process.env.SELLABLE_CONFIG_PATH) ||
636
+ join(homedir(), ".sellable", "config.json")
637
+ );
581
638
  }
582
639
 
583
640
  function normalizeStoredAuth(raw) {
@@ -598,15 +655,15 @@ function normalizeStoredAuth(raw) {
598
655
  };
599
656
  }
600
657
 
601
- function readStoredAuth() {
602
- const raw = readExisting(authPath());
658
+ function readStoredAuth(opts = {}) {
659
+ const raw = readExisting(authPath(opts));
603
660
  return raw ? normalizeStoredAuth(raw) : null;
604
661
  }
605
662
 
606
663
  async function loadAuthIfPresent(opts) {
607
664
  if (opts.token && opts.workspaceId) return opts;
608
665
 
609
- const stored = readStoredAuth();
666
+ const stored = readStoredAuth(opts);
610
667
  if (stored?.token && stored?.workspaceId) {
611
668
  opts.token ||= stored.token;
612
669
  opts.workspaceId ||= stored.workspaceId;
@@ -670,7 +727,7 @@ function mergeAuthConfig(raw, auth) {
670
727
  Array.isArray(envConfig)
671
728
  ) {
672
729
  throw new Error(
673
- `Unknown active environment '${activeEnv}' in ${authPath()}`
730
+ `Unknown active environment '${activeEnv}' in configured Sellable auth path`
674
731
  );
675
732
  }
676
733
  return {
@@ -691,8 +748,14 @@ function mergeAuthConfig(raw, auth) {
691
748
  };
692
749
  }
693
750
 
694
- function writeAuthSetConfig({ token, workspaceId, apiUrl, dryRun }) {
695
- const configPath = authPath();
751
+ function writeAuthSetConfig({
752
+ token,
753
+ workspaceId,
754
+ apiUrl,
755
+ dryRun,
756
+ sellableConfigPath,
757
+ }) {
758
+ const configPath = authPath({ sellableConfigPath });
696
759
  const raw = readExisting(configPath) || {};
697
760
  const config = mergeAuthConfig(raw, {
698
761
  token,
@@ -843,6 +906,7 @@ function writeFile(path, content, opts, mode = 0o644) {
843
906
  if (opts.dryRun) return;
844
907
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
845
908
  writeFileSync(path, content, { mode });
909
+ chmodSync(path, mode);
846
910
  }
847
911
 
848
912
  function ensureSymlink(target, linkPath, opts) {
@@ -935,9 +999,7 @@ function codexPluginMcp(opts) {
935
999
  type: "stdio",
936
1000
  command,
937
1001
  args,
938
- env: {
939
- SELLABLE_WATCH_MODE_DRIVER: "codex",
940
- },
1002
+ env: mcpEnvForHost("codex", opts),
941
1003
  },
942
1004
  },
943
1005
  };
@@ -950,9 +1012,7 @@ function codexPluginMcp(opts) {
950
1012
  type: "stdio",
951
1013
  command,
952
1014
  args,
953
- env: {
954
- SELLABLE_WATCH_MODE_DRIVER: "codex",
955
- },
1015
+ env: mcpEnvForHost("codex", opts),
956
1016
  },
957
1017
  },
958
1018
  };
@@ -1062,6 +1122,7 @@ const REFILL_SENDS_ALLOWED_TOOLS = [
1062
1122
  "mcp__sellable__get_subskill_asset",
1063
1123
  "mcp__sellable__search_subskill_prompts",
1064
1124
  "mcp__sellable__get_scheduler_fill_capacity",
1125
+ "mcp__sellable__run_scheduler_sweep",
1065
1126
  "mcp__sellable__refresh_paid_inmail_credits",
1066
1127
  "mcp__sellable__list_senders",
1067
1128
  "mcp__sellable__get_sender_routing",
@@ -1175,6 +1236,29 @@ the Claude Code instruction for customer-facing language and host functions.
1175
1236
 
1176
1237
  Do not tell Claude Code users to run \`$sellable:${commandName}\`, use
1177
1238
  \`request_user_input\`, or restart Codex Desktop. Do not describe this run as Codex.`;
1239
+ }
1240
+ if (host === "hermes") {
1241
+ return `## Installed Host Contract
1242
+
1243
+ This installed skill is running in Hermes Agent. When the shared workflow body
1244
+ or fallback text mentions Claude Code, Codex, or Hermes for internal parity,
1245
+ choose the Hermes instruction for customer-facing language and host functions.
1246
+
1247
+ - Customer-facing command: \`/sellable-${commandName}\`
1248
+ - MCP tool naming: Hermes exposes Sellable tools as \`mcp_sellable_<tool>\`;
1249
+ when shared instructions show \`mcp__sellable__<tool>\`, call the matching
1250
+ \`mcp_sellable_<tool>\` tool instead.
1251
+ - Structured questions: ask plainly in chat unless a Hermes-native approval or
1252
+ question tool is visible in the current session.
1253
+ - Bootstrap host label: \`host: "Hermes"\`
1254
+ - Install/reload blocker label: Hermes install/reload problem
1255
+ - Reload instruction: restart Hermes, or run \`/reload-mcp\` in the active
1256
+ Hermes session after install
1257
+
1258
+ Do not tell Hermes users to run \`$sellable:${commandName}\` or
1259
+ \`/sellable:${commandName}\`, use \`AskUserQuestion\` or \`request_user_input\`,
1260
+ or restart Claude Code or Codex Desktop. Do not describe this run as Claude Code
1261
+ or Codex.`;
1178
1262
  }
1179
1263
  return "";
1180
1264
  }
@@ -2005,7 +2089,7 @@ Desktop, then start a new thread.
2005
2089
  2. Resolve the target workspace id from the user request, automation config, or install-time workspace mapping. Pass \`workspaceId\` on every scheduled or \`--yolo\` refill tool call. Missing \`workspaceId\` in scheduled or \`--yolo\` mode is a blocker; return \`WORKSPACE_REQUIRED\` instead of running against shared config state. Do not solve automation workspace uncertainty by changing the shared active workspace.
2006
2090
  3. Normalize invocation arguments with \`mcp__sellable__refill_sends({ yolo, executionMode, requireWorkspace, workspaceId, senders, senderIds, senderNames, targetDate, untilDate })\` when the host exposes typed MCP tools. Type shape: \`refill_sends({ yolo?: boolean, executionMode?: "manual" | "scheduled" | "yolo", requireWorkspace?: boolean, workspaceId?: string, senders?: string[], senderIds?: string[], senderNames?: string[], horizonSendDays?: number, untilDate?: "YYYY-MM-DD", targetDate?: "YYYY-MM-DD" })\`.
2007
2091
  4. Clover scheduled automation example: \`mcp__sellable__refill_sends({ yolo:false, executionMode:"scheduled", requireWorkspace:true, workspaceId:"cmlq1v8ms0000jx04ang4hi7e" })\`, then \`mcp__sellable__get_refill_target_plan({ intent:"plain", approvalMode:"mark_ready", workspaceId:"cmlq1v8ms0000jx04ang4hi7e" })\`.
2008
- 5. Call \`mcp__sellable__get_refill_target_plan({ workspaceId, senders, senderIds, senderNames, targetDate, untilDate })\` before any mutation. Render \`target.eligibleSenderLedger\`, \`target.senderRefillPlans[]\`, and \`target.globalActionQueue\` as the structured packet. Preserve the labels \`Need to prepare\`, \`Goal\`, \`Already sent\`, \`Scheduled\`, \`Ready and waiting to be scheduled\`, and \`Still need\`. In \`--yolo\`, \`mcp__sellable__refill_sends\` maintains a run-local \`refreshedPaidInmailSenderIds\` set, refreshes stale/missing paid InMail facts for each selected paid-InMail sender at most once, reruns \`get_refill_target_plan\`, and returns \`autoPaidInmailRefresh\` plus the post-refresh \`targetPlan\` before choosing the next prep/source-copy/bounded-approval/read-only wait action. Refill ladder: approve generated rows only through the explicit bounded approval gate, process existing same-campaign unenriched/unprepared rows before source work, then same-source copy, then provider-aligned source-more. New source/provider switches change reply-rate baseline and are manual alternates, not \`--yolo\` side effects. Do not present paid-credit refresh as the next operator action after \`refill_sends\` returns that post-refresh plan. For exact-date proof, \`mcp__sellable__get_scheduler_fill_capacity\` is read-only and tells how many cells the scheduler will try to place for a sender/action/date. If the returned target is already complete, stop with the no-op receipt. If ready rows cover the target but scheduled rows do not, keep the run open in the read-only scheduler wait/re-read loop until projected coverage fills, a concrete non-scheduler blocker appears, or Christian explicitly stops/statuses the run.
2092
+ 5. Call \`mcp__sellable__get_refill_target_plan({ workspaceId, senders, senderIds, senderNames, targetDate, untilDate })\` before any mutation. Render \`target.eligibleSenderLedger\`, \`target.senderRefillPlans[]\`, and \`target.globalActionQueue\` as the structured packet. Preserve the labels \`Need to prepare\`, \`Goal\`, \`Already sent\`, \`Scheduled\`, \`Ready and waiting to be scheduled\`, and \`Still need\`. In \`--yolo\`, \`mcp__sellable__refill_sends\` maintains a run-local \`refreshedPaidInmailSenderIds\` set, refreshes stale/missing paid InMail facts for each selected paid-InMail sender at most once, reruns \`get_refill_target_plan\`, and returns \`autoPaidInmailRefresh\` plus the post-refresh \`targetPlan\` before choosing the next prep/source-copy/bounded-approval/read-only wait action. Refill ladder: approve generated rows only through the explicit bounded approval gate, process existing same-campaign unenriched/unprepared rows before source work, then same-source copy, then provider-aligned source-more. New source/provider switches change reply-rate baseline and are manual alternates, not \`--yolo\` side effects. Do not present paid-credit refresh as the next operator action after \`refill_sends\` returns that post-refresh plan. For exact-date proof, \`mcp__sellable__get_scheduler_fill_capacity\` is read-only and tells how many cells the scheduler will try to place for a sender/action/date. When ready rows cover the target but scheduled rows do not, \`mcp__sellable__run_scheduler_sweep({ workspaceId, action:"run" })\` may request immediate placement within existing gates and returns a receipt, but it never sends or bypasses limits. If the returned target is already complete, stop with the no-op receipt. If ready rows cover the target but scheduled rows do not, keep the run open in the read-only scheduler wait/re-read loop until projected coverage fills, a concrete non-scheduler blocker appears, or Christian explicitly stops/statuses the run.
2009
2093
  6. Load the canonical prompt and deterministic flow asset via
2010
2094
  \`mcp__sellable__get_subskill_prompt({ subskillName: "refill-sends" })\`,
2011
2095
  then load \`mcp__sellable__get_subskill_prompt({ subskillName: "refill-sends-workflow" })\`,
@@ -2584,6 +2668,256 @@ function codexPluginSkills() {
2584
2668
  ];
2585
2669
  }
2586
2670
 
2671
+ function hermesCommandName(commandName) {
2672
+ return `sellable-${commandName}`;
2673
+ }
2674
+
2675
+ function normalizeHermesSkillMarkdown(markdown, commandName) {
2676
+ const hermesName = hermesCommandName(commandName);
2677
+ let content = String(markdown);
2678
+ if (/^name:\s*.+$/m.test(content)) {
2679
+ content = content.replace(/^name:\s*.+$/m, `name: ${hermesName}`);
2680
+ } else {
2681
+ content = content.replace(/^---\n/m, `---\nname: ${hermesName}\n`);
2682
+ }
2683
+
2684
+ let normalized = content
2685
+ .replace(/mcp__sellable__<tool>/g, "mcp_sellable_<tool>")
2686
+ .replace(/mcp__([A-Za-z0-9_-]+)__<tool>/g, (_, server) => {
2687
+ return `mcp_${String(server).replaceAll("-", "_")}_<tool>`;
2688
+ })
2689
+ .replace(/\bmcp__([A-Za-z0-9_-]+)__([A-Za-z0-9_-]+)\b/g, (_, server, tool) => {
2690
+ return `mcp_${String(server).replaceAll("-", "_")}_${String(tool).replaceAll("-", "_")}`;
2691
+ })
2692
+ .replace(/\bmcp__sellable__/g, "mcp_sellable_")
2693
+ .replace(/\$sellable:/g, "/sellable-")
2694
+ .replace(/\/sellable:/g, "/sellable-")
2695
+ .replace(/Codex install\/reload problem/g, "Hermes install/reload problem")
2696
+ .replace(/Claude Code install\/reload problem/g, "Hermes install/reload problem")
2697
+ .replace(/fully quit and reopen Codex Desktop, then start a new thread/g, "restart Hermes, or run `/reload-mcp` in the active Hermes session")
2698
+ .replace(/fully quit and reopen Claude Code, then start a new session/g, "restart Hermes, or run `/reload-mcp` in the active Hermes session")
2699
+ .replace(/Codex Desktop/g, "Hermes")
2700
+ .replace(/Claude Code\/Codex/g, "Hermes")
2701
+ .replace(/Claude Code or Codex/g, "Hermes")
2702
+ .replace(/Codex or Claude Code/g, "Hermes")
2703
+ .replace(/Claude Code and Codex/g, "Hermes")
2704
+ .replace(/Codex\/Claude/g, "Hermes")
2705
+ .replace(/quick question panel/g, "approval prompt")
2706
+ .replace(/request_user_input/g, "plain chat")
2707
+ .replace(/AskUserQuestion/g, "plain chat");
2708
+
2709
+ normalized = normalized.replace(
2710
+ /Do not tell Hermes users to run[\s\S]*?Do not describe this run as Claude Code\nor Codex\./g,
2711
+ "Do not tell Hermes users to run Codex or Claude command forms, use Codex/Claude structured-question APIs, or restart Codex Desktop or Claude Code. Do not describe this run as Claude Code or Codex."
2712
+ );
2713
+
2714
+ normalized = normalized.replace(
2715
+ /## Structured Questions\n\nUse the host-native structured question gate for intake and approval:[\s\S]*?## Host Runtime Functions/g,
2716
+ `## Structured Questions
2717
+
2718
+ Hermes should ask setup questions directly in chat unless the current Hermes
2719
+ session exposes a native approval or question tool. Use a bounded approval gate
2720
+ only for explicit approval decisions. For open text like LinkedIn URLs, company
2721
+ domains, notes, pasted context, campaign ideas, or feedback, ask in normal chat
2722
+ and wait for the user to paste the value.
2723
+
2724
+ Campaign setup questions are single-choice decisions. Use mutually exclusive
2725
+ options and route blended or custom answers through a short free-text follow-up.
2726
+ Customer-facing language should call this "a couple setup choices" during
2727
+ normal campaign progress.
2728
+
2729
+ ## Host Runtime Functions`
2730
+ );
2731
+
2732
+ normalized = normalized.replace(
2733
+ /## Names To Use\n\nUse these exact public names[\s\S]*?## Structured Questions/g,
2734
+ `## Names To Use
2735
+
2736
+ Use these exact public names for Hermes:
2737
+
2738
+ - Hermes command: \`/${hermesName}\`
2739
+ - Hermes skill directory: \`skills/sellable/${hermesName}/SKILL.md\`
2740
+ - MCP server name: \`sellable\`
2741
+ - Hermes MCP tool prefix: \`mcp_sellable_\`
2742
+ - Internal workflow prompt: \`create-campaign-v2\`
2743
+
2744
+ Do not tell users to run internal subskill names. \`create-campaign-v2\` is only
2745
+ the internal subskill loaded through
2746
+ \`mcp_sellable_get_subskill_prompt({ subskillName: "create-campaign-v2" })\`.
2747
+
2748
+ ## Structured Questions`
2749
+ );
2750
+
2751
+ normalized = normalized.replace(
2752
+ /- `ask_user`:[\s\S]*?(?=\n- `load_subprompt`:)/g,
2753
+ `- \`ask_user\`: ask directly in chat unless a Hermes-native approval or
2754
+ question tool is visible in the current session. Use this for
2755
+ multiple-choice intake, campaign-focus choices, source decisions, and
2756
+ approvals. Campaign setup questions are single-choice only; do not use
2757
+ multi-select or checkbox variants.`
2758
+ );
2759
+
2760
+ normalized = normalized.replace(
2761
+ /- `launch_message_drafting`:[\s\S]*?\n\nIf a required interactive/g,
2762
+ `- \`launch_message_drafting\`: if Hermes exposes a background-agent capability,
2763
+ use the compatible Message Drafting agent. Otherwise run the same message
2764
+ drafting branch inline in the parent session with \`statusSource:
2765
+ "parent-thread-fallback"\`.
2766
+
2767
+ If a required interactive`
2768
+ );
2769
+
2770
+ normalized = normalized.replace(
2771
+ /Do not silently ask (?:Codex|Hermes) intake or approval questions as plain chat[\s\S]*?Plain chat questions are only acceptable in non-interactive `codex exec`\nsmoke\/rehearsal runs because structured user input is unavailable by design\nthere\.\n\n/g,
2772
+ ""
2773
+ );
2774
+
2775
+ normalized = normalized.replace(
2776
+ /## Active Model Metadata\n\nBefore calling `bootstrap_create_campaign`, collect[\s\S]*?Never invent the model or reasoning effort\. Never pass config defaults as active\nmetadata\. If bootstrap returns `modelQuality\.status === "unknown"`, continue\nwithout asking the user to switch models\.\n\n/g,
2777
+ `## Active Model Metadata
2778
+
2779
+ Before calling \`bootstrap_create_campaign\`, pass \`host: "Hermes"\`. If the
2780
+ current Hermes session exposes model and effort metadata for this same turn,
2781
+ pass those exact values with \`modelMetadataSource: "hermes_runtime_metadata"\`.
2782
+ If the current Hermes session context explicitly states both the exact model ID
2783
+ and effort/thinking level, pass them with
2784
+ \`modelMetadataSource: "hermes_session_context"\`. Otherwise omit \`model\`,
2785
+ \`reasoningEffort\`, and \`modelMetadataSource\`.
2786
+
2787
+ Never invent the model or reasoning effort. Never inspect Codex or Claude config
2788
+ files as proof of the active Hermes session. If bootstrap returns
2789
+ \`modelQuality.status === "unknown"\`, continue without asking the user to
2790
+ switch models.
2791
+
2792
+ `
2793
+ );
2794
+
2795
+ normalized = normalized.replace(/not `plain chat`/g, "not internal API names");
2796
+
2797
+ normalized = normalized
2798
+ .replace(/this is a Codex\ninstall\/reload problem/g, "this is a Hermes\ninstall/reload problem")
2799
+ .replace(/Hermes plugin/g, "Hermes skills");
2800
+
2801
+ return normalized;
2802
+ }
2803
+
2804
+ function compactHermesEvergreenSkillMd() {
2805
+ return normalizeHermesSkillMarkdown(
2806
+ stampInstalledHost(`---
2807
+ name: create-evergreen-campaigns
2808
+ description: Create or repair evergreen campaign lanes without launching sends.
2809
+ visibility: public
2810
+ allowed-tools:
2811
+ ${allowedToolsYaml(CREATE_EVERGREEN_CAMPAIGNS_ALLOWED_TOOLS)}
2812
+ ---
2813
+
2814
+ # Sellable Create Evergreen Campaigns
2815
+
2816
+ Use this as the customer-facing entrypoint for the Sellable
2817
+ \`create-evergreen-campaigns\` workflow in Hermes.
2818
+
2819
+ ## Bootstrap
2820
+
2821
+ MCP prompt and command access are required. First call
2822
+ \`mcp__sellable__get_auth_status({})\`. Do not inspect repo files, run shell
2823
+ commands, use \`npm\`, \`node\`, local harness scripts, or read local prompt
2824
+ files to emulate this workflow.
2825
+
2826
+ If the Sellable MCP prompt tools are unavailable, stop and say this is a Hermes
2827
+ install/reload problem. Tell the user to run
2828
+ \`curl -fsSL "https://app.sellable.dev/api/v2/cli/install" | sh\`, then restart
2829
+ Hermes or run \`/reload-mcp\`.
2830
+
2831
+ ## Execute Workflow
2832
+
2833
+ 1. Load the canonical prompt via
2834
+ \`mcp__sellable__get_subskill_prompt({ subskillName: "create-evergreen-campaigns" })\`.
2835
+ If the response has \`hasMore=true\`, continue with \`nextOffset\` until
2836
+ \`hasMore=false\`.
2837
+ 2. Ask for bounded approval in normal chat before mutating if the plan requires
2838
+ approval. Do not call Codex app thread tools or Codex CLI workers from
2839
+ Hermes.
2840
+ 3. Use \`mcp__sellable__setup_evergreen_campaigns({ mode: "plan" })\` for the
2841
+ plan/readback packet, then use the canonical prompt's approved mutation path
2842
+ and return to \`setup_evergreen_campaigns({ mode: "verify" })\` with
2843
+ customer-visible receipts.
2844
+ 4. Do not launch campaigns, schedule sends, archive campaigns, delete tables,
2845
+ or spend paid credits.
2846
+
2847
+ ## MCP Prompt Fallback
2848
+
2849
+ If exact subskill lookup fails, use
2850
+ \`mcp__sellable__search_subskill_prompts({ query: "create-evergreen-campaigns", includePublic: true, includeInternal: true })\`,
2851
+ then retry \`get_subskill_prompt\`.
2852
+ `, "hermes", "create-evergreen-campaigns"),
2853
+ "create-evergreen-campaigns"
2854
+ );
2855
+ }
2856
+
2857
+ function hermesSkillDefinitions() {
2858
+ return [
2859
+ {
2860
+ commandName: "create-campaign",
2861
+ dir: hermesCommandName("create-campaign"),
2862
+ skillMd: normalizeHermesSkillMarkdown(
2863
+ createCampaignSkillMd("hermes"),
2864
+ "create-campaign"
2865
+ ),
2866
+ soulMd: createCampaignSoulMd(),
2867
+ },
2868
+ {
2869
+ commandName: "create-ab-test",
2870
+ dir: hermesCommandName("create-ab-test"),
2871
+ skillMd: normalizeHermesSkillMarkdown(
2872
+ createAbTestSkillMd("hermes"),
2873
+ "create-ab-test"
2874
+ ),
2875
+ },
2876
+ {
2877
+ commandName: "create-evergreen-campaigns",
2878
+ dir: hermesCommandName("create-evergreen-campaigns"),
2879
+ skillMd: compactHermesEvergreenSkillMd(),
2880
+ },
2881
+ {
2882
+ commandName: "foundation",
2883
+ dir: hermesCommandName("foundation"),
2884
+ skillMd: normalizeHermesSkillMarkdown(
2885
+ foundationSkillMd("hermes"),
2886
+ "foundation"
2887
+ ),
2888
+ },
2889
+ {
2890
+ commandName: "content",
2891
+ dir: hermesCommandName("content"),
2892
+ skillMd: normalizeHermesSkillMarkdown(contentSkillMd("hermes"), "content"),
2893
+ },
2894
+ {
2895
+ commandName: "create-post",
2896
+ dir: hermesCommandName("create-post"),
2897
+ skillMd: normalizeHermesSkillMarkdown(
2898
+ createPostSkillMd("hermes"),
2899
+ "create-post"
2900
+ ),
2901
+ },
2902
+ {
2903
+ commandName: "refresh-sender-engagement",
2904
+ dir: hermesCommandName("refresh-sender-engagement"),
2905
+ skillMd: normalizeHermesSkillMarkdown(
2906
+ refreshSenderEngagementSkillMd("hermes"),
2907
+ "refresh-sender-engagement"
2908
+ ),
2909
+ },
2910
+ {
2911
+ commandName: "refill-sends",
2912
+ dir: hermesCommandName("refill-sends"),
2913
+ skillMd: normalizeHermesSkillMarkdown(
2914
+ refillSendsSkillMd("hermes"),
2915
+ "refill-sends"
2916
+ ),
2917
+ },
2918
+ ];
2919
+ }
2920
+
2587
2921
  function installerPackageRoot() {
2588
2922
  return join(dirname(fileURLToPath(import.meta.url)), "..");
2589
2923
  }
@@ -3095,7 +3429,7 @@ function writeAuth(opts) {
3095
3429
  activeWorkspaceId: opts.workspaceId,
3096
3430
  apiUrl: opts.apiUrl,
3097
3431
  };
3098
- writeJson(authPath(), config, opts);
3432
+ writeJson(authPath(opts), config, opts);
3099
3433
  return { written: true, reused: false };
3100
3434
  }
3101
3435
 
@@ -3152,8 +3486,43 @@ exec ${shellQuote(npmCommand)} exec --yes --package ${shellQuote(INSTALL_PACKAGE
3152
3486
  const WATCH_MODE_DRIVER_ENV = {
3153
3487
  claude: "SELLABLE_WATCH_MODE_DRIVER=claude",
3154
3488
  codex: "SELLABLE_WATCH_MODE_DRIVER=codex",
3489
+ hermes: "SELLABLE_WATCH_MODE_DRIVER=hermes",
3155
3490
  };
3156
3491
 
3492
+ function mcpEnvForHost(host, opts) {
3493
+ const env = {
3494
+ SELLABLE_WATCH_MODE_DRIVER: host,
3495
+ };
3496
+ if (opts.server === "hosted") return env;
3497
+ if (opts.sellableConfigPath) {
3498
+ env.SELLABLE_CONFIG_PATH = opts.sellableConfigPath;
3499
+ }
3500
+ if (opts.sellableConfigsDir) {
3501
+ env.SELLABLE_CONFIGS_DIR = opts.sellableConfigsDir;
3502
+ }
3503
+ return env;
3504
+ }
3505
+
3506
+ function mcpEnvVarArgs(host, opts) {
3507
+ return Object.entries(mcpEnvForHost(host, opts)).map(
3508
+ ([key, value]) => `${key}=${value}`
3509
+ );
3510
+ }
3511
+
3512
+ function tomlEnvTable(host, opts) {
3513
+ return Object.entries(mcpEnvForHost(host, opts))
3514
+ .map(([key, value]) => `${key} = ${quoteToml(value)}`)
3515
+ .join("\n");
3516
+ }
3517
+
3518
+ function runtimeMcpEnvForVerify(opts) {
3519
+ const host =
3520
+ opts.host === "claude" || opts.host === "codex" || opts.host === "hermes"
3521
+ ? opts.host
3522
+ : "codex";
3523
+ return mcpEnvForHost(host, opts);
3524
+ }
3525
+
3157
3526
  function withHostedWatchModeDriver(rawUrl, driver) {
3158
3527
  if (!WATCH_MODE_DRIVER_ENV[driver]) {
3159
3528
  throw new Error(`Unknown watch mode driver: ${driver}`);
@@ -3207,8 +3576,7 @@ function codexMcpAddArgs(opts) {
3207
3576
  "mcp",
3208
3577
  "add",
3209
3578
  "sellable",
3210
- "--env",
3211
- WATCH_MODE_DRIVER_ENV.codex,
3579
+ ...mcpEnvVarArgs("codex", opts).flatMap((entry) => ["--env", entry]),
3212
3580
  "--",
3213
3581
  command,
3214
3582
  ...args,
@@ -3239,7 +3607,7 @@ args = ${tomlArray(args)}`
3239
3607
  content,
3240
3608
  "mcp_servers.sellable.env",
3241
3609
  `[mcp_servers.sellable.env]
3242
- SELLABLE_WATCH_MODE_DRIVER = "codex"`
3610
+ ${tomlEnvTable("codex", opts)}`
3243
3611
  );
3244
3612
  return content;
3245
3613
  }
@@ -3262,7 +3630,9 @@ function codexMcpServerMatches(content, opts) {
3262
3630
  return (
3263
3631
  server.includes(`command = ${quoteToml(command)}`) &&
3264
3632
  server.includes(`args = ${tomlArray(args)}`) &&
3265
- env.includes('SELLABLE_WATCH_MODE_DRIVER = "codex"')
3633
+ Object.entries(mcpEnvForHost("codex", opts)).every(([key, value]) =>
3634
+ env.includes(`${key} = ${quoteToml(value)}`)
3635
+ )
3266
3636
  );
3267
3637
  }
3268
3638
 
@@ -3277,6 +3647,568 @@ function codexPluginMcpServerMatches(content, opts) {
3277
3647
  }
3278
3648
  }
3279
3649
 
3650
+ function hermesHome(opts = {}) {
3651
+ return (
3652
+ opts.hermesHome ||
3653
+ process.env.HERMES_HOME?.trim() ||
3654
+ join(homedir(), ".hermes")
3655
+ );
3656
+ }
3657
+
3658
+ function hermesConfigPath(opts = {}) {
3659
+ return join(hermesHome(opts), "config.yaml");
3660
+ }
3661
+
3662
+ function hermesSkillsRoot(opts = {}) {
3663
+ return join(hermesHome(opts), "skills", "sellable");
3664
+ }
3665
+
3666
+ function hermesLikelyInstalled() {
3667
+ return (
3668
+ Boolean(process.env.HERMES_HOME?.trim()) ||
3669
+ commandExists("hermes") ||
3670
+ existsSync(hermesConfigPath()) ||
3671
+ existsSync(join(hermesHome(), "skills"))
3672
+ );
3673
+ }
3674
+
3675
+ function parseHermesConfig(raw, configPath = hermesConfigPath()) {
3676
+ const source = raw.trim() ? raw : "{}\n";
3677
+ const doc = parseDocument(source, { prettyErrors: false });
3678
+ if (doc.errors.length > 0) {
3679
+ throw new Error(
3680
+ `Invalid Hermes config YAML at ${configPath}: ${doc.errors[0].message}`
3681
+ );
3682
+ }
3683
+ const value = doc.toJSON() ?? {};
3684
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
3685
+ throw new Error(
3686
+ `Invalid Hermes config YAML at ${configPath}: expected a mapping at the document root`
3687
+ );
3688
+ }
3689
+ return value;
3690
+ }
3691
+
3692
+ function readHermesConfig(opts = {}) {
3693
+ const configPath = hermesConfigPath(opts);
3694
+ const raw = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
3695
+ return { raw, config: parseHermesConfig(raw, configPath), configPath };
3696
+ }
3697
+
3698
+ function hermesMcpServer(opts) {
3699
+ if (opts.server === "hosted") {
3700
+ return {
3701
+ url: withHostedWatchModeDriver(opts.hostedUrl, "hermes"),
3702
+ enabled: true,
3703
+ env: {
3704
+ SELLABLE_WATCH_MODE_DRIVER: "hermes",
3705
+ },
3706
+ };
3707
+ }
3708
+
3709
+ const [command, args] = mcpCommand(opts);
3710
+ return {
3711
+ command,
3712
+ args,
3713
+ enabled: true,
3714
+ env: mcpEnvForHost("hermes", opts),
3715
+ };
3716
+ }
3717
+
3718
+ function writeHermesConfig(configPath, config, opts) {
3719
+ if (opts.dryRun) return;
3720
+ mkdirSync(dirname(configPath), { recursive: true, mode: 0o700 });
3721
+ const doc = parseDocument("{}\n");
3722
+ doc.contents = config;
3723
+ writeFileSync(configPath, String(doc), { mode: 0o600 });
3724
+ }
3725
+
3726
+ function writeHermesMcpServer(opts) {
3727
+ const { config, configPath } = readHermesConfig(opts);
3728
+ const existingMcpServers =
3729
+ config.mcp_servers &&
3730
+ typeof config.mcp_servers === "object" &&
3731
+ !Array.isArray(config.mcp_servers)
3732
+ ? config.mcp_servers
3733
+ : {};
3734
+ const nextConfig = {
3735
+ ...config,
3736
+ mcp_servers: {
3737
+ ...existingMcpServers,
3738
+ sellable: hermesMcpServer(opts),
3739
+ },
3740
+ };
3741
+ writeHermesConfig(configPath, nextConfig, opts);
3742
+ return configPath;
3743
+ }
3744
+
3745
+ function installHermesSkills(opts) {
3746
+ const root = hermesSkillsRoot(opts);
3747
+ for (const skill of hermesSkillDefinitions()) {
3748
+ const skillRoot = join(root, skill.dir);
3749
+ writeFile(join(skillRoot, "SKILL.md"), skill.skillMd, opts);
3750
+ if (skill.soulMd) {
3751
+ writeFile(join(skillRoot, "SOUL.md"), skill.soulMd, opts);
3752
+ }
3753
+ }
3754
+ return root;
3755
+ }
3756
+
3757
+ function installHermes(opts) {
3758
+ // Parse config before writing skills so invalid user YAML cannot leave a
3759
+ // half-installed Hermes skill tree behind.
3760
+ readHermesConfig(opts);
3761
+ let skillsRoot = null;
3762
+ try {
3763
+ skillsRoot = installHermesSkills(opts);
3764
+ const configPath = writeHermesMcpServer(opts);
3765
+ logVerbose(`${C.grey}+ Hermes config updated: ${configPath}${C.reset}`);
3766
+ return { installed: true, configPath, skillsRoot };
3767
+ } catch (err) {
3768
+ if (skillsRoot && !opts.dryRun) {
3769
+ rmSync(skillsRoot, { recursive: true, force: true });
3770
+ }
3771
+ throw err;
3772
+ }
3773
+ }
3774
+
3775
+ function parseHermesProfileBootstrapArgs(argv) {
3776
+ const opts = {
3777
+ profile: "",
3778
+ profileRoot: "",
3779
+ profilesRoot: "",
3780
+ workspaceId: "",
3781
+ workspaceName: "",
3782
+ token: "",
3783
+ tokenFile: "",
3784
+ apiUrl: process.env.SELLABLE_API_URL || DEFAULT_API_URL,
3785
+ slackBotToken: process.env.SLACK_BOT_TOKEN || "",
3786
+ slackAppToken: process.env.SLACK_APP_TOKEN || "",
3787
+ server: process.env.SELLABLE_INSTALL_SERVER || "package",
3788
+ mcpPackage: DEFAULT_SERVER_PACKAGE,
3789
+ localCommand: process.env.SELLABLE_MCP_LOCAL_COMMAND || "",
3790
+ dryRun: false,
3791
+ json: false,
3792
+ force: false,
3793
+ overwriteEnv: false,
3794
+ allowDuplicateSlackTokens: false,
3795
+ verbose: false,
3796
+ };
3797
+
3798
+ for (let i = 0; i < argv.length; i += 1) {
3799
+ const arg = argv[i];
3800
+ const next = () => {
3801
+ const value = argv[i + 1];
3802
+ if (!value || value.startsWith("--")) {
3803
+ throw new Error(`Missing value for ${arg}`);
3804
+ }
3805
+ i += 1;
3806
+ return value;
3807
+ };
3808
+
3809
+ if (arg === "--profile") opts.profile = next();
3810
+ else if (arg === "--profile-root") opts.profileRoot = next();
3811
+ else if (arg === "--profiles-root") opts.profilesRoot = next();
3812
+ else if (arg === "--workspace-id") opts.workspaceId = next();
3813
+ else if (arg === "--workspace-name") opts.workspaceName = next();
3814
+ else if (arg === "--token") opts.token = next();
3815
+ else if (arg === "--token-file") opts.tokenFile = next();
3816
+ else if (arg === "--api-url") opts.apiUrl = next();
3817
+ else if (arg === "--slack-bot-token") opts.slackBotToken = next();
3818
+ else if (arg === "--slack-app-token") opts.slackAppToken = next();
3819
+ else if (arg === "--server") opts.server = next();
3820
+ else if (arg === "--mcp-package") {
3821
+ opts.mcpPackage = normalizeWindowsPackageSpec(next());
3822
+ } else if (arg === "--local-command") opts.localCommand = next();
3823
+ else if (arg === "--dry-run") opts.dryRun = true;
3824
+ else if (arg === "--json") opts.json = true;
3825
+ else if (arg === "--force") opts.force = true;
3826
+ else if (arg === "--overwrite-env") opts.overwriteEnv = true;
3827
+ else if (arg === "--allow-duplicate-slack-tokens") {
3828
+ opts.allowDuplicateSlackTokens = true;
3829
+ } else if (arg === "--verbose") opts.verbose = true;
3830
+ else {
3831
+ throw new Error(`Unknown hermes profile bootstrap option: ${arg}`);
3832
+ }
3833
+ }
3834
+
3835
+ if (!["package", "local"].includes(opts.server)) {
3836
+ throw new Error("hermes profile bootstrap supports --server package or local");
3837
+ }
3838
+ return opts;
3839
+ }
3840
+
3841
+ function assertSafeProfileName(profile) {
3842
+ if (!profile) return;
3843
+ if (profile.includes("/") || profile.includes("\\") || profile === "." || profile === "..") {
3844
+ throw new Error("--profile must be a profile name, not a path");
3845
+ }
3846
+ }
3847
+
3848
+ function resolveHermesProfileRoot(opts) {
3849
+ if (opts.profileRoot) {
3850
+ return normalizeOptionalPath(opts.profileRoot);
3851
+ }
3852
+
3853
+ if (opts.profilesRoot || opts.profile) {
3854
+ if (!opts.profilesRoot || !opts.profile) {
3855
+ throw new Error("--profiles-root requires --profile for Hermes bootstrap");
3856
+ }
3857
+ assertSafeProfileName(opts.profile);
3858
+ return join(normalizeOptionalPath(opts.profilesRoot), opts.profile);
3859
+ }
3860
+
3861
+ const envHermesHome = process.env.HERMES_HOME?.trim();
3862
+ if (envHermesHome) {
3863
+ return resolve(envHermesHome);
3864
+ }
3865
+
3866
+ throw new Error(
3867
+ "Missing Hermes profile root. Pass --profile-root, or --profiles-root with --profile, or set HERMES_HOME."
3868
+ );
3869
+ }
3870
+
3871
+ function readBootstrapToken(opts) {
3872
+ if (opts.tokenFile) {
3873
+ const tokenPath = normalizeOptionalPath(opts.tokenFile);
3874
+ if (!existsSync(tokenPath)) {
3875
+ throw new Error(`Token file not found: ${tokenPath}`);
3876
+ }
3877
+ return {
3878
+ token: readFileSync(tokenPath, "utf8").trim(),
3879
+ source: "token-file",
3880
+ };
3881
+ }
3882
+
3883
+ const envToken = process.env.SELLABLE_API_TOKEN || process.env.SELLABLE_TOKEN || "";
3884
+ if (envToken) return { token: envToken.trim(), source: "env" };
3885
+ if (opts.token) return { token: opts.token.trim(), source: "flag" };
3886
+ return { token: "", source: "none" };
3887
+ }
3888
+
3889
+ function tokenFingerprint(value) {
3890
+ if (!value) return null;
3891
+ return createHash("sha256").update(value).digest("hex").slice(0, 16);
3892
+ }
3893
+
3894
+ function parseEnvLines(raw) {
3895
+ const entries = new Map();
3896
+ const passthrough = [];
3897
+ for (const line of String(raw || "").split(/\r?\n/)) {
3898
+ if (!line) continue;
3899
+ const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
3900
+ if (!match) {
3901
+ passthrough.push(line);
3902
+ continue;
3903
+ }
3904
+ entries.set(match[1], match[2]);
3905
+ }
3906
+ return { entries, passthrough };
3907
+ }
3908
+
3909
+ function renderEnvFile({ entries, passthrough }) {
3910
+ const lines = [...passthrough];
3911
+ for (const [key, value] of entries.entries()) {
3912
+ if (String(value).includes("\n")) {
3913
+ throw new Error(`Invalid newline in ${key}`);
3914
+ }
3915
+ lines.push(`${key}=${value}`);
3916
+ }
3917
+ return `${lines.join("\n")}${lines.length ? "\n" : ""}`;
3918
+ }
3919
+
3920
+ function findDuplicateSlackTokenProfiles(profileRoot, slackTokens) {
3921
+ const duplicates = [];
3922
+ const profilesRoot = dirname(profileRoot);
3923
+ if (!existsSync(profilesRoot)) return duplicates;
3924
+
3925
+ const requested = Object.entries(slackTokens)
3926
+ .filter(([, value]) => value)
3927
+ .map(([key, value]) => [key, tokenFingerprint(value), value]);
3928
+ if (requested.length === 0) return duplicates;
3929
+
3930
+ for (const name of readdirSync(profilesRoot)) {
3931
+ const siblingRoot = join(profilesRoot, name);
3932
+ if (resolve(siblingRoot) === resolve(profileRoot)) continue;
3933
+ const envPath = join(siblingRoot, ".env");
3934
+ if (!existsSync(envPath)) continue;
3935
+ const existing = parseEnvLines(readFileSync(envPath, "utf8")).entries;
3936
+ for (const [key, fingerprint, rawValue] of requested) {
3937
+ if (existing.get(key) === rawValue) {
3938
+ duplicates.push({ key, fingerprint, profileRoot: siblingRoot });
3939
+ }
3940
+ }
3941
+ }
3942
+ return duplicates;
3943
+ }
3944
+
3945
+ function readJsonFile(path) {
3946
+ if (!existsSync(path)) return {};
3947
+ try {
3948
+ const raw = JSON.parse(readFileSync(path, "utf8"));
3949
+ return raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
3950
+ } catch (err) {
3951
+ throw new Error(
3952
+ `Invalid JSON in ${path}: ${err instanceof Error ? err.message : String(err)}`
3953
+ );
3954
+ }
3955
+ }
3956
+
3957
+ function writeHermesProfileSellableConfig(configPath, raw, opts) {
3958
+ writeJson(configPath, raw, opts);
3959
+ }
3960
+
3961
+ function runHermesProfileBootstrap(argv) {
3962
+ const parsed = parseHermesProfileBootstrapArgs(argv);
3963
+ const previousVerbose = VERBOSE;
3964
+ VERBOSE = Boolean(parsed.verbose);
3965
+ try {
3966
+ const profileRoot = resolveHermesProfileRoot(parsed);
3967
+ const profile = parsed.profile || basenameSafe(profileRoot);
3968
+ const sellableConfigPath = join(profileRoot, "sellable", "config.json");
3969
+ const sellableConfigsDir = join(profileRoot, "sellable", "configs");
3970
+ const envPath = join(profileRoot, ".env");
3971
+ const { token, source: tokenSource } = readBootstrapToken(parsed);
3972
+ const slackTokens = {
3973
+ SLACK_BOT_TOKEN: parsed.slackBotToken.trim(),
3974
+ SLACK_APP_TOKEN: parsed.slackAppToken.trim(),
3975
+ };
3976
+ const opts = {
3977
+ ...parsed,
3978
+ host: "hermes",
3979
+ hermesHome: profileRoot,
3980
+ sellableConfigPath,
3981
+ sellableConfigsDir,
3982
+ token,
3983
+ tokenSource,
3984
+ workspaceId: parsed.workspaceId,
3985
+ apiUrl: parsed.apiUrl,
3986
+ };
3987
+
3988
+ if (token && !parsed.workspaceId) {
3989
+ throw new Error("--workspace-id is required when a Sellable token is supplied");
3990
+ }
3991
+
3992
+ const summary = {
3993
+ ok: true,
3994
+ command: "hermes profile bootstrap",
3995
+ profile,
3996
+ dryRun: opts.dryRun,
3997
+ paths: {
3998
+ profileRoot,
3999
+ hermesConfigPath: hermesConfigPath(opts),
4000
+ envPath,
4001
+ sellableConfigPath,
4002
+ sellableConfigsDir,
4003
+ skillsRoot: hermesSkillsRoot(opts),
4004
+ },
4005
+ auth: {
4006
+ apiUrl: opts.apiUrl,
4007
+ activeWorkspaceId: parsed.workspaceId || null,
4008
+ activeWorkspaceName: parsed.workspaceName || null,
4009
+ tokenPresent: Boolean(token),
4010
+ tokenSource,
4011
+ tokenFingerprint: tokenFingerprint(token),
4012
+ },
4013
+ slack: {
4014
+ keys: Object.entries(slackTokens)
4015
+ .filter(([, value]) => value)
4016
+ .map(([key]) => key),
4017
+ fingerprints: Object.fromEntries(
4018
+ Object.entries(slackTokens)
4019
+ .filter(([, value]) => value)
4020
+ .map(([key, value]) => [key, tokenFingerprint(value)])
4021
+ ),
4022
+ },
4023
+ created: [],
4024
+ updated: [],
4025
+ preserved: [],
4026
+ skipped: [],
4027
+ warnings: [],
4028
+ };
4029
+
4030
+ let hermesConfig = {};
4031
+ try {
4032
+ hermesConfig = readHermesConfig(opts).config;
4033
+ } catch (err) {
4034
+ throw err;
4035
+ }
4036
+ const existingSellablePath =
4037
+ hermesConfig?.mcp_servers?.sellable?.env?.SELLABLE_CONFIG_PATH || "";
4038
+ if (
4039
+ existingSellablePath &&
4040
+ resolve(existingSellablePath) !== sellableConfigPath &&
4041
+ !opts.force
4042
+ ) {
4043
+ throw new Error(
4044
+ `Existing Hermes sellable MCP points at ${existingSellablePath}; pass --force to replace it with ${sellableConfigPath}.`
4045
+ );
4046
+ }
4047
+
4048
+ const existingAuth = readJsonFile(sellableConfigPath);
4049
+ const existingWorkspaceId =
4050
+ existingAuth.activeWorkspaceId || existingAuth.workspaceId || "";
4051
+ if (
4052
+ existingWorkspaceId &&
4053
+ parsed.workspaceId &&
4054
+ existingWorkspaceId !== parsed.workspaceId &&
4055
+ !opts.force
4056
+ ) {
4057
+ throw new Error(
4058
+ `Existing Sellable profile config uses workspace ${existingWorkspaceId}; pass --force to replace it with ${parsed.workspaceId}.`
4059
+ );
4060
+ }
4061
+
4062
+ let envState = { entries: new Map(), passthrough: [] };
4063
+ if (existsSync(envPath)) {
4064
+ envState = parseEnvLines(readFileSync(envPath, "utf8"));
4065
+ }
4066
+ for (const [key, value] of Object.entries(slackTokens)) {
4067
+ if (!value) continue;
4068
+ const existing = envState.entries.get(key);
4069
+ if (existing && existing !== value && !opts.overwriteEnv) {
4070
+ throw new Error(
4071
+ `${envPath} already has ${key}; pass --overwrite-env to replace it.`
4072
+ );
4073
+ }
4074
+ }
4075
+ if (!opts.allowDuplicateSlackTokens) {
4076
+ const duplicates = findDuplicateSlackTokenProfiles(profileRoot, slackTokens);
4077
+ if (duplicates.length > 0) {
4078
+ const rendered = duplicates
4079
+ .map((dup) => `${dup.key} fingerprint ${dup.fingerprint} in ${dup.profileRoot}`)
4080
+ .join("; ");
4081
+ throw new Error(
4082
+ `Duplicate Slack token detected across Hermes profiles: ${rendered}. Pass --allow-duplicate-slack-tokens to allow this.`
4083
+ );
4084
+ }
4085
+ }
4086
+
4087
+ const hermesConfigExisted = existsSync(hermesConfigPath(opts));
4088
+ const skillsExisted = existsSync(hermesSkillsRoot(opts));
4089
+ const authExisted = existsSync(sellableConfigPath);
4090
+ const configsDirExisted = existsSync(sellableConfigsDir);
4091
+ const envExisted = existsSync(envPath);
4092
+
4093
+ const nextAuth = {
4094
+ ...existingAuth,
4095
+ apiUrl: opts.apiUrl,
4096
+ bootstrap: {
4097
+ ...(existingAuth.bootstrap &&
4098
+ typeof existingAuth.bootstrap === "object" &&
4099
+ !Array.isArray(existingAuth.bootstrap)
4100
+ ? existingAuth.bootstrap
4101
+ : {}),
4102
+ profile,
4103
+ status: token ? "ready" : "pending_auth",
4104
+ updatedAt: new Date().toISOString(),
4105
+ },
4106
+ };
4107
+ if (parsed.workspaceId) {
4108
+ nextAuth.activeWorkspaceId = parsed.workspaceId;
4109
+ nextAuth.workspaceId = parsed.workspaceId;
4110
+ }
4111
+ if (parsed.workspaceName) {
4112
+ nextAuth.activeWorkspaceName = parsed.workspaceName;
4113
+ nextAuth.workspaceName = parsed.workspaceName;
4114
+ }
4115
+ if (token) {
4116
+ nextAuth.token = token;
4117
+ } else if (!existingAuth.token) {
4118
+ delete nextAuth.token;
4119
+ }
4120
+
4121
+ if (!opts.dryRun) {
4122
+ mkdirSync(profileRoot, { recursive: true, mode: 0o700 });
4123
+ mkdirSync(sellableConfigsDir, { recursive: true, mode: 0o700 });
4124
+ writeHermesProfileSellableConfig(sellableConfigPath, nextAuth, opts);
4125
+ installHermes(opts);
4126
+ if (Object.values(slackTokens).some(Boolean)) {
4127
+ for (const [key, value] of Object.entries(slackTokens)) {
4128
+ if (value) envState.entries.set(key, value);
4129
+ }
4130
+ writeFile(envPath, renderEnvFile(envState), opts, 0o600);
4131
+ }
4132
+ }
4133
+
4134
+ (configsDirExisted ? summary.preserved : summary.created).push(
4135
+ sellableConfigsDir
4136
+ );
4137
+ (authExisted ? summary.updated : summary.created).push(sellableConfigPath);
4138
+ (hermesConfigExisted ? summary.updated : summary.created).push(
4139
+ hermesConfigPath(opts)
4140
+ );
4141
+ (skillsExisted ? summary.updated : summary.created).push(hermesSkillsRoot(opts));
4142
+ if (Object.values(slackTokens).some(Boolean)) {
4143
+ (envExisted ? summary.updated : summary.created).push(envPath);
4144
+ } else {
4145
+ summary.skipped.push("Slack env: no Slack tokens supplied");
4146
+ }
4147
+ if (!token) {
4148
+ summary.warnings.push(
4149
+ `Manual auth pending. Run: sellable auth set <token> --workspace-id <workspace_id> --sellable-config-path ${sellableConfigPath}`
4150
+ );
4151
+ }
4152
+
4153
+ if (opts.json) {
4154
+ console.log(JSON.stringify(summary, null, 2));
4155
+ } else {
4156
+ logMilestone(`Hermes profile bootstrapped at ${profileRoot}`);
4157
+ logStep(`Sellable config: ${sellableConfigPath}`);
4158
+ logStep(`Sellable configs dir: ${sellableConfigsDir}`);
4159
+ if (!token) logWarn(summary.warnings[0]);
4160
+ }
4161
+ return summary;
4162
+ } finally {
4163
+ VERBOSE = previousVerbose;
4164
+ }
4165
+ }
4166
+
4167
+ function basenameSafe(pathValue) {
4168
+ const normalized = resolve(pathValue);
4169
+ return normalized.split(/[\\/]/).filter(Boolean).pop() || "hermes-profile";
4170
+ }
4171
+
4172
+ function hermesMcpServerMatches(config, opts) {
4173
+ const server = config?.mcp_servers?.sellable;
4174
+ if (!server || typeof server !== "object") return false;
4175
+ const expected = hermesMcpServer(opts);
4176
+ return JSON.stringify(server) === JSON.stringify(expected);
4177
+ }
4178
+
4179
+ function hermesSkillChecks() {
4180
+ const root = hermesSkillsRoot();
4181
+ const skills = hermesSkillDefinitions();
4182
+ const paths = skills.map((skill) => join(root, skill.dir, "SKILL.md"));
4183
+ const allPresent = paths.every((skillPath) => existsSync(skillPath));
4184
+ const contents = paths
4185
+ .filter((skillPath) => existsSync(skillPath))
4186
+ .map((skillPath) => readFileSync(skillPath, "utf8"));
4187
+ const commandNamesCurrent =
4188
+ contents.length === skills.length &&
4189
+ skills.every((skill, index) =>
4190
+ contents[index]?.includes(`name: ${hermesCommandName(skill.commandName)}`)
4191
+ );
4192
+ const slashCommandsCurrent =
4193
+ contents.length === skills.length &&
4194
+ skills.every((skill, index) =>
4195
+ contents[index]?.includes(`/${hermesCommandName(skill.commandName)}`)
4196
+ );
4197
+ const forbiddenSyntax = contents.some((content) =>
4198
+ /\$sellable:|\/sellable:|mcp__sellable__|request_user_input|AskUserQuestion/.test(
4199
+ content
4200
+ )
4201
+ );
4202
+ return {
4203
+ root,
4204
+ paths,
4205
+ allPresent,
4206
+ commandNamesCurrent,
4207
+ slashCommandsCurrent,
4208
+ forbiddenSyntax,
4209
+ };
4210
+ }
4211
+
3280
4212
  function installClaude(opts) {
3281
4213
  if (!commandExists("claude")) {
3282
4214
  const message =
@@ -3356,7 +4288,7 @@ function canonicalClaudeMcpServer(opts) {
3356
4288
  type: "stdio",
3357
4289
  command,
3358
4290
  args,
3359
- env: { SELLABLE_WATCH_MODE_DRIVER: "claude" },
4291
+ env: mcpEnvForHost("claude", opts),
3360
4292
  alwaysLoad: true,
3361
4293
  };
3362
4294
  }
@@ -3385,7 +4317,7 @@ function claudeMcpServerMatches(server, canonical) {
3385
4317
  server.type === canonical.type &&
3386
4318
  server.command === canonical.command &&
3387
4319
  JSON.stringify(server.args || []) === JSON.stringify(canonical.args) &&
3388
- server.env?.SELLABLE_WATCH_MODE_DRIVER === "claude"
4320
+ JSON.stringify(server.env || {}) === JSON.stringify(canonical.env)
3389
4321
  );
3390
4322
  }
3391
4323
 
@@ -3444,12 +4376,10 @@ function patchClaudeAlwaysLoad(opts) {
3444
4376
  server.args = [...canonical.args];
3445
4377
  touched = true;
3446
4378
  }
3447
- const existingDriver = server.env?.SELLABLE_WATCH_MODE_DRIVER;
3448
- server.env = {
3449
- ...(server.env && typeof server.env === "object" ? server.env : {}),
3450
- SELLABLE_WATCH_MODE_DRIVER: "claude",
3451
- };
3452
- if (existingDriver !== "claude") {
4379
+ const existingEnv =
4380
+ server.env && typeof server.env === "object" ? server.env : {};
4381
+ server.env = { ...canonical.env };
4382
+ if (JSON.stringify(existingEnv) !== JSON.stringify(server.env)) {
3453
4383
  touched = true;
3454
4384
  }
3455
4385
  }
@@ -3558,9 +4488,16 @@ function warningCheck(ok, label) {
3558
4488
  return { ok, label, required: false };
3559
4489
  }
3560
4490
 
4491
+ function firstRunCommandForHost(host) {
4492
+ if (host === "hermes") return "/sellable-create-campaign";
4493
+ if (host === "codex") return "$sellable:create-campaign";
4494
+ if (host === "claude") return "/sellable:create-campaign";
4495
+ return "/sellable:create-campaign, $sellable:create-campaign, or /sellable-create-campaign";
4496
+ }
4497
+
3561
4498
  async function verify(opts) {
3562
4499
  const startedAt = new Date().toISOString();
3563
- const stored = readStoredAuth();
4500
+ const stored = readStoredAuth(opts);
3564
4501
  const checks = [];
3565
4502
  const hasWatchModeDriverMarker = (content, driver) =>
3566
4503
  new RegExp(`SELLABLE_WATCH_MODE_DRIVER[\\s\\S]{0,80}${driver}`).test(
@@ -3568,12 +4505,12 @@ async function verify(opts) {
3568
4505
  ) || new RegExp(`[?&]driver=${driver}(?:\\b|&)`).test(content);
3569
4506
 
3570
4507
  if (stored?.token && stored?.workspaceId) {
3571
- checks.push(warningCheck(true, `Auth config: ${authPath()}`));
4508
+ checks.push(warningCheck(true, `Auth config: ${authPath(opts)}`));
3572
4509
  } else {
3573
4510
  checks.push(
3574
4511
  warningCheck(
3575
4512
  true,
3576
- `Auth: not yet signed in — sign in on first run of /sellable:create-campaign`
4513
+ `Auth: not yet signed in — sign in on first run of ${firstRunCommandForHost(opts.host)}`
3577
4514
  )
3578
4515
  );
3579
4516
  }
@@ -3860,6 +4797,88 @@ async function verify(opts) {
3860
4797
  )
3861
4798
  );
3862
4799
  }
4800
+ if (opts.host === "hermes" || opts.host === "all") {
4801
+ let hermesConfig = null;
4802
+ let hermesConfigError = "";
4803
+ try {
4804
+ hermesConfig = readHermesConfig().config;
4805
+ } catch (err) {
4806
+ hermesConfigError = err instanceof Error ? err.message : String(err);
4807
+ }
4808
+ const npmPresent = opts.server !== "package" || commandExists(packageManagerCommand());
4809
+ const skillChecks = hermesSkillChecks();
4810
+ const configMatches = hermesConfig
4811
+ ? hermesMcpServerMatches(hermesConfig, opts)
4812
+ : false;
4813
+ const hasHermesDriver =
4814
+ hermesConfig?.mcp_servers?.sellable?.env?.SELLABLE_WATCH_MODE_DRIVER ===
4815
+ "hermes";
4816
+
4817
+ const check =
4818
+ opts.host === "hermes" ? requiredCheck : warningCheck;
4819
+ checks.push(
4820
+ check(
4821
+ !hermesConfigError,
4822
+ hermesConfigError || `Hermes config readable: ${hermesConfigPath()}`
4823
+ )
4824
+ );
4825
+ checks.push(
4826
+ check(
4827
+ configMatches,
4828
+ configMatches
4829
+ ? "Hermes Sellable MCP entry canonical"
4830
+ : "Hermes Sellable MCP entry missing or stale"
4831
+ )
4832
+ );
4833
+ checks.push(
4834
+ check(
4835
+ hasHermesDriver,
4836
+ hasHermesDriver
4837
+ ? "Hermes watch mode driver pinned to hermes"
4838
+ : "Hermes watch mode driver missing"
4839
+ )
4840
+ );
4841
+ checks.push(
4842
+ check(
4843
+ skillChecks.allPresent,
4844
+ skillChecks.allPresent
4845
+ ? "Hermes Sellable skills present"
4846
+ : "Hermes Sellable skills missing"
4847
+ )
4848
+ );
4849
+ checks.push(
4850
+ check(
4851
+ skillChecks.commandNamesCurrent,
4852
+ skillChecks.commandNamesCurrent
4853
+ ? "Hermes skill names current"
4854
+ : "Hermes skill names stale"
4855
+ )
4856
+ );
4857
+ checks.push(
4858
+ check(
4859
+ skillChecks.slashCommandsCurrent,
4860
+ skillChecks.slashCommandsCurrent
4861
+ ? "Hermes slash command hints current"
4862
+ : "Hermes slash command hints stale"
4863
+ )
4864
+ );
4865
+ checks.push(
4866
+ check(
4867
+ !skillChecks.forbiddenSyntax,
4868
+ !skillChecks.forbiddenSyntax
4869
+ ? "Hermes skills use Hermes MCP/tool syntax"
4870
+ : "Hermes skills contain Claude/Codex-only syntax"
4871
+ )
4872
+ );
4873
+ checks.push(
4874
+ check(
4875
+ npmPresent,
4876
+ npmPresent
4877
+ ? "Hermes package runtime command present"
4878
+ : `Hermes package runtime command missing: ${packageManagerCommand()}`
4879
+ )
4880
+ );
4881
+ }
3863
4882
 
3864
4883
  let runtimeMcp;
3865
4884
  try {
@@ -3870,6 +4889,7 @@ async function verify(opts) {
3870
4889
  cwd: process.cwd(),
3871
4890
  env: {
3872
4891
  ...process.env,
4892
+ ...runtimeMcpEnvForVerify(opts),
3873
4893
  SELLABLE_API_URL: opts.apiUrl,
3874
4894
  SELLABLE_TOKEN: opts.token || process.env.SELLABLE_TOKEN || "",
3875
4895
  SELLABLE_WORKSPACE_ID:
@@ -3925,6 +4945,11 @@ async function verify(opts) {
3925
4945
  install: INSTALL_PACKAGE_SPEC,
3926
4946
  mcp: opts.mcpPackage,
3927
4947
  },
4948
+ sellableConfig: {
4949
+ path: authPath(opts),
4950
+ explicitPath: opts.sellableConfigPath || null,
4951
+ configsDir: opts.sellableConfigsDir || null,
4952
+ },
3928
4953
  checks,
3929
4954
  runtimeMcp,
3930
4955
  threadExposureNotice:
@@ -4042,7 +5067,7 @@ function printNextSteps(installedHosts, authReused) {
4042
5067
  ` ${C.bold}Almost there — install an agent first.${C.reset}`
4043
5068
  );
4044
5069
  console.log(
4045
- ` ${C.yellow}!${C.reset} No agent CLI found (Claude Code or Codex)`
5070
+ ` ${C.yellow}!${C.reset} No agent CLI/profile found (Claude Code, Codex, or Hermes)`
4046
5071
  );
4047
5072
  console.log("");
4048
5073
  console.log("");
@@ -4086,6 +5111,7 @@ function printNextSteps(installedHosts, authReused) {
4086
5111
 
4087
5112
  const hasClaude = installedHosts.includes("Claude Code");
4088
5113
  const hasCodex = installedHosts.includes("Codex");
5114
+ const hasHermes = installedHosts.includes("Hermes");
4089
5115
 
4090
5116
  // Header
4091
5117
  if (authReused) {
@@ -4101,6 +5127,11 @@ function printNextSteps(installedHosts, authReused) {
4101
5127
  console.log(` ${C.green}✓${C.reset} Skills installed`);
4102
5128
  console.log(` ${C.green}✓${C.reset} Codex custom agents installed`);
4103
5129
  }
5130
+ if (hasHermes) {
5131
+ console.log(
5132
+ ` ${C.green}✓${C.reset} Hermes skills and MCP config installed`
5133
+ );
5134
+ }
4104
5135
  if (hasClaude) {
4105
5136
  console.log(
4106
5137
  ` ${C.green}✓${C.reset} Claude slash commands and custom agents installed`
@@ -4144,6 +5175,19 @@ function printNextSteps(installedHosts, authReused) {
4144
5175
  ]);
4145
5176
  console.log("");
4146
5177
  }
5178
+ if (hasHermes) {
5179
+ printAgentBox("Using Hermes?", "hermes", [
5180
+ { label: "Campaign", command: "/sellable-create-campaign" },
5181
+ { label: "A/B Test", command: "/sellable-create-ab-test" },
5182
+ { label: "Evergreen", command: "/sellable-create-evergreen-campaigns" },
5183
+ { label: "Foundation", command: "/sellable-foundation" },
5184
+ { label: "Content", command: "/sellable-content" },
5185
+ { label: "Post", command: "/sellable-create-post" },
5186
+ { label: "Refresh", command: "/sellable-refresh-sender-engagement" },
5187
+ { label: "Refill", command: "/sellable-refill-sends" },
5188
+ ]);
5189
+ console.log("");
5190
+ }
4147
5191
 
4148
5192
  console.log("");
4149
5193
  console.log(` ${"─".repeat(63)}`);
@@ -4173,7 +5217,7 @@ function removeTomlSection(content, header) {
4173
5217
  return content.replace(pattern, "");
4174
5218
  }
4175
5219
 
4176
- function runUninstall() {
5220
+ function runUninstall(opts = { host: "all" }) {
4177
5221
  console.log("");
4178
5222
  console.log(` ${C.bold}Uninstalling Sellable…${C.reset}`);
4179
5223
  console.log("");
@@ -4184,7 +5228,7 @@ function runUninstall() {
4184
5228
 
4185
5229
  // 1) Surgical removal from Codex config.toml
4186
5230
  const codexConfigPath = join(codexHome(), "config.toml");
4187
- if (existsSync(codexConfigPath)) {
5231
+ if ((opts.host === "codex" || opts.host === "all") && existsSync(codexConfigPath)) {
4188
5232
  try {
4189
5233
  const before = readFileSync(codexConfigPath, "utf8");
4190
5234
  const ts = new Date()
@@ -4221,10 +5265,11 @@ function runUninstall() {
4221
5265
  ` ${C.yellow}!${C.reset} Could not edit Codex config: ${err instanceof Error ? err.message : String(err)}`
4222
5266
  );
4223
5267
  }
4224
- } else {
5268
+ } else if (opts.host === "codex" || opts.host === "all") {
4225
5269
  skipped.push(`Codex config not found at ${codexConfigPath}`);
4226
5270
  }
4227
5271
 
5272
+ if (opts.host === "codex" || opts.host === "all") {
4228
5273
  for (const agent of [...codexCustomAgents(), ...legacyCodexCustomAgents()]) {
4229
5274
  const agentPath = join(codexHome(), "agents", agent.filename);
4230
5275
  if (!existsSync(agentPath)) continue;
@@ -4237,9 +5282,10 @@ function runUninstall() {
4237
5282
  );
4238
5283
  }
4239
5284
  }
5285
+ }
4240
5286
 
4241
5287
  // 2) Claude Code MCP removal
4242
- if (commandExists("claude")) {
5288
+ if ((opts.host === "claude" || opts.host === "all") && commandExists("claude")) {
4243
5289
  const r = spawnSync("claude", ["mcp", "remove", "sellable"], {
4244
5290
  encoding: "utf8",
4245
5291
  });
@@ -4248,10 +5294,11 @@ function runUninstall() {
4248
5294
  } else {
4249
5295
  skipped.push(`Claude Code MCP (none registered or already removed)`);
4250
5296
  }
4251
- } else {
5297
+ } else if (opts.host === "claude" || opts.host === "all") {
4252
5298
  skipped.push(`Claude Code CLI not found`);
4253
5299
  }
4254
5300
 
5301
+ if (opts.host === "claude" || opts.host === "all") {
4255
5302
  for (const agent of [
4256
5303
  ...claudeCustomAgents(),
4257
5304
  ...legacyClaudeCustomAgents(),
@@ -4279,10 +5326,66 @@ function runUninstall() {
4279
5326
  );
4280
5327
  }
4281
5328
  }
5329
+ }
5330
+
5331
+ // 3) Hermes config and skill removal
5332
+ if (opts.host === "hermes" || opts.host === "all") {
5333
+ const configPath = hermesConfigPath();
5334
+ if (existsSync(configPath)) {
5335
+ try {
5336
+ const before = readFileSync(configPath, "utf8");
5337
+ const ts = new Date()
5338
+ .toISOString()
5339
+ .replace(/[-:]/g, "")
5340
+ .replace(/\..+/, "");
5341
+ const backupPath = `${configPath}.bak-sellable-uninstall-${ts}`;
5342
+ writeFileSync(backupPath, before, { mode: 0o600 });
5343
+ const config = parseHermesConfig(before, configPath);
5344
+ if (
5345
+ config.mcp_servers &&
5346
+ typeof config.mcp_servers === "object" &&
5347
+ !Array.isArray(config.mcp_servers) &&
5348
+ "sellable" in config.mcp_servers
5349
+ ) {
5350
+ const nextMcpServers = { ...config.mcp_servers };
5351
+ delete nextMcpServers.sellable;
5352
+ writeHermesConfig(
5353
+ configPath,
5354
+ { ...config, mcp_servers: nextMcpServers },
5355
+ { dryRun: false }
5356
+ );
5357
+ removed.push(`Hermes config (sellable removed in ${configPath})`);
5358
+ removed.push(` backup: ${backupPath}`);
5359
+ } else {
5360
+ skipped.push(`Hermes config had no sellable MCP server`);
5361
+ }
5362
+ } catch (err) {
5363
+ console.log(
5364
+ ` ${C.yellow}!${C.reset} Could not edit Hermes config: ${err instanceof Error ? err.message : String(err)}`
5365
+ );
5366
+ }
5367
+ } else {
5368
+ skipped.push(`Hermes config not found at ${configPath}`);
5369
+ }
5370
+
5371
+ const skillsRoot = hermesSkillsRoot();
5372
+ if (existsSync(skillsRoot)) {
5373
+ try {
5374
+ rmSync(skillsRoot, { recursive: true, force: true });
5375
+ removed.push(`${skillsRoot}`);
5376
+ } catch (err) {
5377
+ console.log(
5378
+ ` ${C.yellow}!${C.reset} Could not remove ${skillsRoot}: ${err instanceof Error ? err.message : String(err)}`
5379
+ );
5380
+ }
5381
+ } else {
5382
+ skipped.push(`Hermes Sellable skills not found at ${skillsRoot}`);
5383
+ }
5384
+ }
4282
5385
 
4283
- // 3) Surgical removal of Sellable-installed artifacts inside ~/.sellable/
5386
+ // 4) Surgical removal of Sellable-installed artifacts inside ~/.sellable/
4284
5387
  const sellableDir = join(homedir(), ".sellable");
4285
- if (existsSync(sellableDir)) {
5388
+ if (opts.host === "all" && existsSync(sellableDir)) {
4286
5389
  const tracked = [
4287
5390
  "config.json",
4288
5391
  "update-check.json",
@@ -4321,13 +5424,13 @@ function runUninstall() {
4321
5424
  // ignore
4322
5425
  }
4323
5426
  }
4324
- } else {
5427
+ } else if (opts.host === "all") {
4325
5428
  skipped.push(`~/.sellable not present`);
4326
5429
  }
4327
5430
 
4328
- // 4) Self-shim
5431
+ // 5) Self-shim
4329
5432
  const shimPath = join(homedir(), ".local", "bin", "sellable");
4330
- if (existsSync(shimPath)) {
5433
+ if (opts.host === "all" && existsSync(shimPath)) {
4331
5434
  try {
4332
5435
  rmSync(shimPath, { force: true });
4333
5436
  removed.push(`${shimPath}`);
@@ -4375,6 +5478,7 @@ async function main() {
4375
5478
  const authSetFlags = rawArgs.slice(3);
4376
5479
  let dryRun = false;
4377
5480
  let workspaceId = "";
5481
+ let sellableConfigPath = process.env.SELLABLE_CONFIG_PATH || "";
4378
5482
  for (let i = 0; i < authSetFlags.length; i += 1) {
4379
5483
  const flag = authSetFlags[i];
4380
5484
  if (flag === "--dry-run") {
@@ -4391,6 +5495,16 @@ async function main() {
4391
5495
  i += 1;
4392
5496
  continue;
4393
5497
  }
5498
+ if (flag === "--sellable-config-path") {
5499
+ const value = authSetFlags[i + 1];
5500
+ if (!value || value.startsWith("--")) {
5501
+ console.error("Missing value for --sellable-config-path");
5502
+ process.exit(2);
5503
+ }
5504
+ sellableConfigPath = value;
5505
+ i += 1;
5506
+ continue;
5507
+ }
4394
5508
  console.error(`Unknown auth set option: ${flag}`);
4395
5509
  process.exit(2);
4396
5510
  }
@@ -4415,11 +5529,18 @@ async function main() {
4415
5529
  // Skip installSelfShim() — by definition the user has the shim already
4416
5530
  // (they invoked `sellable auth set` from it).
4417
5531
  const apiUrl = process.env.SELLABLE_API_URL || DEFAULT_API_URL;
4418
- writeAuthSetConfig({ token, workspaceId, apiUrl, dryRun });
5532
+ writeAuthSetConfig({
5533
+ token,
5534
+ workspaceId,
5535
+ apiUrl,
5536
+ dryRun,
5537
+ sellableConfigPath,
5538
+ });
5539
+ const configPath = authPath({ sellableConfigPath });
4419
5540
  if (dryRun) {
4420
- console.log(`Dry run: token would be saved to ${authPath()}`);
5541
+ console.log(`Dry run: token would be saved to ${configPath}`);
4421
5542
  } else {
4422
- console.log(`✓ Token saved to ${authPath()}`);
5543
+ console.log(`✓ Token saved to ${configPath}`);
4423
5544
  }
4424
5545
  console.log(` apiUrl: ${apiUrl}`);
4425
5546
  if (workspaceId) {
@@ -4440,6 +5561,13 @@ async function main() {
4440
5561
  console.log(` Codex: $sellable:create-post`);
4441
5562
  console.log(` Codex: $sellable:refresh-sender-engagement`);
4442
5563
  console.log(` Codex: $sellable:refill-sends`);
5564
+ console.log(` Hermes: /sellable-create-campaign`);
5565
+ console.log(` Hermes: /sellable-create-evergreen-campaigns`);
5566
+ console.log(` Hermes: /sellable-foundation`);
5567
+ console.log(` Hermes: /sellable-content`);
5568
+ console.log(` Hermes: /sellable-create-post`);
5569
+ console.log(` Hermes: /sellable-refresh-sender-engagement`);
5570
+ console.log(` Hermes: /sellable-refill-sends`);
4443
5571
  process.exit(0);
4444
5572
  }
4445
5573
  if (rawArgs[0] === "prefs") {
@@ -4482,13 +5610,22 @@ async function main() {
4482
5610
  process.exit(0);
4483
5611
  }
4484
5612
  if (rawArgs[0] === "uninstall") {
4485
- runUninstall();
5613
+ const opts = parseArgs(rawArgs.slice(1));
5614
+ runUninstall(opts);
4486
5615
  process.exit(0);
4487
5616
  }
4488
5617
  if (rawArgs[0] === "create") {
4489
5618
  printCreateCommandHint();
4490
5619
  process.exit(0);
4491
5620
  }
5621
+ if (
5622
+ rawArgs[0] === "hermes" &&
5623
+ rawArgs[1] === "profile" &&
5624
+ rawArgs[2] === "bootstrap"
5625
+ ) {
5626
+ runHermesProfileBootstrap(rawArgs.slice(3));
5627
+ process.exit(0);
5628
+ }
4492
5629
  const opts = parseArgs(process.argv.slice(2));
4493
5630
  if (opts.help) {
4494
5631
  console.log(usage());
@@ -4500,7 +5637,7 @@ async function main() {
4500
5637
  if (!opts.verifyOnly) {
4501
5638
  printBanner();
4502
5639
  console.log(
4503
- ` ${C.bold}Connecting Sellable to Claude Code and Codex…${C.reset}`
5640
+ ` ${C.bold}Connecting Sellable to Claude Code, Codex, and Hermes…${C.reset}`
4504
5641
  );
4505
5642
  console.log("");
4506
5643
  } else if (!opts.json) {
@@ -4536,6 +5673,15 @@ async function main() {
4536
5673
  installedHosts.push("Codex");
4537
5674
  }
4538
5675
  }
5676
+ if (
5677
+ opts.host === "hermes" ||
5678
+ (opts.host === "all" && hermesLikelyInstalled())
5679
+ ) {
5680
+ const result = installHermes(opts);
5681
+ if (result.installed) {
5682
+ installedHosts.push("Hermes");
5683
+ }
5684
+ }
4539
5685
  }
4540
5686
 
4541
5687
  if (opts.dryRun) {