@sellable/install 0.1.303 → 0.1.304

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
  };
@@ -1175,6 +1235,29 @@ the Claude Code instruction for customer-facing language and host functions.
1175
1235
 
1176
1236
  Do not tell Claude Code users to run \`$sellable:${commandName}\`, use
1177
1237
  \`request_user_input\`, or restart Codex Desktop. Do not describe this run as Codex.`;
1238
+ }
1239
+ if (host === "hermes") {
1240
+ return `## Installed Host Contract
1241
+
1242
+ This installed skill is running in Hermes Agent. When the shared workflow body
1243
+ or fallback text mentions Claude Code, Codex, or Hermes for internal parity,
1244
+ choose the Hermes instruction for customer-facing language and host functions.
1245
+
1246
+ - Customer-facing command: \`/sellable-${commandName}\`
1247
+ - MCP tool naming: Hermes exposes Sellable tools as \`mcp_sellable_<tool>\`;
1248
+ when shared instructions show \`mcp__sellable__<tool>\`, call the matching
1249
+ \`mcp_sellable_<tool>\` tool instead.
1250
+ - Structured questions: ask plainly in chat unless a Hermes-native approval or
1251
+ question tool is visible in the current session.
1252
+ - Bootstrap host label: \`host: "Hermes"\`
1253
+ - Install/reload blocker label: Hermes install/reload problem
1254
+ - Reload instruction: restart Hermes, or run \`/reload-mcp\` in the active
1255
+ Hermes session after install
1256
+
1257
+ Do not tell Hermes users to run \`$sellable:${commandName}\` or
1258
+ \`/sellable:${commandName}\`, use \`AskUserQuestion\` or \`request_user_input\`,
1259
+ or restart Claude Code or Codex Desktop. Do not describe this run as Claude Code
1260
+ or Codex.`;
1178
1261
  }
1179
1262
  return "";
1180
1263
  }
@@ -2584,6 +2667,256 @@ function codexPluginSkills() {
2584
2667
  ];
2585
2668
  }
2586
2669
 
