@sellable/install 0.1.302 → 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.
package/README.md CHANGED
@@ -16,7 +16,7 @@ Windows users can use PowerShell or Windows Terminal:
16
16
  iwr "https://app.sellable.dev/api/v2/cli/install.ps1" | iex
17
17
  ```
18
18
 
19
- After install, restart Claude Code, Codex Desktop, or Hermes so Sellable MCP
19
+ After install, restart Codex Desktop, Claude Code, or Hermes so Sellable MCP
20
20
  tools and skills reload. In an active Hermes session, `/reload-mcp` and
21
21
  `/reload-skills` can reload the new config.
22
22
 
@@ -83,6 +83,80 @@ Auth is stored once at:
83
83
  ~/.sellable/config.json
84
84
  ```
85
85
 
86
+ ## Hermes Profile-Scoped Installs
87
+
88
+ For a single local Hermes profile, point Sellable auth and memory at the profile
89
+ instead of the global `~/.sellable/config.json`:
90
+
91
+ ```bash
92
+ profile="$HOME/.hermes/profiles/acme"
93
+ sellable --host hermes \
94
+ --sellable-config-path "$profile/sellable/config.json" \
95
+ --sellable-configs-dir "$profile/sellable/configs"
96
+ ```
97
+
98
+ That writes `SELLABLE_CONFIG_PATH` and `SELLABLE_CONFIGS_DIR` into
99
+ `mcp_servers.sellable.env`, so first-run login, `get_auth_status`, workspace
100
+ switches, and Sellable memory all use the same profile-local files. Hosted MCP
101
+ mode does not receive local filesystem paths.
102
+
103
+ For zero-shot local or Hostinger/Linux VPS bootstrap, use the profile command:
104
+
105
+ ```bash
106
+ sellable hermes profile bootstrap \
107
+ --profile acme \
108
+ --profiles-root /srv/hermes/profiles \
109
+ --workspace-id ws_acme \
110
+ --workspace-name Acme \
111
+ --token-file /run/secrets/sellable-acme-token \
112
+ --json
113
+ ```
114
+
115
+ It creates:
116
+
117
+ ```text
118
+ /srv/hermes/profiles/acme/config.yaml
119
+ /srv/hermes/profiles/acme/sellable/config.json
120
+ /srv/hermes/profiles/acme/sellable/configs/
121
+ /srv/hermes/profiles/acme/skills/sellable/
122
+ /srv/hermes/profiles/acme/.env # only when Slack token inputs are supplied
123
+ ```
124
+
125
+ If no token is supplied, bootstrap still creates a pending
126
+ `sellable/config.json` so the profile path is concrete from the first MCP
127
+ launch. Finish auth with:
128
+
129
+ ```bash
130
+ sellable auth set <token> --workspace-id <workspace_id> \
131
+ --sellable-config-path /srv/hermes/profiles/acme/sellable/config.json
132
+ ```
133
+
134
+ Slack Socket Mode tokens can be written profile-locally:
135
+
136
+ ```bash
137
+ sellable hermes profile bootstrap \
138
+ --profile acme \
139
+ --profiles-root /srv/hermes/profiles \
140
+ --workspace-id ws_acme \
141
+ --slack-bot-token "$SLACK_BOT_TOKEN" \
142
+ --slack-app-token "$SLACK_APP_TOKEN"
143
+ ```
144
+
145
+ Customer profiles should use customer-scoped Sellable credentials. A shared
146
+ Sellable admin token can work for internal admin automation, but it is weaker
147
+ isolation and should not be the default for customer profiles. After changing
148
+ Hermes profile config, restart the gateway/session or run `/reload-mcp` and
149
+ `/reload-skills`.
150
+
151
+ Validate the active profile with the same path inputs:
152
+
153
+ ```bash
154
+ SELLABLE_CONFIG_PATH=/srv/hermes/profiles/acme/sellable/config.json \
155
+ SELLABLE_CONFIGS_DIR=/srv/hermes/profiles/acme/sellable/configs \
156
+ sellable --verify-only --host hermes --json \
157
+ --artifact /srv/hermes/profiles/acme/sellable/verify-runtime.json
158
+ ```
159
+
86
160
  Claude Code, Codex, and Hermes are configured to launch the same packaged MCP server. The
