@sellable/install 0.1.327 → 0.1.329

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.
@@ -22,6 +22,14 @@ import {
22
22
  REQUIRED_SELLABLE_MCP_TOOLS,
23
23
  verifySellableMcpRuntime,
24
24
  } from "../lib/runtime-verify.mjs";
25
+ import {
26
+ activateCodexCandidateSelection,
27
+ installCodexCandidateCache,
28
+ readCodexPluginSelection,
29
+ resolveCodexPluginSelectionPaths,
30
+ restoreCodexPluginSelection,
31
+ snapshotCodexPluginSelection,
32
+ } from "../lib/codex-plugin-selection.mjs";
25
33
 
26
34
  const DEFAULT_API_URL = "https://app.sellable.dev";
27
35
  const DEFAULT_SERVER_PACKAGE =
@@ -40,7 +48,7 @@ function getInstallVersion() {
40
48
  }
41
49
  }
42
50
 
43
- const CODEX_PLUGIN_VERSION = "0.1.54";
51
+ const CODEX_PLUGIN_VERSION = "0.1.59";
44
52
  const CODEX_PLUGIN_COMPAT_VERSIONS = [
45
53
  "0.1.8",
46
54
  "0.1.9",
@@ -79,6 +87,8 @@ const CODEX_PLUGIN_COMPAT_VERSIONS = [
79
87
  "0.1.51",
80
88
  "0.1.52",
81
89
  "0.1.53",
90
+ "0.1.54",
91
+ "0.1.55",
82
92
  ];
83
93
  const RAW_INSTALL_PACKAGE_SPEC =
84
94
  process.env.SELLABLE_INSTALL_PACKAGE_SPEC || "@sellable/install@latest";
@@ -170,6 +180,8 @@ Commands:
170
180
  Save the ask-first prompt-sharing preference.
171
181
  setup claude-permissions --allow-common-setup
172
182
  Add common Sellable Bash permissions to Claude settings.
183
+ codex plugin-selection snapshot|install-cache|activate|restore|status
184
+ Approval-gated Phase 98 exact-cache selection controls.
173
185
  hermes profile bootstrap Create a profile-local Hermes config, Sellable auth config,
174
186
  configs dir, skills, and optional Slack .env.
175
187
  uninstall Remove Sellable host config and installed artifacts.
@@ -179,6 +191,8 @@ Options:
179
191
  --server <mode> package, local, or hosted. Default: package
180
192
  --token <token> Sellable API token. Also reads SELLABLE_TOKEN.
181
193
  --workspace-id <id> Sellable workspace id. Also reads SELLABLE_WORKSPACE_ID.
194
+ --require-workspace-lock For Hermes customer profiles, fail closed unless
195
+ SELLABLE_LOCK_WORKSPACE_ID is present in MCP env.
182
196
  --api-url <url> Sellable API URL. Default: ${DEFAULT_API_URL}
183
197
  --sellable-config-path <path>
184
198
  Profile-scoped Sellable config path. Also reads SELLABLE_CONFIG_PATH.
@@ -199,7 +213,6 @@ Options:
199
213
  Auth:
200
214
  Install is auth-free by default. Sign in happens on the first run of
201
215
  /sellable:create-campaign, /sellable:create-evergreen-campaigns,
202
- /sellable:find-leads,
203
216
  /sellable:foundation, /sellable:content, /sellable:create-post,
204
217
  /sellable:refresh-sender-engagement, or /sellable:refill-sends in Claude Code,
205
218
  Codex, or Hermes,
@@ -218,9 +231,20 @@ Hermes profile bootstrap:
218
231
 
219
232
  Bootstrap writes <profileRoot>/config.yaml, <profileRoot>/sellable/config.json,
220
233
  <profileRoot>/sellable/configs, Sellable Hermes skills, and optional
221
- <profileRoot>/.env Slack token keys. Use customer-scoped Sellable credentials
222
- for customer profiles; a shared admin token is weaker isolation and should
223
- remain internal-only.
234
+ <profileRoot>/.env Slack token and channel-scope keys. For customer profiles,
235
+ pass --slack-home-channel; bootstrap defaults SLACK_ALLOWED_CHANNELS to that
236
+ exact channel id unless --slack-allowed-channels is supplied. Use
237
+ customer-scoped Sellable credentials and customer-scoped Slack credentials for
238
+ customer profiles; shared admin tokens are weaker isolation and should remain
239
+ internal-only. Bootstrap writes SELLABLE_REQUIRE_WORKSPACE_LOCK=1 whenever a
240
+ workspace id is supplied, so a customer profile fails closed if the workspace
241
+ lock is later removed or not yet provisioned.
242
+
243
+ Hermes shared Slack dispatcher:
244
+ Shared listener setup and route-registry validation are internal Sellable
245
+ Admin responsibilities. This customer-facing installer only bootstraps a
246
+ profile-local Sellable runtime after Sellable Admin provisions the profile
247
+ and dispatcher route.
224
248
  `;
225
249
  }
226
250
 
@@ -1095,35 +1119,6 @@ const CREATE_CAMPAIGN_ALLOWED_TOOLS = [
1095
1119
  "mcp__sellable__start_campaign",
1096
1120
  ];
1097
1121
 
1098
- const FIND_LEADS_ALLOWED_TOOLS = [
1099
- "mcp__sellable__get_auth_status",
1100
- "mcp__sellable__start_cli_login",
1101
- "mcp__sellable__wait_for_cli_login",
1102
- "mcp__sellable__get_active_workspace",
1103
- "mcp__sellable__list_workspaces",
1104
- "mcp__sellable__set_active_workspace",
1105
- "mcp__sellable__get_subskill_prompt",
1106
- "mcp__sellable__get_subskill_asset",
1107
- "mcp__sellable__search_subskill_prompts",
1108
- "mcp__sellable__get_provider_prompt",
1109
- "mcp__sellable__bootstrap_find_leads",
1110
- "mcp__sellable__get_find_leads_run",
1111
- "mcp__sellable__update_find_leads_run",
1112
- "mcp__sellable__wait_for_find_leads_run",
1113
- "mcp__sellable__cancel_find_leads",
1114
- "mcp__sellable__reissue_find_leads_watch_link",
1115
- "mcp__sellable__preflight_find_leads_provider",
1116
- "mcp__sellable__lookup_sales_nav_filter",
1117
- "mcp__sellable__search_sales_nav",
1118
- "mcp__sellable__search_prospeo",
1119
- "mcp__sellable__search_signals",
1120
- "mcp__sellable__select_promising_posts",
1121
- "mcp__sellable__import_leads",
1122
- "mcp__sellable__wait_for_lead_list_ready",
1123
- "mcp__sellable__get_rows_minimal",
1124
- "mcp__sellable__export_table_csv",
1125
- ];
1126
-
1127
1122
  const CREATE_EVERGREEN_CAMPAIGNS_ALLOWED_TOOLS = [
1128
1123
  ...CREATE_CAMPAIGN_ALLOWED_TOOLS,
1129
1124
  "mcp__sellable__setup_evergreen_campaigns",
@@ -1677,7 +1672,7 @@ Treat host capabilities as concrete functions, not prose conventions:
1677
1672
  import and before dispatching Message Drafting only.
1678
1673
  - \`launch_message_drafting\`: Claude Code uses \`Task\` with \`subagent_type\`
1679
1674
  \`post-find-leads-message-scout\` when listed; Codex uses the returned
1680
- compatibility agent or a generic \`gpt-5.5\` / \`xhigh\` Message Drafting agent.
1675
+ compatibility agent or a generic \`gpt-5.6-sol\` / \`high\` Message Drafting agent.
1681
1676
 
1682
1677
  If a required interactive question function or MCP loader is missing, stop and
1683
1678
  explain the Sellable install/reload problem. Source and filter work use
@@ -1939,17 +1934,6 @@ then retry \`get_subskill_prompt\`.
1939
1934
  `, host, "create-campaign");
