@sechroom/cli 2026.7.12 → 2026.7.14

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 +492 -67
  2. package/package.json +4 -2
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
  }
@@ -1403,15 +1542,24 @@ var CLAUDE_HOOK_COMMANDS = {
1403
1542
  SessionStart: "sechroom hook session-start",
1404
1543
  PreCompact: "sechroom hook pre-compact",
1405
1544
  SessionEnd: "sechroom hook session-end",
1406
- // WLP telemetry tap (D-WLP-10) — per-turn executor self-report. No-op (exit 0) unless this
1407
- // checkout is bound to a decomposition+task via `sechroom telemetry bind`, so it's safe to wire
1408
- // for every Claude install. Claude-only (it parses a Claude Code transcript).
1409
- Stop: "sechroom telemetry hook"
1545
+ // WLP telemetry tap (D-WLP-10 + FR-352 Tier 1) — per-turn executor self-report. The one
1546
+ // `telemetry hook` verb dispatches on hook_event_name: Stop/SubagentStop parsed (token/context) +
1547
+ // terminal (turn end), Notification/PermissionDenied → approval. No-op (exit 0) unless this checkout
1548
+ // is bound via `sechroom telemetry bind`, so it's safe to wire for every Claude install; an event a
1549
+ // given Claude Code version doesn't know is inert (never fires). Claude-only.
1550
+ Stop: "sechroom telemetry hook",
1551
+ SubagentStop: "sechroom telemetry hook",
1552
+ Notification: "sechroom telemetry hook",
1553
+ PermissionDenied: "sechroom telemetry hook"
1410
1554
  };
1411
1555
  var CODEX_HOOK_COMMANDS = {
1412
1556
  SessionStart: "sechroom hook session-start",
1557
+ PreCompact: "sechroom hook pre-compact",
1413
1558
  Stop: "sechroom hook session-end --debounce-minutes 10"
1414
1559
  };
