@sechroom/cli 2026.7.13 → 2026.7.15

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.
Files changed (2) hide show
  1. package/dist/index.js +406 -47
  2. package/package.json +2 -1
package/dist/index.js CHANGED
@@ -439,6 +439,9 @@ var quiet = false;
439
439
  function setQuiet(q) {
440
440
  quiet = q;
441
441
  }
442
+ function isQuiet() {
443
+ return quiet;
444
+ }
442
445
  function colorOn() {
443
446
  return !quiet && !process.env.NO_COLOR && process.env.FORCE_COLOR !== "0" && Boolean(process.stdout.isTTY);
444
447
  }
@@ -615,6 +618,22 @@ function emitAction(summary, data, json) {
615
618
  process.stdout.write(`${ok("\u2713")} ${summary}
616
619
  `);
617
620
  }
621
+ var GOVERNANCE_QUEUED_PROBLEM_TYPE = "https://sechroom.dev/problems/governance-review-queued";
622
+ function isGovernanceQueued(body) {
623
+ return typeof body === "object" && body !== null && "type" in body && body.type === GOVERNANCE_QUEUED_PROBLEM_TYPE;
624
+ }
625
+ function formatQueuedForApproval(body, json) {
626
+ if (json) return JSON.stringify(body) + "\n";
627
+ const b = body ?? {};
628
+ const detail = typeof b.detail === "string" ? b.detail : void 0;
629
+ const requestId = typeof b.requestId === "string" ? b.requestId : void 0;
630
+ const message = detail ?? "This change requires operator approval before it takes effect.";
631
+ let out = `${warn("\u29D7")} Pending approval \u2014 ${message}
632
+ `;
633
+ if (requestId) out += ` ${style.dim(`request: ${requestId}`)}
634
+ `;
635
+ return out;
636
+ }
618
637
  async function runApi(label, fn) {
619
638
  const s = spinner(label);
620
639
  let res;
@@ -624,6 +643,12 @@ async function runApi(label, fn) {
624
643
  s.fail();
625
644
  fail(err2);
626
645
  }
646
+ const queuedBody = res.data ?? res.error;
647
+ if (res.response?.status === 202 && isGovernanceQueued(queuedBody)) {
648
+ s.stop();
649
+ process.stdout.write(formatQueuedForApproval(queuedBody, isQuiet()));
650
+ process.exit(0);
651
+ }
627
652
  const httpFailed = res.response !== void 0 && !res.response.ok;
628
653
  if (res.error !== void 0 && res.error !== null || httpFailed) {
629
654
  s.fail();
@@ -1076,14 +1101,23 @@ function bodyOf(row) {
1076
1101
  const m = row?.item ?? row;
1077
1102
  return m?.text ?? m?.Text ?? "";
1078
1103
  }
1104
+ function idOf(row) {
1105
+ const m = row?.item ?? row;
1106
+ return String(m?.id ?? m?.memoryId ?? "unknown");
1107
+ }
1079
1108
  function entriesFromRows(rows, surface, source, roleTag, namePrefix) {
1080
1109
  const out = /* @__PURE__ */ new Map();
1081
1110
  for (const row of rows ?? []) {
1082
1111
  const tags = tagsOf(row);
1083
1112
  if (!tags.includes(roleTag)) continue;
1084
- if (tagValue(tags, "target:") !== surface) continue;
1113
+ if (!tags.includes(`target:${surface}`)) continue;
1085
1114
  const name = tagValue(tags, namePrefix);
1086
1115
  if (!name) continue;
1116
+ if (out.has(name)) {
1117
+ throw new Error(
1118
+ `Ambiguous ${source} ${roleTag} '${name}' for target:${surface}: ${idOf(row)} duplicates another eligible component. Resolve the duplicate before installing.`
1119
+ );
1120
+ }
1087
1121
  out.set(name, { name, body: bodyOf(row), source });
1088
1122
  }
1089
1123
  return out;
@@ -1111,20 +1145,33 @@ function agentTargetFor(surface) {
1111
1145
  return AGENT_TARGET[surface] ?? `${surface}-agent`;
1112
1146
  }
1113
1147
  async function fetchFeedRows(cfg, workspaceId) {
1114
- try {
1115
- const client = await makeClient(cfg);
1116
- const feed = await client.GET("/workspaces/{workspaceId}/memories/feed", {
1148
+ const client = await makeClient(cfg);
1149
+ const rows = [];
1150
+ let cursor;
1151
+ do {
1152
+ const { data, error, response } = await client.GET("/workspaces/{workspaceId}/memories/feed", {
1117
1153
  params: {
1118
1154
  path: { workspaceId },
1119
1155
  // cascadeWorkspaces: skills land in an "Operator Skills" SUB-workspace;
1120
1156
  // includeText: the feed omits bodies by default, we need them for SKILL.md.
1121
- query: { limit: 200, cascadeWorkspaces: true, includeText: true }
1157
+ query: {
1158
+ limit: 200,
1159
+ cascadeWorkspaces: true,
1160
+ includeText: true,
1161
+ ...cursor ? { cursor } : {}
1162
+ }
1122
1163
  }
1123
- }).then((r) => r.data).catch(() => void 0);
1124
- return feed?.results ?? feed?.Results ?? [];
1125
- } catch {
1126
- return [];
1127
- }
1164
+ });
1165
+ if (error || !response.ok || !data) {
1166
+ throw new Error(
1167
+ `Could not read operator-skill catalogue from ${workspaceId}: HTTP ${response.status}.`
1168
+ );
1169
+ }
1170
+ const feed = data;
1171
+ rows.push(...feed.results ?? feed.Results ?? []);
1172
+ cursor = feed.nextCursor ?? feed.NextCursor ?? void 0;
1173
+ } while (cursor);
1174
+ return rows;
1128
1175
  }
1129
1176
  async function fetchTemplateRows(cfg, personalWorkspaceId) {
1130
1177
  const [systemRows, personalRows] = await Promise.all([
@@ -1144,7 +1191,10 @@ function resolveReferenceSet(rows, surface) {
1144
1191
  }
1145
1192
 
1146
1193
  // src/setup/materialise.ts
1147
- var CLIENT_SURFACE = "claude-code";
1194
+ var CLIENT_SURFACE = {
1195
+ claude: "claude-code",
1196
+ codex: "gpt-codex"
1197
+ };
1148
1198
  function writeSkills(dir, skills, surface) {
1149
1199
  const written = [];
1150
1200
  for (const s of skills) {
@@ -1177,11 +1227,71 @@ function writeReferencesIntoSkillDirs(dir, skills, refs) {
1177
1227
  }
1178
1228
  return refs.map((r) => r.name);
1179
1229
  }
1180
- var SKILL_SPEC = { kind: "skill", dir: skillsDir, resolve: resolveSkillSet, write: writeSkills };
1181
- var AGENT_SPEC = { kind: "agent", dir: agentsDir, resolve: resolveAgentSet, write: writeAgents };
1230
+ var SKILL_SPEC = {
1231
+ kind: "skill",
1232
+ dir: skillsDir,
1233
+ resolve: resolveSkillSet,
1234
+ write: writeSkills,
1235
+ supportsCodex: true
1236
+ };
1237
+ var AGENT_SPEC = {
1238
+ kind: "agent",
1239
+ dir: agentsDir,
1240
+ resolve: resolveAgentSet,
1241
+ write: writeAgents,
1242
+ supportsCodex: false
1243
+ };
1182
1244
  function scopeOf(opts) {
1183
1245
  return opts.local ? "project" : resolveScope(opts.scope);
1184
1246
  }
1247
+ function parseClient(value) {
1248
+ if (value == null) return void 0;
1249
+ if (value === "claude" || value === "codex" || value === "all") return value;
1250
+ throw new Error(`--client must be 'claude', 'codex', or 'all' (got '${value}')`);
1251
+ }
1252
+ function clientSelection(spec, g, raw) {
1253
+ if (!spec.supportsCodex) return "claude";
1254
+ const explicit = parseClient(raw);
1255
+ if (explicit) return explicit;
1256
+ const claudeConfigured = Boolean(g.claudeConfigDir || process.env.CLAUDE_CONFIG_DIR);
1257
+ const codexConfigured = Boolean(g.codexHome || process.env.CODEX_HOME);
1258
+ if (claudeConfigured || codexConfigured) {
1259
+ if (claudeConfigured && codexConfigured) return "all";
1260
+ return codexConfigured ? "codex" : "claude";
1261
+ }
1262
+ const claudeDetected = resolveClaudeTargets({})[0]?.dir;
1263
+ const codexDetected = resolveCodexHomes({})[0];
1264
+ const hasClaude = Boolean(claudeDetected && existsSync3(claudeDetected));
1265
+ const hasCodex = Boolean(codexDetected && existsSync3(codexDetected));
1266
+ if (hasClaude && hasCodex) return "all";
1267
+ if (hasCodex) return "codex";
1268
+ return "claude";
1269
+ }
1270
+ function targetsFor(spec, g, scope, selection) {
1271
+ const clients = selection === "all" ? ["claude", "codex"] : [selection];
1272
+ if (scope === "project" && clients.includes("codex")) {
1273
+ throw new Error("Codex skills have no supported project scope; use --scope global or --client claude.");
1274
+ }
1275
+ const targets = [];
1276
+ for (const client of clients) {
1277
+ if (client === "claude") {
1278
+ targets.push(...resolveClaudeTargets({ override: g.claudeConfigDir, scope, cwd: process.cwd() }).map((target) => ({
1279
+ client,
1280
+ surface: CLIENT_SURFACE[client],
1281
+ dir: spec.dir(target.dir),
1282
+ label: target.label
1283
+ })));
1284
+ continue;
1285
+ }
1286
+ targets.push(...resolveCodexHomes({ override: g.codexHome, scope }).map((home) => ({
1287
+ client,
1288
+ surface: CLIENT_SURFACE[client],
1289
+ dir: spec.dir(home),
1290
+ label: home
1291
+ })));
1292
+ }
1293
+ return targets;
1294
+ }
1185
1295
  async function runInstall(spec, cmd, opts) {
1186
1296
  const g = cmd.optsWithGlobals();
1187
1297
  let scope;
@@ -1193,31 +1303,48 @@ async function runInstall(spec, cmd, opts) {
1193
1303
  const cfg = resolveConfig(g);
1194
1304
  const dryRun = Boolean(opts.dryRun);
1195
1305
  const json = Boolean(g.json || opts.json);
1196
- const targets = resolveClaudeTargets({ override: g.claudeConfigDir, scope, cwd: process.cwd() });
1306
+ let selection;
1307
+ let targets;
1308
+ try {
1309
+ selection = clientSelection(spec, g, opts.client);
1310
+ targets = targetsFor(spec, g, scope, selection);
1311
+ } catch (err2) {
1312
+ return fail(err2.message);
1313
+ }
1197
1314
  const personalWorkspaceId = await getPersonalWorkspaceId(cfg);
1198
1315
  const rows = await fetchTemplateRows(cfg, personalWorkspaceId);
1199
- const items = spec.resolve(rows, CLIENT_SURFACE);
1200
- const refs = spec.kind === "skill" ? resolveReferenceSet(rows, CLIENT_SURFACE) : [];
1201
1316
  const results = targets.map((t) => {
1202
- const dir = spec.dir(t.dir);
1203
- const written = dryRun ? items.map((i) => i.name) : spec.write(dir, items, CLIENT_SURFACE);
1204
- const refsWritten = dryRun ? refs.map((r) => r.name) : writeReferencesIntoSkillDirs(dir, items, refs);
1205
- return { dir, label: t.label, written, refsWritten };
1317
+ const items = spec.resolve(rows, t.surface);
1318
+ const refs = spec.kind === "skill" ? resolveReferenceSet(rows, t.surface) : [];
1319
+ const written = dryRun ? items.map((i) => i.name) : spec.write(t.dir, items, t.surface);
1320
+ const refsWritten = dryRun ? refs.map((r) => r.name) : writeReferencesIntoSkillDirs(t.dir, items, refs);
1321
+ return {
1322
+ client: t.client,
1323
+ surface: t.surface,
1324
+ dir: t.dir,
1325
+ label: t.label,
1326
+ available: items.length,
1327
+ references: refs.length,
1328
+ items: items.map(({ name, source }) => ({ name, source })),
1329
+ referenceItems: refs.map(({ name, source }) => ({ name, source })),
1330
+ written,
1331
+ refsWritten
1332
+ };
1206
1333
  });
1207
- if (json) return emit({ kind: spec.kind, dryRun, available: items.length, references: refs.length, targets: results }, true);
1208
- if (items.length === 0) {
1334
+ if (json) return emit({ kind: spec.kind, client: selection, dryRun, targets: results }, true);
1335
+ if (results.every((r) => r.available === 0)) {
1209
1336
  console.log(style.dim(`No ${spec.kind}s available to install \u2014 is the bundle installed for your account?`));
1210
1337
  return;
1211
1338
  }
1212
1339
  for (const r of results) {
1213
1340
  console.log(
1214
- `${dryRun ? "" : style.green("\u2713 ")}${dryRun ? "would write" : "wrote"} ${r.written.length} ${spec.kind}(s) ${style.dim("\u2192")} ${r.dir}`
1341
+ `${dryRun ? "" : style.green("\u2713 ")}${dryRun ? "would write" : "wrote"} ${r.written.length} ${spec.kind}(s) for ${r.client} ${style.dim("\u2192")} ${r.dir}`
1215
1342
  );
1216
1343
  if (r.refsWritten.length)
1217
1344
  console.log(
1218
1345
  `${dryRun ? "" : style.green("\u2713 ")}${dryRun ? "would write" : "wrote"} ${r.refsWritten.length} reference(s) into each skill ${style.dim("\u2192")} ${r.dir}/<skill>/references`
1219
1346
  );
1220
- if (dryRun) for (const i of items) console.log(` ${i.name} ${style.dim(`[${i.source}]`)}`);
1347
+ if (dryRun) for (const i of r.items) console.log(` ${i.name} ${style.dim(`[${i.source}]`)}`);
1221
1348
  }
1222
1349
  }
1223
1350
  function runList(spec, cmd, opts) {
@@ -1229,16 +1356,22 @@ function runList(spec, cmd, opts) {
1229
1356
  return fail(err2.message);
1230
1357
  }
1231
1358
  const json = Boolean(g.json || opts.json);
1232
- const targets = resolveClaudeTargets({ override: g.claudeConfigDir, scope, cwd: process.cwd() });
1359
+ let selection;
1360
+ let targets;
1361
+ try {
1362
+ selection = clientSelection(spec, g, opts.client);
1363
+ targets = targetsFor(spec, g, scope, selection);
1364
+ } catch (err2) {
1365
+ return fail(err2.message);
1366
+ }
1233
1367
  const out = targets.map((t) => {
1234
- const dir = spec.dir(t.dir);
1235
- const lock = readSkillsLock(dir);
1368
+ const lock = readSkillsLock(t.dir);
1236
1369
  const entries = Object.entries(lock).flatMap(
1237
- ([slug, e]) => (e.skills ?? []).map((name) => ({ slug, name, present: existsSync3(join4(dir, name)) }))
1370
+ ([slug, e]) => (e.skills ?? []).map((name) => ({ slug, name, present: existsSync3(join4(t.dir, name)) }))
1238
1371
  );
1239
- return { dir, label: t.label, entries };
1372
+ return { client: t.client, surface: t.surface, dir: t.dir, label: t.label, entries };
1240
1373
  });
1241
- if (json) return emit({ kind: spec.kind, targets: out }, true);
1374
+ if (json) return emit({ kind: spec.kind, client: selection, targets: out }, true);
1242
1375
  let any = false;
1243
1376
  for (const t of out) {
1244
1377
  if (t.entries.length === 0) continue;
@@ -1261,33 +1394,39 @@ function runClean(spec, cmd, opts, slugArg) {
1261
1394
  return fail(err2.message);
1262
1395
  }
1263
1396
  const json = Boolean(g.json || opts.json);
1264
- const targets = resolveClaudeTargets({ override: g.claudeConfigDir, scope, cwd: process.cwd() });
1397
+ let selection;
1398
+ let targets;
1399
+ try {
1400
+ selection = clientSelection(spec, g, opts.client);
1401
+ targets = targetsFor(spec, g, scope, selection);
1402
+ } catch (err2) {
1403
+ return fail(err2.message);
1404
+ }
1265
1405
  const cleaned = [];
1266
1406
  const missing = [];
1267
1407
  for (const t of targets) {
1268
- const dir = spec.dir(t.dir);
1269
- const lock = readSkillsLock(dir);
1408
+ const lock = readSkillsLock(t.dir);
1270
1409
  const entry = lock[slug];
1271
1410
  if (!entry) {
1272
- missing.push(join4(dir, SKILLS_LOCK));
1411
+ missing.push(join4(t.dir, SKILLS_LOCK));
1273
1412
  continue;
1274
1413
  }
1275
1414
  const removed = [];
1276
1415
  for (const name of entry.skills) {
1277
- const p = join4(dir, name);
1416
+ const p = join4(t.dir, name);
1278
1417
  if (existsSync3(p)) {
1279
1418
  rmSync2(p, { recursive: true, force: true });
1280
1419
  removed.push(name);
1281
1420
  }
1282
1421
  }
1283
1422
  delete lock[slug];
1284
- writeSkillsLock(dir, lock);
1285
- cleaned.push({ dir, removed });
1423
+ writeSkillsLock(t.dir, lock);
1424
+ cleaned.push({ client: t.client, surface: t.surface, dir: t.dir, removed });
1286
1425
  }
1287
1426
  if (cleaned.length === 0) {
1288
1427
  return fail(`No materialised ${spec.kind}s recorded for '${slug}' in ${missing.join(", ")}.`);
1289
1428
  }
1290
- if (json) return emit({ kind: spec.kind, slug, cleaned, missing }, true);
1429
+ if (json) return emit({ kind: spec.kind, client: selection, slug, cleaned, missing }, true);
1291
1430
  for (const c of cleaned) {
1292
1431
  console.log(style.green(`Removed ${c.removed.length} ${spec.kind}(s) for ${slug} from ${c.dir}`));
1293
1432
  }
@@ -1415,8 +1554,12 @@ var CLAUDE_HOOK_COMMANDS = {
1415
1554
  };
1416
1555
  var CODEX_HOOK_COMMANDS = {
1417
1556
  SessionStart: "sechroom hook session-start",
1557
+ PreCompact: "sechroom hook pre-compact",
1418
1558
  Stop: "sechroom hook session-end --debounce-minutes 10"
1419
1559
  };
1560
+ function hookCommandsForSurface(surface) {
1561
+ return surface === "claude" ? CLAUDE_HOOK_COMMANDS : CODEX_HOOK_COMMANDS;
1562
+ }
1420
1563
  function hasHookCommand(config2, event, command) {
1421
1564
  const groups = config2.hooks?.[event] ?? [];
1422
1565
  return groups.some((g) => (g.hooks ?? []).some((h) => h.type === "command" && h.command === command));
@@ -1554,7 +1697,10 @@ function registerChannel(program2) {
1554
1697
  "--tag <tag...>",
1555
1698
  "Tag(s) the event must carry to match (repeatable). Default targets WLP task dispatches.",
1556
1699
  ["kind:task"]
1557
- ).option("--workspace <wsp...>", "Restrict to these workspace id(s)");
1700
+ ).option("--workspace <wsp...>", "Restrict to these workspace id(s)").option(
1701
+ "--executor-instance <id>",
1702
+ "Join the exact executor-instance SignalR group"
1703
+ );
1558
1704
  withFilterOpts(
1559
1705
  channel.command("connect").description(
1560
1706
  "Register a SignalR subscription and stream matched events to stdout"
@@ -1572,7 +1718,7 @@ function registerChannel(program2) {
1572
1718
  (typeof payload === "string" ? payload : JSON.stringify(payload)) + "\n"
1573
1719
  )
1574
1720
  );
1575
- const conn = await openConnection(cfg, deliver);
1721
+ const conn = await openConnection(cfg, deliver, opts.executorInstance);
1576
1722
  await reconcile(cfg, filter, deliver);
1577
1723
  if (json) {
1578
1724
  emit(
@@ -1621,7 +1767,7 @@ function registerChannel(program2) {
1621
1767
  `))
1622
1768
  );
1623
1769
  });
1624
- const conn = await openConnection(cfg, deliver);
1770
+ const conn = await openConnection(cfg, deliver, opts.executorInstance);
1625
1771
  await reconcile(cfg, filter, deliver);
1626
1772
  process.stderr.write(
1627
1773
  style.dim(
@@ -1732,8 +1878,9 @@ async function ensureSubscription(cfg, name, filter) {
1732
1878
  );
1733
1879
  return await resp.json();
1734
1880
  }
1735
- async function openConnection(cfg, onEvent) {
1736
- const conn = new HubConnectionBuilder().withUrl(`${cfg.baseUrl}/notifications/${cfg.tenant}`, {
1881
+ async function openConnection(cfg, onEvent, executorInstanceId) {
1882
+ const query = executorInstanceId ? `?executorInstanceId=${encodeURIComponent(executorInstanceId)}` : "";
1883
+ const conn = new HubConnectionBuilder().withUrl(`${cfg.baseUrl}/notifications/${cfg.tenant}${query}`, {
1737
1884
  transport: HttpTransportType.LongPolling,
1738
1885
  accessTokenFactory: () => requireToken(cfg)
1739
1886
  }).withAutomaticReconnect().build();
@@ -2436,6 +2583,12 @@ Fail-soft: no lane / no auth / no-or-partial intent file / API error -> exit 0,
2436
2583
  const surfaceResults = installHookSurfaces(["codex"], { dryRun, claudeDir: "", codexHome })[0].results;
2437
2584
  process.stdout.write(`${HOOK_SURFACE_LABEL.codex}:
2438
2585
  `);
2586
+ if (dryRun) {
2587
+ for (const [event, command] of Object.entries(hookCommandsForSurface("codex"))) {
2588
+ process.stdout.write(` ${event}: ${command}
2589
+ `);
2590
+ }
2591
+ }
2439
2592
  for (const r of surfaceResults) {
2440
2593
  results.push(r);
2441
2594
  process.stdout.write(describe(r, dryRun) + "\n");
@@ -2987,6 +3140,205 @@ Examples:
2987
3140
  });
2988
3141
  }
2989
3142
 
3143
+ // src/commands/executor.ts
3144
+ function registerExecutor(program2) {
3145
+ const executor = program2.command("executor").description(
3146
+ "Register and operate a local Claude Code/Codex executor advertisement"
3147
+ );
3148
+ executor.command("submit-connector").description(
3149
+ "Submit a local-session connector definition for governed approval"
3150
+ ).requiredOption("--slug <slug>", "Unique connector definition slug").requiredOption("--display-name <name>", "Human-readable connector name").requiredOption("--transport <kind>", "push | pull").option("--profile <profile...>", "Advertised runtime profiles", [
3151
+ "base",
3152
+ "dotnet-10"
3153
+ ]).action(async (opts, cmd) => {
3154
+ const cfg = resolveConfig(cmd.optsWithGlobals());
3155
+ const data = await api(cfg, "/connectors/definitions", {
3156
+ method: "POST",
3157
+ body: JSON.stringify({
3158
+ slug: opts.slug,
3159
+ displayName: opts.displayName,
3160
+ runtimeProfiles: opts.profile,
3161
+ connectorKind: "ExecutionRuntime",
3162
+ providerKind: "local-session",
3163
+ dispatchTransport: parseTransport(opts.transport)
3164
+ })
3165
+ });
3166
+ emit(data, Boolean(cmd.optsWithGlobals().json));
3167
+ if (!cmd.optsWithGlobals().json) {
3168
+ process.stderr.write(
3169
+ style.dim(
3170
+ "approve this connector definition before registering executors\n"
3171
+ )
3172
+ );
3173
+ }
3174
+ });
3175
+ executor.command("register").description(
3176
+ "Create/reuse a SignalR binding and register this local executor instance"
3177
+ ).requiredOption(
3178
+ "--instance-key <key>",
3179
+ "Stable key for this concrete session/lane"
3180
+ ).requiredOption(
3181
+ "--connector <id>",
3182
+ "Approved local-session ConnectorDefinition id"
3183
+ ).option("--runtime <kind>", "claude-code | codex", "claude-code").option(
3184
+ "--relay <id>",
3185
+ "Relay identity shared by sibling instances",
3186
+ "sechroom-cli-local"
3187
+ ).option(
3188
+ "--subscription-name <name>",
3189
+ "SignalR delivery binding name",
3190
+ "executor-dispatch"
3191
+ ).option(
3192
+ "--capability <key...>",
3193
+ "Capability operation keys claimed by this instance"
3194
+ ).option("--tool-set-ref <ref>", "Optional governed tool-set reference").option("--ttl <seconds>", "Advertisement TTL (30-600)", parseInteger, 120).action(async (opts, cmd) => {
3195
+ const cfg = resolveConfig(cmd.optsWithGlobals());
3196
+ if (!cfg.workspaceId)
3197
+ fail("executor register requires a configured workspaceId");
3198
+ const subscription = await api(
3199
+ cfg,
3200
+ "/me/delivery-subscriptions/signalr",
3201
+ {
3202
+ method: "POST",
3203
+ body: JSON.stringify({
3204
+ name: opts.subscriptionName,
3205
+ enabled: true,
3206
+ filter: { tags: ["kind:task"], workspaceScope: [cfg.workspaceId] }
3207
+ })
3208
+ }
3209
+ );
3210
+ const data = await api(
3211
+ cfg,
3212
+ "/me/executor-instances",
3213
+ {
3214
+ method: "POST",
3215
+ body: JSON.stringify({
3216
+ relayId: opts.relay,
3217
+ instanceKey: opts.instanceKey,
3218
+ runtimeKind: parseRuntimeKind(opts.runtime),
3219
+ activationMode: "Attached",
3220
+ deliverySubscriptionId: subscription.id,
3221
+ connectorId: opts.connector,
3222
+ claimedCapabilityKeys: opts.capability ?? [],
3223
+ toolSetRef: opts.toolSetRef ?? null,
3224
+ ttlSeconds: opts.ttl
3225
+ })
3226
+ }
3227
+ );
3228
+ emit(data, Boolean(cmd.optsWithGlobals().json));
3229
+ if (!cmd.optsWithGlobals().json)
3230
+ process.stderr.write(
3231
+ style.dim(`refresh with: sechroom executor heartbeat ${data.id}
3232
+ `)
3233
+ );
3234
+ });
3235
+ executor.command("refresh <id>").description("Refresh one advertisement lease once").option("--ttl <seconds>", "Advertisement TTL (30-600)", parseInteger, 120).action(async (id, opts, cmd) => {
3236
+ const data = await refresh(
3237
+ resolveConfig(cmd.optsWithGlobals()),
3238
+ id,
3239
+ opts.ttl
3240
+ );
3241
+ emit(data, Boolean(cmd.optsWithGlobals().json));
3242
+ });
3243
+ executor.command("heartbeat <id>").description("Keep an advertisement alive until interrupted").option("--ttl <seconds>", "Advertisement TTL (30-600)", parseInteger, 120).option("--interval <seconds>", "Refresh interval", parseInteger, 40).action(async (id, opts, cmd) => {
3244
+ if (opts.interval >= opts.ttl)
3245
+ fail("heartbeat interval must be shorter than the TTL");
3246
+ const cfg = resolveConfig(cmd.optsWithGlobals());
3247
+ await refresh(cfg, id, opts.ttl);
3248
+ process.stderr.write(
3249
+ style.green("executor heartbeat active") + style.dim(` \u2014 ${id}
3250
+ `)
3251
+ );
3252
+ await holdHeartbeat(async () => {
3253
+ await refresh(cfg, id, opts.ttl);
3254
+ }, opts.interval * 1e3);
3255
+ });
3256
+ executor.command("offers <id>").description("List live dispatch offers addressed to this exact instance").action(async (id, _opts, cmd) => {
3257
+ const cfg = resolveConfig(cmd.optsWithGlobals());
3258
+ const data = await api(
3259
+ cfg,
3260
+ `/me/executor-instances/${encodeURIComponent(id)}/dispatch-offers`
3261
+ );
3262
+ emit(data, Boolean(cmd.optsWithGlobals().json));
3263
+ });
3264
+ executor.command("deregister <id>").description("Stop advertising this executor instance").action(async (id, _opts, cmd) => {
3265
+ const cfg = resolveConfig(cmd.optsWithGlobals());
3266
+ const data = await api(
3267
+ cfg,
3268
+ `/me/executor-instances/${encodeURIComponent(id)}`,
3269
+ {
3270
+ method: "DELETE",
3271
+ body: JSON.stringify({})
3272
+ }
3273
+ );
3274
+ emit(data, Boolean(cmd.optsWithGlobals().json));
3275
+ });
3276
+ }
3277
+ function parseRuntimeKind(value) {
3278
+ switch (value.trim().toLowerCase()) {
3279
+ case "claude":
3280
+ case "claude-code":
3281
+ return "ClaudeCode";
3282
+ case "codex":
3283
+ return "Codex";
3284
+ default:
3285
+ return fail("runtime must be claude-code or codex");
3286
+ }
3287
+ }
3288
+ function parseTransport(value) {
3289
+ switch (value.trim().toLowerCase()) {
3290
+ case "push":
3291
+ return "Push";
3292
+ case "pull":
3293
+ return "Pull";
3294
+ default:
3295
+ return fail("transport must be push or pull");
3296
+ }
3297
+ }
3298
+ async function refresh(cfg, id, ttlSeconds) {
3299
+ return api(
3300
+ cfg,
3301
+ `/me/executor-instances/${encodeURIComponent(id)}/refresh`,
3302
+ {
3303
+ method: "POST",
3304
+ body: JSON.stringify({ ttlSeconds })
3305
+ }
3306
+ );
3307
+ }
3308
+ async function api(cfg, path, init) {
3309
+ const token = await requireToken(cfg);
3310
+ const response = await fetch(`${cfg.baseUrl}${path}`, {
3311
+ ...init,
3312
+ headers: {
3313
+ authorization: `Bearer ${token}`,
3314
+ tenant: cfg.tenant,
3315
+ "content-type": "application/json",
3316
+ "x-sechroom-surface": "cli"
3317
+ }
3318
+ });
3319
+ if (!response.ok)
3320
+ fail(
3321
+ `${init?.method ?? "GET"} ${path} failed (${response.status}): ${await response.text()}`
3322
+ );
3323
+ return response.json();
3324
+ }
3325
+ function parseInteger(value) {
3326
+ const parsed = Number.parseInt(value, 10);
3327
+ if (!Number.isFinite(parsed)) fail(`expected an integer, got '${value}'`);
3328
+ return parsed;
3329
+ }
3330
+ function holdHeartbeat(tick, intervalMs) {
3331
+ return new Promise((resolve3, reject) => {
3332
+ const timer = setInterval(() => void tick().catch(reject), intervalMs);
3333
+ const stop = () => {
3334
+ clearInterval(timer);
3335
+ resolve3();
3336
+ };
3337
+ process.once("SIGINT", stop);
3338
+ process.once("SIGTERM", stop);
3339
+ });
3340
+ }
3341
+
2990
3342
  // src/commands/filing.ts
2991
3343
  function registerFiling(program2) {
2992
3344
  const filing = program2.command("filing").description("Review and act on filing suggestions");
@@ -5444,7 +5796,9 @@ function registerSkills(program2) {
5444
5796
  "after",
5445
5797
  `
5446
5798
  Examples:
5447
- $ sechroom skills install materialise your installed skills to ~/.claude/skills
5799
+ $ sechroom skills install --client claude materialise target:claude-code skills to ~/.claude/skills
5800
+ $ sechroom skills install --client codex materialise target:gpt-codex skills to ~/.codex/skills
5801
+ $ sechroom skills install --client all keep both global clients in sync
5448
5802
  $ sechroom skills install --scope project write them to ./.claude/skills instead
5449
5803
  $ sechroom skills list what's materialised on disk
5450
5804
  $ sechroom skills clean remove the materialised skill files
@@ -5453,11 +5807,15 @@ Examples:
5453
5807
  $ sechroom skills package --from-source --workspace wsp_abc -o ./dist zip a draft from source
5454
5808
  $ sechroom skills set-lane --code-lane claude-code-chris --design-lane claude-design-chris
5455
5809
 
5810
+ When --client is omitted, configured client flags/environment variables win;
5811
+ otherwise existing default client homes are detected. Both configured/detected
5812
+ clients select all; an unconfigured machine preserves the legacy Claude default.
5813
+
5456
5814
  `
5457
5815
  );
5458
- skills.command("install").description("Materialise your installed skills to disk (the already-installed bundle \u2014 no server install)").option("--scope <scope>", "global (config dir / CLAUDE_CONFIG_DIR) or project (<cwd>/.claude) \u2014 default global").option("--local", "alias for --scope project").option("--dry-run", "print what would be written; write nothing").option("--json", "machine output").action((opts, cmd) => runInstall(SKILL_SPEC, cmd, opts));
5459
- skills.command("list").description("List the skills materialised on disk (per resolved config dir)").option("--scope <scope>", "global (config dir / CLAUDE_CONFIG_DIR) or project (<cwd>/.claude) \u2014 default global").option("--local", "alias for --scope project").option("--json", "machine output").action((opts, cmd) => runList(SKILL_SPEC, cmd, opts));
5460
- skills.command("clean [slug]").description(`Remove skill files materialised to disk (default ${DEFAULT_SKILLS_SLUG})`).option("--scope <scope>", "global (config dir / CLAUDE_CONFIG_DIR) or project (<cwd>/.claude) \u2014 default global").option("--local", "alias for --scope project").option("--json", "machine output").action((slugArg, opts, cmd) => runClean(SKILL_SPEC, cmd, opts, slugArg));
5816
+ skills.command("install").description("Materialise your installed skills to disk (the already-installed bundle \u2014 no server install)").option("--scope <scope>", "global (config dir / CLAUDE_CONFIG_DIR) or project (<cwd>/.claude) \u2014 default global").option("--local", "alias for --scope project").option("--client <client>", "claude, codex, or all (default: configured/detected clients)").option("--dry-run", "print what would be written; write nothing").option("--json", "machine output").action((opts, cmd) => runInstall(SKILL_SPEC, cmd, opts));
5817
+ skills.command("list").description("List the skills materialised on disk (per resolved config dir)").option("--scope <scope>", "global (config dir / CLAUDE_CONFIG_DIR) or project (<cwd>/.claude) \u2014 default global").option("--local", "alias for --scope project").option("--client <client>", "claude, codex, or all (default: configured/detected clients)").option("--json", "machine output").action((opts, cmd) => runList(SKILL_SPEC, cmd, opts));
5818
+ skills.command("clean [slug]").description(`Remove skill files materialised to disk (default ${DEFAULT_SKILLS_SLUG})`).option("--scope <scope>", "global (config dir / CLAUDE_CONFIG_DIR) or project (<cwd>/.claude) \u2014 default global").option("--local", "alias for --scope project").option("--client <client>", "claude, codex, or all (default: configured/detected clients)").option("--json", "machine output").action((slugArg, opts, cmd) => runClean(SKILL_SPEC, cmd, opts, slugArg));
5461
5819
  skills.command("preview").description("Render a workspace's draft bundle from source (no publish/install) and report the components").requiredOption("--workspace <id>", "workspace holding the draft bundle sources (wsp_\u2026)").option("--slug <slug>", "override the derived bundle slug").option("--title <title>", "override the derived bundle title").option("--version <version>", "override the derived bundle version").option("--default-install-parent <path>", "override the derived default install parent").option("--json", "machine output (the full RenderBundlePreviewResponse)").action(async (opts, cmd) => {
5462
5820
  const json = Boolean(opts.json) || Boolean(cmd.optsWithGlobals().json);
5463
5821
  const client = await makeClient(resolveConfig(cmd.optsWithGlobals()));
@@ -6399,6 +6757,7 @@ registerRelationships(program);
6399
6757
  registerWorkspace(program);
6400
6758
  registerProject(program);
6401
6759
  registerDecomposition(program);
6760
+ registerExecutor(program);
6402
6761
  registerClose(program);
6403
6762
  registerFiling(program);
6404
6763
  registerContinuity(program);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sechroom/cli",
3
- "version": "2026.7.13",
3
+ "version": "2026.7.15",
4
4
  "description": "Sechroom CLI — a thin, generated client over the Sechroom HTTP API. An agent/human surface alongside MCP.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -43,6 +43,7 @@
43
43
  "gen": "openapi-typescript \"${SECHROOM_OPENAPI_URL:-https://app.sechroom.ai/api/openapi/v1.json}\" -o src/generated/api.d.ts",
44
44
  "build": "tsup src/index.ts --format esm --target node20 --clean",
45
45
  "dev": "tsx src/index.ts",
46
+ "test": "node --import tsx --test \"src/**/*.test.ts\"",
46
47
  "check-types": "tsc --noEmit"
47
48
  }
48
49
  }