@sechroom/cli 2026.6.23 → 2026.6.25

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 +376 -88
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { readFileSync as readFileSync7 } from "fs";
4
+ import { readFileSync as readFileSync8 } from "fs";
5
5
  import { Command } from "commander";
6
6
 
7
7
  // src/auth.ts
@@ -11,14 +11,15 @@ import open from "open";
11
11
 
12
12
  // src/config.ts
13
13
  import { homedir } from "os";
14
- import { join, dirname, basename } from "path";
14
+ import { join, dirname } from "path";
15
15
  import { mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } from "fs";
16
16
  var CONFIG_DIR = join(homedir(), ".config", "sechroom");
17
17
  var CONFIG_FILE = join(CONFIG_DIR, "config.json");
18
18
  var TOKEN_FILE = join(CONFIG_DIR, "token.json");
19
19
  var STATE_DIR_NAME = ".sechroom";
20
- var LOCAL_CONFIG_NAME = join(STATE_DIR_NAME, "config.json");
21
- var LEGACY_LOCAL_CONFIG_NAME = ".sechroom.json";
20
+ var BASELINE_CONFIG_NAME = ".sechroom.json";
21
+ var OVERRIDE_CONFIG_NAME = join(STATE_DIR_NAME, "config.json");
22
+ var BINDING_FIELDS = ["schemaVersion", "baseUrl", "tenant", "workspaceId", "defaultProjectId"];
22
23
  var DEFAULT_BASE_URL = "https://app.sechroom.ai/api";
23
24
  var LOCAL_CONFIG_SCHEMA_VERSION = 2;