2670
+ function hermesCommandName(commandName) {
2671
+ return `sellable-${commandName}`;
2672
+ }
2673
+
2674
+ function normalizeHermesSkillMarkdown(markdown, commandName) {
2675
+ const hermesName = hermesCommandName(commandName);
2676
+ let content = String(markdown);
2677
+ if (/^name:\s*.+$/m.test(content)) {
2678
+ content = content.replace(/^name:\s*.+$/m, `name: ${hermesName}`);
2679
+ } else {
2680
+ content = content.replace(/^---\n/m, `---\nname: ${hermesName}\n`);
2681
+ }
2682
+
2683
+ let normalized = content
2684
+ .replace(/mcp__sellable__<tool>/g, "mcp_sellable_<tool>")
2685
+ .replace(/mcp__([A-Za-z0-9_-]+)__<tool>/g, (_, server) => {
2686
+ return `mcp_${String(server).replaceAll("-", "_")}_<tool>`;
2687
+ })
2688
+ .replace(/\bmcp__([A-Za-z0-9_-]+)__([A-Za-z0-9_-]+)\b/g, (_, server, tool) => {
2689
+ return `mcp_${String(server).replaceAll("-", "_")}_${String(tool).replaceAll("-", "_")}`;
2690
+ })
2691
+ .replace(/\bmcp__sellable__/g, "mcp_sellable_")
2692
+ .replace(/\$sellable:/g, "/sellable-")
2693
+ .replace(/\/sellable:/g, "/sellable-")
2694
+ .replace(/Codex install\/reload problem/g, "Hermes install/reload problem")
2695
+ .replace(/Claude Code install\/reload problem/g, "Hermes install/reload problem")
2696
+ .replace(/fully quit and reopen Codex Desktop, then start a new thread/g, "restart Hermes, or run `/reload-mcp` in the active Hermes session")
2697
+ .replace(/fully quit and reopen Claude Code, then start a new session/g, "restart Hermes, or run `/reload-mcp` in the active Hermes session")
2698
+ .replace(/Codex Desktop/g, "Hermes")
2699
+ .replace(/Claude Code\/Codex/g, "Hermes")
2700
+ .replace(/Claude Code or Codex/g, "Hermes")
2701
+ .replace(/Codex or Claude Code/g, "Hermes")
2702
+ .replace(/Claude Code and Codex/g, "Hermes")
2703
+ .replace(/Codex\/Claude/g, "Hermes")
2704
+ .replace(/quick question panel/g, "approval prompt")
2705
+ .replace(/request_user_input/g, "plain chat")
2706
+ .replace(/AskUserQuestion/g, "plain chat");
2707
+
2708
+ normalized = normalized.replace(
2709
+ /Do not tell Hermes users to run[\s\S]*?Do not describe this run as Claude Code\nor Codex\./g,
2710
+ "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."
2711
+ );
2712
+
2713
+ normalized = normalized.replace(
2714
+ /## Structured Questions\n\nUse the host-native structured question gate for intake and approval:[\s\S]*?## Host Runtime Functions/g,
2715
+ `## Structured Questions
2716
+
2717
+ Hermes should ask setup questions directly in chat unless the current Hermes
2718
+ session exposes a native approval or question tool. Use a bounded approval gate
2719
+ only for explicit approval decisions. For open text like LinkedIn URLs, company
2720
+ domains, notes, pasted context, campaign ideas, or feedback, ask in normal chat
2721
+ and wait for the user to paste the value.
2722
+
2723
+ Campaign setup questions are single-choice decisions. Use mutually exclusive
2724
+ options and route blended or custom answers through a short free-text follow-up.
2725
+ Customer-facing language should call this "a couple setup choices" during
2726
+ normal campaign progress.
2727
+
2728
+ ## Host Runtime Functions`
2729
+ );
2730
+
2731
+ normalized = normalized.replace(
2732
+ /## Names To Use\n\nUse these exact public names[\s\S]*?## Structured Questions/g,
2733
+ `## Names To Use
2734
+
2735
+ Use these exact public names for Hermes:
2736
+
2737
+ - Hermes command: \`/${hermesName}\`
2738
+ - Hermes skill directory: \`skills/sellable/${hermesName}/SKILL.md\`
2739
+ - MCP server name: \`sellable\`
2740
+ - Hermes MCP tool prefix: \`mcp_sellable_\`
2741
+ - Internal workflow prompt: \`create-campaign-v2\`
2742
+
2743
+ Do not tell users to run internal subskill names. \`create-campaign-v2\` is only
2744
+ the internal subskill loaded through
2745
+ \`mcp_sellable_get_subskill_prompt({ subskillName: "create-campaign-v2" })\`.
2746
+
2747
+ ## Structured Questions`
2748
+ );
2749
+
2750
+ normalized = normalized.replace(
2751
+ /- `ask_user`:[\s\S]*?(?=\n- `load_subprompt`:)/g,
2752
+ `- \`ask_user\`: ask directly in chat unless a Hermes-native approval or
2753
+ question tool is visible in the current session. Use this for
2754
+ multiple-choice intake, campaign-focus choices, source decisions, and
2755
+ approvals. Campaign setup questions are single-choice only; do not use
2756
+ multi-select or checkbox variants.`
2757
+ );
2758
+
2759
+ normalized = normalized.replace(
2760
+ /- `launch_message_drafting`:[\s\S]*?\n\nIf a required interactive/g,
2761
+ `- \`launch_message_drafting\`: if Hermes exposes a background-agent capability,
2762
+ use the compatible Message Drafting agent. Otherwise run the same message
2763
+ drafting branch inline in the parent session with \`statusSource:
2764
+ "parent-thread-fallback"\`.
2765
+
2766
+ If a required interactive`
2767
+ );
2768
+
2769
+ normalized = normalized.replace(
2770
+ /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,
2771
+ ""
2772
+ );
2773
+
2774
+ normalized = normalized.replace(
2775
+ /## 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,
2776
+ `## Active Model Metadata
2777
+
2778
+ Before calling \`bootstrap_create_campaign\`, pass \`host: "Hermes"\`. If the
2779
+ current Hermes session exposes model and effort metadata for this same turn,
2780
+ pass those exact values with \`modelMetadataSource: "hermes_runtime_metadata"\`.
2781
+ If the current Hermes session context explicitly states both the exact model ID
2782
+ and effort/thinking level, pass them with
2783
+ \`modelMetadataSource: "hermes_session_context"\`. Otherwise omit \`model\`,
2784
+ \`reasoningEffort\`, and \`modelMetadataSource\`.
2785
+
2786
+ Never invent the model or reasoning effort. Never inspect Codex or Claude config
2787
+ files as proof of the active Hermes session. If bootstrap returns
2788
+ \`modelQuality.status === "unknown"\`, continue without asking the user to
2789
+ switch models.
2790
+
2791
+ `
2792
+ );
2793
+
2794
+ normalized = normalized.replace(/not `plain chat`/g, "not internal API names");
2795
+
2796
+ normalized = normalized
2797
+ .replace(/this is a Codex\ninstall\/reload problem/g, "this is a Hermes\ninstall/reload problem")
2798
+ .replace(/Hermes plugin/g, "Hermes skills");
2799
+
2800
+ return normalized;
2801
+ }
2802
+
2803
+ function compactHermesEvergreenSkillMd() {
2804
+ return normalizeHermesSkillMarkdown(
2805
+ stampInstalledHost(`---
2806
+ name: create-evergreen-campaigns
2807
+ description: Create or repair evergreen campaign lanes without launching sends.
2808
+ visibility: public
2809
+ allowed-tools:
2810
+ ${allowedToolsYaml(CREATE_EVERGREEN_CAMPAIGNS_ALLOWED_TOOLS)}
2811
+ ---
2812
+
2813
+ # Sellable Create Evergreen Campaigns
2814
+
2815
+ Use this as the customer-facing entrypoint for the Sellable
2816
+ \`create-evergreen-campaigns\` workflow in Hermes.
2817
+
2818
+ ## Bootstrap
2819
+
2820
+ MCP prompt and command access are required. First call
2821
+ \`mcp__sellable__get_auth_status({})\`. Do not inspect repo files, run shell
2822
+ commands, use \`npm\`, \`node\`, local harness scripts, or read local prompt
2823
+ files to emulate this workflow.
2824
+
2825
+ If the Sellable MCP prompt tools are unavailable, stop and say this is a Hermes
2826
+ install/reload problem. Tell the user to run
2827
+ \`curl -fsSL "https://app.sellable.dev/api/v2/cli/install" | sh\`, then restart
2828
+ Hermes or run \`/reload-mcp\`.
2829
+
2830
+ ## Execute Workflow
2831
+
2832
+ 1. Load the canonical prompt via
2833
+ \`mcp__sellable__get_subskill_prompt({ subskillName: "create-evergreen-campaigns" })\`.
2834
+ If the response has \`hasMore=true\`, continue with \`nextOffset\` until
2835
+ \`hasMore=false\`.
2836
+ 2. Ask for bounded approval in normal chat before mutating if the plan requires
2837
+ approval. Do not call Codex app thread tools or Codex CLI workers from
2838
+ Hermes.
2839
+ 3. Use \`mcp__sellable__setup_evergreen_campaigns({ mode: "plan" })\` for the
2840
+ plan/readback packet, then use the canonical prompt's approved mutation path
2841
+ and return to \`setup_evergreen_campaigns({ mode: "verify" })\` with
2842
+ customer-visible receipts.
2843
+ 4. Do not launch campaigns, schedule sends, archive campaigns, delete tables,
2844
+ or spend paid credits.
2845
+
2846
+ ## MCP Prompt Fallback
2847
+
2848
+ If exact subskill lookup fails, use
2849
+ \`mcp__sellable__search_subskill_prompts({ query: "create-evergreen-campaigns", includePublic: true, includeInternal: true })\`,
2850
+ then retry \`get_subskill_prompt\`.
2851
+ `, "hermes", "create-evergreen-campaigns"),
2852
+ "create-evergreen-campaigns"
2853
+ );
2854
+ }
2855
+
2856
+ function hermesSkillDefinitions() {
2857
+ return [
2858
+ {
2859
+ commandName: "create-campaign",
2860
+ dir: hermesCommandName("create-campaign"),
2861
+ skillMd: normalizeHermesSkillMarkdown(
2862
+ createCampaignSkillMd("hermes"),
2863
+ "create-campaign"
2864
+ ),
2865
+ soulMd: createCampaignSoulMd(),
2866
+ },
2867
+ {
2868
+ commandName: "create-ab-test",
2869
+ dir: hermesCommandName("create-ab-test"),
2870
+ skillMd: normalizeHermesSkillMarkdown(
2871
+ createAbTestSkillMd("hermes"),
2872
+ "create-ab-test"
2873
+ ),
2874
+ },
2875
+ {
2876
+ commandName: "create-evergreen-campaigns",
2877
+ dir: hermesCommandName("create-evergreen-campaigns"),
2878
+ skillMd: compactHermesEvergreenSkillMd(),
2879
+ },
2880
+ {
2881
+ commandName: "foundation",
2882
+ dir: hermesCommandName("foundation"),
2883
+ skillMd: normalizeHermesSkillMarkdown(
2884
+ foundationSkillMd("hermes"),
2885
+ "foundation"
2886
+ ),
2887
+ },
2888
+ {
2889
+ commandName: "content",
2890
+ dir: hermesCommandName("content"),
2891
+ skillMd: normalizeHermesSkillMarkdown(contentSkillMd("hermes"), "content"),
2892
+ },
2893
+ {
2894
+ commandName: "create-post",
2895
+ dir: hermesCommandName("create-post"),
2896
+ skillMd: normalizeHermesSkillMarkdown(
2897
+ createPostSkillMd("hermes"),
2898
+ "create-post"
2899
+ ),
2900
+ },
2901
+ {
2902
+ commandName: "refresh-sender-engagement",
2903
+ dir: hermesCommandName("refresh-sender-engagement"),
2904
+ skillMd: normalizeHermesSkillMarkdown(
2905
+ refreshSenderEngagementSkillMd("hermes"),
2906
+ "refresh-sender-engagement"
2907
+ ),
2908
+ },
2909
+ {
2910
+ commandName: "refill-sends",
2911
+ dir: hermesCommandName("refill-sends"),
2912
+ skillMd: normalizeHermesSkillMarkdown(
2913
+ refillSendsSkillMd("hermes"),
2914
+ "refill-sends"
2915
+ ),
2916
+ },
2917
+ ];
2918
+ }
2919
+
2587
2920
  function installerPackageRoot() {
2588
2921
  return join(dirname(fileURLToPath(import.meta.url)), "..");
2589
2922
  }
@@ -3095,7 +3428,7 @@ function writeAuth(opts) {
3095
3428
  activeWorkspaceId: opts.workspaceId,
3096
3429
  apiUrl: opts.apiUrl,
3097
3430
  };
3098
- writeJson(authPath(), config, opts);
3431
+ writeJson(authPath(opts), config, opts);
3099
3432
  return { written: true, reused: false };
3100
3433
  }
3101
3434
 