1940
1935
  }
1941
1936
 
1942
- function findLeadsSkillMd(host = "shared") {
1943
- const here = dirname(fileURLToPath(import.meta.url));
1944
- const templatePath = join(here, "..", "skill-templates", "find-leads.md");
1945
- if (!existsSync(templatePath)) {
1946
- throw new Error(
1947
- "Find Leads skill template is missing or stale. Reinstall @sellable/install and retry."
1948
- );
1949
- }
1950
- return stampInstalledHost(readFileSync(templatePath, "utf8"), host, "find-leads");
1951
- }
1952
-
1953
1937
  function createAbTestSkillMd(host = "shared") {
1954
1938
  try {
1955
1939
  const here = dirname(fileURLToPath(import.meta.url));
@@ -2066,7 +2050,55 @@ then retry \`get_subskill_prompt\`.
2066
2050
  `, host, "create-evergreen-campaigns");
2067
2051
  }
2068
2052
 
2069
- function refillSendsSkillMd(host = "shared") {
2053
+ const REFILL_SENDS_FALLBACK_CONTRACT = `<!-- REFILL_CONTRACT_GENERATED:START -->
2054
+ contractVersion: 2.0.0
2055
+ cacheVersion: refill-contract-v1
2056
+ contractHash: e54ba0f80bc534fd70fd8c10c1caab109fe9a91c205cfc423f5f6735cd53fdc1
2057
+ scope: workspace_id, target_date, sender_selectors, campaign_selectors, approval_mode, contract_identity, installed_identity
2058
+ gates: approval_packet, one_primitive, fresh_reread, normalized_accounting, prep_circuit
2059
+ forbidden: direct_send, raw_scheduler_write, campaign_create, campaign_archive_delete, threshold_lowering, unselected_source_mutation, unapproved_campaign_start, sender_config_write
2060
+ terminal: projected_full, concrete_blocker, deadline_reached, iteration_limit, operator_stopped
2061
+ ready_buffer_exists_is_not_complete
2062
+ mcpPackageVersion: installed MCP package spec in host MCP config
2063
+ installPackageVersion: ${getInstallVersion()}
2064
+ codexPluginVersion: ${CODEX_PLUGIN_VERSION}
2065
+ <!-- REFILL_CONTRACT_GENERATED:END -->`;
2066
+
2067
+ function refillReleaseIdentityBlock(host, opts = {}) {
2068
+ const match = /^@sellable\/mcp@(.+)$/.exec(opts.mcpPackage || "");
2069
+ const mcpVersion = match?.[1] || String(opts.mcpPackage || "unknown");
2070
+ const pluginVersion = host === "codex" ? CODEX_PLUGIN_VERSION : "not-applicable";
2071
+ const cacheIdentity =
2072
+ host === "codex"
2073
+ ? `codex-plugin:${pluginVersion}|mcp:${mcpVersion}`
2074
+ : `${host}-host|mcp:${mcpVersion}`;
2075
+ return `## Installed release identity
2076
+
2077
+ expectedMcpPackageVersion: ${mcpVersion}
2078
+ expectedInstallPackageVersion: ${getInstallVersion()}
2079
+ expectedCodexPluginVersion: ${pluginVersion}
2080
+ expectedPluginCacheIdentity: ${cacheIdentity}
2081
+
2082
+ Immediately after \`get_auth_status\`, require
2083
+ \`update.mcp.currentVersion\` to equal \`${mcpVersion}\`. If it is missing or
2084
+ different, stop with \`installed_mcp_identity_mismatch\` before any product mutation.
2085
+ Do not call \`get_refill_target_plan\`, \`refill_sends\`, or another mutating
2086
+ workflow tool. Explain that Codex is still using a stale Sellable MCP runtime,
2087
+ then fully quit and reopen Codex Desktop and start a new thread.`;
2088
+ }
2089
+
2090
+ function stampRefillReleaseIdentity(markdown, host, opts) {
2091
+ const marker = markdown.includes("## Bootstrap and execute")
2092
+ ? "## Bootstrap and execute"
2093
+ : "## Bootstrap";
2094
+ if (!markdown.includes(marker)) return markdown;
2095
+ return markdown.replace(
2096
+ marker,
2097
+ `${refillReleaseIdentityBlock(host, opts)}\n\n${marker}`
2098
+ );
2099
+ }
2100
+
2101
+ function refillSendsSkillMd(host = "shared", opts = {}) {
2070
2102
  try {
2071
2103
  const here = dirname(fileURLToPath(import.meta.url));
2072
2104
  const templatePath = join(
@@ -2075,11 +2107,18 @@ function refillSendsSkillMd(host = "shared") {
2075
2107
  "skill-templates",
2076
2108
  "refill-sends.md"
2077
2109
  );
2078
- if (existsSync(templatePath)) {
2110
+ if (
2111
+ process.env.SELLABLE_FORCE_REFILL_TEMPLATE_MISSING !== "1" &&
2112
+ existsSync(templatePath)
2113
+ ) {
2079
2114
  return stampInstalledHost(
2080
- addOrReplaceAllowedTools(
2081
- readFileSync(templatePath, "utf8"),
2082
- REFILL_SENDS_ALLOWED_TOOLS
2115
+ stampRefillReleaseIdentity(
2116
+ addOrReplaceAllowedTools(
2117
+ readFileSync(templatePath, "utf8"),
2118
+ REFILL_SENDS_ALLOWED_TOOLS
2119
+ ),
2120
+ host,
2121
+ opts
2083
2122
  ),
2084
2123
  host,
2085
2124
  "refill-sends"
@@ -2088,7 +2127,7 @@ function refillSendsSkillMd(host = "shared") {
2088
2127
  } catch {
2089
2128
  // fall through to hardcoded fallback below
2090
2129
  }
2091
- return stampInstalledHost(`---
2130
+ return stampInstalledHost(stampRefillReleaseIdentity(`---
2092
2131
  name: refill-sends
2093
2132
  description: Refill Sellable sender sends through the approval-gated refill workflow.
2094
2133
  visibility: public
@@ -2098,6 +2137,14 @@ ${allowedToolsYaml(REFILL_SENDS_ALLOWED_TOOLS)}
2098
2137
 
2099
2138
  # Sellable Refill Sends
2100
2139
 
2140
+ ${REFILL_SENDS_FALLBACK_CONTRACT}
2141
+
2142
+ fallbackSource: embedded
2143
+
2144
+ Load \`refill-sends-workflow\`, then \`core/flow.v1.json\`, then
2145
+ \`core/contract.v2.json\`, each through the Sellable MCP prompt tools until
2146
+ \`hasMore:false\`. Fail closed on unsupported major or stale contract hash.
2147
+
2101
2148
  Use this as the customer-facing entrypoint for the Sellable \`refill-sends\`
2102
2149
  workflow. It supports \`--yolo\` and optional repeated \`--sender <name-or-id>\`
2103
2150
  selectors. It also supports \`--target-date YYYY-MM-DD\` to fill one exact
@@ -2127,9 +2174,9 @@ Desktop, then start a new thread.
2127
2174
 
2128
2175
  1. Call \`mcp__sellable__get_auth_status({})\`.
2129
2176
  2. Resolve the target workspace id from the user request, automation config, or install-time workspace mapping. Pass \`workspaceId\` on every scheduled or \`--yolo\` refill tool call. Missing \`workspaceId\` in scheduled or \`--yolo\` mode is a blocker; return \`WORKSPACE_REQUIRED\` instead of running against shared config state. Do not solve automation workspace uncertainty by changing the shared active workspace.
2130
- 3. Normalize invocation arguments with \`mcp__sellable__refill_sends({ yolo, executionMode, requireWorkspace, workspaceId, senders, senderIds, senderNames, targetDate, untilDate })\` when the host exposes typed MCP tools. Type shape: \`refill_sends({ yolo?: boolean, executionMode?: "manual" | "scheduled" | "yolo", requireWorkspace?: boolean, workspaceId?: string, senders?: string[], senderIds?: string[], senderNames?: string[], horizonSendDays?: number, untilDate?: "YYYY-MM-DD", targetDate?: "YYYY-MM-DD" })\`.
2177
+ 3. Normalize invocation arguments with \`mcp__sellable__refill_sends({ yolo, executionMode, requireWorkspace, workspaceId, senders, senderIds, senderNames, actionTypes, targetDate, untilDate })\` when the host exposes typed MCP tools. Type shape: \`refill_sends({ yolo?: boolean, executionMode?: "manual" | "scheduled" | "yolo", requireWorkspace?: boolean, workspaceId?: string, senders?: string[], senderIds?: string[], senderNames?: string[], actionTypes?: ("send_invite" | "send_inmail_closed")[], horizonSendDays?: number, untilDate?: "YYYY-MM-DD", targetDate?: "YYYY-MM-DD" })\`.
2131
2178
  4. Clover scheduled automation example: \`mcp__sellable__refill_sends({ yolo:false, executionMode:"scheduled", requireWorkspace:true, workspaceId:"cmlq1v8ms0000jx04ang4hi7e" })\`, then \`mcp__sellable__get_refill_target_plan({ intent:"plain", approvalMode:"mark_ready", workspaceId:"cmlq1v8ms0000jx04ang4hi7e" })\`.
2132
- 5. Call \`mcp__sellable__get_refill_target_plan({ workspaceId, senders, senderIds, senderNames, targetDate, untilDate })\` before any mutation. Render \`target.eligibleSenderLedger\`, \`target.senderRefillPlans[]\`, and \`target.globalActionQueue\` as the structured packet. Preserve the labels \`Need to prepare\`, \`Goal\`, \`Already sent\`, \`Scheduled\`, \`Ready and waiting to be scheduled\`, and \`Still need\`. In \`--yolo\`, \`mcp__sellable__refill_sends\` maintains a run-local \`refreshedPaidInmailSenderIds\` set, refreshes stale/missing paid InMail facts for each selected paid-InMail sender at most once, reruns \`get_refill_target_plan\`, and returns \`autoPaidInmailRefresh\` plus the post-refresh \`targetPlan\` before choosing the next prep/source-copy/bounded-approval/read-only wait action. Refill ladder: approve generated rows only through the explicit bounded approval gate, process existing same-campaign unenriched/unprepared rows before source work, then same-source copy, then provider-aligned source-more. New source/provider switches change reply-rate baseline and are manual alternates, not \`--yolo\` side effects. Do not present paid-credit refresh as the next operator action after \`refill_sends\` returns that post-refresh plan. For exact-date proof, \`mcp__sellable__get_scheduler_fill_capacity\` is read-only and tells how many cells the scheduler will try to place for a sender/action/date. When ready rows cover the target but scheduled rows do not, \`mcp__sellable__run_scheduler_sweep({ workspaceId, action:"run" })\` may request immediate placement within existing gates and returns a receipt, but it never sends or bypasses limits. If the returned target is already complete, stop with the no-op receipt. If ready rows cover the target but scheduled rows do not, keep the run open in the read-only scheduler wait/re-read loop until projected coverage fills, a concrete non-scheduler blocker appears, or Christian explicitly stops/statuses the run.
2179
+ 5. Call \`mcp__sellable__get_refill_target_plan({ workspaceId, senders, senderIds, senderNames, actionTypes, targetDate, untilDate })\` before any mutation. Render \`target.eligibleSenderLedger\`, \`target.senderRefillPlans[]\`, and \`target.globalActionQueue\` as the structured packet. Preserve the labels \`Need to prepare\`, \`Goal\`, \`Already sent\`, \`Scheduled\`, \`Ready and waiting to be scheduled\`, and \`Still need\`. In \`--yolo\`, \`mcp__sellable__refill_sends\` maintains a run-local \`refreshedPaidInmailSenderIds\` set, refreshes stale/missing paid InMail facts for each selected paid-InMail sender at most once, reruns \`get_refill_target_plan\`, and returns \`autoPaidInmailRefresh\` plus the post-refresh \`targetPlan\` before choosing the next prep/source-copy/bounded-approval/read-only wait or exact-date sweep action. Refill ladder: approve generated rows only through the explicit bounded approval gate, process existing same-campaign unenriched/unprepared rows before source work, then same-source copy, then provider-aligned source-more. New source/provider switches change reply-rate baseline and are manual alternates, not \`--yolo\` side effects. Do not present paid-credit refresh as the next operator action after \`refill_sends\` returns that post-refresh plan. For exact-date proof, \`mcp__sellable__get_scheduler_fill_capacity\` is read-only and tells how many cells the scheduler will try to place for a sender/action/date. When the planner returns a request-scoped exact-date scheduler sweep, execute \`mcp__sellable__run_scheduler_sweep({ workspaceId, action:"run", targetDate, requestKey, targetShapeRevision })\` once, capture its receipt, and fully reread before another action. It may schedule eligible workspace cells through existing gates, but it never sends directly or bypasses limits. If the returned target is already complete, stop with the no-op receipt. Only non-exact or no-sweep waits use the read-only scheduler wait/re-read loop until projected coverage fills, a concrete non-scheduler blocker appears, or Christian explicitly stops/statuses the run.
2133
2180
  6. Load the canonical prompt and deterministic flow asset via
2134
2181
  \`mcp__sellable__get_subskill_prompt({ subskillName: "refill-sends" })\`,
2135
2182
  then load \`mcp__sellable__get_subskill_prompt({ subskillName: "refill-sends-workflow" })\`,
@@ -2140,15 +2187,14 @@ Desktop, then start a new thread.
2140
2187
  facts are present, refresh exact selected sender facts once, rerun
2141
2188
  \`get_refill_target_plan\`, and only then enter \`wait_for_scheduler\` if
2142
2189
  freshness is clean.
2143
- 7. Follow the canonical prompt exactly. Do not launch, start, archive, delete,
2144
- or write scheduler rows as part of refill sends.
2190
+ 7. Follow the canonical prompt exactly. Do not directly send, bypass scheduler gates, or raw-write scheduler fields. Only the exact rendered paused-campaign start and request-scoped scheduler sweep may use their existing product-gated paths; do not launch, archive, or delete unrelated work.
2145
2191
 
2146
2192
  ## MCP Prompt Fallback
2147
2193
 
2148
2194
  If exact subskill lookup fails, use
2149
2195
  \`mcp__sellable__search_subskill_prompts({ query: "refill-sends", includePublic: true, includeInternal: true })\`,
2150
2196
  then retry \`get_subskill_prompt\`.
2151
- `, host, "refill-sends");
2197
+ `, host, opts), host, "refill-sends");
2152
2198
  }
2153
2199
 
2154
2200
  function refreshSenderEngagementSkillMd(host = "shared") {
@@ -2660,14 +2706,8 @@ will change or after they approve the settings update.
2660
2706
  `;
2661
2707
  }
2662
2708
 
2663
- function codexPluginSkills() {
2709
+ function codexPluginSkills(opts = {}) {
2664
2710
  return [
2665
- {
2666
- dir: "sellable-find-leads",
2667
- displayName: "Sellable Find Leads",
2668
- description: "Find prospects and build a live exportable lead list",
2669
- skillMd: findLeadsSkillMd("codex"),
2670
- },
2671
2711
  {
2672
2712
  dir: "sellable-create-campaign",
2673
2713
  displayName: "Sellable Create Campaign",
@@ -2715,7 +2755,7 @@ function codexPluginSkills() {
2715
2755
  dir: "sellable-refill-sends",
2716
2756
  displayName: "Sellable Refill Sends",
2717
2757
  description: "Refill sender sends with approval-gated campaign selection",
2718
- skillMd: refillSendsSkillMd("codex"),
2758
+ skillMd: refillSendsSkillMd("codex", opts),
2719
2759
  },
2720
2760
  ];
2721
2761
  }
@@ -2906,16 +2946,8 @@ then retry \`get_subskill_prompt\`.
2906
2946
  );
2907
2947
  }
2908
2948
 
2909
- function hermesSkillDefinitions() {
2949
+ function hermesSkillDefinitions(opts = {}) {
2910
2950
  return [
2911
- {
2912
- commandName: "find-leads",
2913
- dir: hermesCommandName("find-leads"),
2914
- skillMd: normalizeHermesSkillMarkdown(
2915
- findLeadsSkillMd("hermes"),
2916
- "find-leads"
2917
- ),
2918
- },
2919
2951
  {
2920
2952
  commandName: "create-campaign",
2921
2953
  dir: hermesCommandName("create-campaign"),
@@ -2971,7 +3003,7 @@ function hermesSkillDefinitions() {
2971
3003
  commandName: "refill-sends",
2972
3004
  dir: hermesCommandName("refill-sends"),
2973
3005
  skillMd: normalizeHermesSkillMarkdown(
2974
- refillSendsSkillMd("hermes"),
3006
+ refillSendsSkillMd("hermes", opts),
2975
3007
  "refill-sends"
2976
3008
  ),
2977
3009
  },
@@ -3099,8 +3131,8 @@ function generateCodexAgentToml(agent) {
3099
3131
  const codex = agent.codex;
3100
3132
  return `name = ${quoteToml(agent.name)}
3101
3133
  description = ${quoteToml(codex.description)}
3102
- model = ${quoteToml(codex.model || "gpt-5.5")}
3103
- model_reasoning_effort = ${quoteToml(codex.modelReasoningEffort || "xhigh")}
3134
+ model = ${quoteToml(codex.model || "gpt-5.6-sol")}
3135
+ model_reasoning_effort = ${quoteToml(codex.modelReasoningEffort || "high")}
3104
3136
  sandbox_mode = ${quoteToml(codex.sandboxMode || "read-only")}
3105
3137
  nickname_candidates = ${tomlArray(codex.nicknameCandidates || [agent.displayName])}
3106
3138
  developer_instructions = ${tomlMultilineString(agent.prompt)}
@@ -3164,17 +3196,8 @@ ${stripFrontmatter(command.skillMd)}
3164
3196
  `;
3165
3197
  }
3166
3198
 
3167
- function claudeCommands() {
3199
+ function claudeCommands(opts = {}) {
3168
3200
  return [
3169
- {
3170
- name: "find-leads",
3171
- title: "Find Leads",
3172
- filename: join("sellable", "find-leads.md"),
3173
- description: "Find prospects and build a live exportable Sellable lead list.",
3174
- argumentHint: "[target audience, provider, or lead-list goal]",
3175
- skillMd: findLeadsSkillMd("claude"),
3176
- allowedTools: ["AskUserQuestion", ...FIND_LEADS_ALLOWED_TOOLS],
3177
- },
3178
3201
  {
3179
3202
  name: "create-campaign",
3180
3203
  title: "Create Campaign",
@@ -3229,7 +3252,7 @@ function claudeCommands() {
3229
3252
  description:
3230
3253
  "Refill Sellable sender sends through the approval-gated refill workflow.",
3231
3254
  argumentHint: "[--yolo] [--sender name-or-id ...]",
3232
- skillMd: refillSendsSkillMd("claude"),
3255
+ skillMd: refillSendsSkillMd("claude", opts),
3233
3256
  allowedTools: ["AskUserQuestion", ...REFILL_SENDS_ALLOWED_TOOLS],
3234
3257
  },
3235
3258
  {
@@ -3254,7 +3277,7 @@ function claudeCommands() {
3254
3277
  }
3255
3278
 
3256
3279
  function writeCodexPluginSkills(pluginRoot, opts) {
3257
- const skills = codexPluginSkills();
3280
+ const skills = codexPluginSkills(opts);
3258
3281
  const currentSkillDirs = new Set(skills.map((skill) => skill.dir));
3259
3282
  const skillsRoot = join(pluginRoot, "skills");
3260
3283
 
@@ -3318,7 +3341,7 @@ function writeClaudeCustomAgents(opts) {
3318
3341
 
3319
3342
  function writeClaudeCommands(opts) {
3320
3343
  const home = claudeHome();
3321
- for (const command of claudeCommands()) {
3344
+ for (const command of claudeCommands(opts)) {
3322
3345
  writeFile(join(home, "commands", command.filename), command.content, opts);
3323
3346
  }
3324
3347
  }
@@ -3559,8 +3582,19 @@ const WATCH_MODE_DRIVER_ENV = {
3559
3582
  };
3560
3583
 
3561
3584
  function mcpEnvForHost(host, opts) {
3585
+ const mcpVersionMatch = /^@sellable\/mcp@(.+)$/.exec(opts.mcpPackage || "");
3586
+ const mcpVersion = mcpVersionMatch?.[1] || String(opts.mcpPackage || "unknown");
3587
+ const pluginVersion = host === "codex" ? CODEX_PLUGIN_VERSION : "not-applicable";
3588
+ const cacheIdentity =
3589
+ host === "codex"
3590
+ ? `codex-plugin:${CODEX_PLUGIN_VERSION}|mcp:${mcpVersion}`
3591
+ : `${host}-host|mcp:${mcpVersion}`;
3562
3592
  const env = {
3563
3593
  SELLABLE_WATCH_MODE_DRIVER: host,
3594
+ SELLABLE_INSTALL_PACKAGE_VERSION: getInstallVersion(),
3595
+ SELLABLE_MCP_PACKAGE_VERSION: mcpVersion,
3596
+ SELLABLE_CODEX_PLUGIN_VERSION: pluginVersion,
3597
+ SELLABLE_PLUGIN_CACHE_IDENTITY: cacheIdentity,
3564
3598
  };
3565
3599
  if (opts.server === "hosted") return env;
3566
3600
  if (opts.sellableConfigPath) {
@@ -3569,6 +3603,12 @@ function mcpEnvForHost(host, opts) {
3569
3603
  if (opts.sellableConfigsDir) {
3570
3604
  env.SELLABLE_CONFIGS_DIR = opts.sellableConfigsDir;
3571
3605
  }
3606
+ if (opts.lockWorkspaceId) {
3607
+ env.SELLABLE_LOCK_WORKSPACE_ID = opts.lockWorkspaceId;
3608
+ }
3609
+ if (opts.requireWorkspaceLock) {
3610
+ env.SELLABLE_REQUIRE_WORKSPACE_LOCK = "1";
3611
+ }
3572
3612
  return env;
3573
3613
  }
3574
3614
 
@@ -3813,7 +3853,7 @@ function writeHermesMcpServer(opts) {
3813
3853
 
3814
3854
  function installHermesSkills(opts) {
3815
3855
  const root = hermesSkillsRoot(opts);
3816
- for (const skill of hermesSkillDefinitions()) {
3856
+ for (const skill of hermesSkillDefinitions(opts)) {
3817
3857
  const skillRoot = join(root, skill.dir);
3818
3858
  writeFile(join(skillRoot, "SKILL.md"), skill.skillMd, opts);
3819
3859
  if (skill.soulMd) {
@@ -3853,6 +3893,12 @@ function parseHermesProfileBootstrapArgs(argv) {
3853
3893
  apiUrl: process.env.SELLABLE_API_URL || DEFAULT_API_URL,
3854
3894
  slackBotToken: process.env.SLACK_BOT_TOKEN || "",
3855
3895
  slackAppToken: process.env.SLACK_APP_TOKEN || "",
3896
+ slackHomeChannel: process.env.SLACK_HOME_CHANNEL || "",
3897
+ slackHomeChannelName: process.env.SLACK_HOME_CHANNEL_NAME || "",
3898
+ slackAllowedChannels: process.env.SLACK_ALLOWED_CHANNELS || "",
3899
+ slackFreeResponseChannels: process.env.SLACK_FREE_RESPONSE_CHANNELS || "",
3900
+ slackAllowedUsers: process.env.SLACK_ALLOWED_USERS || "",
3901
+ slackRequireMention: process.env.SLACK_REQUIRE_MENTION || "",
3856
3902
  server: process.env.SELLABLE_INSTALL_SERVER || "package",
3857
3903
  mcpPackage: DEFAULT_SERVER_PACKAGE,
3858
3904
  localCommand: process.env.SELLABLE_MCP_LOCAL_COMMAND || "",
@@ -3861,6 +3907,7 @@ function parseHermesProfileBootstrapArgs(argv) {
3861
3907
  force: false,
3862
3908
  overwriteEnv: false,
3863
3909
  allowDuplicateSlackTokens: false,
3910
+ requireWorkspaceLock: false,
3864
3911
  verbose: false,
3865
3912
  };
3866
3913
 
@@ -3885,6 +3932,12 @@ function parseHermesProfileBootstrapArgs(argv) {
3885
3932
  else if (arg === "--api-url") opts.apiUrl = next();
3886
3933
  else if (arg === "--slack-bot-token") opts.slackBotToken = next();
3887
3934
  else if (arg === "--slack-app-token") opts.slackAppToken = next();
3935
+ else if (arg === "--slack-home-channel") opts.slackHomeChannel = next();
3936
+ else if (arg === "--slack-home-channel-name") opts.slackHomeChannelName = next();
3937
+ else if (arg === "--slack-allowed-channels") opts.slackAllowedChannels = next();
3938
+ else if (arg === "--slack-free-response-channels") opts.slackFreeResponseChannels = next();
3939
+ else if (arg === "--slack-allowed-users") opts.slackAllowedUsers = next();
3940
+ else if (arg === "--slack-require-mention") opts.slackRequireMention = next();
3888
3941
  else if (arg === "--server") opts.server = next();
3889
3942
  else if (arg === "--mcp-package") {
3890
3943
  opts.mcpPackage = normalizeWindowsPackageSpec(next());
@@ -3893,7 +3946,9 @@ function parseHermesProfileBootstrapArgs(argv) {
3893
3946
  else if (arg === "--json") opts.json = true;
3894
3947
  else if (arg === "--force") opts.force = true;
3895
3948
  else if (arg === "--overwrite-env") opts.overwriteEnv = true;
3896
- else if (arg === "--allow-duplicate-slack-tokens") {
3949
+ else if (arg === "--require-workspace-lock") {
3950
+ opts.requireWorkspaceLock = true;
3951
+ } else if (arg === "--allow-duplicate-slack-tokens") {
3897
3952
  opts.allowDuplicateSlackTokens = true;
3898
3953
  } else if (arg === "--verbose") opts.verbose = true;
3899
3954
  else {
@@ -3986,6 +4041,111 @@ function renderEnvFile({ entries, passthrough }) {
3986
4041
  return `${lines.join("\n")}${lines.length ? "\n" : ""}`;
3987
4042
  }
3988
4043
 
4044
+ function normalizeSlackChannelIds(value, key) {
4045
+ const raw = String(value || "").trim();
4046
+ if (!raw) return "";
4047
+ const ids = raw
4048
+ .split(",")
4049
+ .map((item) => item.trim())
4050
+ .filter(Boolean);
4051
+ if (ids.length === 0) return "";
4052
+
4053
+ for (const id of ids) {
4054
+ if (
4055
+ id === "*" ||
4056
+ /^all$/i.test(id) ||
4057
+ id.startsWith("#") ||
4058
+ /\s/.test(id) ||
4059
+ !/^[A-Z][A-Z0-9]{8,}$/.test(id)
4060
+ ) {
4061
+ throw new Error(
4062
+ `${key} must be comma-separated Slack channel IDs, not names, wildcards, or free text. Bad value: ${id}`
4063
+ );
4064
+ }
4065
+ }
4066
+ return [...new Set(ids)].join(",");
4067
+ }
4068
+
4069
+ function normalizeSlackSingleChannelId(value, key) {
4070
+ const normalized = normalizeSlackChannelIds(value, key);
4071
+ if (normalized.includes(",")) {
4072
+ throw new Error(`${key} must be a single Slack channel ID`);
4073
+ }
4074
+ return normalized;
4075
+ }
4076
+
4077
+ function normalizeSlackUserIds(value, key) {
4078
+ const raw = String(value || "").trim();
4079
+ if (!raw) return "";
4080
+ const ids = raw
4081
+ .split(",")
4082
+ .map((item) => item.trim())
4083
+ .filter(Boolean);
4084
+ if (ids.length === 0) return "";
4085
+
4086
+ for (const id of ids) {
4087
+ if (!/^[UW][A-Z0-9]{8,}$/.test(id)) {
4088
+ throw new Error(`${key} must be comma-separated Slack user IDs. Bad value: ${id}`);
4089
+ }
4090
+ }
4091
+ return [...new Set(ids)].join(",");
4092
+ }
4093
+
4094
+ function normalizeSlackChannelName(value, key) {
4095
+ const raw = String(value || "").trim();
4096
+ if (!raw) return "";
4097
+ if (raw.startsWith("#") || /[\s,]/.test(raw)) {
4098
+ throw new Error(`${key} must be a Slack channel name without #, spaces, or commas`);
4099
+ }
4100
+ return raw;
4101
+ }
4102
+
4103
+ function normalizeSlackBoolean(value, key) {
4104
+ const raw = String(value || "").trim().toLowerCase();
4105
+ if (!raw) return "";
4106
+ if (raw !== "true" && raw !== "false") {
4107
+ throw new Error(`${key} must be true or false`);
4108
+ }
4109
+ return raw;
4110
+ }
4111
+
4112
+ function buildSlackProfileEnv(parsed) {
4113
+ const homeChannel = normalizeSlackSingleChannelId(
4114
+ parsed.slackHomeChannel,
4115
+ "SLACK_HOME_CHANNEL"
4116
+ );
4117
+ const explicitAllowedChannels = normalizeSlackChannelIds(
4118
+ parsed.slackAllowedChannels,
4119
+ "SLACK_ALLOWED_CHANNELS"
4120
+ );
4121
+ const allowedChannels = explicitAllowedChannels || homeChannel;
4122
+
4123
+ return {
4124
+ SLACK_BOT_TOKEN: parsed.slackBotToken.trim(),
4125
+ SLACK_APP_TOKEN: parsed.slackAppToken.trim(),
4126
+ SLACK_HOME_CHANNEL: homeChannel,
4127
+ SLACK_HOME_CHANNEL_NAME: normalizeSlackChannelName(
4128
+ parsed.slackHomeChannelName,
4129
+ "SLACK_HOME_CHANNEL_NAME"
4130
+ ),
4131
+ // Customer Hermes profiles must fail closed to their channel. If the caller
4132
+ // supplies only a home channel, we treat that as the allowlist too.
4133
+ SLACK_ALLOWED_CHANNELS: allowedChannels,
4134
+ SLACK_FREE_RESPONSE_CHANNELS: normalizeSlackChannelIds(
4135
+ parsed.slackFreeResponseChannels,
4136
+ "SLACK_FREE_RESPONSE_CHANNELS"
4137
+ ),
4138
+ SLACK_ALLOWED_USERS: normalizeSlackUserIds(
4139
+ parsed.slackAllowedUsers,
4140
+ "SLACK_ALLOWED_USERS"
4141
+ ),
4142
+ SLACK_REQUIRE_MENTION: normalizeSlackBoolean(
4143
+ parsed.slackRequireMention,
4144
+ "SLACK_REQUIRE_MENTION"
4145
+ ),
4146
+ };
4147
+ }
4148
+
3989
4149
  function findDuplicateSlackTokenProfiles(profileRoot, slackTokens) {
3990
4150
  const duplicates = [];
3991
4151
  const profilesRoot = dirname(profileRoot);
@@ -4038,10 +4198,16 @@ function runHermesProfileBootstrap(argv) {
4038
4198
  const sellableConfigsDir = join(profileRoot, "sellable", "configs");
4039
4199
  const envPath = join(profileRoot, ".env");
4040
4200
  const { token, source: tokenSource } = readBootstrapToken(parsed);
4201
+ const slackEnv = buildSlackProfileEnv(parsed);
4041
4202
  const slackTokens = {
4042
- SLACK_BOT_TOKEN: parsed.slackBotToken.trim(),
4043
- SLACK_APP_TOKEN: parsed.slackAppToken.trim(),
4203
+ SLACK_BOT_TOKEN: slackEnv.SLACK_BOT_TOKEN,
4204
+ SLACK_APP_TOKEN: slackEnv.SLACK_APP_TOKEN,
4044
4205
  };
4206
+ const slackEnvKeys = Object.entries(slackEnv)
4207
+ .filter(([, value]) => value)
4208
+ .map(([key]) => key);
4209
+ const requireWorkspaceLock =
4210
+ parsed.requireWorkspaceLock || Boolean(parsed.workspaceId);
4045
4211
  const opts = {
4046
4212
  ...parsed,
4047
4213
  host: "hermes",
@@ -4051,6 +4217,8 @@ function runHermesProfileBootstrap(argv) {
4051
4217
  token,
4052
4218
  tokenSource,
4053
4219
  workspaceId: parsed.workspaceId,
4220
+ lockWorkspaceId: parsed.workspaceId,
4221
+ requireWorkspaceLock,
4054
4222
  apiUrl: parsed.apiUrl,
4055
4223
  };
4056
4224
 
@@ -4075,6 +4243,17 @@ function runHermesProfileBootstrap(argv) {
4075
4243
  apiUrl: opts.apiUrl,
4076
4244
  activeWorkspaceId: parsed.workspaceId || null,
4077
4245
  activeWorkspaceName: parsed.workspaceName || null,
4246
+ workspaceLock: parsed.workspaceId
4247
+ ? {
4248
+ enabled: true,
4249
+ workspaceId: parsed.workspaceId,
4250
+ required: requireWorkspaceLock,
4251
+ }
4252
+ : {
4253
+ enabled: false,
4254
+ workspaceId: null,
4255
+ required: requireWorkspaceLock,
4256
+ },
4078
4257
  tokenPresent: Boolean(token),
4079
4258
  tokenSource,
4080
4259
  tokenFingerprint: tokenFingerprint(token),
@@ -4083,11 +4262,29 @@ function runHermesProfileBootstrap(argv) {
4083
4262
  keys: Object.entries(slackTokens)
4084
4263
  .filter(([, value]) => value)
4085
4264
  .map(([key]) => key),
4265
+ envKeys: slackEnvKeys,
4086
4266
  fingerprints: Object.fromEntries(
4087
4267
  Object.entries(slackTokens)
4088
4268
  .filter(([, value]) => value)
4089
4269
  .map(([key, value]) => [key, tokenFingerprint(value)])
4090
4270
  ),
4271
+ scope: {
4272
+ homeChannel: slackEnv.SLACK_HOME_CHANNEL || null,
4273
+ homeChannelName: slackEnv.SLACK_HOME_CHANNEL_NAME || null,
4274
+ allowedChannels: slackEnv.SLACK_ALLOWED_CHANNELS
4275
+ ? slackEnv.SLACK_ALLOWED_CHANNELS.split(",")
4276
+ : [],
4277
+ freeResponseChannels: slackEnv.SLACK_FREE_RESPONSE_CHANNELS
4278
+ ? slackEnv.SLACK_FREE_RESPONSE_CHANNELS.split(",")
4279
+ : [],
4280
+ allowedUsersCount: slackEnv.SLACK_ALLOWED_USERS
4281
+ ? slackEnv.SLACK_ALLOWED_USERS.split(",").length
4282
+ : 0,
4283
+ requireMention:
4284
+ slackEnv.SLACK_REQUIRE_MENTION === ""
4285
+ ? null
4286
+ : slackEnv.SLACK_REQUIRE_MENTION === "true",
4287
+ },
4091
4288
  },
4092
4289
  created: [],
4093
4290
  updated: [],
@@ -4132,7 +4329,7 @@ function runHermesProfileBootstrap(argv) {
4132
4329
  if (existsSync(envPath)) {
4133
4330
  envState = parseEnvLines(readFileSync(envPath, "utf8"));
4134
4331
  }
4135
- for (const [key, value] of Object.entries(slackTokens)) {
4332
+ for (const [key, value] of Object.entries(slackEnv)) {
4136
4333
  if (!value) continue;
4137
4334
  const existing = envState.entries.get(key);
4138
4335
  if (existing && existing !== value && !opts.overwriteEnv) {
@@ -4192,8 +4389,8 @@ function runHermesProfileBootstrap(argv) {
4192
4389
  mkdirSync(sellableConfigsDir, { recursive: true, mode: 0o700 });
4193
4390
  writeHermesProfileSellableConfig(sellableConfigPath, nextAuth, opts);
4194
4391
  installHermes(opts);
4195
- if (Object.values(slackTokens).some(Boolean)) {
4196
- for (const [key, value] of Object.entries(slackTokens)) {
4392
+ if (Object.values(slackEnv).some(Boolean)) {
4393
+ for (const [key, value] of Object.entries(slackEnv)) {
4197
4394
  if (value) envState.entries.set(key, value);
4198
4395
  }
4199
4396
  writeFile(envPath, renderEnvFile(envState), opts, 0o600);
@@ -4208,16 +4405,31 @@ function runHermesProfileBootstrap(argv) {
4208
4405
  hermesConfigPath(opts)
4209
4406
  );
4210
4407
  (skillsExisted ? summary.updated : summary.created).push(hermesSkillsRoot(opts));
4211
- if (Object.values(slackTokens).some(Boolean)) {
4408
+ if (Object.values(slackEnv).some(Boolean)) {
4212
4409
  (envExisted ? summary.updated : summary.created).push(envPath);
4213
4410
  } else {
4214
- summary.skipped.push("Slack env: no Slack tokens supplied");
4411
+ summary.skipped.push("Slack env: no Slack tokens or channel scope supplied");
4412
+ }
4413
+ if (slackEnv.SLACK_HOME_CHANNEL && !parsed.slackAllowedChannels.trim()) {
4414
+ summary.warnings.push(
4415
+ "Slack scope defaulted SLACK_ALLOWED_CHANNELS to SLACK_HOME_CHANNEL for fail-closed profile listening."
4416
+ );
4417
+ }
4418
+ if (slackEnv.SLACK_BOT_TOKEN && !slackEnv.SLACK_APP_TOKEN) {
4419
+ summary.warnings.push(
4420
+ "Slack bot token is present without SLACK_APP_TOKEN; outbound Slack sends may work, but native Hermes Socket Mode listening still requires an xapp app-level token with connections:write."
4421
+ );
4215
4422
  }
4216
4423
  if (!token) {
4217
4424
  summary.warnings.push(
4218
4425
  `Manual auth pending. Run: sellable auth set <token> --workspace-id <workspace_id> --sellable-config-path ${sellableConfigPath}`
4219
4426
  );
4220
4427
  }
4428
+ if (requireWorkspaceLock && !parsed.workspaceId) {
4429
+ summary.warnings.push(
4430
+ "Sellable MCP will fail closed until sellable-admin provisions SELLABLE_LOCK_WORKSPACE_ID for this customer profile."
4431
+ );
4432
+ }
4221
4433
 
4222
4434
  if (opts.json) {
4223
4435
  console.log(JSON.stringify(summary, null, 2));
@@ -5220,8 +5432,7 @@ function printNextSteps(installedHosts, authReused) {
5220
5432
  console.log("");
5221
5433
 
5222
5434
  if (hasClaude) {
5223
- printAgentBox("Using Claude Code?", "claude", [
5224
- { label: "Find Leads", command: "/sellable:find-leads" },
5435
+ printAgentBox("Using Claude Code?", "claude", [
5225
5436
  { label: "Campaign", command: "/sellable:create-campaign" },
5226
5437
  { label: "Evergreen", command: "/sellable:create-evergreen-campaigns" },
5227
5438
  { label: "Foundation", command: "/sellable:foundation" },
@@ -5234,8 +5445,7 @@ function printNextSteps(installedHosts, authReused) {
5234
5445
  console.log("");
5235
5446
  }
5236
5447
  if (hasCodex) {
5237
- printAgentBox("Using Codex?", "codex", [
5238
- { label: "Find Leads", command: "$sellable:find-leads" },
5448
+ printAgentBox("Using Codex?", "codex", [
5239
5449
  { label: "Campaign", command: "$sellable:create-campaign" },
5240
5450
  { label: "Evergreen", command: "$sellable:create-evergreen-campaigns" },
5241
5451
  { label: "Foundation", command: "$sellable:foundation" },
@@ -5247,8 +5457,7 @@ function printNextSteps(installedHosts, authReused) {
5247
5457
  console.log("");
5248
5458
  }
5249
5459
  if (hasHermes) {
5250
- printAgentBox("Using Hermes?", "hermes", [
5251
- { label: "Find Leads", command: "/sellable-find-leads" },
5460
+ printAgentBox("Using Hermes?", "hermes", [
5252
5461
  { label: "Campaign", command: "/sellable-create-campaign" },
5253
5462
  { label: "A/B Test", command: "/sellable-create-ab-test" },
5254
5463
  { label: "Evergreen", command: "/sellable-create-evergreen-campaigns" },
@@ -5618,25 +5827,22 @@ async function main() {
5618
5827
  if (workspaceId) {
5619
5828
  console.log(` activeWorkspaceId: ${workspaceId}`);
5620
5829
  }
5621
- console.log(` Continue in your agent:`);
5622
- console.log(` Claude Code: /sellable:find-leads`);
5623
- console.log(` Claude Code: /sellable:create-campaign`);
5830
+ console.log(` Continue in your agent:`);
5831
+ console.log(` Claude Code: /sellable:create-campaign`);
5624
5832
  console.log(` Claude Code: /sellable:create-evergreen-campaigns`);
5625
5833
  console.log(` Claude Code: /sellable:foundation`);
5626
5834
  console.log(` Claude Code: /sellable:content`);
5627
5835
  console.log(` Claude Code: /sellable:create-post`);
5628
5836
  console.log(` Claude Code: /sellable:refresh-sender-engagement`);
5629
5837
  console.log(` Claude Code: /sellable:refill-sends`);
5630
- console.log(` Codex: $sellable:find-leads`);
5631
- console.log(` Codex: $sellable:create-campaign`);
5838
+ console.log(` Codex: $sellable:create-campaign`);
5632
5839
  console.log(` Codex: $sellable:create-evergreen-campaigns`);
5633
5840
  console.log(` Codex: $sellable:foundation`);
5634
5841
  console.log(` Codex: $sellable:content`);
5635
5842
  console.log(` Codex: $sellable:create-post`);
5636
5843
  console.log(` Codex: $sellable:refresh-sender-engagement`);
5637
5844
  console.log(` Codex: $sellable:refill-sends`);
5638
- console.log(` Hermes: /sellable-find-leads`);
5639
- console.log(` Hermes: /sellable-create-campaign`);
5845
+ console.log(` Hermes: /sellable-create-campaign`);
5640
5846
  console.log(` Hermes: /sellable-create-evergreen-campaigns`);
5641
5847
  console.log(` Hermes: /sellable-foundation`);
5642
5848
  console.log(` Hermes: /sellable-content`);
@@ -5693,6 +5899,73 @@ async function main() {
5693
5899
  printCreateCommandHint();
5694
5900
  process.exit(0);
5695
5901
  }
5902
+ if (rawArgs[0] === "codex" && rawArgs[1] === "plugin-selection") {
5903
+ const command = rawArgs[2];
5904
+ const flags = new Map();
5905
+ for (let index = 3; index < rawArgs.length; index += 2) {
5906
+ const flag = rawArgs[index];
5907
+ const value = rawArgs[index + 1];
5908
+ if (!flag?.startsWith("--") || !value || value.startsWith("--")) {
5909
+ throw new Error(`Invalid Codex plugin-selection option: ${flag ?? "<missing>"}`);
5910
+ }
5911
+ flags.set(flag, value);
5912
+ }
5913
+ const required = (name) => {
5914
+ const value = flags.get(name);
5915
+ if (!value) throw new Error(`Missing value for ${name}`);
5916
+ return value;
5917
+ };
5918
+ const paths = resolveCodexPluginSelectionPaths({ home: homedir() });
5919
+ let result;
5920
+ if (command === "status") {
5921
+ result = readCodexPluginSelection({ paths, stableVersion: flags.get("--stable-version") });
5922
+ } else if (command === "snapshot") {
5923
+ result = snapshotCodexPluginSelection({
5924
+ paths,
5925
+ snapshotRoot: required("--snapshot-root"),
5926
+ });
5927
+ } else if (command === "install-cache") {
5928
+ result = installCodexCandidateCache({
5929
+ paths,
5930
+ sourcePluginRoot: required("--source"),
5931
+ version: required("--version"),
5932
+ });
5933
+ } else if (command === "activate") {
5934
+ result = activateCodexCandidateSelection({
5935
+ paths,
5936
+ snapshotManifestPath: required("--snapshot"),
5937
+ expectedSnapshotHash: required("--snapshot-hash"),
5938
+ candidateVersion: required("--candidate-version"),
5939
+ expectedCandidateHash: required("--candidate-hash"),
5940
+ approvalId: required("--approval-id"),
5941
+ approvedAt: required("--approved-at"),
5942
+ restoreBy: required("--restore-by"),
5943
+ now: required("--now"),
5944
+ });
5945
+ } else if (command === "restore") {
5946
+ result = restoreCodexPluginSelection({
5947
+ paths,
5948
+ snapshotManifestPath: required("--snapshot"),
5949
+ expectedSnapshotHash: required("--snapshot-hash"),
5950
+ activationPath: required("--activation"),
5951
+ now: required("--now"),
5952
+ recoveryReason: flags.get("--recovery-reason") || undefined,
5953
+ taskEvidence:
5954
+ flags.has("--task-id") && flags.has("--resolved-candidate-hash")
5955
+ ? {
5956
+ taskId: flags.get("--task-id"),
5957
+ resolvedCandidateHash: flags.get("--resolved-candidate-hash"),
5958
+ }
5959
+ : undefined,
5960
+ });
5961
+ } else {
5962
+ throw new Error(
5963
+ "Usage: sellable codex plugin-selection snapshot|install-cache|activate|restore|status"
5964
+ );
5965
+ }
5966
+ console.log(JSON.stringify(result, null, 2));
5967
+ process.exit(0);
5968
+ }
5696
5969
  if (
5697
5970
  rawArgs[0] === "hermes" &&
5698
5971
  rawArgs[1] === "profile" &&