24
25
  function ensureDir() {
@@ -66,54 +67,55 @@ function clearPersisted() {
66
67
  rmSync(CONFIG_FILE);
67
68
  return CONFIG_FILE;
68
69
  }
69
- function findLocalConfigPath(start = process.cwd()) {
70
+ function readJsonConfig(path) {
71
+ try {
72
+ return JSON.parse(readFileSync(path, "utf8"));
73
+ } catch {
74
+ return void 0;
75
+ }
76
+ }
77
+ function findConfigHome(start = process.cwd()) {
70
78
  let dir = start;
71
79
  for (; ; ) {
72
- const candidate = join(dir, LOCAL_CONFIG_NAME);
73
- if (existsSync(candidate)) return candidate;
74
- const legacy = join(dir, LEGACY_LOCAL_CONFIG_NAME);
75
- if (existsSync(legacy)) return legacy;
80
+ if (existsSync(join(dir, BASELINE_CONFIG_NAME)) || existsSync(join(dir, OVERRIDE_CONFIG_NAME))) return dir;
76
81
  const parent = dirname(dir);
77
82
  if (parent === dir) return void 0;
78
83
  dir = parent;
79
84
  }
80
85
  }
81
- function localConfigWritePath(resolvedPath) {
82
- const baseDir = resolvedPath ? dirname(resolvedPath) : process.cwd();
83
- const dir = basename(baseDir) === STATE_DIR_NAME ? dirname(baseDir) : baseDir;
84
- return join(dir, LOCAL_CONFIG_NAME);
85
- }
86
86
  function readLocalConfig() {
87
- const path = findLocalConfigPath();
88
- if (!path) return {};
89
- try {
90
- const c = JSON.parse(readFileSync(path, "utf8"));
91
- return {
92
- schemaVersion: c.schemaVersion,
93
- baseUrl: c.baseUrl,
94
- tenant: c.tenant,
95
- workspaceId: c.workspaceId,
96
- defaultProjectId: c.defaultProjectId,
97
- path
98
- };
99
- } catch {
100
- return {};
101
- }
87
+ const home = findConfigHome();
88
+ if (!home) return {};
89
+ const baselinePath = join(home, BASELINE_CONFIG_NAME);
90
+ const overridePath = join(home, OVERRIDE_CONFIG_NAME);
91
+ const merged = { ...readJsonConfig(baselinePath) ?? {}, ...readJsonConfig(overridePath) ?? {} };
92
+ return {
93
+ schemaVersion: merged.schemaVersion,
94
+ baseUrl: merged.baseUrl,
95
+ tenant: merged.tenant,
96
+ workspaceId: merged.workspaceId,
97
+ defaultProjectId: merged.defaultProjectId,
98
+ path: existsSync(baselinePath) ? baselinePath : overridePath
99
+ };
102
100
  }
103
101
  function writeLocalConfig(patch) {
104
- const readPath = findLocalConfigPath();
105
- let current = {};
106
- if (readPath) {
107
- try {
108
- current = JSON.parse(readFileSync(readPath, "utf8"));
109
- } catch {
110
- }
111
- }
112
- const path = localConfigWritePath(readPath);
113
- mkdirSync(dirname(path), { recursive: true, mode: 448 });
102
+ const home = findConfigHome() ?? process.cwd();
103
+ const baselinePath = join(home, BASELINE_CONFIG_NAME);
104
+ const overridePath = join(home, OVERRIDE_CONFIG_NAME);
105
+ const current = readJsonConfig(baselinePath) ?? {};
114
106
  const next = { ...current, ...patch, schemaVersion: LOCAL_CONFIG_SCHEMA_VERSION };
115
- writeFileSync(path, JSON.stringify(next, null, 2), { mode: 384 });
116
- return path;
107
+ writeFileSync(baselinePath, JSON.stringify(next, null, 2), { mode: 420 });
108
+ const override = readJsonConfig(overridePath);
109
+ if (override) {
110
+ for (const f of BINDING_FIELDS) delete override[f];
111
+ if (Object.keys(override).length === 0) rmSync(overridePath, { force: true });
112
+ else writeFileSync(overridePath, JSON.stringify(override, null, 2), { mode: 384 });
113
+ }
114
+ return baselinePath;
115
+ }
116
+ function committedBindingPath(dir) {
117
+ const p = join(dir, BASELINE_CONFIG_NAME);
118
+ return existsSync(p) ? p : void 0;
117
119
  }
118
120
  function resolveConfig(flags) {
119
121
  const local = readLocalConfig();
@@ -373,8 +375,8 @@ async function promptYesNo(question) {
373
375
  const { createInterface } = await import("readline");
374
376
  const rl = createInterface({ input: process.stdin, output: process.stderr });
375
377
  try {
376
- const answer = await new Promise((resolve) => {
377
- rl.question(`${question} [y/N] `, resolve);
378
+ const answer = await new Promise((resolve3) => {
379
+ rl.question(`${question} [y/N] `, resolve3);
378
380
  });
379
381
  return /^y(es)?$/i.test(answer.trim());
380
382
  } finally {
@@ -387,8 +389,8 @@ async function promptText(question, def) {
387
389
  const rl = createInterface({ input: process.stdin, output: process.stderr });
388
390
  try {
389
391
  const suffix = def ? ` [${def}]` : "";
390
- const answer = await new Promise((resolve) => {
391
- rl.question(`${question}${suffix} `, resolve);
392
+ const answer = await new Promise((resolve3) => {
393
+ rl.question(`${question}${suffix} `, resolve3);
392
394
  });
393
395
  const trimmed = answer.trim();
394
396
  return trimmed.length > 0 ? trimmed : def ?? "";
@@ -414,8 +416,8 @@ async function promptSelect(question, choices, def) {
414
416
  process.stderr.write(` ${marker} ${style.bold(String(i + 1))}. ${c.label}${hint}
415
417
  `);
416
418
  });
417
- const answer = await new Promise((resolve) => {
418
- rl.question(`Choose ${style.dim(`[${defIdx + 1}]`)} `, resolve);
419
+ const answer = await new Promise((resolve3) => {
420
+ rl.question(`Choose ${style.dim(`[${defIdx + 1}]`)} `, resolve3);
419
421
  });
420
422
  const trimmed = answer.trim();
421
423
  if (!trimmed) return choices[defIdx].value;
@@ -447,8 +449,8 @@ async function promptMultiSelect(question, choices, preselected = []) {
447
449
  process.stderr.write(` ${box} ${style.bold(String(i + 1))}. ${c.label}${hint}
448
450
  `);
449
451
  });
450
- const answer = await new Promise((resolve) => {
451
- rl.question(`Select ${style.dim("[Enter = \u25C9]")} `, resolve);
452
+ const answer = await new Promise((resolve3) => {
453
+ rl.question(`Select ${style.dim("[Enter = \u25C9]")} `, resolve3);
452
454
  });
453
455
  const trimmed = answer.trim().toLowerCase();
454
456
  if (!trimmed) return preValues();
@@ -1973,7 +1975,7 @@ function mergeHooks(config2) {
1973
1975
  }
1974
1976
  return added;
1975
1977
  }
1976
- function readJsonConfig(path) {
1978
+ function readJsonConfig2(path) {
1977
1979
  if (!existsSync4(path)) return {};
1978
1980
  const raw = readFileSync3(path, "utf8");
1979
1981
  if (!raw.trim()) return {};
@@ -1981,7 +1983,7 @@ function readJsonConfig(path) {
1981
1983
  }
1982
1984
  function installHooksJson(path, dryRun) {
1983
1985
  const existed = existsSync4(path) && readFileSync3(path, "utf8").trim().length > 0;
1984
- const config2 = readJsonConfig(path);
1986
+ const config2 = readJsonConfig2(path);
1985
1987
  const added = mergeHooks(config2);
1986
1988
  if (added === 0 && existed) return { path, status: "current" };
1987
1989
  if (!dryRun) {
@@ -2936,6 +2938,113 @@ async function runClients(clients, cmd, opts) {
2936
2938
  process.stdout.write(opts.dryRun ? "\n(dry run \u2014 nothing written)\n" : "\nDone.\n");
2937
2939
  }
2938
2940
 
2941
+ // src/commands/onboard.ts
2942
+ import { existsSync as existsSync7 } from "fs";
2943
+ import { join as join7 } from "path";
2944
+
2945
+ // src/commands/fanout.ts
2946
+ import { spawnSync } from "child_process";
2947
+ import { existsSync as existsSync6, readFileSync as readFileSync5, readdirSync, statSync } from "fs";
2948
+ import { isAbsolute, join as join6, resolve } from "path";
2949
+ var ICON = {
2950
+ refresh: "\u21BB",
2951
+ bind: "+",
2952
+ "skip-missing": "\u2013",
2953
+ "skip-unbound": "\u26A0"
2954
+ };
2955
+ function resolveChildDir(path, root) {
2956
+ return isAbsolute(path) ? path : resolve(root, path);
2957
+ }
2958
+ function discoverChildren(root) {
2959
+ let names;
2960
+ try {
2961
+ names = readdirSync(root);
2962
+ } catch {
2963
+ return [];
2964
+ }
2965
+ const out = [];
2966
+ for (const name of names.sort()) {
2967
+ if (name.startsWith(".") || name === "node_modules") continue;
2968
+ const dir = join6(root, name);
2969
+ try {
2970
+ if (!statSync(dir).isDirectory()) continue;
2971
+ } catch {
2972
+ continue;
2973
+ }
2974
+ if (existsSync6(join6(dir, ".git")) || committedBindingPath(dir)) out.push(name);
2975
+ }
2976
+ return out;
2977
+ }
2978
+ function readManifest(path) {
2979
+ if (!existsSync6(path)) return null;
2980
+ let parsed;
2981
+ try {
2982
+ parsed = JSON.parse(readFileSync5(path, "utf8"));
2983
+ } catch (err2) {
2984
+ throw new Error(`couldn't parse ${path}: ${err2 instanceof Error ? err2.message : String(err2)}`);
2985
+ }
2986
+ return Array.isArray(parsed.repos) ? parsed.repos.filter((r) => r && typeof r.path === "string") : [];
2987
+ }
2988
+ function passthroughGlobals(g) {
2989
+ const out = [];
2990
+ if (g.baseUrl) out.push("--base-url", g.baseUrl);
2991
+ if (g.tenant) out.push("--tenant", g.tenant);
2992
+ return out;
2993
+ }
2994
+ function runChildren(plans, o) {
2995
+ const { globals, dryRun, json } = o;
2996
+ const results = [];
2997
+ for (const plan of plans) {
2998
+ const runs = plan.argv.length > 0;
2999
+ const argv = runs ? [...globals, ...plan.argv] : [];
3000
+ if (!json) {
3001
+ process.stderr.write(` ${ICON[plan.disposition]} ${style.cyan(plan.label)} ${style.dim(plan.reason)}
3002
+ `);
3003
+ if (runs && dryRun) process.stderr.write(` ${style.dim(`would run: sechroom ${argv.join(" ")}`)}
3004
+ `);
3005
+ }
3006
+ let exitCode = runs ? 0 : null;
3007
+ if (runs && !dryRun) {
3008
+ const res = spawnSync(process.execPath, [process.argv[1], ...argv], {
3009
+ cwd: plan.dir,
3010
+ stdio: json ? "ignore" : "inherit"
3011
+ });
3012
+ exitCode = res.status;
3013
+ if (!json) {
3014
+ process.stderr.write(
3015
+ exitCode === 0 ? ` ${ok("\u2713")} ${style.dim("onboard ok")}
3016
+ ` : ` ${warn("\u2717")} ${style.dim(`onboard exited ${exitCode ?? "signal"}`)}
3017
+ `
3018
+ );
3019
+ }
3020
+ }
3021
+ results.push({
3022
+ path: plan.label,
3023
+ dir: plan.dir,
3024
+ disposition: plan.disposition,
3025
+ ran: runs && !dryRun,
3026
+ exitCode,
3027
+ reason: plan.reason
3028
+ });
3029
+ }
3030
+ return results;
3031
+ }
3032
+ function summarizeFanout(results, o) {
3033
+ const ran = results.filter((r) => r.ran);
3034
+ const failed = ran.filter((r) => r.exitCode !== 0);
3035
+ const skipped = results.filter((r) => r.disposition.startsWith("skip"));
3036
+ const wouldRun = results.filter((r) => !r.disposition.startsWith("skip"));
3037
+ const tally = (o.dryRun ? [wouldRun.length ? `${wouldRun.length} would onboard` : null, skipped.length ? `${skipped.length} would skip` : null] : [
3038
+ ran.length ? `${ran.length - failed.length}/${ran.length} onboarded` : null,
3039
+ skipped.length ? `${skipped.length} skipped` : null,
3040
+ failed.length ? `${failed.length} failed` : null
3041
+ ]).filter(Boolean).join(", ");
3042
+ process.stderr.write(`
3043
+ ${failed.length ? warn("\u26A0") : ok("\u2713")} ${tally || "nothing to do"}${o.dryRun ? style.dim(" (dry run)") : ""}
3044
+ `);
3045
+ if (failed.length) process.exit(1);
3046
+ }
3047
+
2939
3048
  // src/commands/onboard.ts
2940
3049
  var DEFAULT_BASE_URL2 = "https://app.sechroom.ai/api";
2941
3050
  function systemTimezone() {
@@ -3008,7 +3117,7 @@ async function warnIfProjectStray(client, projectId, workspaceId, json) {
3008
3117
  );
3009
3118
  }
3010
3119
  }
3011
- async function pickWorkspace(client) {
3120
+ async function pickWorkspace(client, promptLabel = "Bind this directory to a workspace:") {
3012
3121
  const all = await withSpinner("Listing your workspaces", () => fetchWorkspaces(client));
3013
3122
  if (all.length === 0) {
3014
3123
  process.stderr.write(`no workspaces found \u2014 skipping workspace binding (you can set it later with \`sechroom config set --local workspaceId <id>\`)
@@ -3031,7 +3140,7 @@ async function pickWorkspace(client) {
3031
3140
  ...pool.slice().sort((a, b) => workspacePath(a, byId).localeCompare(workspacePath(b, byId))).map((w) => ({ label: workspacePath(w, byId), value: w.id, hint: w.id })),
3032
3141
  { label: style.dim("skip \u2014 don't bind a workspace"), value: SKIP, hint: void 0 }
3033
3142
  ];
3034
- const chosen = await promptSelect("Bind this directory to a workspace:", choices, SKIP);
3143
+ const chosen = await promptSelect(promptLabel, choices, SKIP);
3035
3144
  if (chosen === SKIP) return void 0;
3036
3145
  const picked = byId.get(chosen);
3037
3146
  const collisions = all.filter((w) => w.id !== picked.id && namesCollide(w.name, picked.name));
@@ -3114,7 +3223,7 @@ async function ensureTenant(baseUrl, g, opts) {
3114
3223
  "Where should this tenant + base URL be saved?",
3115
3224
  [
3116
3225
  { label: "Globally", value: "global", hint: "all projects on this machine" },
3117
- { label: "This directory", value: "local", hint: ".sechroom/config.json \u2014 project + subdirs" }
3226
+ { label: "This directory", value: "local", hint: ".sechroom.json \u2014 committed, project + subdirs" }
3118
3227
  ],
3119
3228
  local.path ? "local" : "global"
3120
3229
  ) === "local";
@@ -3189,16 +3298,88 @@ async function chooseClients(clientFlag, yes, cwd) {
3189
3298
  );
3190
3299
  return picks.length > 0 ? picks : preselected;
3191
3300
  }
3301
+ async function planRecurseChild(entry, root, client, opts) {
3302
+ const dir = resolveChildDir(entry.path, root);
3303
+ if (!existsSync7(dir)) {
3304
+ return { label: entry.path, dir, disposition: "skip-missing", argv: [], reason: "directory does not exist" };
3305
+ }
3306
+ if (existsSync7(join7(dir, ".sechroom.json"))) {
3307
+ return {
3308
+ label: entry.path,
3309
+ dir,
3310
+ disposition: "refresh",
3311
+ argv: ["onboard", "--refresh", "--yes"],
3312
+ reason: "bound (committed .sechroom.json) \u2014 refresh in place"
3313
+ };
3314
+ }
3315
+ if (entry.workspaceId) {
3316
+ return {
3317
+ label: entry.path,
3318
+ dir,
3319
+ disposition: "bind",
3320
+ argv: ["onboard", "--yes", "--local", "--workspace", entry.workspaceId],
3321
+ reason: `unbound \u2014 bind to ${entry.workspaceId}`
3322
+ };
3323
+ }
3324
+ if (opts.dryRun) {
3325
+ return { label: entry.path, dir, disposition: "bind", argv: ["onboard", "--yes", "--local", "--workspace", "<prompt>"], reason: "unbound \u2014 would prompt for a workspace" };
3326
+ }
3327
+ if (opts.yes || !canPrompt()) {
3328
+ return { label: entry.path, dir, disposition: "skip-unbound", argv: [], reason: "unbound + no workspace (run interactively, or add it to ./.sechroom/repos.json)" };
3329
+ }
3330
+ process.stderr.write(`
3331
+ ${style.bold(entry.path)} ${style.dim("is not bound yet.")}
3332
+ `);
3333
+ const ws = await pickWorkspace(client, `Bind ${style.cyan(entry.path)} to a workspace:`);
3334
+ if (!ws) {
3335
+ return { label: entry.path, dir, disposition: "skip-unbound", argv: [], reason: "unbound \u2014 no workspace chosen (skipped)" };
3336
+ }
3337
+ return {
3338
+ label: entry.path,
3339
+ dir,
3340
+ disposition: "bind",
3341
+ argv: ["onboard", "--yes", "--local", "--workspace", ws],
3342
+ reason: `unbound \u2014 bind to ${ws}`
3343
+ };
3344
+ }
3345
+ async function runRecurse(cfg, g, opts) {
3346
+ const { yes, dryRun, json } = opts;
3347
+ const root = process.cwd();
3348
+ const manifestPath = join7(root, ".sechroom", "repos.json");
3349
+ const fromManifest = readManifest(manifestPath);
3350
+ const entries = fromManifest ?? discoverChildren(root).map((path) => ({ path }));
3351
+ const sourceLabel = fromManifest ? `manifest ${manifestPath}` : `auto-discovered under ${root}`;
3352
+ if (entries.length === 0) {
3353
+ if (json) process.stdout.write(JSON.stringify({ recurse: true, root, repos: [] }) + "\n");
3354
+ else process.stderr.write(`${warn("\u26A0")} no child repos found ${fromManifest ? `in ${manifestPath}` : `under ${root}`} \u2014 nothing to do.
3355
+ `);
3356
+ return;
3357
+ }
3358
+ if (!json) {
3359
+ process.stderr.write(`${style.bold("onboard --recurse")} ${style.dim(`(${entries.length} repo${entries.length === 1 ? "" : "s"} from ${sourceLabel})`)}
3360
+ `);
3361
+ }
3362
+ const client = await makeClient(cfg);
3363
+ const plans = [];
3364
+ for (const entry of entries) plans.push(await planRecurseChild(entry, root, client, { yes, dryRun }));
3365
+ const results = runChildren(plans, { globals: passthroughGlobals(g), dryRun, json });
3366
+ if (json) {
3367
+ process.stdout.write(JSON.stringify({ recurse: true, root, dryRun, repos: results }) + "\n");
3368
+ return;
3369
+ }
3370
+ summarizeFanout(results, { dryRun });
3371
+ }
3192
3372
  function registerOnboard(program2) {
3193
- 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 tenant + base URL to a directory-local .sechroom/config.json 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(
3373
+ 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("--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(
3194
3374
  "after",
3195
3375
  `
3196
3376
  Examples:
3197
3377
  $ sechroom onboard guided, interactive (asks where to save config + how to wire)
3198
3378
  $ sechroom onboard --cli-only just the CLI \u2014 no .mcp.json, no agent files
3199
3379
  $ sechroom onboard --no-mcp agent instructions only, skip MCP config
3200
- $ sechroom onboard --local save tenant + base URL to ./.sechroom/config.json
3380
+ $ sechroom onboard --local save tenant + base URL to a committed ./.sechroom.json
3201
3381
  $ sechroom onboard --workspace wsp_XX bind this directory to a workspace (no pick prompt)
3382
+ $ sechroom onboard --recurse orchestration root: onboard every child repo under this dir
3202
3383
  $ sechroom onboard --refresh refresh out-of-date instruction blocks in place
3203
3384
  $ sechroom onboard --check CI/pre-commit: nonzero exit if instructions are out of date
3204
3385
  $ sechroom onboard --yes non-interactive: defaults + global config + full wire
@@ -3210,6 +3391,13 @@ Examples:
3210
3391
  const mode = opts.check ? "check" : opts.force ? "force" : "apply";
3211
3392
  const check = mode === "check";
3212
3393
  const yes = Boolean(opts.yes) || check;
3394
+ if (opts.recurse) {
3395
+ const baseUrl2 = resolveBaseUrl(g);
3396
+ await ensureAuth({ baseUrl: baseUrl2, tenant: "", clientId: readPersisted().clientId }, yes);
3397
+ const cfg2 = await ensureTenant(baseUrl2, g, { yes: true, json, persist: false });
3398
+ await runRecurse(cfg2, g, { yes, dryRun, json });
3399
+ return;
3400
+ }
3213
3401
  const baseUrl = resolveBaseUrl(g);
3214
3402
  await ensureAuth({ baseUrl, tenant: "", clientId: readPersisted().clientId }, yes);
3215
3403
  const cfg = await ensureTenant(baseUrl, g, { yes, json, local: Boolean(opts.local), workspace: opts.workspace, persist: !check });
@@ -3362,15 +3550,114 @@ ${style.bold("Next:")} paste this into your AI agent to get going \u2014
3362
3550
  );
3363
3551
  }
3364
3552
 
3553
+ // src/commands/sweep.ts
3554
+ import { existsSync as existsSync8 } from "fs";
3555
+ import { dirname as dirname6, join as join8, resolve as resolve2 } from "path";
3556
+ var DEFAULT_MANIFEST = join8(".sechroom", "repos.json");
3557
+ function planEntry(entry, root) {
3558
+ const dir = resolveChildDir(entry.path, root);
3559
+ if (!existsSync8(dir)) {
3560
+ return { label: entry.path, dir, disposition: "skip-missing", argv: [], reason: "directory does not exist" };
3561
+ }
3562
+ if (committedBindingPath(dir)) {
3563
+ return {
3564
+ label: entry.path,
3565
+ dir,
3566
+ disposition: "refresh",
3567
+ argv: ["onboard", "--refresh", "--yes"],
3568
+ reason: "bound (committed .sechroom.json) \u2014 refresh in place"
3569
+ };
3570
+ }
3571
+ if (entry.workspaceId) {
3572
+ return {
3573
+ label: entry.path,
3574
+ dir,
3575
+ disposition: "bind",
3576
+ argv: ["onboard", "--yes", "--local", "--workspace", entry.workspaceId],
3577
+ reason: `unbound \u2014 bind to ${entry.workspaceId} + commit .sechroom.json`
3578
+ };
3579
+ }
3580
+ return {
3581
+ label: entry.path,
3582
+ dir,
3583
+ disposition: "skip-unbound",
3584
+ argv: [],
3585
+ reason: "unbound + no workspaceId in the manifest \u2014 add one or run `sechroom onboard` there"
3586
+ };
3587
+ }
3588
+ function registerSweep(program2) {
3589
+ 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(
3590
+ "after",
3591
+ `
3592
+ For an interactive, no-manifest run use ${"`sechroom onboard --recurse`"} instead \u2014 it
3593
+ auto-discovers the child repos and prompts for a workspace per new one. ${"`sweep`"} is
3594
+ the deterministic manifest-driven form for scripts / CI.
3595
+
3596
+ Manifest \u2014 ./.sechroom/repos.json (per-operator, gitignored, alongside lane.json):
3597
+ {
3598
+ "repos": [
3599
+ { "path": "sechroom", "workspaceId": "wsp_XXXX" },
3600
+ { "path": "../other-repo", "workspaceId": "wsp_YYYY" },
3601
+ { "path": "already-bound" }
3602
+ ]
3603
+ }
3604
+
3605
+ Per repo (paths resolve relative to the manifest's root):
3606
+ ${ICON.refresh} bound committed .sechroom.json present \u2192 onboard --refresh (manifest workspace ignored)
3607
+ ${ICON.bind} unbound bind to the manifest workspaceId \u2192 onboard --local --workspace <id>
3608
+ ${ICON["skip-missing"]} missing directory does not exist \u2192 skipped
3609
+ ${ICON["skip-unbound"]} no workspace unbound + no workspaceId in manifest \u2192 skipped (add one, or onboard manually)
3610
+
3611
+ Examples:
3612
+ $ sechroom sweep --dry-run preview every repo's disposition, run nothing
3613
+ $ sechroom sweep onboard the whole tree from the root
3614
+ $ sechroom --tenant ocd sweep force a tenant for every child (else each resolves its own)`
3615
+ ).action((opts, cmd) => {
3616
+ const g = cmd.optsWithGlobals();
3617
+ const json = Boolean(g.json);
3618
+ const dryRun = Boolean(opts.dryRun);
3619
+ const manifestPath = resolve2(opts.manifest);
3620
+ let repos;
3621
+ try {
3622
+ repos = readManifest(manifestPath);
3623
+ } catch (err2) {
3624
+ fail(err2 instanceof Error ? err2.message : String(err2));
3625
+ }
3626
+ if (repos === null) {
3627
+ fail(`no manifest at ${manifestPath} \u2014 create ./.sechroom/repos.json, or use \`sechroom onboard --recurse\` to auto-discover (see \`sechroom sweep --help\`).`);
3628
+ }
3629
+ if (repos.length === 0) {
3630
+ if (json) process.stdout.write(JSON.stringify({ manifest: manifestPath, repos: [] }) + "\n");
3631
+ else process.stderr.write(`${warn("\u26A0")} ${manifestPath} lists no repos \u2014 nothing to do.
3632
+ `);
3633
+ return;
3634
+ }
3635
+ const root = dirname6(dirname6(manifestPath));
3636
+ const plans = repos.map((entry) => planEntry(entry, root));
3637
+ if (!json) {
3638
+ process.stderr.write(
3639
+ `${style.bold("sweep")} ${style.dim(`(${plans.length} repo${plans.length === 1 ? "" : "s"} from ${manifestPath})`)}
3640
+ `
3641
+ );
3642
+ }
3643
+ const results = runChildren(plans, { globals: passthroughGlobals(g), dryRun, json });
3644
+ if (json) {
3645
+ process.stdout.write(JSON.stringify({ manifest: manifestPath, dryRun, repos: results }) + "\n");
3646
+ return;
3647
+ }
3648
+ summarizeFanout(results, { dryRun });
3649
+ });
3650
+ }
3651
+
3365
3652
  // src/commands/skills.ts
3366
3653
  import { homedir as homedir6 } from "os";
3367
- import { join as join6 } from "path";
3368
- import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync6, rmSync as rmSync2, existsSync as existsSync6, readFileSync as readFileSync5 } from "fs";
3654
+ import { join as join9 } from "path";
3655
+ import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync6, rmSync as rmSync2, existsSync as existsSync9, readFileSync as readFileSync6 } from "fs";
3369
3656
  var DEFAULT_SLUG = "operator-skills";
3370
3657
  var ROLE_TAGS = ["sechroom:role:skill-template", "role:skill-template"];
3371
3658
  var LOCK = ".sechroom-skills.json";
3372
3659
  function skillsDir(global) {
3373
- return global ? join6(homedir6(), ".claude", "skills") : join6(process.cwd(), ".claude", "skills");
3660
+ return global ? join9(homedir6(), ".claude", "skills") : join9(process.cwd(), ".claude", "skills");
3374
3661
  }
3375
3662
  function tagValue2(tags, prefix) {
3376
3663
  return (tags ?? []).find((t) => t.startsWith(prefix))?.slice(prefix.length);
@@ -3448,13 +3735,13 @@ Examples:
3448
3735
  const name = tagValue2(tags, "skill:");
3449
3736
  if (!name) continue;
3450
3737
  const body = m.text ?? m.Text ?? "";
3451
- mkdirSync6(join6(dir, name), { recursive: true });
3452
- writeFileSync6(join6(dir, name, "SKILL.md"), body.endsWith("\n") ? body : body + "\n");
3738
+ mkdirSync6(join9(dir, name), { recursive: true });
3739
+ writeFileSync6(join9(dir, name, "SKILL.md"), body.endsWith("\n") ? body : body + "\n");
3453
3740
  written.push(name);
3454
3741
  }
3455
3742
  mkdirSync6(dir, { recursive: true });
3456
- const lockPath = join6(dir, LOCK);
3457
- const lock = existsSync6(lockPath) ? JSON.parse(readFileSync5(lockPath, "utf8")) : {};
3743
+ const lockPath = join9(dir, LOCK);
3744
+ const lock = existsSync9(lockPath) ? JSON.parse(readFileSync6(lockPath, "utf8")) : {};
3458
3745
  lock[slug] = { surface: opts.surface, version, instance: wantInstance, skills: written.sort() };
3459
3746
  writeFileSync6(lockPath, JSON.stringify(lock, null, 2) + "\n");
3460
3747
  if (opts.json) return emit({ slug, version, instance: wantInstance, surface: opts.surface, dir, installed: written }, true);
@@ -3478,15 +3765,15 @@ Examples:
3478
3765
  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) => {
3479
3766
  const slug = slugArg || DEFAULT_SLUG;
3480
3767
  const dir = skillsDir(!opts.local);
3481
- const lockPath = join6(dir, LOCK);
3482
- if (!existsSync6(lockPath)) fail(`No skills lockfile at ${lockPath}; nothing to clean.`);
3483
- const lock = JSON.parse(readFileSync5(lockPath, "utf8"));
3768
+ const lockPath = join9(dir, LOCK);
3769
+ if (!existsSync9(lockPath)) fail(`No skills lockfile at ${lockPath}; nothing to clean.`);
3770
+ const lock = JSON.parse(readFileSync6(lockPath, "utf8"));
3484
3771
  const entry = lock[slug];
3485
3772
  if (!entry) fail(`No installed record for '${slug}' in ${lockPath}.`);
3486
3773
  const removed = [];
3487
3774
  for (const name of entry.skills) {
3488
- const skillPath = join6(dir, name);
3489
- if (existsSync6(skillPath)) {
3775
+ const skillPath = join9(dir, name);
3776
+ if (existsSync9(skillPath)) {
3490
3777
  rmSync2(skillPath, { recursive: true, force: true });
3491
3778
  removed.push(name);
3492
3779
  }
@@ -3582,21 +3869,21 @@ Examples:
3582
3869
 
3583
3870
  // src/commands/reset.ts
3584
3871
  import { homedir as homedir7 } from "os";
3585
- import { join as join7 } from "path";
3586
- import { existsSync as existsSync7, readFileSync as readFileSync6, rmSync as rmSync3 } from "fs";
3872
+ import { join as join10 } from "path";
3873
+ import { existsSync as existsSync10, readFileSync as readFileSync7, rmSync as rmSync3 } from "fs";
3587
3874
  var SKILLS_LOCK = ".sechroom-skills.json";
3588
- var localSkillsDir = () => join7(process.cwd(), ".claude", "skills");
3589
- var globalSkillsDir = () => join7(homedir7(), ".claude", "skills");
3875
+ var localSkillsDir = () => join10(process.cwd(), ".claude", "skills");
3876
+ var globalSkillsDir = () => join10(homedir7(), ".claude", "skills");
3590
3877
  function removeMaterialisedSkills(dir) {
3591
3878
  const removed = [];
3592
- const lockPath = join7(dir, SKILLS_LOCK);
3593
- if (!existsSync7(lockPath)) return removed;
3879
+ const lockPath = join10(dir, SKILLS_LOCK);
3880
+ if (!existsSync10(lockPath)) return removed;
3594
3881
  try {
3595
- const lock = JSON.parse(readFileSync6(lockPath, "utf8"));
3882
+ const lock = JSON.parse(readFileSync7(lockPath, "utf8"));
3596
3883
  for (const entry of Object.values(lock)) {
3597
3884
  for (const name of entry.skills ?? []) {
3598
- const p = join7(dir, name);
3599
- if (existsSync7(p)) {
3885
+ const p = join10(dir, name);
3886
+ if (existsSync10(p)) {
3600
3887
  rmSync3(p, { recursive: true, force: true });
3601
3888
  removed.push(p);
3602
3889
  }
@@ -3627,18 +3914,18 @@ function registerReset(program2) {
3627
3914
  }
3628
3915
  }
3629
3916
  const removed = [];
3630
- const stateDir = join7(process.cwd(), ".sechroom");
3631
- if (existsSync7(stateDir)) {
3917
+ const stateDir = join10(process.cwd(), ".sechroom");
3918
+ if (existsSync10(stateDir)) {
3632
3919
  rmSync3(stateDir, { recursive: true, force: true });
3633
3920
  removed.push(stateDir);
3634
3921
  }
3635
- const legacyCfg = join7(process.cwd(), ".sechroom.json");
3636
- if (existsSync7(legacyCfg)) {
3922
+ const legacyCfg = join10(process.cwd(), ".sechroom.json");
3923
+ if (existsSync10(legacyCfg)) {
3637
3924
  rmSync3(legacyCfg, { force: true });
3638
3925
  removed.push(legacyCfg);
3639
3926
  }
3640
- const legacySem = join7(process.cwd(), ".sem");
3641
- if (existsSync7(legacySem)) {
3927
+ const legacySem = join10(process.cwd(), ".sem");
3928
+ if (existsSync10(legacySem)) {
3642
3929
  rmSync3(legacySem, { force: true });
3643
3930
  removed.push(legacySem);
3644
3931
  }
@@ -3665,7 +3952,7 @@ function registerReset(program2) {
3665
3952
  function resolveVersion() {
3666
3953
  try {
3667
3954
  const pkg = JSON.parse(
3668
- readFileSync7(new URL("../package.json", import.meta.url), "utf8")
3955
+ readFileSync8(new URL("../package.json", import.meta.url), "utf8")
3669
3956
  );
3670
3957
  return pkg.version ?? "0.0.0";
3671
3958
  } catch {
@@ -3681,7 +3968,7 @@ Examples:
3681
3968
  $ sechroom onboard guided first-run: configure, sign in, wire this project
3682
3969
  $ sechroom login sign in via browser (OAuth + PKCE)
3683
3970
  $ sechroom config set tenant ocd set your tenant (global)
3684
- $ sechroom config set --local tenant cli-smoke pin tenant for this directory (.sechroom/config.json)
3971
+ $ sechroom config set --local tenant cli-smoke pin tenant for this directory (committed .sechroom.json)
3685
3972
  $ sechroom config show resolved config + which source won
3686
3973
 
3687
3974
  $ sechroom memory create --text "a note" --title "Note" --tag idea
@@ -3693,7 +3980,7 @@ Examples:
3693
3980
  $ sechroom --json memory search "auth" compact JSON for scripts and agents
3694
3981
  $ SECHROOM_TOKEN=<bearer> sechroom --json memory get mem_XXXX headless
3695
3982
 
3696
- Config precedence (high -> low): --flag > env (SECHROOM_*) > ./.sechroom/config.json (legacy ./.sechroom.json) > global > default.
3983
+ Config precedence (high -> low): --flag > env (SECHROOM_*) > directory-local (committed ./.sechroom.json, shadowed per-field by the gitignored ./.sechroom/config.json override) > global > default.
3697
3984
  Run 'sechroom <command> --help' for command-specific examples.`
3698
3985
  );
3699
3986
  program.hook("preAction", (_thisCmd, actionCmd) => {
@@ -3719,11 +4006,11 @@ config.addHelpText(
3719
4006
  Examples:
3720
4007
  $ sechroom config set baseUrl https://app.sechroom.ai/api prod (staging: https://staging.app.sechroom.ai/api)
3721
4008
  $ sechroom config set tenant ocd
3722
- $ sechroom config set --local tenant cli-smoke this dir + subdirs (.sechroom/config.json)
4009
+ $ sechroom config set --local tenant cli-smoke this dir + subdirs (committed .sechroom.json)
3723
4010
  $ sechroom config set clientId dyn-XXXX global-only escape hatch (no DCR endpoint)
3724
4011
  $ sechroom config show --json`
3725
4012
  );
3726
- config.command("set <key> <value>").description("Set baseUrl | tenant | workspaceId | defaultProjectId | clientId (clientId is global-only)").option("--local", "Write to the directory-local .sechroom/config.json (nearest up the tree, else cwd; migrates a legacy .sechroom.json) instead of the global config").action((key, value, opts) => {
4013
+ config.command("set <key> <value>").description("Set baseUrl | tenant | workspaceId | defaultProjectId | clientId (clientId is global-only)").option("--local", "Write the committed directory-local .sechroom.json (nearest up the tree, else cwd) instead of the global config").action((key, value, opts) => {
3727
4014
  if (opts.local) {
3728
4015
  if (!["baseUrl", "tenant", "workspaceId", "defaultProjectId"].includes(key)) {
3729
4016
  process.stderr.write(`--local supports only: baseUrl | tenant | workspaceId | defaultProjectId (clientId is global)
@@ -3782,6 +4069,7 @@ registerChat(program);
3782
4069
  registerInit(program);
3783
4070
  registerSetup(program);
3784
4071
  registerOnboard(program);
4072
+ registerSweep(program);
3785
4073
  registerSkills(program);
3786
4074
  registerReset(program);
3787
4075
  program.parseAsync().catch((err2) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sechroom/cli",
3
- "version": "2026.6.23",
3
+ "version": "2026.6.25",
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",