@@ -3152,8 +3485,43 @@ exec ${shellQuote(npmCommand)} exec --yes --package ${shellQuote(INSTALL_PACKAGE
3152
3485
  const WATCH_MODE_DRIVER_ENV = {
3153
3486
  claude: "SELLABLE_WATCH_MODE_DRIVER=claude",
3154
3487
  codex: "SELLABLE_WATCH_MODE_DRIVER=codex",
3488
+ hermes: "SELLABLE_WATCH_MODE_DRIVER=hermes",
3155
3489
  };
3156
3490
 
3491
+ function mcpEnvForHost(host, opts) {
3492
+ const env = {
3493
+ SELLABLE_WATCH_MODE_DRIVER: host,
3494
+ };
3495
+ if (opts.server === "hosted") return env;
3496
+ if (opts.sellableConfigPath) {
3497
+ env.SELLABLE_CONFIG_PATH = opts.sellableConfigPath;
3498
+ }
3499
+ if (opts.sellableConfigsDir) {
3500
+ env.SELLABLE_CONFIGS_DIR = opts.sellableConfigsDir;
3501
+ }
3502
+ return env;
3503
+ }
3504
+
3505
+ function mcpEnvVarArgs(host, opts) {
3506
+ return Object.entries(mcpEnvForHost(host, opts)).map(
3507
+ ([key, value]) => `${key}=${value}`
3508
+ );
3509
+ }
3510
+
3511
+ function tomlEnvTable(host, opts) {
3512
+ return Object.entries(mcpEnvForHost(host, opts))
3513
+ .map(([key, value]) => `${key} = ${quoteToml(value)}`)
3514
+ .join("\n");
3515
+ }
3516
+
3517
+ function runtimeMcpEnvForVerify(opts) {
3518
+ const host =
3519
+ opts.host === "claude" || opts.host === "codex" || opts.host === "hermes"
3520
+ ? opts.host
3521
+ : "codex";
3522
+ return mcpEnvForHost(host, opts);
3523
+ }
3524
+
3157
3525
  function withHostedWatchModeDriver(rawUrl, driver) {
3158
3526
  if (!WATCH_MODE_DRIVER_ENV[driver]) {
3159
3527
  throw new Error(`Unknown watch mode driver: ${driver}`);
@@ -3207,8 +3575,7 @@ function codexMcpAddArgs(opts) {
3207
3575
  "mcp",
3208
3576
  "add",
3209
3577
  "sellable",
3210
- "--env",
3211
- WATCH_MODE_DRIVER_ENV.codex,
3578
+ ...mcpEnvVarArgs("codex", opts).flatMap((entry) => ["--env", entry]),
3212
3579
  "--",
3213
3580
  command,
3214
3581
  ...args,
@@ -3239,7 +3606,7 @@ args = ${tomlArray(args)}`
3239
3606
  content,
3240
3607
  "mcp_servers.sellable.env",
3241
3608
  `[mcp_servers.sellable.env]
3242
- SELLABLE_WATCH_MODE_DRIVER = "codex"`
3609
+ ${tomlEnvTable("codex", opts)}`
3243
3610
  );
3244
3611
  return content;
3245
3612
  }
@@ -3262,7 +3629,9 @@ function codexMcpServerMatches(content, opts) {
3262
3629
  return (
3263
3630
  server.includes(`command = ${quoteToml(command)}`) &&
3264
3631
  server.includes(`args = ${tomlArray(args)}`) &&
3265
- env.includes('SELLABLE_WATCH_MODE_DRIVER = "codex"')
3632
+ Object.entries(mcpEnvForHost("codex", opts)).every(([key, value]) =>
3633
+ env.includes(`${key} = ${quoteToml(value)}`)
3634
+ )
3266
3635
  );
3267
3636
  }
3268
3637
 
@@ -3277,6 +3646,568 @@ function codexPluginMcpServerMatches(content, opts) {
3277
3646
  }
3278
3647
  }
3279
3648
 
3649
+ function hermesHome(opts = {}) {
3650
+ return (
3651
+ opts.hermesHome ||
3652
+ process.env.HERMES_HOME?.trim() ||
3653
+ join(homedir(), ".hermes")
3654
+ );
3655
+ }
3656
+
3657
+ function hermesConfigPath(opts = {}) {
3658
+ return join(hermesHome(opts), "config.yaml");
3659
+ }
3660
+
3661
+ function hermesSkillsRoot(opts = {}) {
3662
+ return join(hermesHome(opts), "skills", "sellable");
3663
+ }
3664
+
3665
+ function hermesLikelyInstalled() {
3666
+ return (
3667
+ Boolean(process.env.HERMES_HOME?.trim()) ||
3668
+ commandExists("hermes") ||
3669
+ existsSync(hermesConfigPath()) ||
3670
+ existsSync(join(hermesHome(), "skills"))
3671
+ );
3672
+ }
3673
+
3674
+ function parseHermesConfig(raw, configPath = hermesConfigPath()) {
3675
+ const source = raw.trim() ? raw : "{}\n";
3676
+ const doc = parseDocument(source, { prettyErrors: false });
3677
+ if (doc.errors.length > 0) {
3678
+ throw new Error(
3679
+ `Invalid Hermes config YAML at ${configPath}: ${doc.errors[0].message}`
3680
+ );
3681
+ }
3682
+ const value = doc.toJSON() ?? {};
3683
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
3684
+ throw new Error(
3685
+ `Invalid Hermes config YAML at ${configPath}: expected a mapping at the document root`
3686
+ );
3687
+ }
3688
+ return value;
3689
+ }
3690
+
3691
+ function readHermesConfig(opts = {}) {
3692
+ const configPath = hermesConfigPath(opts);
3693
+ const raw = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
3694
+ return { raw, config: parseHermesConfig(raw, configPath), configPath };
3695
+ }
3696
+
3697
+ function hermesMcpServer(opts) {
3698
+ if (opts.server === "hosted") {
3699
+ return {
3700
+ url: withHostedWatchModeDriver(opts.hostedUrl, "hermes"),
3701
+ enabled: true,
3702
+ env: {
3703
+ SELLABLE_WATCH_MODE_DRIVER: "hermes",
3704
+ },
3705
+ };
3706
+ }
3707
+
3708
+ const [command, args] = mcpCommand(opts);
3709
+ return {
3710
+ command,
3711
+ args,
3712
+ enabled: true,
3713
+ env: mcpEnvForHost("hermes", opts),
3714
+ };
3715
+ }
3716
+
3717
+ function writeHermesConfig(configPath, config, opts) {
3718
+ if (opts.dryRun) return;
3719
+ mkdirSync(dirname(configPath), { recursive: true, mode: 0o700 });
3720
+ const doc = parseDocument("{}\n");
3721
+ doc.contents = config;
3722
+ writeFileSync(configPath, String(doc), { mode: 0o600 });
3723
+ }
3724
+
3725
+ function writeHermesMcpServer(opts) {
3726
+ const { config, configPath } = readHermesConfig(opts);
3727
+ const existingMcpServers =
3728
+ config.mcp_servers &&
3729
+ typeof config.mcp_servers === "object" &&
3730
+ !Array.isArray(config.mcp_servers)
3731
+ ? config.mcp_servers
3732
+ : {};
3733
+ const nextConfig = {
3734
+ ...config,
3735
+ mcp_servers: {
3736
+ ...existingMcpServers,
3737
+ sellable: hermesMcpServer(opts),
3738
+ },
3739
+ };
3740
+ writeHermesConfig(configPath, nextConfig, opts);
3741
+ return configPath;
3742
+ }
3743
+
3744
+ function installHermesSkills(opts) {
3745
+ const root = hermesSkillsRoot(opts);
3746
+ for (const skill of hermesSkillDefinitions()) {
3747
+ const skillRoot = join(root, skill.dir);
3748
+ writeFile(join(skillRoot, "SKILL.md"), skill.skillMd, opts);
3749
+ if (skill.soulMd) {
3750
+ writeFile(join(skillRoot, "SOUL.md"), skill.soulMd, opts);
3751
+ }
3752
+ }
3753
+ return root;
3754
+ }
3755
+
3756
+ function installHermes(opts) {
3757
+ // Parse config before writing skills so invalid user YAML cannot leave a
3758
+ // half-installed Hermes skill tree behind.
3759
+ readHermesConfig(opts);
3760
+ let skillsRoot = null;
3761
+ try {
3762
+ skillsRoot = installHermesSkills(opts);
3763
+ const configPath = writeHermesMcpServer(opts);
3764
+ logVerbose(`${C.grey}+ Hermes config updated: ${configPath}${C.reset}`);
3765
+ return { installed: true, configPath, skillsRoot };
3766
+ } catch (err) {
3767
+ if (skillsRoot && !opts.dryRun) {
3768
+ rmSync(skillsRoot, { recursive: true, force: true });
3769
+ }
3770
+ throw err;
3771
+ }
3772
+ }
3773
+
3774
+ function parseHermesProfileBootstrapArgs(argv) {
3775
+ const opts = {
3776
+ profile: "",
3777
+ profileRoot: "",
3778
+ profilesRoot: "",
3779
+ workspaceId: "",
3780
+ workspaceName: "",
3781
+ token: "",
3782
+ tokenFile: "",
3783
+ apiUrl: process.env.SELLABLE_API_URL || DEFAULT_API_URL,
3784
+ slackBotToken: process.env.SLACK_BOT_TOKEN || "",
3785
+ slackAppToken: process.env.SLACK_APP_TOKEN || "",
3786
+ server: process.env.SELLABLE_INSTALL_SERVER || "package",
3787
+ mcpPackage: DEFAULT_SERVER_PACKAGE,
3788
+ localCommand: process.env.SELLABLE_MCP_LOCAL_COMMAND || "",
3789
+ dryRun: false,
3790
+ json: false,
3791
+ force: false,
3792
+ overwriteEnv: false,
3793
+ allowDuplicateSlackTokens: false,
3794
+ verbose: false,
3795
+ };
3796
+
3797
+ for (let i = 0; i < argv.length; i += 1) {
3798
+ const arg = argv[i];
3799
+ const next = () => {
3800
+ const value = argv[i + 1];
3801
+ if (!value || value.startsWith("--")) {
3802
+ throw new Error(`Missing value for ${arg}`);
3803
+ }
3804
+ i += 1;
3805
+ return value;
3806
+ };
3807
+
3808
+ if (arg === "--profile") opts.profile = next();
3809
+ else if (arg === "--profile-root") opts.profileRoot = next();
3810
+ else if (arg === "--profiles-root") opts.profilesRoot = next();
3811
+ else if (arg === "--workspace-id") opts.workspaceId = next();
3812
+ else if (arg === "--workspace-name") opts.workspaceName = next();
3813
+ else if (arg === "--token") opts.token = next();
3814
+ else if (arg === "--token-file") opts.tokenFile = next();
3815
+ else if (arg === "--api-url") opts.apiUrl = next();
3816
+ else if (arg === "--slack-bot-token") opts.slackBotToken = next();
3817
+ else if (arg === "--slack-app-token") opts.slackAppToken = next();
3818
+ else if (arg === "--server") opts.server = next();
3819
+ else if (arg === "--mcp-package") {
3820
+ opts.mcpPackage = normalizeWindowsPackageSpec(next());
3821
+ } else if (arg === "--local-command") opts.localCommand = next();
3822
+ else if (arg === "--dry-run") opts.dryRun = true;
3823
+ else if (arg === "--json") opts.json = true;
3824
+ else if (arg === "--force") opts.force = true;
3825
+ else if (arg === "--overwrite-env") opts.overwriteEnv = true;
3826
+ else if (arg === "--allow-duplicate-slack-tokens") {
3827
+ opts.allowDuplicateSlackTokens = true;
3828
+ } else if (arg === "--verbose") opts.verbose = true;
3829
+ else {
3830
+ throw new Error(`Unknown hermes profile bootstrap option: ${arg}`);
3831
+ }
3832
+ }
3833
+
3834
+ if (!["package", "local"].includes(opts.server)) {
3835
+ throw new Error("hermes profile bootstrap supports --server package or local");
3836
+ }
3837
+ return opts;
3838
+ }
3839
+
3840
+ function assertSafeProfileName(profile) {
3841
+ if (!profile) return;
3842
+ if (profile.includes("/") || profile.includes("\\") || profile === "." || profile === "..") {
3843
+ throw new Error("--profile must be a profile name, not a path");
3844
+ }
3845
+ }
3846
+
3847
+ function resolveHermesProfileRoot(opts) {
3848
+ if (opts.profileRoot) {
3849
+ return normalizeOptionalPath(opts.profileRoot);
3850
+ }
3851
+
3852
+ if (opts.profilesRoot || opts.profile) {
3853
+ if (!opts.profilesRoot || !opts.profile) {
3854
+ throw new Error("--profiles-root requires --profile for Hermes bootstrap");
3855
+ }
3856
+ assertSafeProfileName(opts.profile);
3857
+ return join(normalizeOptionalPath(opts.profilesRoot), opts.profile);
3858
+ }
3859
+
3860
+ const envHermesHome = process.env.HERMES_HOME?.trim();
3861
+ if (envHermesHome) {
3862
+ return resolve(envHermesHome);
3863
+ }
3864
+
3865
+ throw new Error(
3866
+ "Missing Hermes profile root. Pass --profile-root, or --profiles-root with --profile, or set HERMES_HOME."
3867
+ );
3868
+ }
3869
+
3870
+ function readBootstrapToken(opts) {
3871
+ if (opts.tokenFile) {
3872
+ const tokenPath = normalizeOptionalPath(opts.tokenFile);
3873
+ if (!existsSync(tokenPath)) {
3874
+ throw new Error(`Token file not found: ${tokenPath}`);
3875
+ }
3876
+ return {
3877
+ token: readFileSync(tokenPath, "utf8").trim(),
3878
+ source: "token-file",
3879
+ };
3880
+ }
3881
+
3882
+ const envToken = process.env.SELLABLE_API_TOKEN || process.env.SELLABLE_TOKEN || "";
3883
+ if (envToken) return { token: envToken.trim(), source: "env" };
3884
+ if (opts.token) return { token: opts.token.trim(), source: "flag" };
3885
+ return { token: "", source: "none" };
3886
+ }
3887
+
3888
+ function tokenFingerprint(value) {
3889
+ if (!value) return null;
3890
+ return createHash("sha256").update(value).digest("hex").slice(0, 16);
3891
+ }
3892
+
3893
+ function parseEnvLines(raw) {
3894
+ const entries = new Map();
3895
+ const passthrough = [];
3896
+ for (const line of String(raw || "").split(/\r?\n/)) {
3897
+ if (!line) continue;
3898
+ const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
3899
+ if (!match) {
3900
+ passthrough.push(line);
3901
+ continue;
3902
+ }
3903
+ entries.set(match[1], match[2]);
3904
+ }
3905
+ return { entries, passthrough };
3906
+ }
3907
+
3908
+ function renderEnvFile({ entries, passthrough }) {
3909
+ const lines = [...passthrough];
3910
+ for (const [key, value] of entries.entries()) {
3911
+ if (String(value).includes("\n")) {
3912
+ throw new Error(`Invalid newline in ${key}`);
3913
+ }
3914
+ lines.push(`${key}=${value}`);
3915
+ }
3916
+ return `${lines.join("\n")}${lines.length ? "\n" : ""}`;
3917
+ }
3918
+
3919
+ function findDuplicateSlackTokenProfiles(profileRoot, slackTokens) {
3920
+ const duplicates = [];
3921
+ const profilesRoot = dirname(profileRoot);
3922
+ if (!existsSync(profilesRoot)) return duplicates;
3923
+
3924
+ const requested = Object.entries(slackTokens)
3925
+ .filter(([, value]) => value)
3926
+ .map(([key, value]) => [key, tokenFingerprint(value), value]);
3927
+ if (requested.length === 0) return duplicates;
3928
+
3929
+ for (const name of readdirSync(profilesRoot)) {
3930
+ const siblingRoot = join(profilesRoot, name);
3931
+ if (resolve(siblingRoot) === resolve(profileRoot)) continue;
3932
+ const envPath = join(siblingRoot, ".env");
3933
+ if (!existsSync(envPath)) continue;
3934
+ const existing = parseEnvLines(readFileSync(envPath, "utf8")).entries;
3935
+ for (const [key, fingerprint, rawValue] of requested) {
3936
+ if (existing.get(key) === rawValue) {
3937
+ duplicates.push({ key, fingerprint, profileRoot: siblingRoot });
3938
+ }
3939
+ }
3940
+ }
3941
+ return duplicates;
3942
+ }
3943
+
3944
+ function readJsonFile(path) {
3945
+ if (!existsSync(path)) return {};
3946
+ try {
3947
+ const raw = JSON.parse(readFileSync(path, "utf8"));
3948
+ return raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
3949
+ } catch (err) {
3950
+ throw new Error(
3951
+ `Invalid JSON in ${path}: ${err instanceof Error ? err.message : String(err)}`
3952
+ );
3953
+ }
3954
+ }
3955
+
3956
+ function writeHermesProfileSellableConfig(configPath, raw, opts) {
3957
+ writeJson(configPath, raw, opts);
3958
+ }
3959
+
3960
+ function runHermesProfileBootstrap(argv) {
3961
+ const parsed = parseHermesProfileBootstrapArgs(argv);
3962
+ const previousVerbose = VERBOSE;
3963
+ VERBOSE = Boolean(parsed.verbose);
3964
+ try {
3965
+ const profileRoot = resolveHermesProfileRoot(parsed);
3966
+ const profile = parsed.profile || basenameSafe(profileRoot);
3967
+ const sellableConfigPath = join(profileRoot, "sellable", "config.json");
3968
+ const sellableConfigsDir = join(profileRoot, "sellable", "configs");
3969
+ const envPath = join(profileRoot, ".env");
3970
+ const { token, source: tokenSource } = readBootstrapToken(parsed);
3971
+ const slackTokens = {
3972
+ SLACK_BOT_TOKEN: parsed.slackBotToken.trim(),
3973
+ SLACK_APP_TOKEN: parsed.slackAppToken.trim(),
3974
+ };
3975
+ const opts = {
3976
+ ...parsed,
3977
+ host: "hermes",
3978
+ hermesHome: profileRoot,
3979
+ sellableConfigPath,
3980
+ sellableConfigsDir,
3981
+ token,
3982
+ tokenSource,
3983
+ workspaceId: parsed.workspaceId,
3984
+ apiUrl: parsed.apiUrl,
3985
+ };
3986
+
3987
+ if (token && !parsed.workspaceId) {
3988
+ throw new Error("--workspace-id is required when a Sellable token is supplied");
3989
+ }
3990
+
3991
+ const summary = {
3992
+ ok: true,
3993
+ command: "hermes profile bootstrap",
3994
+ profile,
3995
+ dryRun: opts.dryRun,
3996
+ paths: {
3997
+ profileRoot,
3998
+ hermesConfigPath: hermesConfigPath(opts),
3999
+ envPath,
4000
+ sellableConfigPath,
4001
+ sellableConfigsDir,
4002
+ skillsRoot: hermesSkillsRoot(opts),
4003
+ },
4004
+ auth: {
4005
+ apiUrl: opts.apiUrl,
4006
+ activeWorkspaceId: parsed.workspaceId || null,
4007
+ activeWorkspaceName: parsed.workspaceName || null,
4008
+ tokenPresent: Boolean(token),
4009
+ tokenSource,
4010
+ tokenFingerprint: tokenFingerprint(token),
4011
+ },
4012
+ slack: {
4013
+ keys: Object.entries(slackTokens)
4014
+ .filter(([, value]) => value)
4015
+ .map(([key]) => key),
4016
+ fingerprints: Object.fromEntries(
4017
+ Object.entries(slackTokens)
4018
+ .filter(([, value]) => value)
4019
+ .map(([key, value]) => [key, tokenFingerprint(value)])
4020
+ ),
4021
+ },
4022
+ created: [],
4023
+ updated: [],
4024
+ preserved: [],
4025
+ skipped: [],
4026
+ warnings: [],
4027
+ };
4028
+
4029
+ let hermesConfig = {};
4030
+ try {
4031
+ hermesConfig = readHermesConfig(opts).config;
4032
+ } catch (err) {
4033
+ throw err;
4034
+ }
4035
+ const existingSellablePath =
4036
+ hermesConfig?.mcp_servers?.sellable?.env?.SELLABLE_CONFIG_PATH || "";
4037
+ if (
4038
+ existingSellablePath &&
4039
+ resolve(existingSellablePath) !== sellableConfigPath &&
4040
+ !opts.force
4041
+ ) {
4042
+ throw new Error(
4043
+ `Existing Hermes sellable MCP points at ${existingSellablePath}; pass --force to replace it with ${sellableConfigPath}.`
4044
+ );
4045
+ }
4046
+
4047
+ const existingAuth = readJsonFile(sellableConfigPath);
4048
+ const existingWorkspaceId =
4049
+ existingAuth.activeWorkspaceId || existingAuth.workspaceId || "";
4050
+ if (
4051
+ existingWorkspaceId &&
4052
+ parsed.workspaceId &&
4053
+ existingWorkspaceId !== parsed.workspaceId &&
4054
+ !opts.force
4055
+ ) {
4056
+ throw new Error(
4057
+ `Existing Sellable profile config uses workspace ${existingWorkspaceId}; pass --force to replace it with ${parsed.workspaceId}.`
4058
+ );
4059
+ }
4060
+
4061
+ let envState = { entries: new Map(), passthrough: [] };
4062
+ if (existsSync(envPath)) {
4063
+ envState = parseEnvLines(readFileSync(envPath, "utf8"));
4064
+ }
4065
+ for (const [key, value] of Object.entries(slackTokens)) {
4066
+ if (!value) continue;
4067
+ const existing = envState.entries.get(key);
4068
+ if (existing && existing !== value && !opts.overwriteEnv) {
4069
+ throw new Error(
4070
+ `${envPath} already has ${key}; pass --overwrite-env to replace it.`
4071
+ );
4072
+ }
4073
+ }
4074
+ if (!opts.allowDuplicateSlackTokens) {
4075
+ const duplicates = findDuplicateSlackTokenProfiles(profileRoot, slackTokens);
4076
+ if (duplicates.length > 0) {
4077
+ const rendered = duplicates
4078
+ .map((dup) => `${dup.key} fingerprint ${dup.fingerprint} in ${dup.profileRoot}`)
4079
+ .join("; ");
4080
+ throw new Error(
4081
+ `Duplicate Slack token detected across Hermes profiles: ${rendered}. Pass --allow-duplicate-slack-tokens to allow this.`
4082
+ );
4083
+ }
4084
+ }
4085
+
4086
+ const hermesConfigExisted = existsSync(hermesConfigPath(opts));
4087
+ const skillsExisted = existsSync(hermesSkillsRoot(opts));
4088
+ const authExisted = existsSync(sellableConfigPath);
4089
+ const configsDirExisted = existsSync(sellableConfigsDir);
4090
+ const envExisted = existsSync(envPath);
4091
+
4092
+ const nextAuth = {
4093
+ ...existingAuth,
4094
+ apiUrl: opts.apiUrl,
4095
+ bootstrap: {
4096
+ ...(existingAuth.bootstrap &&
4097
+ typeof existingAuth.bootstrap === "object" &&
4098
+ !Array.isArray(existingAuth.bootstrap)
4099
+ ? existingAuth.bootstrap
4100
+ : {}),
4101
+ profile,
4102
+ status: token ? "ready" : "pending_auth",
4103
+ updatedAt: new Date().toISOString(),
4104
+ },
4105
+ };
4106
+ if (parsed.workspaceId) {
4107
+ nextAuth.activeWorkspaceId = parsed.workspaceId;
4108
+ nextAuth.workspaceId = parsed.workspaceId;
4109
+ }
4110
+ if (parsed.workspaceName) {
4111
+ nextAuth.activeWorkspaceName = parsed.workspaceName;
4112
+ nextAuth.workspaceName = parsed.workspaceName;
4113
+ }
4114
+ if (token) {
4115
+ nextAuth.token = token;
4116
+ } else if (!existingAuth.token) {
4117
+ delete nextAuth.token;
4118
+ }
4119
+
4120
+ if (!opts.dryRun) {
4121
+ mkdirSync(profileRoot, { recursive: true, mode: 0o700 });
4122
+ mkdirSync(sellableConfigsDir, { recursive: true, mode: 0o700 });
4123
+ writeHermesProfileSellableConfig(sellableConfigPath, nextAuth, opts);
4124
+ installHermes(opts);
4125
+ if (Object.values(slackTokens).some(Boolean)) {
4126
+ for (const [key, value] of Object.entries(slackTokens)) {
4127
+ if (value) envState.entries.set(key, value);
4128
+ }
4129
+ writeFile(envPath, renderEnvFile(envState), opts, 0o600);
4130
+ }
4131
+ }
4132
+
4133
+ (configsDirExisted ? summary.preserved : summary.created).push(
4134
+ sellableConfigsDir
4135
+ );
4136
+ (authExisted ? summary.updated : summary.created).push(sellableConfigPath);
4137
+ (hermesConfigExisted ? summary.updated : summary.created).push(
4138
+ hermesConfigPath(opts)
4139
+ );
4140
+ (skillsExisted ? summary.updated : summary.created).push(hermesSkillsRoot(opts));
4141
+ if (Object.values(slackTokens).some(Boolean)) {
4142
+ (envExisted ? summary.updated : summary.created).push(envPath);
4143
+ } else {
4144
+ summary.skipped.push("Slack env: no Slack tokens supplied");
4145
+ }
4146
+ if (!token) {
4147
+ summary.warnings.push(
4148
+ `Manual auth pending. Run: sellable auth set <token> --workspace-id <workspace_id> --sellable-config-path ${sellableConfigPath}`
4149
+ );
4150
+ }
4151
+
4152
+ if (opts.json) {
4153
+ console.log(JSON.stringify(summary, null, 2));
4154
+ } else {
4155
+ logMilestone(`Hermes profile bootstrapped at ${profileRoot}`);
4156
+ logStep(`Sellable config: ${sellableConfigPath}`);
4157
+ logStep(`Sellable configs dir: ${sellableConfigsDir}`);
4158
+ if (!token) logWarn(summary.warnings[0]);
4159
+ }
4160
+ return summary;
4161
+ } finally {
4162
+ VERBOSE = previousVerbose;
4163
+ }
4164
+ }
4165
+
4166
+ function basenameSafe(pathValue) {
4167
+ const normalized = resolve(pathValue);
4168
+ return normalized.split(/[\\/]/).filter(Boolean).pop() || "hermes-profile";
4169
+ }
4170
+
4171
+ function hermesMcpServerMatches(config, opts) {
4172
+ const server = config?.mcp_servers?.sellable;
4173
+ if (!server || typeof server !== "object") return false;
4174
+ const expected = hermesMcpServer(opts);
4175
+ return JSON.stringify(server) === JSON.stringify(expected);
4176
+ }
4177
+
4178
+ function hermesSkillChecks() {
4179
+ const root = hermesSkillsRoot();
4180
+ const skills = hermesSkillDefinitions();
4181
+ const paths = skills.map((skill) => join(root, skill.dir, "SKILL.md"));
4182
+ const allPresent = paths.every((skillPath) => existsSync(skillPath));
4183
+ const contents = paths
4184
+ .filter((skillPath) => existsSync(skillPath))
4185
+ .map((skillPath) => readFileSync(skillPath, "utf8"));
4186
+ const commandNamesCurrent =
4187
+ contents.length === skills.length &&
4188
+ skills.every((skill, index) =>
4189
+ contents[index]?.includes(`name: ${hermesCommandName(skill.commandName)}`)
4190
+ );
4191
+ const slashCommandsCurrent =
4192
+ contents.length === skills.length &&
4193
+ skills.every((skill, index) =>
4194
+ contents[index]?.includes(`/${hermesCommandName(skill.commandName)}`)
4195
+ );
4196
+ const forbiddenSyntax = contents.some((content) =>
4197
+ /\$sellable:|\/sellable:|mcp__sellable__|request_user_input|AskUserQuestion/.test(
4198
+ content
4199
+ )
4200
+ );
4201
+ return {
4202
+ root,
4203
+ paths,
4204
+ allPresent,
4205
+ commandNamesCurrent,
4206
+ slashCommandsCurrent,
4207
+ forbiddenSyntax,
4208
+ };
4209
+ }
4210
+
3280
4211
  function installClaude(opts) {
3281
4212
  if (!commandExists("claude")) {
3282
4213
  const message =
@@ -3356,7 +4287,7 @@ function canonicalClaudeMcpServer(opts) {
3356
4287
  type: "stdio",
3357
4288
  command,
3358
4289
  args,
3359
- env: { SELLABLE_WATCH_MODE_DRIVER: "claude" },
4290
+ env: mcpEnvForHost("claude", opts),
3360
4291
  alwaysLoad: true,
3361
4292
  };
3362
4293
  }
@@ -3385,7 +4316,7 @@ function claudeMcpServerMatches(server, canonical) {
3385
4316
  server.type === canonical.type &&
3386
4317
  server.command === canonical.command &&
3387
4318
  JSON.stringify(server.args || []) === JSON.stringify(canonical.args) &&
3388
- server.env?.SELLABLE_WATCH_MODE_DRIVER === "claude"
4319
+ JSON.stringify(server.env || {}) === JSON.stringify(canonical.env)
3389
4320
  );
3390
4321
  }
3391
4322
 
@@ -3444,12 +4375,10 @@ function patchClaudeAlwaysLoad(opts) {
3444
4375
  server.args = [...canonical.args];
3445
4376
  touched = true;
3446
4377
  }
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") {
4378
+ const existingEnv =
4379
+ server.env && typeof server.env === "object" ? server.env : {};
4380
+ server.env = { ...canonical.env };
4381
+ if (JSON.stringify(existingEnv) !== JSON.stringify(server.env)) {
3453
4382
  touched = true;
3454
4383
  }
3455
4384
  }
