@sechroom/cli 2026.6.20 → 2026.6.21-rc.1ee7250f

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 +496 -135
  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();
@@ -1574,7 +1576,7 @@ Examples:
1574
1576
  // src/commands/hook.ts
1575
1577
  import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
1576
1578
  import { homedir as homedir3 } from "os";
1577
- import { dirname as dirname4, join as join4 } from "path";
1579
+ import { delimiter, dirname as dirname4, join as join4 } from "path";
1578
1580
 
1579
1581
  // src/sem.ts
1580
1582
  import { basename as basename2, dirname as dirname2, join as join2 } from "path";
@@ -1652,6 +1654,15 @@ function ignoresSem(content) {
1652
1654
  return t === STATE_DIR_NAME2 || t === STATE_DIR_IGNORE || t === `/${STATE_DIR_NAME2}` || t === `/${STATE_DIR_IGNORE}` || t === `**/${STATE_DIR_NAME2}` || t === `**/${STATE_DIR_IGNORE}`;
1653
1655
  });
1654
1656
  }
1657
+ function inGitRepo(startDir) {
1658
+ let dir = startDir;
1659
+ for (; ; ) {
1660
+ if (existsSync2(join2(dir, ".git"))) return true;
1661
+ const parent = dirname2(dir);
1662
+ if (parent === dir) return false;
1663
+ dir = parent;
1664
+ }
1665
+ }
1655
1666
  function resolveGitignoreTarget(startDir) {
1656
1667
  let dir = startDir;
1657
1668
  for (; ; ) {
@@ -1667,6 +1678,7 @@ function resolveGitignoreTarget(startDir) {
1667
1678
  function ensureSemIgnored(semPath) {
1668
1679
  try {
1669
1680
  const checkoutDir = dirname2(dirname2(semPath));
1681
+ if (!inGitRepo(checkoutDir)) return;
1670
1682
  const target = resolveGitignoreTarget(checkoutDir);
1671
1683
  if (target.exists) {
1672
1684
  const content = readFileSync2(target.path, "utf8");
@@ -1963,7 +1975,7 @@ function mergeHooks(config2) {
1963
1975
  }
1964
1976
  return added;
1965
1977
  }
1966
- function readJsonConfig(path) {
1978
+ function readJsonConfig2(path) {
1967
1979
  if (!existsSync4(path)) return {};
1968
1980
  const raw = readFileSync3(path, "utf8");
1969
1981
  if (!raw.trim()) return {};
@@ -1971,7 +1983,7 @@ function readJsonConfig(path) {
1971
1983
  }
1972
1984
  function installHooksJson(path, dryRun) {
1973
1985
  const existed = existsSync4(path) && readFileSync3(path, "utf8").trim().length > 0;
1974
- const config2 = readJsonConfig(path);
1986
+ const config2 = readJsonConfig2(path);
1975
1987
  const added = mergeHooks(config2);
1976
1988
  if (added === 0 && existed) return { path, status: "current" };
1977
1989
  if (!dryRun) {
@@ -2049,6 +2061,25 @@ function detectHookSurfaces(cwd) {
2049
2061
  if (detected.includes("codex")) surfaces.push("codex");
2050
2062
  return surfaces;
2051
2063
  }
2064
+ function isSechroomOnPath() {
2065
+ const pathEnv = process.env.PATH ?? "";
2066
+ if (!pathEnv) return false;
2067
+ const names = process.platform === "win32" ? ["sechroom.cmd", "sechroom.exe", "sechroom.bat", "sechroom"] : ["sechroom"];
2068
+ for (const dir of pathEnv.split(delimiter)) {
2069
+ if (!dir) continue;
2070
+ for (const name of names) {
2071
+ if (existsSync4(join4(dir, name))) return true;
2072
+ }
2073
+ }
2074
+ return false;
2075
+ }
2076
+ function warnIfSechroomNotOnPath(write = (s) => void process.stderr.write(s)) {
2077
+ if (isSechroomOnPath()) return false;
2078
+ write(
2079
+ "\n\u26A0 `sechroom` isn't on your PATH. The hooks run a bare `sechroom hook \u2026` command\n when your agent fires them, so a non-global install (npx / local) will fail at\n that point. Install globally so the command resolves:\n npm i -g @sechroom/cli\n"
2080
+ );
2081
+ return true;
2082
+ }
2052
2083
  function registerHook(program2) {
2053
2084
  const hook = program2.command("hook").description("Agent-lifecycle hook adapter (Claude Code / Codex) \u2014 bridges hooks to continuity");
2054
2085
  hook.addHelpText(
@@ -2160,6 +2191,7 @@ Fail-soft: no lane / no auth / no-or-partial intent file / API error -> exit 0,
2160
2191
  } else {
2161
2192
  process.stdout.write("\nRestart (or reload) your agent for the hooks to take effect.\n");
2162
2193
  }
2194
+ warnIfSechroomNotOnPath();
2163
2195
  return process.exit(0);
2164
2196
  });
2165
2197
  }
@@ -2588,9 +2620,47 @@ async function applyClient(cfg, setup, target, opts) {
2588
2620
  return actions;
2589
2621
  }
2590
2622
 
2623
+ // src/setup/hooks-offer.ts
2624
+ import { homedir as homedir4 } from "os";
2625
+ async function maybeOfferHooks(opts) {
2626
+ if (opts.dryRun) return;
2627
+ const cwd = opts.cwd ?? process.cwd();
2628
+ const surfaces = detectHookSurfaces(cwd);
2629
+ if (surfaces.length === 0) return;
2630
+ const names = surfaces.map((s) => HOOK_SURFACE_LABEL[s]).join(" + ");
2631
+ process.stderr.write(
2632
+ `
2633
+ Sechroom can wire continuity lifecycle hooks into ${style.bold(names)} so your agent
2634
+ auto-resumes where you left off and checkpoints working state before compacting.
2635
+ `
2636
+ );
2637
+ const install = opts.yes ? true : canPrompt() ? await promptYesNo(`Install the continuity hooks for ${names}?`) : false;
2638
+ if (!install) return;
2639
+ try {
2640
+ const installed = installHookSurfaces(surfaces, { dryRun: false, cwd, home: homedir4() });
2641
+ let changed = false;
2642
+ for (const { surface, results } of installed) {
2643
+ for (const r of results) {
2644
+ if (r.status !== "current") changed = true;
2645
+ const verb = r.status === "current" ? "already configured" : r.status === "created" ? "created" : "updated";
2646
+ process.stderr.write(`${style.green("\u2713")} ${HOOK_SURFACE_LABEL[surface]}: ${r.path} (${verb})
2647
+ `);
2648
+ }
2649
+ }
2650
+ if (changed) {
2651
+ process.stderr.write(`${style.dim("Restart (or reload) your agent for the hooks to take effect.")}
2652
+ `);
2653
+ }
2654
+ warnIfSechroomNotOnPath();
2655
+ } catch (err2) {
2656
+ process.stderr.write(`${style.dim(`(skipped hook install: ${err2.message})`)}
2657
+ `);
2658
+ }
2659
+ }
2660
+
2591
2661
  // src/setup/skills-offer.ts
2592
2662
  import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "fs";
2593
- import { homedir as homedir4 } from "os";
2663
+ import { homedir as homedir5 } from "os";
2594
2664
  import { join as join5 } from "path";
2595
2665
 
2596
2666
  // src/setup/lane-pin.ts
@@ -2611,6 +2681,24 @@ function codeLanePrefix(clients) {
2611
2681
  for (const c of CLIENT_PRIORITY) if (clients.includes(c)) return CODE_LANE_PREFIX_BY_CLIENT[c];
2612
2682
  return "claude-code";
2613
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
+ }
2614
2702
  function writePin(code, design) {
2615
2703
  const values = {};
2616
2704
  if (code) values["code-lane"] = code;
@@ -2623,20 +2711,7 @@ function writePin(code, design) {
2623
2711
  async function ensureLanePin(cfg, opts) {
2624
2712
  if (opts.dryRun) return;
2625
2713
  if (readSem()) return;
2626
- let wf;
2627
- let profile;
2628
- try {
2629
- const client = await makeClient(cfg);
2630
- [wf, profile] = await Promise.all([
2631
- client.GET("/me/workflow-preferences", {}).then((r) => r.data).catch(() => void 0),
2632
- client.GET("/me/profile", {}).then((r) => r.data).catch(() => void 0)
2633
- ]);
2634
- } catch {
2635
- }
2636
- const handle = handleFromDisplayName(profile?.effectiveDisplayName);
2637
- const prefix = codeLanePrefix(opts.clients ?? ["claude-code"]);
2638
- const codeGuess = wf?.defaultCodeLane ?? (handle ? `${prefix}-${handle}` : void 0);
2639
- const designGuess = wf?.defaultDesignLane ?? (handle ? `claude-design-${handle}` : void 0);
2714
+ const { code: codeGuess, design: designGuess } = await inferLanes(cfg, opts.clients);
2640
2715
  if (!canPrompt() || opts.yes) {
2641
2716
  if (opts.yes && (codeGuess || designGuess)) writePin(codeGuess, designGuess);
2642
2717
  return;
@@ -2706,7 +2781,7 @@ async function maybeOfferSkills(cfg, personalWorkspaceId, opts) {
2706
2781
  Found ${style.bold(String(names.length))} operator skill(s) installed in your workspace: ${names.join(", ")}.
2707
2782
  `
2708
2783
  );
2709
- const dir = join5(homedir4(), ".claude", "skills");
2784
+ const dir = join5(homedir5(), ".claude", "skills");
2710
2785
  const materialise = opts.yes ? true : canPrompt() ? await promptYesNo(`Write them to ${dir}/ so ${surface} can use them?`) : false;
2711
2786
  if (!materialise) return;
2712
2787
  const written = [];
@@ -2810,6 +2885,9 @@ Examples:
2810
2885
  if (!json && !opts.dryRun && !opts.mcpOnly) {
2811
2886
  await maybeOfferSkills(cfg, personalWorkspaceId, { yes: false, dryRun: Boolean(opts.dryRun), surface: "claude-code" });
2812
2887
  }
2888
+ if (!json && !opts.dryRun && !opts.mcpOnly) {
2889
+ await maybeOfferHooks({ yes: false, dryRun: Boolean(opts.dryRun), cwd: process.cwd() });
2890
+ }
2813
2891
  if (json) {
2814
2892
  emit({ dryRun: Boolean(opts.dryRun), clients: result }, true);
2815
2893
  return;
@@ -2865,41 +2943,111 @@ async function runClients(clients, cmd, opts) {
2865
2943
  process.stdout.write(opts.dryRun ? "\n(dry run \u2014 nothing written)\n" : "\nDone.\n");
2866
2944
  }
2867
2945
 
2868
- // src/setup/hooks-offer.ts
2869
- import { homedir as homedir5 } from "os";
2870
- async function maybeOfferHooks(opts) {
2871
- if (opts.dryRun) return;
2872
- const cwd = opts.cwd ?? process.cwd();
2873
- const surfaces = detectHookSurfaces(cwd);
2874
- if (surfaces.length === 0) return;
2875
- const names = surfaces.map((s) => HOOK_SURFACE_LABEL[s]).join(" + ");
2876
- process.stderr.write(
2877
- `
2878
- Sechroom can wire continuity lifecycle hooks into ${style.bold(names)} so your agent
2879
- auto-resumes where you left off and checkpoints working state before compacting.
2880
- `
2881
- );
2882
- const install = opts.yes ? true : canPrompt() ? await promptYesNo(`Install the continuity hooks for ${names}?`) : false;
2883
- if (!install) return;
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;
2884
2965
  try {
2885
- const installed = installHookSurfaces(surfaces, { dryRun: false, cwd, home: homedir5() });
2886
- let changed = false;
2887
- for (const { surface, results } of installed) {
2888
- for (const r of results) {
2889
- if (r.status !== "current") changed = true;
2890
- const verb = r.status === "current" ? "already configured" : r.status === "created" ? "created" : "updated";
2891
- process.stderr.write(`${style.green("\u2713")} ${HOOK_SURFACE_LABEL[surface]}: ${r.path} (${verb})
2892
- `);
2893
- }
2894
- }
2895
- if (changed) {
2896
- process.stderr.write(`${style.dim("Restart (or reload) your agent for the hooks to take effect.")}
2897
- `);
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;
2898
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"));
2899
2988
  } catch (err2) {
2900
- process.stderr.write(`${style.dim(`(skipped hook install: ${err2.message})`)}
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(" ")}`)}
2901
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
+ });
2902
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);
2903
3051
  }
2904
3052
 
2905
3053
  // src/commands/onboard.ts
@@ -2974,7 +3122,7 @@ async function warnIfProjectStray(client, projectId, workspaceId, json) {
2974
3122
  );
2975
3123
  }
2976
3124
  }
2977
- async function pickWorkspace(client) {
3125
+ async function pickWorkspace(client, promptLabel = "Bind this directory to a workspace:") {
2978
3126
  const all = await withSpinner("Listing your workspaces", () => fetchWorkspaces(client));
2979
3127
  if (all.length === 0) {
2980
3128
  process.stderr.write(`no workspaces found \u2014 skipping workspace binding (you can set it later with \`sechroom config set --local workspaceId <id>\`)
@@ -2997,7 +3145,7 @@ async function pickWorkspace(client) {
2997
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 })),
2998
3146
  { label: style.dim("skip \u2014 don't bind a workspace"), value: SKIP, hint: void 0 }
2999
3147
  ];
3000
- const chosen = await promptSelect("Bind this directory to a workspace:", choices, SKIP);
3148
+ const chosen = await promptSelect(promptLabel, choices, SKIP);
3001
3149
  if (chosen === SKIP) return void 0;
3002
3150
  const picked = byId.get(chosen);
3003
3151
  const collisions = all.filter((w) => w.id !== picked.id && namesCollide(w.name, picked.name));
@@ -3080,7 +3228,7 @@ async function ensureTenant(baseUrl, g, opts) {
3080
3228
  "Where should this tenant + base URL be saved?",
3081
3229
  [
3082
3230
  { label: "Globally", value: "global", hint: "all projects on this machine" },
3083
- { label: "This directory", value: "local", hint: ".sechroom/config.json \u2014 project + subdirs" }
3231
+ { label: "This directory", value: "local", hint: ".sechroom.json \u2014 committed, project + subdirs" }
3084
3232
  ],
3085
3233
  local.path ? "local" : "global"
3086
3234
  ) === "local";
@@ -3155,16 +3303,120 @@ async function chooseClients(clientFlag, yes, cwd) {
3155
3303
  );
3156
3304
  return picks.length > 0 ? picks : preselected;
3157
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 clients = detectInstalledClients(process.cwd());
3355
+ const inferred = await inferLanes(cfg, clients.length ? clients : void 0);
3356
+ code = code ?? inferred.code;
3357
+ design = design ?? inferred.design;
3358
+ }
3359
+ if (!opts.lane && !opts.yes && !opts.dryRun && canPrompt() && (code || design)) {
3360
+ process.stderr.write(`
3361
+ This fan-out will pin the same lane in every repo:
3362
+ `);
3363
+ if (code) process.stderr.write(` ${style.dim("code-lane")} = ${style.cyan(code)}
3364
+ `);
3365
+ if (design) process.stderr.write(` ${style.dim("design-lane")} = ${style.cyan(design)}
3366
+ `);
3367
+ if (!await promptYesNo("Use this lane for all repos?")) {
3368
+ code = await promptText("Code-lane id (blank = let each repo infer)?", code ?? "") || void 0;
3369
+ design = await promptText("Design-lane id (blank = skip)?", design ?? "") || void 0;
3370
+ }
3371
+ }
3372
+ if (code) process.env.SECHROOM_CODE_LANE = code;
3373
+ else delete process.env.SECHROOM_CODE_LANE;
3374
+ if (design) process.env.SECHROOM_DESIGN_LANE = design;
3375
+ else delete process.env.SECHROOM_DESIGN_LANE;
3376
+ return { code, design };
3377
+ }
3378
+ async function runRecurse(cfg, g, opts) {
3379
+ const { yes, dryRun, json } = opts;
3380
+ const root = process.cwd();
3381
+ const manifestPath = join7(root, ".sechroom", "repos.json");
3382
+ const fromManifest = readManifest(manifestPath);
3383
+ const entries = fromManifest ?? discoverChildren(root).map((path) => ({ path }));
3384
+ const sourceLabel = fromManifest ? `manifest ${manifestPath}` : `auto-discovered under ${root}`;
3385
+ if (entries.length === 0) {
3386
+ if (json) process.stdout.write(JSON.stringify({ recurse: true, root, repos: [] }) + "\n");
3387
+ else process.stderr.write(`${warn("\u26A0")} no child repos found ${fromManifest ? `in ${manifestPath}` : `under ${root}`} \u2014 nothing to do.
3388
+ `);
3389
+ return;
3390
+ }
3391
+ if (!json) {
3392
+ process.stderr.write(`${style.bold("onboard --recurse")} ${style.dim(`(${entries.length} repo${entries.length === 1 ? "" : "s"} from ${sourceLabel})`)}
3393
+ `);
3394
+ }
3395
+ const lane = await resolveFanoutLane(cfg, { lane: opts.lane, designLane: opts.designLane, yes, dryRun });
3396
+ if (!json && lane.code) process.stderr.write(`${ok("\u2713")} lane ${style.cyan(lane.code)}${lane.design ? ` ${style.dim(`/ ${lane.design}`)}` : ""} for every repo
3397
+ `);
3398
+ const client = await makeClient(cfg);
3399
+ const plans = [];
3400
+ for (const entry of entries) plans.push(await planRecurseChild(entry, root, client, { yes, dryRun }));
3401
+ const results = runChildren(plans, { globals: passthroughGlobals(g), dryRun, json });
3402
+ if (json) {
3403
+ process.stdout.write(JSON.stringify({ recurse: true, root, dryRun, repos: results }) + "\n");
3404
+ return;
3405
+ }
3406
+ summarizeFanout(results, { dryRun });
3407
+ }
3158
3408
  function registerOnboard(program2) {
3159
- 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(
3409
+ 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(
3160
3410
  "after",
3161
3411
  `
3162
3412
  Examples:
3163
3413
  $ sechroom onboard guided, interactive (asks where to save config + how to wire)
3164
3414
  $ sechroom onboard --cli-only just the CLI \u2014 no .mcp.json, no agent files
3165
3415
  $ sechroom onboard --no-mcp agent instructions only, skip MCP config
3166
- $ sechroom onboard --local save tenant + base URL to ./.sechroom/config.json
3416
+ $ sechroom onboard --local save tenant + base URL to a committed ./.sechroom.json
3167
3417
  $ sechroom onboard --workspace wsp_XX bind this directory to a workspace (no pick prompt)
3418
+ $ sechroom onboard --recurse orchestration root: onboard every child repo under this dir
3419
+ $ sechroom onboard --recurse --lane claude-code-you pin one lane across every repo in the tree
3168
3420
  $ sechroom onboard --refresh refresh out-of-date instruction blocks in place
3169
3421
  $ sechroom onboard --check CI/pre-commit: nonzero exit if instructions are out of date
3170
3422
  $ sechroom onboard --yes non-interactive: defaults + global config + full wire
@@ -3176,6 +3428,15 @@ Examples:
3176
3428
  const mode = opts.check ? "check" : opts.force ? "force" : "apply";
3177
3429
  const check = mode === "check";
3178
3430
  const yes = Boolean(opts.yes) || check;
3431
+ if (opts.lane) process.env.SECHROOM_CODE_LANE = opts.lane;
3432
+ if (opts.designLane) process.env.SECHROOM_DESIGN_LANE = opts.designLane;
3433
+ if (opts.recurse) {
3434
+ const baseUrl2 = resolveBaseUrl(g);
3435
+ await ensureAuth({ baseUrl: baseUrl2, tenant: "", clientId: readPersisted().clientId }, yes);
3436
+ const cfg2 = await ensureTenant(baseUrl2, g, { yes: true, json, persist: false });
3437
+ await runRecurse(cfg2, g, { yes, dryRun, json, lane: opts.lane, designLane: opts.designLane });
3438
+ return;
3439
+ }
3179
3440
  const baseUrl = resolveBaseUrl(g);
3180
3441
  await ensureAuth({ baseUrl, tenant: "", clientId: readPersisted().clientId }, yes);
3181
3442
  const cfg = await ensureTenant(baseUrl, g, { yes, json, local: Boolean(opts.local), workspace: opts.workspace, persist: !check });
@@ -3328,15 +3589,114 @@ ${style.bold("Next:")} paste this into your AI agent to get going \u2014
3328
3589
  );
3329
3590
  }
3330
3591
 
3592
+ // src/commands/sweep.ts
3593
+ import { existsSync as existsSync8 } from "fs";
3594
+ import { dirname as dirname6, join as join8, resolve as resolve2 } from "path";
3595
+ var DEFAULT_MANIFEST = join8(".sechroom", "repos.json");
3596
+ function planEntry(entry, root) {
3597
+ const dir = resolveChildDir(entry.path, root);
3598
+ if (!existsSync8(dir)) {
3599
+ return { label: entry.path, dir, disposition: "skip-missing", argv: [], reason: "directory does not exist" };
3600
+ }
3601
+ if (committedBindingPath(dir)) {
3602
+ return {
3603
+ label: entry.path,
3604
+ dir,
3605
+ disposition: "refresh",
3606
+ argv: ["onboard", "--refresh", "--yes"],
3607
+ reason: "bound (committed .sechroom.json) \u2014 refresh in place"
3608
+ };
3609
+ }
3610
+ if (entry.workspaceId) {
3611
+ return {
3612
+ label: entry.path,
3613
+ dir,
3614
+ disposition: "bind",
3615
+ argv: ["onboard", "--yes", "--local", "--workspace", entry.workspaceId],
3616
+ reason: `unbound \u2014 bind to ${entry.workspaceId} + commit .sechroom.json`
3617
+ };
3618
+ }
3619
+ return {
3620
+ label: entry.path,
3621
+ dir,
3622
+ disposition: "skip-unbound",
3623
+ argv: [],
3624
+ reason: "unbound + no workspaceId in the manifest \u2014 add one or run `sechroom onboard` there"
3625
+ };
3626
+ }
3627
+ function registerSweep(program2) {
3628
+ 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(
3629
+ "after",
3630
+ `
3631
+ For an interactive, no-manifest run use ${"`sechroom onboard --recurse`"} instead \u2014 it
3632
+ auto-discovers the child repos and prompts for a workspace per new one. ${"`sweep`"} is
3633
+ the deterministic manifest-driven form for scripts / CI.
3634
+
3635
+ Manifest \u2014 ./.sechroom/repos.json (per-operator, gitignored, alongside lane.json):
3636
+ {
3637
+ "repos": [
3638
+ { "path": "sechroom", "workspaceId": "wsp_XXXX" },
3639
+ { "path": "../other-repo", "workspaceId": "wsp_YYYY" },
3640
+ { "path": "already-bound" }
3641
+ ]
3642
+ }
3643
+
3644
+ Per repo (paths resolve relative to the manifest's root):
3645
+ ${ICON.refresh} bound committed .sechroom.json present \u2192 onboard --refresh (manifest workspace ignored)
3646
+ ${ICON.bind} unbound bind to the manifest workspaceId \u2192 onboard --local --workspace <id>
3647
+ ${ICON["skip-missing"]} missing directory does not exist \u2192 skipped
3648
+ ${ICON["skip-unbound"]} no workspace unbound + no workspaceId in manifest \u2192 skipped (add one, or onboard manually)
3649
+
3650
+ Examples:
3651
+ $ sechroom sweep --dry-run preview every repo's disposition, run nothing
3652
+ $ sechroom sweep onboard the whole tree from the root
3653
+ $ sechroom --tenant ocd sweep force a tenant for every child (else each resolves its own)`
3654
+ ).action((opts, cmd) => {
3655
+ const g = cmd.optsWithGlobals();
3656
+ const json = Boolean(g.json);
3657
+ const dryRun = Boolean(opts.dryRun);
3658
+ const manifestPath = resolve2(opts.manifest);
3659
+ let repos;
3660
+ try {
3661
+ repos = readManifest(manifestPath);
3662
+ } catch (err2) {
3663
+ fail(err2 instanceof Error ? err2.message : String(err2));
3664
+ }
3665
+ if (repos === null) {
3666
+ fail(`no manifest at ${manifestPath} \u2014 create ./.sechroom/repos.json, or use \`sechroom onboard --recurse\` to auto-discover (see \`sechroom sweep --help\`).`);
3667
+ }
3668
+ if (repos.length === 0) {
3669
+ if (json) process.stdout.write(JSON.stringify({ manifest: manifestPath, repos: [] }) + "\n");
3670
+ else process.stderr.write(`${warn("\u26A0")} ${manifestPath} lists no repos \u2014 nothing to do.
3671
+ `);
3672
+ return;
3673
+ }
3674
+ const root = dirname6(dirname6(manifestPath));
3675
+ const plans = repos.map((entry) => planEntry(entry, root));
3676
+ if (!json) {
3677
+ process.stderr.write(
3678
+ `${style.bold("sweep")} ${style.dim(`(${plans.length} repo${plans.length === 1 ? "" : "s"} from ${manifestPath})`)}
3679
+ `
3680
+ );
3681
+ }
3682
+ const results = runChildren(plans, { globals: passthroughGlobals(g), dryRun, json });
3683
+ if (json) {
3684
+ process.stdout.write(JSON.stringify({ manifest: manifestPath, dryRun, repos: results }) + "\n");
3685
+ return;
3686
+ }
3687
+ summarizeFanout(results, { dryRun });
3688
+ });
3689
+ }
3690
+
3331
3691
  // src/commands/skills.ts
3332
3692
  import { homedir as homedir6 } from "os";
3333
- import { join as join6 } from "path";
3334
- import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync6, rmSync as rmSync2, existsSync as existsSync6, readFileSync as readFileSync5 } from "fs";
3693
+ import { join as join9 } from "path";
3694
+ import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync6, rmSync as rmSync2, existsSync as existsSync9, readFileSync as readFileSync6 } from "fs";
3335
3695
  var DEFAULT_SLUG = "operator-skills";
3336
3696
  var ROLE_TAGS = ["sechroom:role:skill-template", "role:skill-template"];
3337
3697
  var LOCK = ".sechroom-skills.json";
3338
3698
  function skillsDir(global) {
3339
- return global ? join6(homedir6(), ".claude", "skills") : join6(process.cwd(), ".claude", "skills");
3699
+ return global ? join9(homedir6(), ".claude", "skills") : join9(process.cwd(), ".claude", "skills");
3340
3700
  }
3341
3701
  function tagValue2(tags, prefix) {
3342
3702
  return (tags ?? []).find((t) => t.startsWith(prefix))?.slice(prefix.length);
@@ -3414,13 +3774,13 @@ Examples:
3414
3774
  const name = tagValue2(tags, "skill:");
3415
3775
  if (!name) continue;
3416
3776
  const body = m.text ?? m.Text ?? "";
3417
- mkdirSync6(join6(dir, name), { recursive: true });
3418
- writeFileSync6(join6(dir, name, "SKILL.md"), body.endsWith("\n") ? body : body + "\n");
3777
+ mkdirSync6(join9(dir, name), { recursive: true });
3778
+ writeFileSync6(join9(dir, name, "SKILL.md"), body.endsWith("\n") ? body : body + "\n");
3419
3779
  written.push(name);
3420
3780
  }
3421
3781
  mkdirSync6(dir, { recursive: true });
3422
- const lockPath = join6(dir, LOCK);
3423
- const lock = existsSync6(lockPath) ? JSON.parse(readFileSync5(lockPath, "utf8")) : {};
3782
+ const lockPath = join9(dir, LOCK);
3783
+ const lock = existsSync9(lockPath) ? JSON.parse(readFileSync6(lockPath, "utf8")) : {};
3424
3784
  lock[slug] = { surface: opts.surface, version, instance: wantInstance, skills: written.sort() };
3425
3785
  writeFileSync6(lockPath, JSON.stringify(lock, null, 2) + "\n");
3426
3786
  if (opts.json) return emit({ slug, version, instance: wantInstance, surface: opts.surface, dir, installed: written }, true);
@@ -3444,15 +3804,15 @@ Examples:
3444
3804
  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) => {
3445
3805
  const slug = slugArg || DEFAULT_SLUG;
3446
3806
  const dir = skillsDir(!opts.local);
3447
- const lockPath = join6(dir, LOCK);
3448
- if (!existsSync6(lockPath)) fail(`No skills lockfile at ${lockPath}; nothing to clean.`);
3449
- const lock = JSON.parse(readFileSync5(lockPath, "utf8"));
3807
+ const lockPath = join9(dir, LOCK);
3808
+ if (!existsSync9(lockPath)) fail(`No skills lockfile at ${lockPath}; nothing to clean.`);
3809
+ const lock = JSON.parse(readFileSync6(lockPath, "utf8"));
3450
3810
  const entry = lock[slug];
3451
3811
  if (!entry) fail(`No installed record for '${slug}' in ${lockPath}.`);
3452
3812
  const removed = [];
3453
3813
  for (const name of entry.skills) {
3454
- const skillPath = join6(dir, name);
3455
- if (existsSync6(skillPath)) {
3814
+ const skillPath = join9(dir, name);
3815
+ if (existsSync9(skillPath)) {
3456
3816
  rmSync2(skillPath, { recursive: true, force: true });
3457
3817
  removed.push(name);
3458
3818
  }
@@ -3548,21 +3908,21 @@ Examples:
3548
3908
 
3549
3909
  // src/commands/reset.ts
3550
3910
  import { homedir as homedir7 } from "os";
3551
- import { join as join7 } from "path";
3552
- import { existsSync as existsSync7, readFileSync as readFileSync6, rmSync as rmSync3 } from "fs";
3911
+ import { join as join10 } from "path";
3912
+ import { existsSync as existsSync10, readFileSync as readFileSync7, rmSync as rmSync3 } from "fs";
3553
3913
  var SKILLS_LOCK = ".sechroom-skills.json";
3554
- var localSkillsDir = () => join7(process.cwd(), ".claude", "skills");
3555
- var globalSkillsDir = () => join7(homedir7(), ".claude", "skills");
3914
+ var localSkillsDir = () => join10(process.cwd(), ".claude", "skills");
3915
+ var globalSkillsDir = () => join10(homedir7(), ".claude", "skills");
3556
3916
  function removeMaterialisedSkills(dir) {
3557
3917
  const removed = [];
3558
- const lockPath = join7(dir, SKILLS_LOCK);
3559
- if (!existsSync7(lockPath)) return removed;
3918
+ const lockPath = join10(dir, SKILLS_LOCK);
3919
+ if (!existsSync10(lockPath)) return removed;
3560
3920
  try {
3561
- const lock = JSON.parse(readFileSync6(lockPath, "utf8"));
3921
+ const lock = JSON.parse(readFileSync7(lockPath, "utf8"));
3562
3922
  for (const entry of Object.values(lock)) {
3563
3923
  for (const name of entry.skills ?? []) {
3564
- const p = join7(dir, name);
3565
- if (existsSync7(p)) {
3924
+ const p = join10(dir, name);
3925
+ if (existsSync10(p)) {
3566
3926
  rmSync3(p, { recursive: true, force: true });
3567
3927
  removed.push(p);
3568
3928
  }
@@ -3593,18 +3953,18 @@ function registerReset(program2) {
3593
3953
  }
3594
3954
  }
3595
3955
  const removed = [];
3596
- const stateDir = join7(process.cwd(), ".sechroom");
3597
- if (existsSync7(stateDir)) {
3956
+ const stateDir = join10(process.cwd(), ".sechroom");
3957
+ if (existsSync10(stateDir)) {
3598
3958
  rmSync3(stateDir, { recursive: true, force: true });
3599
3959
  removed.push(stateDir);
3600
3960
  }
3601
- const legacyCfg = join7(process.cwd(), ".sechroom.json");
3602
- if (existsSync7(legacyCfg)) {
3961
+ const legacyCfg = join10(process.cwd(), ".sechroom.json");
3962
+ if (existsSync10(legacyCfg)) {
3603
3963
  rmSync3(legacyCfg, { force: true });
3604
3964
  removed.push(legacyCfg);
3605
3965
  }
3606
- const legacySem = join7(process.cwd(), ".sem");
3607
- if (existsSync7(legacySem)) {
3966
+ const legacySem = join10(process.cwd(), ".sem");
3967
+ if (existsSync10(legacySem)) {
3608
3968
  rmSync3(legacySem, { force: true });
3609
3969
  removed.push(legacySem);
3610
3970
  }
@@ -3631,7 +3991,7 @@ function registerReset(program2) {
3631
3991
  function resolveVersion() {
3632
3992
  try {
3633
3993
  const pkg = JSON.parse(
3634
- readFileSync7(new URL("../package.json", import.meta.url), "utf8")
3994
+ readFileSync8(new URL("../package.json", import.meta.url), "utf8")
3635
3995
  );
3636
3996
  return pkg.version ?? "0.0.0";
3637
3997
  } catch {
@@ -3647,7 +4007,7 @@ Examples:
3647
4007
  $ sechroom onboard guided first-run: configure, sign in, wire this project
3648
4008
  $ sechroom login sign in via browser (OAuth + PKCE)
3649
4009
  $ sechroom config set tenant ocd set your tenant (global)
3650
- $ sechroom config set --local tenant cli-smoke pin tenant for this directory (.sechroom/config.json)
4010
+ $ sechroom config set --local tenant cli-smoke pin tenant for this directory (committed .sechroom.json)
3651
4011
  $ sechroom config show resolved config + which source won
3652
4012
 
3653
4013
  $ sechroom memory create --text "a note" --title "Note" --tag idea
@@ -3659,7 +4019,7 @@ Examples:
3659
4019
  $ sechroom --json memory search "auth" compact JSON for scripts and agents
3660
4020
  $ SECHROOM_TOKEN=<bearer> sechroom --json memory get mem_XXXX headless
3661
4021
 
3662
- Config precedence (high -> low): --flag > env (SECHROOM_*) > ./.sechroom/config.json (legacy ./.sechroom.json) > global > default.
4022
+ Config precedence (high -> low): --flag > env (SECHROOM_*) > directory-local (committed ./.sechroom.json, shadowed per-field by the gitignored ./.sechroom/config.json override) > global > default.
3663
4023
  Run 'sechroom <command> --help' for command-specific examples.`
3664
4024
  );
3665
4025
  program.hook("preAction", (_thisCmd, actionCmd) => {
@@ -3685,11 +4045,11 @@ config.addHelpText(
3685
4045
  Examples:
3686
4046
  $ sechroom config set baseUrl https://app.sechroom.ai/api prod (staging: https://staging.app.sechroom.ai/api)
3687
4047
  $ sechroom config set tenant ocd
3688
- $ sechroom config set --local tenant cli-smoke this dir + subdirs (.sechroom/config.json)
4048
+ $ sechroom config set --local tenant cli-smoke this dir + subdirs (committed .sechroom.json)
3689
4049
  $ sechroom config set clientId dyn-XXXX global-only escape hatch (no DCR endpoint)
3690
4050
  $ sechroom config show --json`
3691
4051
  );
3692
- 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) => {
4052
+ 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) => {
3693
4053
  if (opts.local) {
3694
4054
  if (!["baseUrl", "tenant", "workspaceId", "defaultProjectId"].includes(key)) {
3695
4055
  process.stderr.write(`--local supports only: baseUrl | tenant | workspaceId | defaultProjectId (clientId is global)
@@ -3748,6 +4108,7 @@ registerChat(program);
3748
4108
  registerInit(program);
3749
4109
  registerSetup(program);
3750
4110
  registerOnboard(program);
4111
+ registerSweep(program);
3751
4112
  registerSkills(program);
3752
4113
  registerReset(program);
3753
4114
  program.parseAsync().catch((err2) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sechroom/cli",
3
- "version": "2026.6.20",
3
+ "version": "2026.6.21-rc.1ee7250f",
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",