@sechroom/cli 2026.6.24 → 2026.6.26

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 +299 -135
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -375,8 +375,8 @@ async function promptYesNo(question) {
375
375
  const { createInterface } = await import("readline");
376
376
  const rl = createInterface({ input: process.stdin, output: process.stderr });
377
377
  try {
378
- const answer = await new Promise((resolve2) => {
379
- rl.question(`${question} [y/N] `, resolve2);
378
+ const answer = await new Promise((resolve3) => {
379
+ rl.question(`${question} [y/N] `, resolve3);
380
380
  });
381
381
  return /^y(es)?$/i.test(answer.trim());
382
382
  } finally {
@@ -389,8 +389,8 @@ async function promptText(question, def) {
389
389
  const rl = createInterface({ input: process.stdin, output: process.stderr });
390
390
  try {
391
391
  const suffix = def ? ` [${def}]` : "";
392
- const answer = await new Promise((resolve2) => {
393
- rl.question(`${question}${suffix} `, resolve2);
392
+ const answer = await new Promise((resolve3) => {
393
+ rl.question(`${question}${suffix} `, resolve3);
394
394
  });
395
395
  const trimmed = answer.trim();
396
396
  return trimmed.length > 0 ? trimmed : def ?? "";
@@ -416,8 +416,8 @@ async function promptSelect(question, choices, def) {
416
416
  process.stderr.write(` ${marker} ${style.bold(String(i + 1))}. ${c.label}${hint}
417
417
  `);
418
418
  });
419
- const answer = await new Promise((resolve2) => {
420
- rl.question(`Choose ${style.dim(`[${defIdx + 1}]`)} `, resolve2);
419
+ const answer = await new Promise((resolve3) => {
420
+ rl.question(`Choose ${style.dim(`[${defIdx + 1}]`)} `, resolve3);
421
421
  });
422
422
  const trimmed = answer.trim();
423
423
  if (!trimmed) return choices[defIdx].value;
@@ -449,8 +449,8 @@ async function promptMultiSelect(question, choices, preselected = []) {
449
449
  process.stderr.write(` ${box} ${style.bold(String(i + 1))}. ${c.label}${hint}
450
450
  `);
451
451
  });
452
- const answer = await new Promise((resolve2) => {
453
- rl.question(`Select ${style.dim("[Enter = \u25C9]")} `, resolve2);
452
+ const answer = await new Promise((resolve3) => {
453
+ rl.question(`Select ${style.dim("[Enter = \u25C9]")} `, resolve3);
454
454
  });
455
455
  const trimmed = answer.trim().toLowerCase();
456
456
  if (!trimmed) return preValues();
@@ -2681,6 +2681,24 @@ function codeLanePrefix(clients) {
2681
2681
  for (const c of CLIENT_PRIORITY) if (clients.includes(c)) return CODE_LANE_PREFIX_BY_CLIENT[c];
2682
2682
  return "claude-code";
2683
2683
  }
2684
+ async function inferLanes(cfg, clients) {
2685
+ let wf;
2686
+ let profile;
2687
+ try {
2688
+ const client = await makeClient(cfg);
2689
+ [wf, profile] = await Promise.all([
2690
+ client.GET("/me/workflow-preferences", {}).then((r) => r.data).catch(() => void 0),
2691
+ client.GET("/me/profile", {}).then((r) => r.data).catch(() => void 0)
2692
+ ]);
2693
+ } catch {
2694
+ }
2695
+ const handle = handleFromDisplayName(profile?.effectiveDisplayName);
2696
+ const prefix = codeLanePrefix(clients ?? ["claude-code"]);
2697
+ return {
2698
+ code: process.env.SECHROOM_CODE_LANE ?? wf?.defaultCodeLane ?? (handle ? `${prefix}-${handle}` : void 0),
2699
+ design: process.env.SECHROOM_DESIGN_LANE ?? wf?.defaultDesignLane ?? (handle ? `claude-design-${handle}` : void 0)
2700
+ };
2701
+ }
2684
2702
  function writePin(code, design) {
2685
2703
  const values = {};
2686
2704
  if (code) values["code-lane"] = code;
@@ -2693,20 +2711,7 @@ function writePin(code, design) {
2693
2711
  async function ensureLanePin(cfg, opts) {
2694
2712
  if (opts.dryRun) return;
2695
2713
  if (readSem()) return;
2696
- let wf;
2697
- let profile;
2698
- try {
2699
- const client = await makeClient(cfg);
2700
- [wf, profile] = await Promise.all([
2701
- client.GET("/me/workflow-preferences", {}).then((r) => r.data).catch(() => void 0),
2702
- client.GET("/me/profile", {}).then((r) => r.data).catch(() => void 0)
2703
- ]);
2704
- } catch {
2705
- }
2706
- const handle = handleFromDisplayName(profile?.effectiveDisplayName);
2707
- const prefix = codeLanePrefix(opts.clients ?? ["claude-code"]);
2708
- const codeGuess = wf?.defaultCodeLane ?? (handle ? `${prefix}-${handle}` : void 0);
2709
- const designGuess = wf?.defaultDesignLane ?? (handle ? `claude-design-${handle}` : void 0);
2714
+ const { code: codeGuess, design: designGuess } = await inferLanes(cfg, opts.clients);
2710
2715
  if (!canPrompt() || opts.yes) {
2711
2716
  if (opts.yes && (codeGuess || designGuess)) writePin(codeGuess, designGuess);
2712
2717
  return;
@@ -2938,6 +2943,113 @@ async function runClients(clients, cmd, opts) {
2938
2943
  process.stdout.write(opts.dryRun ? "\n(dry run \u2014 nothing written)\n" : "\nDone.\n");
2939
2944
  }
2940
2945
 
2946
+ // src/commands/onboard.ts
2947
+ import { existsSync as existsSync7 } from "fs";
2948
+ import { join as join7 } from "path";
2949
+
2950
+ // src/commands/fanout.ts
2951
+ import { spawnSync } from "child_process";
2952
+ import { existsSync as existsSync6, readFileSync as readFileSync5, readdirSync, statSync } from "fs";
2953
+ import { isAbsolute, join as join6, resolve } from "path";
2954
+ var ICON = {
2955
+ refresh: "\u21BB",
2956
+ bind: "+",
2957
+ "skip-missing": "\u2013",
2958
+ "skip-unbound": "\u26A0"
2959
+ };
2960
+ function resolveChildDir(path, root) {
2961
+ return isAbsolute(path) ? path : resolve(root, path);
2962
+ }
2963
+ function discoverChildren(root) {
2964
+ let names;
2965
+ try {
2966
+ names = readdirSync(root);
2967
+ } catch {
2968
+ return [];
2969
+ }
2970
+ const out = [];
2971
+ for (const name of names.sort()) {
2972
+ if (name.startsWith(".") || name === "node_modules") continue;
2973
+ const dir = join6(root, name);
2974
+ try {
2975
+ if (!statSync(dir).isDirectory()) continue;
2976
+ } catch {
2977
+ continue;
2978
+ }
2979
+ if (existsSync6(join6(dir, ".git")) || committedBindingPath(dir)) out.push(name);
2980
+ }
2981
+ return out;
2982
+ }
2983
+ function readManifest(path) {
2984
+ if (!existsSync6(path)) return null;
2985
+ let parsed;
2986
+ try {
2987
+ parsed = JSON.parse(readFileSync5(path, "utf8"));
2988
+ } catch (err2) {
2989
+ throw new Error(`couldn't parse ${path}: ${err2 instanceof Error ? err2.message : String(err2)}`);
2990
+ }
2991
+ return Array.isArray(parsed.repos) ? parsed.repos.filter((r) => r && typeof r.path === "string") : [];
2992
+ }
2993
+ function passthroughGlobals(g) {
2994
+ const out = [];
2995
+ if (g.baseUrl) out.push("--base-url", g.baseUrl);
2996
+ if (g.tenant) out.push("--tenant", g.tenant);
2997
+ return out;
2998
+ }
2999
+ function runChildren(plans, o) {
3000
+ const { globals, dryRun, json } = o;
3001
+ const results = [];
3002
+ for (const plan of plans) {
3003
+ const runs = plan.argv.length > 0;
3004
+ const argv = runs ? [...globals, ...plan.argv] : [];
3005
+ if (!json) {
3006
+ process.stderr.write(` ${ICON[plan.disposition]} ${style.cyan(plan.label)} ${style.dim(plan.reason)}
3007
+ `);
3008
+ if (runs && dryRun) process.stderr.write(` ${style.dim(`would run: sechroom ${argv.join(" ")}`)}
3009
+ `);
3010
+ }
3011
+ let exitCode = runs ? 0 : null;
3012
+ if (runs && !dryRun) {
3013
+ const res = spawnSync(process.execPath, [process.argv[1], ...argv], {
3014
+ cwd: plan.dir,
3015
+ stdio: json ? "ignore" : "inherit"
3016
+ });
3017
+ exitCode = res.status;
3018
+ if (!json) {
3019
+ process.stderr.write(
3020
+ exitCode === 0 ? ` ${ok("\u2713")} ${style.dim("onboard ok")}
3021
+ ` : ` ${warn("\u2717")} ${style.dim(`onboard exited ${exitCode ?? "signal"}`)}
3022
+ `
3023
+ );
3024
+ }
3025
+ }
3026
+ results.push({
3027
+ path: plan.label,
3028
+ dir: plan.dir,
3029
+ disposition: plan.disposition,
3030
+ ran: runs && !dryRun,
3031
+ exitCode,
3032
+ reason: plan.reason
3033
+ });
3034
+ }
3035
+ return results;
3036
+ }
3037
+ function summarizeFanout(results, o) {
3038
+ const ran = results.filter((r) => r.ran);
3039
+ const failed = ran.filter((r) => r.exitCode !== 0);
3040
+ const skipped = results.filter((r) => r.disposition.startsWith("skip"));
3041
+ const wouldRun = results.filter((r) => !r.disposition.startsWith("skip"));
3042
+ const tally = (o.dryRun ? [wouldRun.length ? `${wouldRun.length} would onboard` : null, skipped.length ? `${skipped.length} would skip` : null] : [
3043
+ ran.length ? `${ran.length - failed.length}/${ran.length} onboarded` : null,
3044
+ skipped.length ? `${skipped.length} skipped` : null,
3045
+ failed.length ? `${failed.length} failed` : null
3046
+ ]).filter(Boolean).join(", ");
3047
+ process.stderr.write(`
3048
+ ${failed.length ? warn("\u26A0") : ok("\u2713")} ${tally || "nothing to do"}${o.dryRun ? style.dim(" (dry run)") : ""}
3049
+ `);
3050
+ if (failed.length) process.exit(1);
3051
+ }
3052
+
2941
3053
  // src/commands/onboard.ts
2942
3054
  var DEFAULT_BASE_URL2 = "https://app.sechroom.ai/api";
2943
3055
  function systemTimezone() {
@@ -3010,7 +3122,7 @@ async function warnIfProjectStray(client, projectId, workspaceId, json) {
3010
3122
  );
3011
3123
  }
3012
3124
  }
3013
- async function pickWorkspace(client) {
3125
+ async function pickWorkspace(client, promptLabel = "Bind this directory to a workspace:") {
3014
3126
  const all = await withSpinner("Listing your workspaces", () => fetchWorkspaces(client));
3015
3127
  if (all.length === 0) {
3016
3128
  process.stderr.write(`no workspaces found \u2014 skipping workspace binding (you can set it later with \`sechroom config set --local workspaceId <id>\`)
@@ -3033,7 +3145,7 @@ async function pickWorkspace(client) {
3033
3145
  ...pool.slice().sort((a, b) => workspacePath(a, byId).localeCompare(workspacePath(b, byId))).map((w) => ({ label: workspacePath(w, byId), value: w.id, hint: w.id })),
3034
3146
  { label: style.dim("skip \u2014 don't bind a workspace"), value: SKIP, hint: void 0 }
3035
3147
  ];
3036
- const chosen = await promptSelect("Bind this directory to a workspace:", choices, SKIP);
3148
+ const chosen = await promptSelect(promptLabel, choices, SKIP);
3037
3149
  if (chosen === SKIP) return void 0;
3038
3150
  const picked = byId.get(chosen);
3039
3151
  const collisions = all.filter((w) => w.id !== picked.id && namesCollide(w.name, picked.name));
@@ -3191,8 +3303,109 @@ async function chooseClients(clientFlag, yes, cwd) {
3191
3303
  );
3192
3304
  return picks.length > 0 ? picks : preselected;
3193
3305
  }
3306
+ async function planRecurseChild(entry, root, client, opts) {
3307
+ const dir = resolveChildDir(entry.path, root);
3308
+ if (!existsSync7(dir)) {
3309
+ return { label: entry.path, dir, disposition: "skip-missing", argv: [], reason: "directory does not exist" };
3310
+ }
3311
+ if (existsSync7(join7(dir, ".sechroom.json"))) {
3312
+ return {
3313
+ label: entry.path,
3314
+ dir,
3315
+ disposition: "refresh",
3316
+ argv: ["onboard", "--refresh", "--yes"],
3317
+ reason: "bound (committed .sechroom.json) \u2014 refresh in place"
3318
+ };
3319
+ }
3320
+ if (entry.workspaceId) {
3321
+ return {
3322
+ label: entry.path,
3323
+ dir,
3324
+ disposition: "bind",
3325
+ argv: ["onboard", "--yes", "--local", "--workspace", entry.workspaceId],
3326
+ reason: `unbound \u2014 bind to ${entry.workspaceId}`
3327
+ };
3328
+ }
3329
+ if (opts.dryRun) {
3330
+ return { label: entry.path, dir, disposition: "bind", argv: ["onboard", "--yes", "--local", "--workspace", "<prompt>"], reason: "unbound \u2014 would prompt for a workspace" };
3331
+ }
3332
+ if (opts.yes || !canPrompt()) {
3333
+ return { label: entry.path, dir, disposition: "skip-unbound", argv: [], reason: "unbound + no workspace (run interactively, or add it to ./.sechroom/repos.json)" };
3334
+ }
3335
+ process.stderr.write(`
3336
+ ${style.bold(entry.path)} ${style.dim("is not bound yet.")}
3337
+ `);
3338
+ const ws = await pickWorkspace(client, `Bind ${style.cyan(entry.path)} to a workspace:`);
3339
+ if (!ws) {
3340
+ return { label: entry.path, dir, disposition: "skip-unbound", argv: [], reason: "unbound \u2014 no workspace chosen (skipped)" };
3341
+ }
3342
+ return {
3343
+ label: entry.path,
3344
+ dir,
3345
+ disposition: "bind",
3346
+ argv: ["onboard", "--yes", "--local", "--workspace", ws],
3347
+ reason: `unbound \u2014 bind to ${ws}`
3348
+ };
3349
+ }
3350
+ async function resolveFanoutLane(cfg, opts) {
3351
+ let code = opts.lane ?? process.env.SECHROOM_CODE_LANE;
3352
+ let design = opts.designLane ?? process.env.SECHROOM_DESIGN_LANE;
3353
+ if (!code || !design) {
3354
+ const inferred = await inferLanes(cfg, ["claude-code"]);
3355
+ code = code ?? inferred.code;
3356
+ design = design ?? inferred.design;
3357
+ }
3358
+ if (!opts.lane && !opts.yes && !opts.dryRun && canPrompt() && (code || design)) {
3359
+ process.stderr.write(`
3360
+ This fan-out will pin the same lane in every repo:
3361
+ `);
3362
+ if (code) process.stderr.write(` ${style.dim("code-lane")} = ${style.cyan(code)}
3363
+ `);
3364
+ if (design) process.stderr.write(` ${style.dim("design-lane")} = ${style.cyan(design)}
3365
+ `);
3366
+ if (!await promptYesNo("Use this lane for all repos?")) {
3367
+ code = await promptText("Code-lane id (blank = let each repo infer)?", code ?? "") || void 0;
3368
+ design = await promptText("Design-lane id (blank = skip)?", design ?? "") || void 0;
3369
+ }
3370
+ }
3371
+ if (code) process.env.SECHROOM_CODE_LANE = code;
3372
+ else delete process.env.SECHROOM_CODE_LANE;
3373
+ if (design) process.env.SECHROOM_DESIGN_LANE = design;
3374
+ else delete process.env.SECHROOM_DESIGN_LANE;
3375
+ return { code, design };
3376
+ }
3377
+ async function runRecurse(cfg, g, opts) {
3378
+ const { yes, dryRun, json } = opts;
3379
+ const root = process.cwd();
3380
+ const manifestPath = join7(root, ".sechroom", "repos.json");
3381
+ const fromManifest = readManifest(manifestPath);
3382
+ const entries = fromManifest ?? discoverChildren(root).map((path) => ({ path }));
3383
+ const sourceLabel = fromManifest ? `manifest ${manifestPath}` : `auto-discovered under ${root}`;
3384
+ if (entries.length === 0) {
3385
+ if (json) process.stdout.write(JSON.stringify({ recurse: true, root, repos: [] }) + "\n");
3386
+ else process.stderr.write(`${warn("\u26A0")} no child repos found ${fromManifest ? `in ${manifestPath}` : `under ${root}`} \u2014 nothing to do.
3387
+ `);
3388
+ return;
3389
+ }
3390
+ if (!json) {
3391
+ process.stderr.write(`${style.bold("onboard --recurse")} ${style.dim(`(${entries.length} repo${entries.length === 1 ? "" : "s"} from ${sourceLabel})`)}
3392
+ `);
3393
+ }
3394
+ const lane = await resolveFanoutLane(cfg, { lane: opts.lane, designLane: opts.designLane, yes, dryRun });
3395
+ if (!json && lane.code) process.stderr.write(`${ok("\u2713")} lane ${style.cyan(lane.code)}${lane.design ? ` ${style.dim(`/ ${lane.design}`)}` : ""} for every repo
3396
+ `);
3397
+ const client = await makeClient(cfg);
3398
+ const plans = [];
3399
+ for (const entry of entries) plans.push(await planRecurseChild(entry, root, client, { yes, dryRun }));
3400
+ const results = runChildren(plans, { globals: passthroughGlobals(g), dryRun, json });
3401
+ if (json) {
3402
+ process.stdout.write(JSON.stringify({ recurse: true, root, dryRun, repos: results }) + "\n");
3403
+ return;
3404
+ }
3405
+ summarizeFanout(results, { dryRun });
3406
+ }
3194
3407
  function registerOnboard(program2) {
3195
- program2.command("onboard").description("Guided first-run setup: configure, sign in, set timezone, detect clients, and wire this project").option("--client <list>", `comma-separated clients (${ALL_CLIENT_KEYS.join(", ")}) or 'all' (default: auto-detected)`).option("--local", "save the binding (tenant + base URL + workspace) to a committed .sechroom.json in this repo instead of the global config", false).option("--workspace <id>", "bind this directory to a workspace (skips the interactive workspace pick)").option("--cli-only", "configure the CLI only \u2014 don't wire any AI client (no MCP config, no agent files)", false).option("--no-mcp", "skip the MCP server config (.mcp.json etc.); still write the agent instruction files").option("--copy", "make a personal copy of the agent instructions you can edit (default: prompt on a TTY, else skip)").option("--dry-run", "walk through without writing files or changing the profile", false).option("--refresh", "re-fetch descriptors and refresh any out-of-date managed blocks (local edits preserved to .proposed)", false).option("--force", "rewrite every managed block, overwriting local edits inside the markers (content outside untouched)", false).option("--check", "report whether anything would change and exit (0 = all current, 1 = stale/drift/absent); writes nothing", false).option("-y, --yes", "non-interactive: accept defaults (system timezone, detected clients, global config, full wire)", false).addHelpText(
3408
+ program2.command("onboard").description("Guided first-run setup: configure, sign in, set timezone, detect clients, and wire this project").option("--recurse", "orchestration-root mode: onboard every child repo under this dir (auto-discovered, or from ./.sechroom/repos.json) \u2014 refreshes bound repos, prompts a workspace per new one", false).option("--lane <id>", "set the code-lane (substrate source identity) explicitly instead of inferring it; with --recurse it's used for every child repo").option("--design-lane <id>", "set the design-lane explicitly (substrate-authoring identity); with --recurse applies to every child").option("--client <list>", `comma-separated clients (${ALL_CLIENT_KEYS.join(", ")}) or 'all' (default: auto-detected)`).option("--local", "save the binding (tenant + base URL + workspace) to a committed .sechroom.json in this repo instead of the global config", false).option("--workspace <id>", "bind this directory to a workspace (skips the interactive workspace pick)").option("--cli-only", "configure the CLI only \u2014 don't wire any AI client (no MCP config, no agent files)", false).option("--no-mcp", "skip the MCP server config (.mcp.json etc.); still write the agent instruction files").option("--copy", "make a personal copy of the agent instructions you can edit (default: prompt on a TTY, else skip)").option("--dry-run", "walk through without writing files or changing the profile", false).option("--refresh", "re-fetch descriptors and refresh any out-of-date managed blocks (local edits preserved to .proposed)", false).option("--force", "rewrite every managed block, overwriting local edits inside the markers (content outside untouched)", false).option("--check", "report whether anything would change and exit (0 = all current, 1 = stale/drift/absent); writes nothing", false).option("-y, --yes", "non-interactive: accept defaults (system timezone, detected clients, global config, full wire)", false).addHelpText(
3196
3409
  "after",
3197
3410
  `
3198
3411
  Examples:
@@ -3201,6 +3414,8 @@ Examples:
3201
3414
  $ sechroom onboard --no-mcp agent instructions only, skip MCP config
3202
3415
  $ sechroom onboard --local save tenant + base URL to a committed ./.sechroom.json
3203
3416
  $ sechroom onboard --workspace wsp_XX bind this directory to a workspace (no pick prompt)
3417
+ $ sechroom onboard --recurse orchestration root: onboard every child repo under this dir
3418
+ $ sechroom onboard --recurse --lane claude-code-you pin one lane across every repo in the tree
3204
3419
  $ sechroom onboard --refresh refresh out-of-date instruction blocks in place
3205
3420
  $ sechroom onboard --check CI/pre-commit: nonzero exit if instructions are out of date
3206
3421
  $ sechroom onboard --yes non-interactive: defaults + global config + full wire
@@ -3212,6 +3427,15 @@ Examples:
3212
3427
  const mode = opts.check ? "check" : opts.force ? "force" : "apply";
3213
3428
  const check = mode === "check";
3214
3429
  const yes = Boolean(opts.yes) || check;
3430
+ if (opts.lane) process.env.SECHROOM_CODE_LANE = opts.lane;
3431
+ if (opts.designLane) process.env.SECHROOM_DESIGN_LANE = opts.designLane;
3432
+ if (opts.recurse) {
3433
+ const baseUrl2 = resolveBaseUrl(g);
3434
+ await ensureAuth({ baseUrl: baseUrl2, tenant: "", clientId: readPersisted().clientId }, yes);
3435
+ const cfg2 = await ensureTenant(baseUrl2, g, { yes: true, json, persist: false });
3436
+ await runRecurse(cfg2, g, { yes, dryRun, json, lane: opts.lane, designLane: opts.designLane });
3437
+ return;
3438
+ }
3215
3439
  const baseUrl = resolveBaseUrl(g);
3216
3440
  await ensureAuth({ baseUrl, tenant: "", clientId: readPersisted().clientId }, yes);
3217
3441
  const cfg = await ensureTenant(baseUrl, g, { yes, json, local: Boolean(opts.local), workspace: opts.workspace, persist: !check });
@@ -3365,18 +3589,17 @@ ${style.bold("Next:")} paste this into your AI agent to get going \u2014
3365
3589
  }
3366
3590
 
3367
3591
  // src/commands/sweep.ts
3368
- import { spawnSync } from "child_process";
3369
- import { existsSync as existsSync6, readFileSync as readFileSync5 } from "fs";
3370
- import { dirname as dirname6, isAbsolute, join as join6, resolve } from "path";
3371
- var DEFAULT_MANIFEST = join6(".sechroom", "repos.json");
3592
+ import { existsSync as existsSync8 } from "fs";
3593
+ import { dirname as dirname6, join as join8, resolve as resolve2 } from "path";
3594
+ var DEFAULT_MANIFEST = join8(".sechroom", "repos.json");
3372
3595
  function planEntry(entry, root) {
3373
- const dir = isAbsolute(entry.path) ? entry.path : resolve(root, entry.path);
3374
- if (!existsSync6(dir)) {
3375
- return { entry, dir, disposition: "skip-missing", argv: [], reason: "directory does not exist" };
3596
+ const dir = resolveChildDir(entry.path, root);
3597
+ if (!existsSync8(dir)) {
3598
+ return { label: entry.path, dir, disposition: "skip-missing", argv: [], reason: "directory does not exist" };
3376
3599
  }
3377
3600
  if (committedBindingPath(dir)) {
3378
3601
  return {
3379
- entry,
3602
+ label: entry.path,
3380
3603
  dir,
3381
3604
  disposition: "refresh",
3382
3605
  argv: ["onboard", "--refresh", "--yes"],
@@ -3385,7 +3608,7 @@ function planEntry(entry, root) {
3385
3608
  }
3386
3609
  if (entry.workspaceId) {
3387
3610
  return {
3388
- entry,
3611
+ label: entry.path,
3389
3612
  dir,
3390
3613
  disposition: "bind",
3391
3614
  argv: ["onboard", "--yes", "--local", "--workspace", entry.workspaceId],
@@ -3393,29 +3616,21 @@ function planEntry(entry, root) {
3393
3616
  };
3394
3617
  }
3395
3618
  return {
3396
- entry,
3619
+ label: entry.path,
3397
3620
  dir,
3398
3621
  disposition: "skip-unbound",
3399
3622
  argv: [],
3400
3623
  reason: "unbound + no workspaceId in the manifest \u2014 add one or run `sechroom onboard` there"
3401
3624
  };
3402
3625
  }
3403
- function passthroughGlobals(g) {
3404
- const out = [];
3405
- if (g.baseUrl) out.push("--base-url", g.baseUrl);
3406
- if (g.tenant) out.push("--tenant", g.tenant);
3407
- return out;
3408
- }
3409
- var ICON = {
3410
- refresh: "\u21BB",
3411
- bind: "+",
3412
- "skip-missing": "\u2013",
3413
- "skip-unbound": "\u26A0"
3414
- };
3415
3626
  function registerSweep(program2) {
3416
- program2.command("sweep").description("Orchestration-root fan-out: run `sechroom onboard` in every repo listed in ./.sechroom/repos.json").option("--manifest <path>", "path to the repos manifest", DEFAULT_MANIFEST).option("--dry-run", "print the plan (per-repo disposition + the onboard command) without running anything", false).addHelpText(
3627
+ program2.command("sweep").description("Non-interactive fan-out from ./.sechroom/repos.json (headless sibling of `onboard --recurse`)").option("--manifest <path>", "path to the repos manifest", DEFAULT_MANIFEST).option("--dry-run", "print the plan (per-repo disposition + the onboard command) without running anything", false).addHelpText(
3417
3628
  "after",
3418
3629
  `
3630
+ For an interactive, no-manifest run use ${"`sechroom onboard --recurse`"} instead \u2014 it
3631
+ auto-discovers the child repos and prompts for a workspace per new one. ${"`sweep`"} is
3632
+ the deterministic manifest-driven form for scripts / CI.
3633
+
3419
3634
  Manifest \u2014 ./.sechroom/repos.json (per-operator, gitignored, alongside lane.json):
3420
3635
  {
3421
3636
  "repos": [
@@ -3439,28 +3654,23 @@ Examples:
3439
3654
  const g = cmd.optsWithGlobals();
3440
3655
  const json = Boolean(g.json);
3441
3656
  const dryRun = Boolean(opts.dryRun);
3442
- const manifestPath = resolve(opts.manifest);
3443
- if (!existsSync6(manifestPath)) {
3444
- fail(`no manifest at ${manifestPath} \u2014 create ./.sechroom/repos.json listing the repos under this root (see \`sechroom sweep --help\`).`);
3445
- }
3446
- let manifest;
3657
+ const manifestPath = resolve2(opts.manifest);
3658
+ let repos;
3447
3659
  try {
3448
- manifest = JSON.parse(readFileSync5(manifestPath, "utf8"));
3660
+ repos = readManifest(manifestPath);
3449
3661
  } catch (err2) {
3450
- fail(`couldn't parse ${manifestPath}: ${err2 instanceof Error ? err2.message : String(err2)}`);
3662
+ fail(err2 instanceof Error ? err2.message : String(err2));
3663
+ }
3664
+ if (repos === null) {
3665
+ fail(`no manifest at ${manifestPath} \u2014 create ./.sechroom/repos.json, or use \`sechroom onboard --recurse\` to auto-discover (see \`sechroom sweep --help\`).`);
3451
3666
  }
3452
- const repos = Array.isArray(manifest.repos) ? manifest.repos.filter((r) => r && typeof r.path === "string") : [];
3453
3667
  if (repos.length === 0) {
3454
- if (json) {
3455
- process.stdout.write(JSON.stringify({ manifest: manifestPath, repos: [] }) + "\n");
3456
- } else {
3457
- process.stderr.write(`${warn("\u26A0")} ${manifestPath} lists no repos \u2014 nothing to do.
3668
+ if (json) process.stdout.write(JSON.stringify({ manifest: manifestPath, repos: [] }) + "\n");
3669
+ else process.stderr.write(`${warn("\u26A0")} ${manifestPath} lists no repos \u2014 nothing to do.
3458
3670
  `);
3459
- }
3460
3671
  return;
3461
3672
  }
3462
3673
  const root = dirname6(dirname6(manifestPath));
3463
- const globals = passthroughGlobals(g);
3464
3674
  const plans = repos.map((entry) => planEntry(entry, root));
3465
3675
  if (!json) {
3466
3676
  process.stderr.write(
@@ -3468,70 +3678,24 @@ Examples:
3468
3678
  `
3469
3679
  );
3470
3680
  }
3471
- const results = [];
3472
- for (const plan of plans) {
3473
- const runs = plan.argv.length > 0;
3474
- const argv = runs ? [...globals, ...plan.argv] : [];
3475
- const skipped2 = !runs;
3476
- if (!json) {
3477
- const head = ` ${ICON[plan.disposition]} ${style.cyan(plan.entry.path)} ${style.dim(plan.reason)}`;
3478
- process.stderr.write(head + "\n");
3479
- if (runs && dryRun) process.stderr.write(` ${style.dim(`would run: sechroom ${argv.join(" ")}`)}
3480
- `);
3481
- }
3482
- let exitCode = skipped2 ? null : 0;
3483
- if (runs && !dryRun) {
3484
- const res = spawnSync(process.execPath, [process.argv[1], ...argv], {
3485
- cwd: plan.dir,
3486
- stdio: json ? "ignore" : "inherit"
3487
- });
3488
- exitCode = res.status;
3489
- if (!json) {
3490
- process.stderr.write(
3491
- exitCode === 0 ? ` ${ok("\u2713")} ${style.dim("onboard ok")}
3492
- ` : ` ${warn("\u2717")} ${style.dim(`onboard exited ${exitCode ?? "signal"}`)}
3493
- `
3494
- );
3495
- }
3496
- }
3497
- results.push({
3498
- path: plan.entry.path,
3499
- dir: plan.dir,
3500
- disposition: plan.disposition,
3501
- ran: runs && !dryRun,
3502
- exitCode,
3503
- reason: plan.reason
3504
- });
3505
- }
3681
+ const results = runChildren(plans, { globals: passthroughGlobals(g), dryRun, json });
3506
3682
  if (json) {
3507
3683
  process.stdout.write(JSON.stringify({ manifest: manifestPath, dryRun, repos: results }) + "\n");
3508
3684
  return;
3509
3685
  }
3510
- const ran = results.filter((r) => r.ran);
3511
- const failed = ran.filter((r) => r.exitCode !== 0);
3512
- const skipped = results.filter((r) => r.disposition.startsWith("skip"));
3513
- const wouldRun = results.filter((r) => !r.disposition.startsWith("skip"));
3514
- const tally = (dryRun ? [wouldRun.length ? `${wouldRun.length} would onboard` : null, skipped.length ? `${skipped.length} would skip` : null] : [
3515
- ran.length ? `${ran.length - failed.length}/${ran.length} onboarded` : null,
3516
- skipped.length ? `${skipped.length} skipped` : null,
3517
- failed.length ? `${failed.length} failed` : null
3518
- ]).filter(Boolean).join(", ");
3519
- process.stderr.write(`
3520
- ${failed.length ? warn("\u26A0") : ok("\u2713")} ${tally || "nothing to do"}${dryRun ? style.dim(" (dry run)") : ""}
3521
- `);
3522
- if (failed.length) process.exit(1);
3686
+ summarizeFanout(results, { dryRun });
3523
3687
  });
3524
3688
  }
3525
3689
 
3526
3690
  // src/commands/skills.ts
3527
3691
  import { homedir as homedir6 } from "os";
3528
- import { join as join7 } from "path";
3529
- import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync6, rmSync as rmSync2, existsSync as existsSync7, readFileSync as readFileSync6 } from "fs";
3692
+ import { join as join9 } from "path";
3693
+ import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync6, rmSync as rmSync2, existsSync as existsSync9, readFileSync as readFileSync6 } from "fs";
3530
3694
  var DEFAULT_SLUG = "operator-skills";
3531
3695
  var ROLE_TAGS = ["sechroom:role:skill-template", "role:skill-template"];
3532
3696
  var LOCK = ".sechroom-skills.json";
3533
3697
  function skillsDir(global) {
3534
- return global ? join7(homedir6(), ".claude", "skills") : join7(process.cwd(), ".claude", "skills");
3698
+ return global ? join9(homedir6(), ".claude", "skills") : join9(process.cwd(), ".claude", "skills");
3535
3699
  }
3536
3700
  function tagValue2(tags, prefix) {
3537
3701
  return (tags ?? []).find((t) => t.startsWith(prefix))?.slice(prefix.length);
@@ -3609,13 +3773,13 @@ Examples:
3609
3773
  const name = tagValue2(tags, "skill:");
3610
3774
  if (!name) continue;
3611
3775
  const body = m.text ?? m.Text ?? "";
3612
- mkdirSync6(join7(dir, name), { recursive: true });
3613
- writeFileSync6(join7(dir, name, "SKILL.md"), body.endsWith("\n") ? body : body + "\n");
3776
+ mkdirSync6(join9(dir, name), { recursive: true });
3777
+ writeFileSync6(join9(dir, name, "SKILL.md"), body.endsWith("\n") ? body : body + "\n");
3614
3778
  written.push(name);
3615
3779
  }
3616
3780
  mkdirSync6(dir, { recursive: true });
3617
- const lockPath = join7(dir, LOCK);
3618
- const lock = existsSync7(lockPath) ? JSON.parse(readFileSync6(lockPath, "utf8")) : {};
3781
+ const lockPath = join9(dir, LOCK);
3782
+ const lock = existsSync9(lockPath) ? JSON.parse(readFileSync6(lockPath, "utf8")) : {};
3619
3783
  lock[slug] = { surface: opts.surface, version, instance: wantInstance, skills: written.sort() };
3620
3784
  writeFileSync6(lockPath, JSON.stringify(lock, null, 2) + "\n");
3621
3785
  if (opts.json) return emit({ slug, version, instance: wantInstance, surface: opts.surface, dir, installed: written }, true);
@@ -3639,15 +3803,15 @@ Examples:
3639
3803
  skills.command("clean [slug]").description(`Remove materialised skill files written by install (default ${DEFAULT_SLUG})`).option("--local", "clean ./.claude/skills instead of ~/.claude/skills").option("--json", "machine output").action(async (slugArg, opts) => {
3640
3804
  const slug = slugArg || DEFAULT_SLUG;
3641
3805
  const dir = skillsDir(!opts.local);
3642
- const lockPath = join7(dir, LOCK);
3643
- if (!existsSync7(lockPath)) fail(`No skills lockfile at ${lockPath}; nothing to clean.`);
3806
+ const lockPath = join9(dir, LOCK);
3807
+ if (!existsSync9(lockPath)) fail(`No skills lockfile at ${lockPath}; nothing to clean.`);
3644
3808
  const lock = JSON.parse(readFileSync6(lockPath, "utf8"));
3645
3809
  const entry = lock[slug];
3646
3810
  if (!entry) fail(`No installed record for '${slug}' in ${lockPath}.`);
3647
3811
  const removed = [];
3648
3812
  for (const name of entry.skills) {
3649
- const skillPath = join7(dir, name);
3650
- if (existsSync7(skillPath)) {
3813
+ const skillPath = join9(dir, name);
3814
+ if (existsSync9(skillPath)) {
3651
3815
  rmSync2(skillPath, { recursive: true, force: true });
3652
3816
  removed.push(name);
3653
3817
  }
@@ -3743,21 +3907,21 @@ Examples:
3743
3907
 
3744
3908
  // src/commands/reset.ts
3745
3909
  import { homedir as homedir7 } from "os";
3746
- import { join as join8 } from "path";
3747
- import { existsSync as existsSync8, readFileSync as readFileSync7, rmSync as rmSync3 } from "fs";
3910
+ import { join as join10 } from "path";
3911
+ import { existsSync as existsSync10, readFileSync as readFileSync7, rmSync as rmSync3 } from "fs";
3748
3912
  var SKILLS_LOCK = ".sechroom-skills.json";
3749
- var localSkillsDir = () => join8(process.cwd(), ".claude", "skills");
3750
- var globalSkillsDir = () => join8(homedir7(), ".claude", "skills");
3913
+ var localSkillsDir = () => join10(process.cwd(), ".claude", "skills");
3914
+ var globalSkillsDir = () => join10(homedir7(), ".claude", "skills");
3751
3915
  function removeMaterialisedSkills(dir) {
3752
3916
  const removed = [];
3753
- const lockPath = join8(dir, SKILLS_LOCK);
3754
- if (!existsSync8(lockPath)) return removed;
3917
+ const lockPath = join10(dir, SKILLS_LOCK);
3918
+ if (!existsSync10(lockPath)) return removed;
3755
3919
  try {
3756
3920
  const lock = JSON.parse(readFileSync7(lockPath, "utf8"));
3757
3921
  for (const entry of Object.values(lock)) {
3758
3922
  for (const name of entry.skills ?? []) {
3759
- const p = join8(dir, name);
3760
- if (existsSync8(p)) {
3923
+ const p = join10(dir, name);
3924
+ if (existsSync10(p)) {
3761
3925
  rmSync3(p, { recursive: true, force: true });
3762
3926
  removed.push(p);
3763
3927
  }
@@ -3788,18 +3952,18 @@ function registerReset(program2) {
3788
3952
  }
3789
3953
  }
3790
3954
  const removed = [];
3791
- const stateDir = join8(process.cwd(), ".sechroom");
3792
- if (existsSync8(stateDir)) {
3955
+ const stateDir = join10(process.cwd(), ".sechroom");
3956
+ if (existsSync10(stateDir)) {
3793
3957
  rmSync3(stateDir, { recursive: true, force: true });
3794
3958
  removed.push(stateDir);
3795
3959
  }
3796
- const legacyCfg = join8(process.cwd(), ".sechroom.json");
3797
- if (existsSync8(legacyCfg)) {
3960
+ const legacyCfg = join10(process.cwd(), ".sechroom.json");
3961
+ if (existsSync10(legacyCfg)) {
3798
3962
  rmSync3(legacyCfg, { force: true });
3799
3963
  removed.push(legacyCfg);
3800
3964
  }
3801
- const legacySem = join8(process.cwd(), ".sem");
3802
- if (existsSync8(legacySem)) {
3965
+ const legacySem = join10(process.cwd(), ".sem");
3966
+ if (existsSync10(legacySem)) {
3803
3967
  rmSync3(legacySem, { force: true });
3804
3968
  removed.push(legacySem);
3805
3969
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sechroom/cli",
3
- "version": "2026.6.24",
3
+ "version": "2026.6.26",
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",