@@ -3558,9 +4487,16 @@ function warningCheck(ok, label) {
3558
4487
  return { ok, label, required: false };
3559
4488
  }
3560
4489
 
4490
+ function firstRunCommandForHost(host) {
4491
+ if (host === "hermes") return "/sellable-create-campaign";
4492
+ if (host === "codex") return "$sellable:create-campaign";
4493
+ if (host === "claude") return "/sellable:create-campaign";
4494
+ return "/sellable:create-campaign, $sellable:create-campaign, or /sellable-create-campaign";
4495
+ }
4496
+
3561
4497
  async function verify(opts) {
3562
4498
  const startedAt = new Date().toISOString();
3563
- const stored = readStoredAuth();
4499
+ const stored = readStoredAuth(opts);
3564
4500
  const checks = [];
3565
4501
  const hasWatchModeDriverMarker = (content, driver) =>
3566
4502
  new RegExp(`SELLABLE_WATCH_MODE_DRIVER[\\s\\S]{0,80}${driver}`).test(
@@ -3568,12 +4504,12 @@ async function verify(opts) {
3568
4504
  ) || new RegExp(`[?&]driver=${driver}(?:\\b|&)`).test(content);
3569
4505
 
3570
4506
  if (stored?.token && stored?.workspaceId) {
3571
- checks.push(warningCheck(true, `Auth config: ${authPath()}`));
4507
+ checks.push(warningCheck(true, `Auth config: ${authPath(opts)}`));
3572
4508
  } else {
3573
4509
  checks.push(
3574
4510
  warningCheck(
3575
4511
  true,
3576
- `Auth: not yet signed in — sign in on first run of /sellable:create-campaign`
4512
+ `Auth: not yet signed in — sign in on first run of ${firstRunCommandForHost(opts.host)}`
3577
4513
  )
3578
4514
  );
3579
4515
  }
@@ -3860,6 +4796,88 @@ async function verify(opts) {
3860
4796
  )
3861
4797
  );
3862
4798
  }