87
161
  installer also writes Sellable agent definitions from the packaged `agents/`
88
162
  registry, but normal create-campaign runs use only the Message Drafting
@@ -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,7 +13,7 @@ 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";
@@ -168,6 +170,8 @@ Commands:
168
170
  Save the ask-first prompt-sharing preference.
169
171
  setup claude-permissions --allow-common-setup
170
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.
171
175
  uninstall Remove Sellable host config and installed artifacts.
172
176
 
173
177
  Options:
@@ -176,6 +180,10 @@ Options:
176
180
  --token <token> Sellable API token. Also reads SELLABLE_TOKEN.
177
181
  --workspace-id <id> Sellable workspace id. Also reads SELLABLE_WORKSPACE_ID.
178
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.
179
187
  --mcp-package <pkg> MCP server package. Default: ${DEFAULT_SERVER_PACKAGE}
180
188
  --local-command <cmd> Local MCP command for --server local.
181
189
  --hosted-url <url> Hosted MCP URL for --server hosted.
@@ -199,7 +207,19 @@ Auth:
199
207
 
200
208
  For CI/scripted installs, pass --token + --workspace-id or set
201
209
  SELLABLE_TOKEN + SELLABLE_WORKSPACE_ID and the installer will write the
202
- 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.
203
223
  `;
204
224
  }
205
225
 
@@ -271,6 +291,8 @@ function parseArgs(argv) {
271
291
  token: process.env.SELLABLE_TOKEN || "",
272
292
  workspaceId: process.env.SELLABLE_WORKSPACE_ID || "",
273
293
  apiUrl: process.env.SELLABLE_API_URL || DEFAULT_API_URL,
294
+ sellableConfigPath: process.env.SELLABLE_CONFIG_PATH || "",
295
+ sellableConfigsDir: process.env.SELLABLE_CONFIGS_DIR || "",
274
296
  mcpPackage: DEFAULT_SERVER_PACKAGE,
275
297
  localCommand: process.env.SELLABLE_MCP_LOCAL_COMMAND || "",
276
298
  hostedUrl: process.env.SELLABLE_MCP_HOSTED_URL || "",
@@ -305,6 +327,10 @@ function parseArgs(argv) {
305
327
  opts.workspaceId = next();
306
328
  } else if (arg === "--api-url") {
307
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();
308
334
  } else if (arg === "--mcp-package") {
309
335
  opts.mcpPackage = normalizeWindowsPackageSpec(next());
310
336
  } else if (arg === "--local-command") {
@@ -336,6 +362,11 @@ function parseArgs(argv) {
336
362
  if (!["package", "local", "hosted"].includes(opts.server)) {
337
363
  throw new Error("--server must be package, local, or hosted");
338
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
+ }
339
370
 
340
371
  return opts;
341
372
  }
@@ -593,8 +624,17 @@ function getMcpVersion() {
593
624
  return "latest";
594
625
  }
595
626
 
596
- function authPath() {
597
- 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
+ );
598
638
  }
599
639
 
600
640
  function normalizeStoredAuth(raw) {
@@ -615,15 +655,15 @@ function normalizeStoredAuth(raw) {
615
655
  };
616
656
  }
617
657
 
618
- function readStoredAuth() {
619
- const raw = readExisting(authPath());
658
+ function readStoredAuth(opts = {}) {
659
+ const raw = readExisting(authPath(opts));
620
660
  return raw ? normalizeStoredAuth(raw) : null;
621
661
  }
622
662
 
623
663
  async function loadAuthIfPresent(opts) {
624
664
  if (opts.token && opts.workspaceId) return opts;
625
665
 
626
- const stored = readStoredAuth();
666
+ const stored = readStoredAuth(opts);
627
667
  if (stored?.token && stored?.workspaceId) {
628
668
  opts.token ||= stored.token;
629
669
  opts.workspaceId ||= stored.workspaceId;
@@ -687,7 +727,7 @@ function mergeAuthConfig(raw, auth) {
687
727
  Array.isArray(envConfig)
688
728
  ) {
689
729
  throw new Error(
690
- `Unknown active environment '${activeEnv}' in ${authPath()}`
730
+ `Unknown active environment '${activeEnv}' in configured Sellable auth path`
691
731
  );
692
732
  }
693
733
  return {
@@ -708,8 +748,14 @@ function mergeAuthConfig(raw, auth) {
708
748
  };
709
749
  }
710
750
 
711
- function writeAuthSetConfig({ token, workspaceId, apiUrl, dryRun }) {
712
- const configPath = authPath();
751
+ function writeAuthSetConfig({
752
+ token,
753
+ workspaceId,
754
+ apiUrl,
755
+ dryRun,
756
+ sellableConfigPath,
757
+ }) {
758
+ const configPath = authPath({ sellableConfigPath });
713
759
  const raw = readExisting(configPath) || {};
714
760
  const config = mergeAuthConfig(raw, {
715
761
  token,
@@ -860,6 +906,7 @@ function writeFile(path, content, opts, mode = 0o644) {
860
906
  if (opts.dryRun) return;
861
907
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
862
908
  writeFileSync(path, content, { mode });
909
+ chmodSync(path, mode);
863
910
  }
864
911
 
865
912
  function ensureSymlink(target, linkPath, opts) {
@@ -952,9 +999,7 @@ function codexPluginMcp(opts) {
952
999
  type: "stdio",
953
1000
  command,
954
1001
  args,
955
- env: {
956
- SELLABLE_WATCH_MODE_DRIVER: "codex",
957
- },
1002
+ env: mcpEnvForHost("codex", opts),
958
1003
  },
959
1004
  },
960
1005
  };
@@ -967,9 +1012,7 @@ function codexPluginMcp(opts) {
967
1012
  type: "stdio",
968
1013
  command,
969
1014
  args,
970
- env: {
971
- SELLABLE_WATCH_MODE_DRIVER: "codex",
972
- },
1015
+ env: mcpEnvForHost("codex", opts),
973
1016
  },
974
1017
  },
975
1018
  };
@@ -3385,7 +3428,7 @@ function writeAuth(opts) {
3385
3428
  activeWorkspaceId: opts.workspaceId,
3386
3429
  apiUrl: opts.apiUrl,
3387
3430
  };
3388
- writeJson(authPath(), config, opts);
3431
+ writeJson(authPath(opts), config, opts);
3389
3432
  return { written: true, reused: false };
3390
3433
  }
3391
3434
 
@@ -3445,6 +3488,40 @@ const WATCH_MODE_DRIVER_ENV = {
3445
3488
  hermes: "SELLABLE_WATCH_MODE_DRIVER=hermes",
3446
3489
  };
3447
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
+
3448
3525
  function withHostedWatchModeDriver(rawUrl, driver) {
3449
3526
  if (!WATCH_MODE_DRIVER_ENV[driver]) {
3450
3527
  throw new Error(`Unknown watch mode driver: ${driver}`);
@@ -3498,8 +3575,7 @@ function codexMcpAddArgs(opts) {
3498
3575
  "mcp",
3499
3576
  "add",
3500
3577
  "sellable",
3501
- "--env",
3502
- WATCH_MODE_DRIVER_ENV.codex,
3578
+ ...mcpEnvVarArgs("codex", opts).flatMap((entry) => ["--env", entry]),
3503
3579
  "--",
3504
3580
  command,
3505
3581
  ...args,
@@ -3530,7 +3606,7 @@ args = ${tomlArray(args)}`
3530
3606
  content,
