@sechroom/cli 2026.6.24 → 2026.6.25-rc.e6d77816
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.
- package/dist/index.js +300 -135
- 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((
|
|
379
|
-
rl.question(`${question} [y/N] `,
|
|
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((
|
|
393
|
-
rl.question(`${question}${suffix} `,
|
|
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((
|
|
420
|
-
rl.question(`Choose ${style.dim(`[${defIdx + 1}]`)} `,
|
|
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((
|
|
453
|
-
rl.question(`Select ${style.dim("[Enter = \u25C9]")} `,
|
|
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
|
-
|
|
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(
|
|
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,110 @@ 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 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
|
+
}
|
|
3194
3408
|
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(
|
|
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(
|
|
3196
3410
|
"after",
|
|
3197
3411
|
`
|
|
3198
3412
|
Examples:
|
|
@@ -3201,6 +3415,8 @@ Examples:
|
|
|
3201
3415
|
$ sechroom onboard --no-mcp agent instructions only, skip MCP config
|
|
3202
3416
|
$ sechroom onboard --local save tenant + base URL to a committed ./.sechroom.json
|
|
3203
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
|
|
3204
3420
|
$ sechroom onboard --refresh refresh out-of-date instruction blocks in place
|
|
3205
3421
|
$ sechroom onboard --check CI/pre-commit: nonzero exit if instructions are out of date
|
|
3206
3422
|
$ sechroom onboard --yes non-interactive: defaults + global config + full wire
|
|
@@ -3212,6 +3428,15 @@ Examples:
|
|
|
3212
3428
|
const mode = opts.check ? "check" : opts.force ? "force" : "apply";
|
|
3213
3429
|
const check = mode === "check";
|
|
3214
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
|
+
}
|
|
3215
3440
|
const baseUrl = resolveBaseUrl(g);
|
|
3216
3441
|
await ensureAuth({ baseUrl, tenant: "", clientId: readPersisted().clientId }, yes);
|
|
3217
3442
|
const cfg = await ensureTenant(baseUrl, g, { yes, json, local: Boolean(opts.local), workspace: opts.workspace, persist: !check });
|
|
@@ -3365,18 +3590,17 @@ ${style.bold("Next:")} paste this into your AI agent to get going \u2014
|
|
|
3365
3590
|
}
|
|
3366
3591
|
|
|
3367
3592
|
// src/commands/sweep.ts
|
|
3368
|
-
import {
|
|
3369
|
-
import {
|
|
3370
|
-
|
|
3371
|
-
var DEFAULT_MANIFEST = join6(".sechroom", "repos.json");
|
|
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");
|
|
3372
3596
|
function planEntry(entry, root) {
|
|
3373
|
-
const dir =
|
|
3374
|
-
if (!
|
|
3375
|
-
return { entry, dir, disposition: "skip-missing", argv: [], reason: "directory does not exist" };
|
|
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" };
|
|
3376
3600
|
}
|
|
3377
3601
|
if (committedBindingPath(dir)) {
|
|
3378
3602
|
return {
|
|
3379
|
-
entry,
|
|
3603
|
+
label: entry.path,
|
|
3380
3604
|
dir,
|
|
3381
3605
|
disposition: "refresh",
|
|
3382
3606
|
argv: ["onboard", "--refresh", "--yes"],
|
|
@@ -3385,7 +3609,7 @@ function planEntry(entry, root) {
|
|
|
3385
3609
|
}
|
|
3386
3610
|
if (entry.workspaceId) {
|
|
3387
3611
|
return {
|
|
3388
|
-
entry,
|
|
3612
|
+
label: entry.path,
|
|
3389
3613
|
dir,
|
|
3390
3614
|
disposition: "bind",
|
|
3391
3615
|
argv: ["onboard", "--yes", "--local", "--workspace", entry.workspaceId],
|
|
@@ -3393,29 +3617,21 @@ function planEntry(entry, root) {
|
|
|
3393
3617
|
};
|
|
3394
3618
|
}
|
|
3395
3619
|
return {
|
|
3396
|
-
entry,
|
|
3620
|
+
label: entry.path,
|
|
3397
3621
|
dir,
|
|
3398
3622
|
disposition: "skip-unbound",
|
|
3399
3623
|
argv: [],
|
|
3400
3624
|
reason: "unbound + no workspaceId in the manifest \u2014 add one or run `sechroom onboard` there"
|
|
3401
3625
|
};
|
|
3402
3626
|
}
|
|
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
3627
|
function registerSweep(program2) {
|
|
3416
|
-
program2.command("sweep").description("
|
|
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(
|
|
3417
3629
|
"after",
|
|
3418
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
|
+
|
|
3419
3635
|
Manifest \u2014 ./.sechroom/repos.json (per-operator, gitignored, alongside lane.json):
|
|
3420
3636
|
{
|
|
3421
3637
|
"repos": [
|
|
@@ -3439,28 +3655,23 @@ Examples:
|
|
|
3439
3655
|
const g = cmd.optsWithGlobals();
|
|
3440
3656
|
const json = Boolean(g.json);
|
|
3441
3657
|
const dryRun = Boolean(opts.dryRun);
|
|
3442
|
-
const manifestPath =
|
|
3443
|
-
|
|
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;
|
|
3658
|
+
const manifestPath = resolve2(opts.manifest);
|
|
3659
|
+
let repos;
|
|
3447
3660
|
try {
|
|
3448
|
-
|
|
3661
|
+
repos = readManifest(manifestPath);
|
|
3449
3662
|
} catch (err2) {
|
|
3450
|
-
fail(
|
|
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\`).`);
|
|
3451
3667
|
}
|
|
3452
|
-
const repos = Array.isArray(manifest.repos) ? manifest.repos.filter((r) => r && typeof r.path === "string") : [];
|
|
3453
3668
|
if (repos.length === 0) {
|
|
3454
|
-
if (json) {
|
|
3455
|
-
|
|
3456
|
-
} else {
|
|
3457
|
-
process.stderr.write(`${warn("\u26A0")} ${manifestPath} lists no repos \u2014 nothing to do.
|
|
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.
|
|
3458
3671
|
`);
|
|
3459
|
-
}
|
|
3460
3672
|
return;
|
|
3461
3673
|
}
|
|
3462
3674
|
const root = dirname6(dirname6(manifestPath));
|
|
3463
|
-
const globals = passthroughGlobals(g);
|
|
3464
3675
|
const plans = repos.map((entry) => planEntry(entry, root));
|
|
3465
3676
|
if (!json) {
|
|
3466
3677
|
process.stderr.write(
|
|
@@ -3468,70 +3679,24 @@ Examples:
|
|
|
3468
3679
|
`
|
|
3469
3680
|
);
|
|
3470
3681
|
}
|
|
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
|
-
}
|
|
3682
|
+
const results = runChildren(plans, { globals: passthroughGlobals(g), dryRun, json });
|
|
3506
3683
|
if (json) {
|
|
3507
3684
|
process.stdout.write(JSON.stringify({ manifest: manifestPath, dryRun, repos: results }) + "\n");
|
|
3508
3685
|
return;
|
|
3509
3686
|
}
|
|
3510
|
-
|
|
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);
|
|
3687
|
+
summarizeFanout(results, { dryRun });
|
|
3523
3688
|
});
|
|
3524
3689
|
}
|
|
3525
3690
|
|
|
3526
3691
|
// src/commands/skills.ts
|
|
3527
3692
|
import { homedir as homedir6 } from "os";
|
|
3528
|
-
import { join as
|
|
3529
|
-
import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync6, rmSync as rmSync2, existsSync as
|
|
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";
|
|
3530
3695
|
var DEFAULT_SLUG = "operator-skills";
|
|
3531
3696
|
var ROLE_TAGS = ["sechroom:role:skill-template", "role:skill-template"];
|
|
3532
3697
|
var LOCK = ".sechroom-skills.json";
|
|
3533
3698
|
function skillsDir(global) {
|
|
3534
|
-
return global ?
|
|
3699
|
+
return global ? join9(homedir6(), ".claude", "skills") : join9(process.cwd(), ".claude", "skills");
|
|
3535
3700
|
}
|
|
3536
3701
|
function tagValue2(tags, prefix) {
|
|
3537
3702
|
return (tags ?? []).find((t) => t.startsWith(prefix))?.slice(prefix.length);
|
|
@@ -3609,13 +3774,13 @@ Examples:
|
|
|
3609
3774
|
const name = tagValue2(tags, "skill:");
|
|
3610
3775
|
if (!name) continue;
|
|
3611
3776
|
const body = m.text ?? m.Text ?? "";
|
|
3612
|
-
mkdirSync6(
|
|
3613
|
-
writeFileSync6(
|
|
3777
|
+
mkdirSync6(join9(dir, name), { recursive: true });
|
|
3778
|
+
writeFileSync6(join9(dir, name, "SKILL.md"), body.endsWith("\n") ? body : body + "\n");
|
|
3614
3779
|
written.push(name);
|
|
3615
3780
|
}
|
|
3616
3781
|
mkdirSync6(dir, { recursive: true });
|
|
3617
|
-
const lockPath =
|
|
3618
|
-
const lock =
|
|
3782
|
+
const lockPath = join9(dir, LOCK);
|
|
3783
|
+
const lock = existsSync9(lockPath) ? JSON.parse(readFileSync6(lockPath, "utf8")) : {};
|
|
3619
3784
|
lock[slug] = { surface: opts.surface, version, instance: wantInstance, skills: written.sort() };
|
|
3620
3785
|
writeFileSync6(lockPath, JSON.stringify(lock, null, 2) + "\n");
|
|
3621
3786
|
if (opts.json) return emit({ slug, version, instance: wantInstance, surface: opts.surface, dir, installed: written }, true);
|
|
@@ -3639,15 +3804,15 @@ Examples:
|
|
|
3639
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) => {
|
|
3640
3805
|
const slug = slugArg || DEFAULT_SLUG;
|
|
3641
3806
|
const dir = skillsDir(!opts.local);
|
|
3642
|
-
const lockPath =
|
|
3643
|
-
if (!
|
|
3807
|
+
const lockPath = join9(dir, LOCK);
|
|
3808
|
+
if (!existsSync9(lockPath)) fail(`No skills lockfile at ${lockPath}; nothing to clean.`);
|
|
3644
3809
|
const lock = JSON.parse(readFileSync6(lockPath, "utf8"));
|
|
3645
3810
|
const entry = lock[slug];
|
|
3646
3811
|
if (!entry) fail(`No installed record for '${slug}' in ${lockPath}.`);
|
|
3647
3812
|
const removed = [];
|
|
3648
3813
|
for (const name of entry.skills) {
|
|
3649
|
-
const skillPath =
|
|
3650
|
-
if (
|
|
3814
|
+
const skillPath = join9(dir, name);
|
|
3815
|
+
if (existsSync9(skillPath)) {
|
|
3651
3816
|
rmSync2(skillPath, { recursive: true, force: true });
|
|
3652
3817
|
removed.push(name);
|
|
3653
3818
|
}
|
|
@@ -3743,21 +3908,21 @@ Examples:
|
|
|
3743
3908
|
|
|
3744
3909
|
// src/commands/reset.ts
|
|
3745
3910
|
import { homedir as homedir7 } from "os";
|
|
3746
|
-
import { join as
|
|
3747
|
-
import { existsSync as
|
|
3911
|
+
import { join as join10 } from "path";
|
|
3912
|
+
import { existsSync as existsSync10, readFileSync as readFileSync7, rmSync as rmSync3 } from "fs";
|
|
3748
3913
|
var SKILLS_LOCK = ".sechroom-skills.json";
|
|
3749
|
-
var localSkillsDir = () =>
|
|
3750
|
-
var globalSkillsDir = () =>
|
|
3914
|
+
var localSkillsDir = () => join10(process.cwd(), ".claude", "skills");
|
|
3915
|
+
var globalSkillsDir = () => join10(homedir7(), ".claude", "skills");
|
|
3751
3916
|
function removeMaterialisedSkills(dir) {
|
|
3752
3917
|
const removed = [];
|
|
3753
|
-
const lockPath =
|
|
3754
|
-
if (!
|
|
3918
|
+
const lockPath = join10(dir, SKILLS_LOCK);
|
|
3919
|
+
if (!existsSync10(lockPath)) return removed;
|
|
3755
3920
|
try {
|
|
3756
3921
|
const lock = JSON.parse(readFileSync7(lockPath, "utf8"));
|
|
3757
3922
|
for (const entry of Object.values(lock)) {
|
|
3758
3923
|
for (const name of entry.skills ?? []) {
|
|
3759
|
-
const p =
|
|
3760
|
-
if (
|
|
3924
|
+
const p = join10(dir, name);
|
|
3925
|
+
if (existsSync10(p)) {
|
|
3761
3926
|
rmSync3(p, { recursive: true, force: true });
|
|
3762
3927
|
removed.push(p);
|
|
3763
3928
|
}
|
|
@@ -3788,18 +3953,18 @@ function registerReset(program2) {
|
|
|
3788
3953
|
}
|
|
3789
3954
|
}
|
|
3790
3955
|
const removed = [];
|
|
3791
|
-
const stateDir =
|
|
3792
|
-
if (
|
|
3956
|
+
const stateDir = join10(process.cwd(), ".sechroom");
|
|
3957
|
+
if (existsSync10(stateDir)) {
|
|
3793
3958
|
rmSync3(stateDir, { recursive: true, force: true });
|
|
3794
3959
|
removed.push(stateDir);
|
|
3795
3960
|
}
|
|
3796
|
-
const legacyCfg =
|
|
3797
|
-
if (
|
|
3961
|
+
const legacyCfg = join10(process.cwd(), ".sechroom.json");
|
|
3962
|
+
if (existsSync10(legacyCfg)) {
|
|
3798
3963
|
rmSync3(legacyCfg, { force: true });
|
|
3799
3964
|
removed.push(legacyCfg);
|
|
3800
3965
|
}
|
|
3801
|
-
const legacySem =
|
|
3802
|
-
if (
|
|
3966
|
+
const legacySem = join10(process.cwd(), ".sem");
|
|
3967
|
+
if (existsSync10(legacySem)) {
|
|
3803
3968
|
rmSync3(legacySem, { force: true });
|
|
3804
3969
|
removed.push(legacySem);
|
|
3805
3970
|
}
|
package/package.json
CHANGED