4799
+ if (opts.host === "hermes" || opts.host === "all") {
4800
+ let hermesConfig = null;
4801
+ let hermesConfigError = "";
4802
+ try {
4803
+ hermesConfig = readHermesConfig().config;
4804
+ } catch (err) {
4805
+ hermesConfigError = err instanceof Error ? err.message : String(err);
4806
+ }
4807
+ const npmPresent = opts.server !== "package" || commandExists(packageManagerCommand());
4808
+ const skillChecks = hermesSkillChecks();
4809
+ const configMatches = hermesConfig
4810
+ ? hermesMcpServerMatches(hermesConfig, opts)
4811
+ : false;
4812
+ const hasHermesDriver =
4813
+ hermesConfig?.mcp_servers?.sellable?.env?.SELLABLE_WATCH_MODE_DRIVER ===
4814
+ "hermes";
4815
+
4816
+ const check =
4817
+ opts.host === "hermes" ? requiredCheck : warningCheck;
4818
+ checks.push(
4819
+ check(
4820
+ !hermesConfigError,
4821
+ hermesConfigError || `Hermes config readable: ${hermesConfigPath()}`
4822
+ )
4823
+ );
4824
+ checks.push(
4825
+ check(
4826
+ configMatches,
4827
+ configMatches
4828
+ ? "Hermes Sellable MCP entry canonical"
4829
+ : "Hermes Sellable MCP entry missing or stale"
4830
+ )
4831
+ );
4832
+ checks.push(
4833
+ check(
4834
+ hasHermesDriver,
4835
+ hasHermesDriver
4836
+ ? "Hermes watch mode driver pinned to hermes"
4837
+ : "Hermes watch mode driver missing"
4838
+ )
4839
+ );
4840
+ checks.push(
4841
+ check(
4842
+ skillChecks.allPresent,
4843
+ skillChecks.allPresent
4844
+ ? "Hermes Sellable skills present"
4845
+ : "Hermes Sellable skills missing"
4846
+ )
4847
+ );
4848
+ checks.push(
4849
+ check(
4850
+ skillChecks.commandNamesCurrent,
4851
+ skillChecks.commandNamesCurrent
4852
+ ? "Hermes skill names current"
4853
+ : "Hermes skill names stale"
4854
+ )
4855
+ );
4856
+ checks.push(
4857
+ check(
4858
+ skillChecks.slashCommandsCurrent,
4859
+ skillChecks.slashCommandsCurrent
4860
+ ? "Hermes slash command hints current"
4861
+ : "Hermes slash command hints stale"
4862
+ )
4863
+ );
4864
+ checks.push(
4865
+ check(
4866
+ !skillChecks.forbiddenSyntax,
4867
+ !skillChecks.forbiddenSyntax
4868
+ ? "Hermes skills use Hermes MCP/tool syntax"
4869
+ : "Hermes skills contain Claude/Codex-only syntax"
4870
+ )
4871
+ );
4872
+ checks.push(
4873
+ check(
4874
+ npmPresent,
4875
+ npmPresent
4876
+ ? "Hermes package runtime command present"
4877
+ : `Hermes package runtime command missing: ${packageManagerCommand()}`
4878
+ )
4879
+ );
4880
+ }
3863
4881
 