3531
3607
  "mcp_servers.sellable.env",
3532
3608
  `[mcp_servers.sellable.env]
3533
- SELLABLE_WATCH_MODE_DRIVER = "codex"`
3609
+ ${tomlEnvTable("codex", opts)}`
3534
3610
  );
3535
3611
  return content;
3536
3612
  }
@@ -3553,7 +3629,9 @@ function codexMcpServerMatches(content, opts) {
3553
3629
  return (
3554
3630
  server.includes(`command = ${quoteToml(command)}`) &&
3555
3631
  server.includes(`args = ${tomlArray(args)}`) &&
3556
- env.includes('SELLABLE_WATCH_MODE_DRIVER = "codex"')
3632
+ Object.entries(mcpEnvForHost("codex", opts)).every(([key, value]) =>
3633
+ env.includes(`${key} = ${quoteToml(value)}`)
3634
+ )
3557
3635
  );
3558
3636
  }
3559
3637
 
@@ -3568,16 +3646,20 @@ function codexPluginMcpServerMatches(content, opts) {
3568
3646
  }
3569
3647
  }
3570
3648
 
3571
- function hermesHome() {
3572
- return process.env.HERMES_HOME?.trim() || join(homedir(), ".hermes");
3649
+ function hermesHome(opts = {}) {
3650
+ return (
3651
+ opts.hermesHome ||
3652
+ process.env.HERMES_HOME?.trim() ||
3653
+ join(homedir(), ".hermes")
3654
+ );
3573
3655
  }
3574
3656
 
3575
- function hermesConfigPath() {
3576
- return join(hermesHome(), "config.yaml");
3657
+ function hermesConfigPath(opts = {}) {
3658
+ return join(hermesHome(opts), "config.yaml");
3577
3659
  }
3578
3660
 
3579
- function hermesSkillsRoot() {
3580
- return join(hermesHome(), "skills", "sellable");
3661
+ function hermesSkillsRoot(opts = {}) {
3662
+ return join(hermesHome(opts), "skills", "sellable");
3581
3663
  }
3582
3664
 
3583
3665
  function hermesLikelyInstalled() {
@@ -3606,8 +3688,8 @@ function parseHermesConfig(raw, configPath = hermesConfigPath()) {
3606
3688
  return value;
3607
3689
  }
3608
3690
 
3609
- function readHermesConfig() {
3610
- const configPath = hermesConfigPath();
3691
+ function readHermesConfig(opts = {}) {
3692
+ const configPath = hermesConfigPath(opts);
3611
3693
  const raw = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
3612
3694
  return { raw, config: parseHermesConfig(raw, configPath), configPath };
3613
3695
  }
@@ -3628,9 +3710,7 @@ function hermesMcpServer(opts) {
3628
3710
  command,
3629
3711
  args,
3630
3712
  enabled: true,
3631
- env: {
3632
- SELLABLE_WATCH_MODE_DRIVER: "hermes",
3633
- },
3713
+ env: mcpEnvForHost("hermes", opts),
3634
3714
  };
3635
3715
  }
3636
3716
 
@@ -3643,7 +3723,7 @@ function writeHermesConfig(configPath, config, opts) {
3643
3723
  }
3644
3724
 
3645
3725
  function writeHermesMcpServer(opts) {
3646
- const { config, configPath } = readHermesConfig();
3726
+ const { config, configPath } = readHermesConfig(opts);
3647
3727
  const existingMcpServers =
3648
3728
  config.mcp_servers &&
3649
3729
  typeof config.mcp_servers === "object" &&
@@ -3662,7 +3742,7 @@ function writeHermesMcpServer(opts) {
3662
3742
  }
3663
3743
 
3664
3744
  function installHermesSkills(opts) {
3665
- const root = hermesSkillsRoot();
3745
+ const root = hermesSkillsRoot(opts);
3666
3746
  for (const skill of hermesSkillDefinitions()) {
3667
3747
  const skillRoot = join(root, skill.dir);
3668
3748
  writeFile(join(skillRoot, "SKILL.md"), skill.skillMd, opts);
@@ -3676,7 +3756,7 @@ function installHermesSkills(opts) {
3676
3756
  function installHermes(opts) {
3677
3757
  // Parse config before writing skills so invalid user YAML cannot leave a
3678
3758
  // half-installed Hermes skill tree behind.
3679
- readHermesConfig();
3759
+ readHermesConfig(opts);
3680
3760
  let skillsRoot = null;
3681
3761
  try {
3682
3762
  skillsRoot = installHermesSkills(opts);
@@ -3691,6 +3771,403 @@ function installHermes(opts) {
3691
3771
  }
3692
3772
  }
3693
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
+
3694
4171
  function hermesMcpServerMatches(config, opts) {
3695
4172
  const server = config?.mcp_servers?.sellable;
3696
4173
  if (!server || typeof server !== "object") return false;
@@ -3810,7 +4287,7 @@ function canonicalClaudeMcpServer(opts) {
3810
4287
  type: "stdio",
3811
4288
  command,
3812
4289
  args,
3813
- env: { SELLABLE_WATCH_MODE_DRIVER: "claude" },
4290
+ env: mcpEnvForHost("claude", opts),
3814
4291
  alwaysLoad: true,
3815
4292
  };
3816
4293
  }
@@ -3839,7 +4316,7 @@ function claudeMcpServerMatches(server, canonical) {
3839
4316
  server.type === canonical.type &&
3840
4317
  server.command === canonical.command &&
3841
4318
  JSON.stringify(server.args || []) === JSON.stringify(canonical.args) &&
3842
- server.env?.SELLABLE_WATCH_MODE_DRIVER === "claude"
4319
+ JSON.stringify(server.env || {}) === JSON.stringify(canonical.env)
3843
4320
  );
3844
4321
  }
3845
4322
 
@@ -3898,12 +4375,10 @@ function patchClaudeAlwaysLoad(opts) {
3898
4375
  server.args = [...canonical.args];
3899
4376
  touched = true;
3900
4377
  }
3901
- const existingDriver = server.env?.SELLABLE_WATCH_MODE_DRIVER;
3902
- server.env = {
3903
- ...(server.env && typeof server.env === "object" ? server.env : {}),
3904
- SELLABLE_WATCH_MODE_DRIVER: "claude",
3905
- };
3906
- 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)) {
3907
4382
  touched = true;
3908
4383
  }
3909
4384
  }
@@ -4021,7 +4496,7 @@ function firstRunCommandForHost(host) {
4021
4496
 
4022
4497
  async function verify(opts) {
4023
4498
  const startedAt = new Date().toISOString();
4024
- const stored = readStoredAuth();
4499
+ const stored = readStoredAuth(opts);
4025
4500
  const checks = [];
4026
4501
  const hasWatchModeDriverMarker = (content, driver) =>
4027
4502
  new RegExp(`SELLABLE_WATCH_MODE_DRIVER[\\s\\S]{0,80}${driver}`).test(
@@ -4029,7 +4504,7 @@ async function verify(opts) {
4029
4504
  ) || new RegExp(`[?&]driver=${driver}(?:\\b|&)`).test(content);
4030
4505
 
4031
4506
  if (stored?.token && stored?.workspaceId) {
4032
- checks.push(warningCheck(true, `Auth config: ${authPath()}`));
4507
+ checks.push(warningCheck(true, `Auth config: ${authPath(opts)}`));
4033
4508
  } else {
4034
4509
  checks.push(
4035
4510
  warningCheck(
@@ -4413,6 +4888,7 @@ async function verify(opts) {
4413
4888
  cwd: process.cwd(),
4414
4889
  env: {
4415
4890
  ...process.env,
4891
+ ...runtimeMcpEnvForVerify(opts),
4416
4892
  SELLABLE_API_URL: opts.apiUrl,
4417
4893
  SELLABLE_TOKEN: opts.token || process.env.SELLABLE_TOKEN || "",
4418
4894
  SELLABLE_WORKSPACE_ID:
@@ -4468,6 +4944,11 @@ async function verify(opts) {
4468
4944
  install: INSTALL_PACKAGE_SPEC,
4469
4945
  mcp: opts.mcpPackage,
4470
4946
  },
4947
+ sellableConfig: {
4948
+ path: authPath(opts),
4949
+ explicitPath: opts.sellableConfigPath || null,
4950
+ configsDir: opts.sellableConfigsDir || null,
4951
+ },
4471
4952
  checks,
4472
4953
  runtimeMcp,
4473
4954
  threadExposureNotice:
@@ -4996,6 +5477,7 @@ async function main() {
4996
5477
  const authSetFlags = rawArgs.slice(3);
4997
5478
  let dryRun = false;
4998
5479
  let workspaceId = "";
5480
+ let sellableConfigPath = process.env.SELLABLE_CONFIG_PATH || "";
4999
5481
  for (let i = 0; i < authSetFlags.length; i += 1) {
5000
5482
  const flag = authSetFlags[i];
5001
5483
  if (flag === "--dry-run") {
@@ -5012,6 +5494,16 @@ async function main() {
5012
5494
  i += 1;
5013
5495
  continue;
5014
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
+ }
5015
5507
  console.error(`Unknown auth set option: ${flag}`);
5016
5508
  process.exit(2);
5017
5509
  }
@@ -5036,11 +5528,18 @@ async function main() {
5036
5528
  // Skip installSelfShim() — by definition the user has the shim already
5037
5529
  // (they invoked `sellable auth set` from it).
5038
5530
  const apiUrl = process.env.SELLABLE_API_URL || DEFAULT_API_URL;
5039
- writeAuthSetConfig({ token, workspaceId, apiUrl, dryRun });
5531
+ writeAuthSetConfig({
5532
+ token,
5533
+ workspaceId,
5534
+ apiUrl,
5535
+ dryRun,
5536
+ sellableConfigPath,
5537
+ });
5538
+ const configPath = authPath({ sellableConfigPath });
5040
5539
  if (dryRun) {
5041
- console.log(`Dry run: token would be saved to ${authPath()}`);
5540
+ console.log(`Dry run: token would be saved to ${configPath}`);
5042
5541
  } else {
5043
- console.log(`✓ Token saved to ${authPath()}`);
5542
+ console.log(`✓ Token saved to ${configPath}`);
5044
5543
  }
5045
5544
  console.log(` apiUrl: ${apiUrl}`);
5046
5545
  if (workspaceId) {
@@ -5118,6 +5617,14 @@ async function main() {
5118
5617
  printCreateCommandHint();
5119
5618
  process.exit(0);
5120
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
+ }
5121
5628
  const opts = parseArgs(process.argv.slice(2));
5122
5629
  if (opts.help) {
5123
5630
  console.log(usage());
@@ -188,6 +188,10 @@ function summarizeToolResult(name, result) {
188
188
  return {
189
189
  ...summary,
190
190
  authOk: parsed.ok === true,
191
+ configPath: parsed.configPath || null,
192
+ configExists: parsed.configExists === true,
193
+ configsDir: parsed.configsDir || null,
194
+ tokenPresent: parsed.tokenPresent === true,
191
195
  errorType: parsed.error?.type || null,
192
196
  activeWorkspaceId: parsed.activeWorkspaceId || null,
193
197
  activeWorkspaceName: parsed.activeWorkspaceName || null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sellable/install",
3
- "version": "0.1.302",
3
+ "version": "0.1.304",
4
4
  "type": "module",
5
5
  "description": "One-command installer for Sellable MCP in Claude Code, Codex, and Hermes",
6
6
  "bin": {