1560
+ function hookCommandsForSurface(surface) {
1561
+ return surface === "claude" ? CLAUDE_HOOK_COMMANDS : CODEX_HOOK_COMMANDS;
1562
+ }
1415
1563
  function hasHookCommand(config2, event, command) {
1416
1564
  const groups = config2.hooks?.[event] ?? [];
1417
1565
  return groups.some((g) => (g.hooks ?? []).some((h) => h.type === "command" && h.command === command));
@@ -1549,7 +1697,10 @@ function registerChannel(program2) {
1549
1697
  "--tag <tag...>",
1550
1698
  "Tag(s) the event must carry to match (repeatable). Default targets WLP task dispatches.",
1551
1699
  ["kind:task"]
1552
- ).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
+ );
1553
1704
  withFilterOpts(
1554
1705
  channel.command("connect").description(
1555
1706
  "Register a SignalR subscription and stream matched events to stdout"
@@ -1567,7 +1718,7 @@ function registerChannel(program2) {
1567
1718
  (typeof payload === "string" ? payload : JSON.stringify(payload)) + "\n"
1568
1719
  )
1569
1720
  );
1570
- const conn = await openConnection(cfg, deliver);
1721
+ const conn = await openConnection(cfg, deliver, opts.executorInstance);
1571
1722
  await reconcile(cfg, filter, deliver);
1572
1723
  if (json) {
1573
1724
  emit(
@@ -1616,7 +1767,7 @@ function registerChannel(program2) {
1616
1767
  `))
1617
1768
  );
1618
1769
  });
1619
- const conn = await openConnection(cfg, deliver);
1770
+ const conn = await openConnection(cfg, deliver, opts.executorInstance);
1620
1771
  await reconcile(cfg, filter, deliver);
1621
1772
  process.stderr.write(
1622
1773
  style.dim(
@@ -1727,8 +1878,9 @@ async function ensureSubscription(cfg, name, filter) {
1727
1878
  );
1728
1879
  return await resp.json();
1729
1880
  }
1730
- async function openConnection(cfg, onEvent) {
1731
- 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}`, {
1732
1884
  transport: HttpTransportType.LongPolling,
1733
1885
  accessTokenFactory: () => requireToken(cfg)
1734
1886
  }).withAutomaticReconnect().build();
@@ -2431,6 +2583,12 @@ Fail-soft: no lane / no auth / no-or-partial intent file / API error -> exit 0,
2431
2583
  const surfaceResults = installHookSurfaces(["codex"], { dryRun, claudeDir: "", codexHome })[0].results;
2432
2584
  process.stdout.write(`${HOOK_SURFACE_LABEL.codex}:
2433
2585
  `);
2586
+ if (dryRun) {
2587
+ for (const [event, command] of Object.entries(hookCommandsForSurface("codex"))) {
2588
+ process.stdout.write(` ${event}: ${command}
2589
+ `);
2590
+ }
2591
+ }
2434
2592
  for (const r of surfaceResults) {
2435
2593
  results.push(r);
2436
2594
  process.stdout.write(describe(r, dryRun) + "\n");
@@ -2891,6 +3049,7 @@ function registerDecomposition(program2) {
2891
3049
  Examples:
2892
3050
  $ sechroom decomposition decompose mem_XXXX
2893
3051
  $ sechroom decomposition execute sug_XXXX
3052
+ $ sechroom decomposition publish-run sug_XXXX
2894
3053
  $ sechroom decomposition accept sug_XXXX
2895
3054
  $ sechroom decomposition reject sug_XXXX --reason "wrong shape"`
2896
3055
  );
@@ -2928,6 +3087,23 @@ Examples:
2928
3087
  cmd.optsWithGlobals().json
2929
3088
  );
2930
3089
  });
3090
+ decomposition.command("publish-run <decompositionId>").description(
3091
+ "Publish an accepted decomposition's context pack on demand (POST /decompositions/{id}/publish-run)"
3092
+ ).action(async (decompositionId, _opts, cmd) => {
3093
+ const cfg = resolveConfig(cmd.optsWithGlobals());
3094
+ const data = await runApi("Publishing context pack", async () => {
3095
+ const client = await makeClient(cfg);
3096
+ return client.POST("/decompositions/{id}/publish-run", {
3097
+ params: { path: { id: decompositionId } },
3098
+ body: {}
3099
+ });
3100
+ });
3101
+ emitAction(
3102
+ `published ${style.bold(decompositionId)} \u2192 run ${style.bold(data.runRecordId)} (${data.outcome})`,
3103
+ data,
3104
+ cmd.optsWithGlobals().json
3105
+ );
3106
+ });
2931
3107
  decomposition.command("accept <decompositionId>").description(
2932
3108
  "Accept a Pending decomposition \u2014 promote + ratify its Tasks (POST /decompositions/{id}/accept)"
2933
3109
  ).action(async (decompositionId, _opts, cmd) => {
@@ -2964,6 +3140,205 @@ Examples:
2964
3140
  });
2965
3141
  }
2966
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
+
2967
3342
  // src/commands/filing.ts
2968
3343
  function registerFiling(program2) {
2969
3344
  const filing = program2.command("filing").description("Review and act on filing suggestions");
@@ -5421,7 +5796,9 @@ function registerSkills(program2) {
5421
5796
  "after",
5422
5797
  `
5423
5798
  Examples:
5424
- $ 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
5425
5802
  $ sechroom skills install --scope project write them to ./.claude/skills instead
5426
5803
  $ sechroom skills list what's materialised on disk
5427
5804
  $ sechroom skills clean remove the materialised skill files
@@ -5430,11 +5807,15 @@ Examples:
5430
5807
  $ sechroom skills package --from-source --workspace wsp_abc -o ./dist zip a draft from source
5431
5808
  $ sechroom skills set-lane --code-lane claude-code-chris --design-lane claude-design-chris
5432
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
+
5433
5814
  `
5434
5815
  );
5435
- 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));
5436
- 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));
5437
- 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));
5438
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) => {
5439
5820
  const json = Boolean(opts.json) || Boolean(cmd.optsWithGlobals().json);
5440
5821
  const client = await makeClient(resolveConfig(cmd.optsWithGlobals()));
@@ -5788,7 +6169,7 @@ function registerTelemetry(program2) {
5788
6169
  );
5789
6170
  });
5790
6171
  telemetry.command("hook").description(
5791
- "Per-turn telemetry self-report for a Claude Code Stop hook (reads stdin; no-op unless bound). Fail-soft."
6172
+ "Per-turn telemetry self-report for Claude Code hooks \u2014 Stop/SubagentStop \u2192 parsed + terminal, Notification/PermissionDenied \u2192 approval (reads stdin; no-op unless bound). Fail-soft."
5792
6173
  ).action(async (_opts, cmd) => {
5793
6174
  try {
5794
6175
  const raw = await readStdin2();
@@ -5797,21 +6178,10 @@ function registerTelemetry(program2) {
5797
6178
  const binding = findBinding(cwd);
5798
6179
  if (!binding) return process.exit(0);
5799
6180
  const usage = input.transcript_path ? parseTranscript(input.transcript_path) : null;
5800
- if (!usage) return process.exit(0);
6181
+ const events = buildHookEvents(input, usage, binding.taskId);
6182
+ if (events.length === 0) return process.exit(0);
5801
6183
  const cfg = resolveConfig(cmd.optsWithGlobals());
5802
- await postTelemetry(cfg, binding.decompositionId, [
5803
- {
5804
- taskId: binding.taskId,
5805
- kind: "Parsed",
5806
- tokensIn: usage.tokensIn,
5807
- tokensOut: usage.tokensOut,
5808
- contextUsed: usage.contextUsed,
5809
- contextWindow: usage.contextWindow,
5810
- text: null,
5811
- approvalState: null,
5812
- verdict: null
5813
- }
5814
- ]);
6184
+ await postTelemetry(cfg, binding.decompositionId, events);
5815
6185
  return process.exit(0);
5816
6186
  } catch {
5817
6187
  return process.exit(0);
@@ -5839,7 +6209,12 @@ function registerTelemetry(program2) {
5839
6209
  scope,
5840
6210
  cwd
5841
6211
  });
5842
- const commands = { Stop: "sechroom telemetry hook" };
6212
+ const commands = {
6213
+ Stop: "sechroom telemetry hook",
6214
+ SubagentStop: "sechroom telemetry hook",
6215
+ Notification: "sechroom telemetry hook",
6216
+ PermissionDenied: "sechroom telemetry hook"
6217
+ };
5843
6218
  try {
5844
6219
  const multi = targets.length > 1;
5845
6220
  const results = targets.map((t) => {
@@ -5940,6 +6315,55 @@ function windowFor(model, contextUsed = 0) {
5940
6315
  if (m.includes("[1m]") || m.includes("-1m")) return 1e6;
5941
6316
  return contextUsed > 2e5 ? 1e6 : 2e5;
5942
6317
  }
6318
+ function buildHookEvents(input, usage, taskId) {
6319
+ const events = [];
6320
+ const base = (kind, over) => ({
6321
+ taskId,
6322
+ kind,
6323
+ tokensIn: null,
6324
+ tokensOut: null,
6325
+ contextUsed: null,
6326
+ contextWindow: null,
6327
+ text: null,
6328
+ approvalState: null,
6329
+ verdict: null,
6330
+ ...over
6331
+ });
6332
+ if (usage) {
6333
+ events.push(
6334
+ base("Parsed", {
6335
+ tokensIn: usage.tokensIn,
6336
+ tokensOut: usage.tokensOut,
6337
+ contextUsed: usage.contextUsed,
6338
+ contextWindow: usage.contextWindow
6339
+ })
6340
+ );
6341
+ }
6342
+ switch (input.hook_event_name) {
6343
+ case "PermissionDenied":
6344
+ events.push(
6345
+ base("Approval", {
6346
+ approvalState: "denied",
6347
+ text: input.tool_name ?? input.message ?? null
6348
+ })
6349
+ );
6350
+ break;
6351
+ case "Notification":
6352
+ if (isPermissionNotification(input))
6353
+ events.push(base("Approval", { text: input.message ?? null }));
6354
+ break;
6355
+ case "Stop":
6356
+ case "SubagentStop":
6357
+ events.push(base("Terminal", { text: input.last_assistant_message ?? null }));
6358
+ break;
6359
+ }
6360
+ return events;
6361
+ }
6362
+ function isPermissionNotification(input) {
6363
+ const t = (input.notification_type ?? input.type ?? "").toLowerCase();
6364
+ if (t) return t.includes("permission");
6365
+ return (input.message ?? "").toLowerCase().includes("permission");
6366
+ }
5943
6367
  async function readStdin2() {
5944
6368
  if (process.stdin.isTTY) return "";
5945
6369
  const chunks = [];
@@ -6333,6 +6757,7 @@ registerRelationships(program);
6333
6757
  registerWorkspace(program);
6334
6758
  registerProject(program);
6335
6759
  registerDecomposition(program);
6760
+ registerExecutor(program);
6336
6761
  registerClose(program);
6337
6762
  registerFiling(program);
6338
6763
  registerContinuity(program);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sechroom/cli",
3
- "version": "2026.7.12",
3
+ "version": "2026.7.14",
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",
@@ -36,12 +36,14 @@
36
36
  "openapi-typescript": "^7.4.0",
37
37
  "tsup": "^8.3.0",
38
38
  "tsx": "^4.19.0",
39
- "typescript": "^5.6.0"
39
+ "typescript": "^5.6.0",
40
+ "typescript-7": "npm:typescript@^7.0.2"
40
41
  },
41
42
  "scripts": {
42
43
  "gen": "openapi-typescript \"${SECHROOM_OPENAPI_URL:-https://app.sechroom.ai/api/openapi/v1.json}\" -o src/generated/api.d.ts",
43
44
  "build": "tsup src/index.ts --format esm --target node20 --clean",
44
45
  "dev": "tsx src/index.ts",
46
+ "test": "node --import tsx --test \"src/**/*.test.ts\"",
45
47
  "check-types": "tsc --noEmit"
46
48
  }
47
49
  }