3864
4882
  let runtimeMcp;
3865
4883
  try {
@@ -3870,6 +4888,7 @@ async function verify(opts) {
3870
4888
  cwd: process.cwd(),
3871
4889
  env: {
3872
4890
  ...process.env,
4891
+ ...runtimeMcpEnvForVerify(opts),
3873
4892
  SELLABLE_API_URL: opts.apiUrl,
3874
4893
  SELLABLE_TOKEN: opts.token || process.env.SELLABLE_TOKEN || "",
3875
4894
  SELLABLE_WORKSPACE_ID:
@@ -3925,6 +4944,11 @@ async function verify(opts) {
3925
4944
  install: INSTALL_PACKAGE_SPEC,
3926
4945
  mcp: opts.mcpPackage,
3927
4946
  },
4947
+ sellableConfig: {
4948
+ path: authPath(opts),
4949
+ explicitPath: opts.sellableConfigPath || null,
4950
+ configsDir: opts.sellableConfigsDir || null,
4951
+ },
3928
4952
  checks,
3929
4953
  runtimeMcp,
3930
4954
  threadExposureNotice:
@@ -4042,7 +5066,7 @@ function printNextSteps(installedHosts, authReused) {
4042
5066
  ` ${C.bold}Almost there — install an agent first.${C.reset}`
4043
5067
  );
4044
5068
  console.log(
4045
- ` ${C.yellow}!${C.reset} No agent CLI found (Claude Code or Codex)`
5069
+ ` ${C.yellow}!${C.reset} No agent CLI/profile found (Claude Code, Codex, or Hermes)`
4046
5070
  );
4047
5071
  console.log("");
4048
5072
  console.log("");
@@ -4086,6 +5110,7 @@ function printNextSteps(installedHosts, authReused) {
4086
5110
 
4087
5111
  const hasClaude = installedHosts.includes("Claude Code");
4088
5112
  const hasCodex = installedHosts.includes("Codex");
5113
+ const hasHermes = installedHosts.includes("Hermes");
4089
5114
 
4090
5115
  // Header
4091
5116
  if (authReused) {
@@ -4101,6 +5126,11 @@ function printNextSteps(installedHosts, authReused) {
4101
5126
  console.log(` ${C.green}✓${C.reset} Skills installed`);
4102
5127
  console.log(` ${C.green}✓${C.reset} Codex custom agents installed`);
4103
5128
  }
5129
+ if (hasHermes) {
5130
+ console.log(
5131
+ ` ${C.green}✓${C.reset} Hermes skills and MCP config installed`
5132
+ );
5133
+ }
4104
5134
  if (hasClaude) {
4105
5135
  console.log(
4106
5136
  ` ${C.green}✓${C.reset} Claude slash commands and custom agents installed`
@@ -4144,6 +5174,19 @@ function printNextSteps(installedHosts, authReused) {
4144
5174
  ]);
4145
5175
  console.log("");
4146
5176
  }
5177
+ if (hasHermes) {
5178
+ printAgentBox("Using Hermes?", "hermes", [
5179
+ { label: "Campaign", command: "/sellable-create-campaign" },
5180
+ { label: "A/B Test", command: "/sellable-create-ab-test" },
5181
+ { label: "Evergreen", command: "/sellable-create-evergreen-campaigns" },
5182
+ { label: "Foundation", command: "/sellable-foundation" },
5183
+ { label: "Content", command: "/sellable-content" },
5184
+ { label: "Post", command: "/sellable-create-post" },
5185
+ { label: "Refresh", command: "/sellable-refresh-sender-engagement" },
5186
+ { label: "Refill", command: "/sellable-refill-sends" },
5187
+ ]);
5188
+ console.log("");
5189
+ }
4147
5190
 
4148
5191
  console.log("");
4149
5192
  console.log(` ${"─".repeat(63)}`);
@@ -4173,7 +5216,7 @@ function removeTomlSection(content, header) {
4173
5216
  return content.replace(pattern, "");
4174
5217
  }
4175
5218
 
4176
- function runUninstall() {
5219
+ function runUninstall(opts = { host: "all" }) {
4177
5220
  console.log("");
4178
5221
  console.log(` ${C.bold}Uninstalling Sellable…${C.reset}`);
4179
5222
  console.log("");
@@ -4184,7 +5227,7 @@ function runUninstall() {
4184
5227
 
4185
5228
  // 1) Surgical removal from Codex config.toml
4186
5229
  const codexConfigPath = join(codexHome(), "config.toml");
4187
- if (existsSync(codexConfigPath)) {
5230
+ if ((opts.host === "codex" || opts.host === "all") && existsSync(codexConfigPath)) {
4188
5231
  try {
4189
5232
  const before = readFileSync(codexConfigPath, "utf8");
4190
5233
  const ts = new Date()
@@ -4221,10 +5264,11 @@ function runUninstall() {
4221
5264
  ` ${C.yellow}!${C.reset} Could not edit Codex config: ${err instanceof Error ? err.message : String(err)}`
4222
5265
  );
4223
5266
  }
4224
- } else {
5267
+ } else if (opts.host === "codex" || opts.host === "all") {
4225
5268
  skipped.push(`Codex config not found at ${codexConfigPath}`);
4226
5269
  }
4227
5270
 
5271
+ if (opts.host === "codex" || opts.host === "all") {
4228
5272
  for (const agent of [...codexCustomAgents(), ...legacyCodexCustomAgents()]) {
4229
5273
  const agentPath = join(codexHome(), "agents", agent.filename);
4230
5274
  if (!existsSync(agentPath)) continue;
@@ -4237,9 +5281,10 @@ function runUninstall() {
4237
5281
  );
4238
5282
  }
4239
5283
  }
5284
+ }
4240
5285
 
4241
5286
  // 2) Claude Code MCP removal
4242
- if (commandExists("claude")) {
5287
+ if ((opts.host === "claude" || opts.host === "all") && commandExists("claude")) {
4243
5288
  const r = spawnSync("claude", ["mcp", "remove", "sellable"], {
4244
5289
  encoding: "utf8",
4245
5290
  });
@@ -4248,10 +5293,11 @@ function runUninstall() {
4248
5293
  } else {
4249
5294
  skipped.push(`Claude Code MCP (none registered or already removed)`);
4250
5295
  }
4251
- } else {
5296
+ } else if (opts.host === "claude" || opts.host === "all") {
4252
5297
  skipped.push(`Claude Code CLI not found`);
4253
5298
  }
4254
5299
 
5300
+ if (opts.host === "claude" || opts.host === "all") {
4255
5301
  for (const agent of [
4256
5302
  ...claudeCustomAgents(),
4257
5303
  ...legacyClaudeCustomAgents(),
@@ -4279,10 +5325,66 @@ function runUninstall() {
4279
5325
  );
4280
5326
  }
4281
5327
  }
5328
+ }
5329
+
5330
+ // 3) Hermes config and skill removal
5331
+ if (opts.host === "hermes" || opts.host === "all") {
5332
+ const configPath = hermesConfigPath();
5333
+ if (existsSync(configPath)) {
5334
+ try {
5335
+ const before = readFileSync(configPath, "utf8");
5336
+ const ts = new Date()
5337
+ .toISOString()
5338
+ .replace(/[-:]/g, "")
5339
+ .replace(/\..+/, "");
5340
+ const backupPath = `${configPath}.bak-sellable-uninstall-${ts}`;
5341
+ writeFileSync(backupPath, before, { mode: 0o600 });
5342
+ const config = parseHermesConfig(before, configPath);
5343
+ if (
5344
+ config.mcp_servers &&
5345
+ typeof config.mcp_servers === "object" &&
5346
+ !Array.isArray(config.mcp_servers) &&
5347
+ "sellable" in config.mcp_servers
5348
+ ) {
5349
+ const nextMcpServers = { ...config.mcp_servers };
5350
+ delete nextMcpServers.sellable;
5351
+ writeHermesConfig(
5352
+ configPath,
5353
+ { ...config, mcp_servers: nextMcpServers },
5354
+ { dryRun: false }
5355
+ );
5356
+ removed.push(`Hermes config (sellable removed in ${configPath})`);
5357
+ removed.push(` backup: ${backupPath}`);
5358
+ } else {
5359
+ skipped.push(`Hermes config had no sellable MCP server`);
5360
+ }
5361
+ } catch (err) {
5362
+ console.log(
5363
+ ` ${C.yellow}!${C.reset} Could not edit Hermes config: ${err instanceof Error ? err.message : String(err)}`
5364
+ );
5365
+ }
5366
+ } else {
5367
+ skipped.push(`Hermes config not found at ${configPath}`);
5368
+ }
5369
+
5370
+ const skillsRoot = hermesSkillsRoot();
5371
+ if (existsSync(skillsRoot)) {
5372
+ try {
5373
+ rmSync(skillsRoot, { recursive: true, force: true });
5374
+ removed.push(`${skillsRoot}`);
5375
+ } catch (err) {
5376
+ console.log(
5377
+ ` ${C.yellow}!${C.reset} Could not remove ${skillsRoot}: ${err instanceof Error ? err.message : String(err)}`
5378
+ );
5379
+ }
5380
+ } else {
5381
+ skipped.push(`Hermes Sellable skills not found at ${skillsRoot}`);
5382
+ }
5383
+ }
4282
5384
 
4283
- // 3) Surgical removal of Sellable-installed artifacts inside ~/.sellable/
5385
+ // 4) Surgical removal of Sellable-installed artifacts inside ~/.sellable/
4284
5386
  const sellableDir = join(homedir(), ".sellable");
4285
- if (existsSync(sellableDir)) {
5387
+ if (opts.host === "all" && existsSync(sellableDir)) {
4286
5388
  const tracked = [
4287
5389
  "config.json",
4288
5390
  "update-check.json",
@@ -4321,13 +5423,13 @@ function runUninstall() {
4321
5423
  // ignore
4322
5424
  }
4323
5425
  }
4324
- } else {
5426
+ } else if (opts.host === "all") {
4325
5427
  skipped.push(`~/.sellable not present`);
4326
5428
  }
4327
5429
 
4328
- // 4) Self-shim
5430
+ // 5) Self-shim
4329
5431
  const shimPath = join(homedir(), ".local", "bin", "sellable");
4330
- if (existsSync(shimPath)) {
5432
+ if (opts.host === "all" && existsSync(shimPath)) {
4331
5433
  try {
4332
5434
  rmSync(shimPath, { force: true });
4333
5435
  removed.push(`${shimPath}`);
@@ -4375,6 +5477,7 @@ async function main() {
4375
5477
  const authSetFlags = rawArgs.slice(3);
4376
5478
  let dryRun = false;
4377
5479
  let workspaceId = "";
5480
+ let sellableConfigPath = process.env.SELLABLE_CONFIG_PATH || "";
4378
5481
  for (let i = 0; i < authSetFlags.length; i += 1) {
4379
5482
  const flag = authSetFlags[i];
4380
5483
  if (flag === "--dry-run") {
@@ -4391,6 +5494,16 @@ async function main() {
4391
5494
  i += 1;
4392
5495
  continue;
4393
5496
  }
5497
+ if (flag === "--sellable-config-path") {
5498
+ const value = authSetFlags[i + 1];
5499
+ if (!value || value.startsWith("--")) {
5500
+ console.error("Missing value for --sellable-config-path");
5501
+ process.exit(2);
5502
+ }
5503
+ sellableConfigPath = value;
5504
+ i += 1;
5505
+ continue;
5506
+ }
4394
5507
  console.error(`Unknown auth set option: ${flag}`);
4395
5508
  process.exit(2);
4396
5509
  }
@@ -4415,11 +5528,18 @@ async function main() {
4415
5528
  // Skip installSelfShim() — by definition the user has the shim already
4416
5529
  // (they invoked `sellable auth set` from it).
4417
5530
  const apiUrl = process.env.SELLABLE_API_URL || DEFAULT_API_URL;
4418
- writeAuthSetConfig({ token, workspaceId, apiUrl, dryRun });
5531
+ writeAuthSetConfig({
5532
+ token,
5533
+ workspaceId,
5534
+ apiUrl,
5535
+ dryRun,
5536
+ sellableConfigPath,
5537
+ });
5538
+ const configPath = authPath({ sellableConfigPath });
4419
5539
  if (dryRun) {
4420
- console.log(`Dry run: token would be saved to ${authPath()}`);
5540
+ console.log(`Dry run: token would be saved to ${configPath}`);
4421
5541
  } else {
4422
- console.log(`✓ Token saved to ${authPath()}`);
5542
+ console.log(`✓ Token saved to ${configPath}`);
4423
5543
  }
4424
5544
  console.log(` apiUrl: ${apiUrl}`);
4425
5545
  if (workspaceId) {
@@ -4440,6 +5560,13 @@ async function main() {
4440
5560
  console.log(` Codex: $sellable:create-post`);
4441
5561
  console.log(` Codex: $sellable:refresh-sender-engagement`);
4442
5562
  console.log(` Codex: $sellable:refill-sends`);
5563
+ console.log(` Hermes: /sellable-create-campaign`);
5564
+ console.log(` Hermes: /sellable-create-evergreen-campaigns`);
5565
+ console.log(` Hermes: /sellable-foundation`);
5566
+ console.log(` Hermes: /sellable-content`);
5567
+ console.log(` Hermes: /sellable-create-post`);
5568
+ console.log(` Hermes: /sellable-refresh-sender-engagement`);
5569
+ console.log(` Hermes: /sellable-refill-sends`);
4443
5570
  process.exit(0);
4444
5571
  }
4445
5572
  if (rawArgs[0] === "prefs") {
@@ -4482,13 +5609,22 @@ async function main() {
4482
5609
  process.exit(0);
4483
5610
  }
4484
5611
  if (rawArgs[0] === "uninstall") {
4485
- runUninstall();
5612
+ const opts = parseArgs(rawArgs.slice(1));
5613
+ runUninstall(opts);
4486
5614
  process.exit(0);
4487
5615
  }
4488
5616
  if (rawArgs[0] === "create") {
4489
5617
  printCreateCommandHint();
4490
5618
  process.exit(0);
4491
5619
  }
5620
+ if (
5621
+ rawArgs[0] === "hermes" &&
5622
+ rawArgs[1] === "profile" &&
5623
+ rawArgs[2] === "bootstrap"
5624
+ ) {
5625
+ runHermesProfileBootstrap(rawArgs.slice(3));
5626
+ process.exit(0);
5627
+ }
4492
5628
  const opts = parseArgs(process.argv.slice(2));
4493
5629
  if (opts.help) {
4494
5630
  console.log(usage());
@@ -4500,7 +5636,7 @@ async function main() {
4500
5636
  if (!opts.verifyOnly) {
4501
5637
  printBanner();
4502
5638
  console.log(
4503
- ` ${C.bold}Connecting Sellable to Claude Code and Codex…${C.reset}`
5639
+ ` ${C.bold}Connecting Sellable to Claude Code, Codex, and Hermes…${C.reset}`
4504
5640
  );
4505
5641
  console.log("");
4506
5642
  } else if (!opts.json) {
@@ -4536,6 +5672,15 @@ async function main() {
4536
5672
  installedHosts.push("Codex");
4537
5673
  }
4538
5674
  }
5675
+ if (
5676
+ opts.host === "hermes" ||
5677
+ (opts.host === "all" && hermesLikelyInstalled())
5678
+ ) {
5679
+ const result = installHermes(opts);
5680
+ if (result.installed) {
5681
+ installedHosts.push("Hermes");
5682
+ }
5683
+ }
4539
5684
  }
4540
5685
 
4541
5686
  if (opts.dryRun) {