@sechroom/cli 2026.6.19 → 2026.6.20-rc.11acdaab
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 +542 -127
- 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
|
|
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
|
|
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
|
|
21
|
-
var
|
|
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
|
|
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
|
-
|
|
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
|
|
88
|
-
if (!
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
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
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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(
|
|
116
|
-
|
|
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((
|
|
377
|
-
rl.question(`${question} [y/N] `,
|
|
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((
|
|
391
|
-
rl.question(`${question}${suffix} `,
|
|
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((
|
|
418
|
-
rl.question(`Choose ${style.dim(`[${defIdx + 1}]`)} `,
|
|
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((
|
|
451
|
-
rl.question(`Select ${style.dim("[Enter = \u25C9]")} `,
|
|
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
|
|
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 =
|
|
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) {
|
|
@@ -2016,10 +2028,7 @@ function resolveSurfaces(surface, cwd) {
|
|
|
2016
2028
|
if (surface === "codex") return ["codex"];
|
|
2017
2029
|
if (surface === "both") return ["claude", "codex"];
|
|
2018
2030
|
if (surface) throw new Error(`--surface must be one of claude | codex | both (got '${surface}')`);
|
|
2019
|
-
const
|
|
2020
|
-
const surfaces = [];
|
|
2021
|
-
if (detected.includes("claude-code")) surfaces.push("claude");
|
|
2022
|
-
if (detected.includes("codex")) surfaces.push("codex");
|
|
2031
|
+
const surfaces = detectHookSurfaces(cwd);
|
|
2023
2032
|
return surfaces.length > 0 ? surfaces : ["claude", "codex"];
|
|
2024
2033
|
}
|
|
2025
2034
|
function describe(result, dryRun) {
|
|
@@ -2027,6 +2036,50 @@ function describe(result, dryRun) {
|
|
|
2027
2036
|
const verb = dryRun ? "would" : result.status === "created" ? "created" : "updated";
|
|
2028
2037
|
return ` \u2713 ${result.path} (${dryRun ? `${verb} ${result.status === "created" ? "create" : "update"}` : verb})`;
|
|
2029
2038
|
}
|
|
2039
|
+
var HOOK_SURFACE_LABEL = {
|
|
2040
|
+
claude: "Claude Code",
|
|
2041
|
+
codex: "Codex"
|
|
2042
|
+
};
|
|
2043
|
+
function installHookSurfaces(surfaces, opts) {
|
|
2044
|
+
const out = [];
|
|
2045
|
+
for (const surface of surfaces) {
|
|
2046
|
+
if (surface === "claude") {
|
|
2047
|
+
const path = opts.local ? join4(opts.cwd, ".claude", "settings.json") : join4(opts.home, ".claude", "settings.json");
|
|
2048
|
+
out.push({ surface, results: [installHooksJson(path, opts.dryRun)] });
|
|
2049
|
+
} else {
|
|
2050
|
+
const hooksJson = installHooksJson(join4(opts.home, ".codex", "hooks.json"), opts.dryRun);
|
|
2051
|
+
const featureFlag = installCodexFeatureFlag(join4(opts.home, ".codex", "config.toml"), opts.dryRun);
|
|
2052
|
+
out.push({ surface, results: [hooksJson, featureFlag] });
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
return out;
|
|
2056
|
+
}
|
|
2057
|
+
function detectHookSurfaces(cwd) {
|
|
2058
|
+
const detected = detectInstalledClients(cwd);
|
|
2059
|
+
const surfaces = [];
|
|
2060
|
+
if (detected.includes("claude-code")) surfaces.push("claude");
|
|
2061
|
+
if (detected.includes("codex")) surfaces.push("codex");
|
|
2062
|
+
return surfaces;
|
|
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
|
+
}
|
|
2030
2083
|
function registerHook(program2) {
|
|
2031
2084
|
const hook = program2.command("hook").description("Agent-lifecycle hook adapter (Claude Code / Codex) \u2014 bridges hooks to continuity");
|
|
2032
2085
|
hook.addHelpText(
|
|
@@ -2115,26 +2168,15 @@ Fail-soft: no lane / no auth / no-or-partial intent file / API error -> exit 0,
|
|
|
2115
2168
|
`);
|
|
2116
2169
|
return process.exit(2);
|
|
2117
2170
|
}
|
|
2118
|
-
const home = homedir3();
|
|
2119
2171
|
const results = [];
|
|
2120
2172
|
try {
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
process.stdout.write(`Claude Code:
|
|
2173
|
+
const installed = installHookSurfaces(surfaces, { dryRun, local: opts.local, cwd, home: homedir3() });
|
|
2174
|
+
for (const { surface, results: surfaceResults } of installed) {
|
|
2175
|
+
process.stdout.write(`${HOOK_SURFACE_LABEL[surface]}:
|
|
2125
2176
|
`);
|
|
2126
|
-
|
|
2177
|
+
for (const r of surfaceResults) {
|
|
2127
2178
|
results.push(r);
|
|
2128
2179
|
process.stdout.write(describe(r, dryRun) + "\n");
|
|
2129
|
-
} else {
|
|
2130
|
-
process.stdout.write(`Codex:
|
|
2131
|
-
`);
|
|
2132
|
-
const hooksJson = installHooksJson(join4(home, ".codex", "hooks.json"), dryRun);
|
|
2133
|
-
results.push(hooksJson);
|
|
2134
|
-
process.stdout.write(describe(hooksJson, dryRun) + "\n");
|
|
2135
|
-
const featureFlag = installCodexFeatureFlag(join4(home, ".codex", "config.toml"), dryRun);
|
|
2136
|
-
results.push(featureFlag);
|
|
2137
|
-
process.stdout.write(describe(featureFlag, dryRun) + "\n");
|
|
2138
2180
|
}
|
|
2139
2181
|
}
|
|
2140
2182
|
} catch (err2) {
|
|
@@ -2149,6 +2191,7 @@ Fail-soft: no lane / no auth / no-or-partial intent file / API error -> exit 0,
|
|
|
2149
2191
|
} else {
|
|
2150
2192
|
process.stdout.write("\nRestart (or reload) your agent for the hooks to take effect.\n");
|
|
2151
2193
|
}
|
|
2194
|
+
warnIfSechroomNotOnPath();
|
|
2152
2195
|
return process.exit(0);
|
|
2153
2196
|
});
|
|
2154
2197
|
}
|
|
@@ -2577,9 +2620,47 @@ async function applyClient(cfg, setup, target, opts) {
|
|
|
2577
2620
|
return actions;
|
|
2578
2621
|
}
|
|
2579
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
|
+
|
|
2580
2661
|
// src/setup/skills-offer.ts
|
|
2581
2662
|
import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "fs";
|
|
2582
|
-
import { homedir as
|
|
2663
|
+
import { homedir as homedir5 } from "os";
|
|
2583
2664
|
import { join as join5 } from "path";
|
|
2584
2665
|
|
|
2585
2666
|
// src/setup/lane-pin.ts
|
|
@@ -2600,6 +2681,24 @@ function codeLanePrefix(clients) {
|
|
|
2600
2681
|
for (const c of CLIENT_PRIORITY) if (clients.includes(c)) return CODE_LANE_PREFIX_BY_CLIENT[c];
|
|
2601
2682
|
return "claude-code";
|
|
2602
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
|
+
}
|
|
2603
2702
|
function writePin(code, design) {
|
|
2604
2703
|
const values = {};
|
|
2605
2704
|
if (code) values["code-lane"] = code;
|
|
@@ -2612,20 +2711,7 @@ function writePin(code, design) {
|
|
|
2612
2711
|
async function ensureLanePin(cfg, opts) {
|
|
2613
2712
|
if (opts.dryRun) return;
|
|
2614
2713
|
if (readSem()) return;
|
|
2615
|
-
|
|
2616
|
-
let profile;
|
|
2617
|
-
try {
|
|
2618
|
-
const client = await makeClient(cfg);
|
|
2619
|
-
[wf, profile] = await Promise.all([
|
|
2620
|
-
client.GET("/me/workflow-preferences", {}).then((r) => r.data).catch(() => void 0),
|
|
2621
|
-
client.GET("/me/profile", {}).then((r) => r.data).catch(() => void 0)
|
|
2622
|
-
]);
|
|
2623
|
-
} catch {
|
|
2624
|
-
}
|
|
2625
|
-
const handle = handleFromDisplayName(profile?.effectiveDisplayName);
|
|
2626
|
-
const prefix = codeLanePrefix(opts.clients ?? ["claude-code"]);
|
|
2627
|
-
const codeGuess = wf?.defaultCodeLane ?? (handle ? `${prefix}-${handle}` : void 0);
|
|
2628
|
-
const designGuess = wf?.defaultDesignLane ?? (handle ? `claude-design-${handle}` : void 0);
|
|
2714
|
+
const { code: codeGuess, design: designGuess } = await inferLanes(cfg, opts.clients);
|
|
2629
2715
|
if (!canPrompt() || opts.yes) {
|
|
2630
2716
|
if (opts.yes && (codeGuess || designGuess)) writePin(codeGuess, designGuess);
|
|
2631
2717
|
return;
|
|
@@ -2695,7 +2781,7 @@ async function maybeOfferSkills(cfg, personalWorkspaceId, opts) {
|
|
|
2695
2781
|
Found ${style.bold(String(names.length))} operator skill(s) installed in your workspace: ${names.join(", ")}.
|
|
2696
2782
|
`
|
|
2697
2783
|
);
|
|
2698
|
-
const dir = join5(
|
|
2784
|
+
const dir = join5(homedir5(), ".claude", "skills");
|
|
2699
2785
|
const materialise = opts.yes ? true : canPrompt() ? await promptYesNo(`Write them to ${dir}/ so ${surface} can use them?`) : false;
|
|
2700
2786
|
if (!materialise) return;
|
|
2701
2787
|
const written = [];
|
|
@@ -2799,6 +2885,9 @@ Examples:
|
|
|
2799
2885
|
if (!json && !opts.dryRun && !opts.mcpOnly) {
|
|
2800
2886
|
await maybeOfferSkills(cfg, personalWorkspaceId, { yes: false, dryRun: Boolean(opts.dryRun), surface: "claude-code" });
|
|
2801
2887
|
}
|
|
2888
|
+
if (!json && !opts.dryRun && !opts.mcpOnly) {
|
|
2889
|
+
await maybeOfferHooks({ yes: false, dryRun: Boolean(opts.dryRun), cwd: process.cwd() });
|
|
2890
|
+
}
|
|
2802
2891
|
if (json) {
|
|
2803
2892
|
emit({ dryRun: Boolean(opts.dryRun), clients: result }, true);
|
|
2804
2893
|
return;
|
|
@@ -2854,6 +2943,113 @@ async function runClients(clients, cmd, opts) {
|
|
|
2854
2943
|
process.stdout.write(opts.dryRun ? "\n(dry run \u2014 nothing written)\n" : "\nDone.\n");
|
|
2855
2944
|
}
|
|
2856
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
|
+
|
|
2857
3053
|
// src/commands/onboard.ts
|
|
2858
3054
|
var DEFAULT_BASE_URL2 = "https://app.sechroom.ai/api";
|
|
2859
3055
|
function systemTimezone() {
|
|
@@ -2926,7 +3122,7 @@ async function warnIfProjectStray(client, projectId, workspaceId, json) {
|
|
|
2926
3122
|
);
|
|
2927
3123
|
}
|
|
2928
3124
|
}
|
|
2929
|
-
async function pickWorkspace(client) {
|
|
3125
|
+
async function pickWorkspace(client, promptLabel = "Bind this directory to a workspace:") {
|
|
2930
3126
|
const all = await withSpinner("Listing your workspaces", () => fetchWorkspaces(client));
|
|
2931
3127
|
if (all.length === 0) {
|
|
2932
3128
|
process.stderr.write(`no workspaces found \u2014 skipping workspace binding (you can set it later with \`sechroom config set --local workspaceId <id>\`)
|
|
@@ -2949,7 +3145,7 @@ async function pickWorkspace(client) {
|
|
|
2949
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 })),
|
|
2950
3146
|
{ label: style.dim("skip \u2014 don't bind a workspace"), value: SKIP, hint: void 0 }
|
|
2951
3147
|
];
|
|
2952
|
-
const chosen = await promptSelect(
|
|
3148
|
+
const chosen = await promptSelect(promptLabel, choices, SKIP);
|
|
2953
3149
|
if (chosen === SKIP) return void 0;
|
|
2954
3150
|
const picked = byId.get(chosen);
|
|
2955
3151
|
const collisions = all.filter((w) => w.id !== picked.id && namesCollide(w.name, picked.name));
|
|
@@ -3032,7 +3228,7 @@ async function ensureTenant(baseUrl, g, opts) {
|
|
|
3032
3228
|
"Where should this tenant + base URL be saved?",
|
|
3033
3229
|
[
|
|
3034
3230
|
{ label: "Globally", value: "global", hint: "all projects on this machine" },
|
|
3035
|
-
{ label: "This directory", value: "local", hint: ".sechroom
|
|
3231
|
+
{ label: "This directory", value: "local", hint: ".sechroom.json \u2014 committed, project + subdirs" }
|
|
3036
3232
|
],
|
|
3037
3233
|
local.path ? "local" : "global"
|
|
3038
3234
|
) === "local";
|
|
@@ -3107,16 +3303,120 @@ async function chooseClients(clientFlag, yes, cwd) {
|
|
|
3107
3303
|
);
|
|
3108
3304
|
return picks.length > 0 ? picks : preselected;
|
|
3109
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
|
+
}
|
|
3110
3408
|
function registerOnboard(program2) {
|
|
3111
|
-
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
|
|
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(
|
|
3112
3410
|
"after",
|
|
3113
3411
|
`
|
|
3114
3412
|
Examples:
|
|
3115
3413
|
$ sechroom onboard guided, interactive (asks where to save config + how to wire)
|
|
3116
3414
|
$ sechroom onboard --cli-only just the CLI \u2014 no .mcp.json, no agent files
|
|
3117
3415
|
$ sechroom onboard --no-mcp agent instructions only, skip MCP config
|
|
3118
|
-
$ sechroom onboard --local save tenant + base URL to ./.sechroom
|
|
3416
|
+
$ sechroom onboard --local save tenant + base URL to a committed ./.sechroom.json
|
|
3119
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
|
|
3120
3420
|
$ sechroom onboard --refresh refresh out-of-date instruction blocks in place
|
|
3121
3421
|
$ sechroom onboard --check CI/pre-commit: nonzero exit if instructions are out of date
|
|
3122
3422
|
$ sechroom onboard --yes non-interactive: defaults + global config + full wire
|
|
@@ -3128,6 +3428,15 @@ Examples:
|
|
|
3128
3428
|
const mode = opts.check ? "check" : opts.force ? "force" : "apply";
|
|
3129
3429
|
const check = mode === "check";
|
|
3130
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
|
+
}
|
|
3131
3440
|
const baseUrl = resolveBaseUrl(g);
|
|
3132
3441
|
await ensureAuth({ baseUrl, tenant: "", clientId: readPersisted().clientId }, yes);
|
|
3133
3442
|
const cfg = await ensureTenant(baseUrl, g, { yes, json, local: Boolean(opts.local), workspace: opts.workspace, persist: !check });
|
|
@@ -3145,7 +3454,10 @@ Examples:
|
|
|
3145
3454
|
emit({ dryRun, baseUrl: cfg.baseUrl, tenant: cfg.tenant, workspaceId: cfg.workspaceId ?? null, timezone: tz, wire, clients: [] }, true);
|
|
3146
3455
|
return;
|
|
3147
3456
|
}
|
|
3148
|
-
if (!dryRun)
|
|
3457
|
+
if (!dryRun) {
|
|
3458
|
+
await ensureLanePin(cfg, { yes, dryRun, clients: detectInstalledClients(process.cwd()) });
|
|
3459
|
+
await maybeOfferHooks({ yes, dryRun, cwd: process.cwd() });
|
|
3460
|
+
}
|
|
3149
3461
|
process.stdout.write(
|
|
3150
3462
|
`
|
|
3151
3463
|
${style.bold("Done.")} The CLI is configured for ${style.cyan(cfg.tenant)} \u2014 no AI-client files written.
|
|
@@ -3203,6 +3515,9 @@ Try: ${style.cyan('sechroom memory search "..."')} or ${style.cyan("sechroom -
|
|
|
3203
3515
|
if (!json && !dryRun) {
|
|
3204
3516
|
await maybeOfferSkills(cfg, personalWorkspaceId, { yes, dryRun, surface: "claude-code" });
|
|
3205
3517
|
}
|
|
3518
|
+
if (!json && !dryRun) {
|
|
3519
|
+
await maybeOfferHooks({ yes, dryRun, cwd: process.cwd() });
|
|
3520
|
+
}
|
|
3206
3521
|
if (json) {
|
|
3207
3522
|
emit({ dryRun, baseUrl: cfg.baseUrl, tenant: cfg.tenant, workspaceId: cfg.workspaceId ?? null, timezone: tz, wire, eval: evalCounts, clients: result }, true);
|
|
3208
3523
|
return;
|
|
@@ -3274,15 +3589,114 @@ ${style.bold("Next:")} paste this into your AI agent to get going \u2014
|
|
|
3274
3589
|
);
|
|
3275
3590
|
}
|
|
3276
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
|
+
|
|
3277
3691
|
// src/commands/skills.ts
|
|
3278
|
-
import { homedir as
|
|
3279
|
-
import { join as
|
|
3280
|
-
import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync6, rmSync as rmSync2, existsSync as
|
|
3692
|
+
import { homedir as homedir6 } from "os";
|
|
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";
|
|
3281
3695
|
var DEFAULT_SLUG = "operator-skills";
|
|
3282
3696
|
var ROLE_TAGS = ["sechroom:role:skill-template", "role:skill-template"];
|
|
3283
3697
|
var LOCK = ".sechroom-skills.json";
|
|
3284
3698
|
function skillsDir(global) {
|
|
3285
|
-
return global ?
|
|
3699
|
+
return global ? join9(homedir6(), ".claude", "skills") : join9(process.cwd(), ".claude", "skills");
|
|
3286
3700
|
}
|
|
3287
3701
|
function tagValue2(tags, prefix) {
|
|
3288
3702
|
return (tags ?? []).find((t) => t.startsWith(prefix))?.slice(prefix.length);
|
|
@@ -3360,13 +3774,13 @@ Examples:
|
|
|
3360
3774
|
const name = tagValue2(tags, "skill:");
|
|
3361
3775
|
if (!name) continue;
|
|
3362
3776
|
const body = m.text ?? m.Text ?? "";
|
|
3363
|
-
mkdirSync6(
|
|
3364
|
-
writeFileSync6(
|
|
3777
|
+
mkdirSync6(join9(dir, name), { recursive: true });
|
|
3778
|
+
writeFileSync6(join9(dir, name, "SKILL.md"), body.endsWith("\n") ? body : body + "\n");
|
|
3365
3779
|
written.push(name);
|
|
3366
3780
|
}
|
|
3367
3781
|
mkdirSync6(dir, { recursive: true });
|
|
3368
|
-
const lockPath =
|
|
3369
|
-
const lock =
|
|
3782
|
+
const lockPath = join9(dir, LOCK);
|
|
3783
|
+
const lock = existsSync9(lockPath) ? JSON.parse(readFileSync6(lockPath, "utf8")) : {};
|
|
3370
3784
|
lock[slug] = { surface: opts.surface, version, instance: wantInstance, skills: written.sort() };
|
|
3371
3785
|
writeFileSync6(lockPath, JSON.stringify(lock, null, 2) + "\n");
|
|
3372
3786
|
if (opts.json) return emit({ slug, version, instance: wantInstance, surface: opts.surface, dir, installed: written }, true);
|
|
@@ -3390,15 +3804,15 @@ Examples:
|
|
|
3390
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) => {
|
|
3391
3805
|
const slug = slugArg || DEFAULT_SLUG;
|
|
3392
3806
|
const dir = skillsDir(!opts.local);
|
|
3393
|
-
const lockPath =
|
|
3394
|
-
if (!
|
|
3395
|
-
const lock = JSON.parse(
|
|
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"));
|
|
3396
3810
|
const entry = lock[slug];
|
|
3397
3811
|
if (!entry) fail(`No installed record for '${slug}' in ${lockPath}.`);
|
|
3398
3812
|
const removed = [];
|
|
3399
3813
|
for (const name of entry.skills) {
|
|
3400
|
-
const skillPath =
|
|
3401
|
-
if (
|
|
3814
|
+
const skillPath = join9(dir, name);
|
|
3815
|
+
if (existsSync9(skillPath)) {
|
|
3402
3816
|
rmSync2(skillPath, { recursive: true, force: true });
|
|
3403
3817
|
removed.push(name);
|
|
3404
3818
|
}
|
|
@@ -3493,22 +3907,22 @@ Examples:
|
|
|
3493
3907
|
}
|
|
3494
3908
|
|
|
3495
3909
|
// src/commands/reset.ts
|
|
3496
|
-
import { homedir as
|
|
3497
|
-
import { join as
|
|
3498
|
-
import { existsSync as
|
|
3910
|
+
import { homedir as homedir7 } from "os";
|
|
3911
|
+
import { join as join10 } from "path";
|
|
3912
|
+
import { existsSync as existsSync10, readFileSync as readFileSync7, rmSync as rmSync3 } from "fs";
|
|
3499
3913
|
var SKILLS_LOCK = ".sechroom-skills.json";
|
|
3500
|
-
var localSkillsDir = () =>
|
|
3501
|
-
var globalSkillsDir = () =>
|
|
3914
|
+
var localSkillsDir = () => join10(process.cwd(), ".claude", "skills");
|
|
3915
|
+
var globalSkillsDir = () => join10(homedir7(), ".claude", "skills");
|
|
3502
3916
|
function removeMaterialisedSkills(dir) {
|
|
3503
3917
|
const removed = [];
|
|
3504
|
-
const lockPath =
|
|
3505
|
-
if (!
|
|
3918
|
+
const lockPath = join10(dir, SKILLS_LOCK);
|
|
3919
|
+
if (!existsSync10(lockPath)) return removed;
|
|
3506
3920
|
try {
|
|
3507
|
-
const lock = JSON.parse(
|
|
3921
|
+
const lock = JSON.parse(readFileSync7(lockPath, "utf8"));
|
|
3508
3922
|
for (const entry of Object.values(lock)) {
|
|
3509
3923
|
for (const name of entry.skills ?? []) {
|
|
3510
|
-
const p =
|
|
3511
|
-
if (
|
|
3924
|
+
const p = join10(dir, name);
|
|
3925
|
+
if (existsSync10(p)) {
|
|
3512
3926
|
rmSync3(p, { recursive: true, force: true });
|
|
3513
3927
|
removed.push(p);
|
|
3514
3928
|
}
|
|
@@ -3539,18 +3953,18 @@ function registerReset(program2) {
|
|
|
3539
3953
|
}
|
|
3540
3954
|
}
|
|
3541
3955
|
const removed = [];
|
|
3542
|
-
const stateDir =
|
|
3543
|
-
if (
|
|
3956
|
+
const stateDir = join10(process.cwd(), ".sechroom");
|
|
3957
|
+
if (existsSync10(stateDir)) {
|
|
3544
3958
|
rmSync3(stateDir, { recursive: true, force: true });
|
|
3545
3959
|
removed.push(stateDir);
|
|
3546
3960
|
}
|
|
3547
|
-
const legacyCfg =
|
|
3548
|
-
if (
|
|
3961
|
+
const legacyCfg = join10(process.cwd(), ".sechroom.json");
|
|
3962
|
+
if (existsSync10(legacyCfg)) {
|
|
3549
3963
|
rmSync3(legacyCfg, { force: true });
|
|
3550
3964
|
removed.push(legacyCfg);
|
|
3551
3965
|
}
|
|
3552
|
-
const legacySem =
|
|
3553
|
-
if (
|
|
3966
|
+
const legacySem = join10(process.cwd(), ".sem");
|
|
3967
|
+
if (existsSync10(legacySem)) {
|
|
3554
3968
|
rmSync3(legacySem, { force: true });
|
|
3555
3969
|
removed.push(legacySem);
|
|
3556
3970
|
}
|
|
@@ -3577,7 +3991,7 @@ function registerReset(program2) {
|
|
|
3577
3991
|
function resolveVersion() {
|
|
3578
3992
|
try {
|
|
3579
3993
|
const pkg = JSON.parse(
|
|
3580
|
-
|
|
3994
|
+
readFileSync8(new URL("../package.json", import.meta.url), "utf8")
|
|
3581
3995
|
);
|
|
3582
3996
|
return pkg.version ?? "0.0.0";
|
|
3583
3997
|
} catch {
|
|
@@ -3593,7 +4007,7 @@ Examples:
|
|
|
3593
4007
|
$ sechroom onboard guided first-run: configure, sign in, wire this project
|
|
3594
4008
|
$ sechroom login sign in via browser (OAuth + PKCE)
|
|
3595
4009
|
$ sechroom config set tenant ocd set your tenant (global)
|
|
3596
|
-
$ sechroom config set --local tenant cli-smoke pin tenant for this directory (.sechroom
|
|
4010
|
+
$ sechroom config set --local tenant cli-smoke pin tenant for this directory (committed .sechroom.json)
|
|
3597
4011
|
$ sechroom config show resolved config + which source won
|
|
3598
4012
|
|
|
3599
4013
|
$ sechroom memory create --text "a note" --title "Note" --tag idea
|
|
@@ -3605,7 +4019,7 @@ Examples:
|
|
|
3605
4019
|
$ sechroom --json memory search "auth" compact JSON for scripts and agents
|
|
3606
4020
|
$ SECHROOM_TOKEN=<bearer> sechroom --json memory get mem_XXXX headless
|
|
3607
4021
|
|
|
3608
|
-
Config precedence (high -> low): --flag > env (SECHROOM_*) > ./.sechroom
|
|
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.
|
|
3609
4023
|
Run 'sechroom <command> --help' for command-specific examples.`
|
|
3610
4024
|
);
|
|
3611
4025
|
program.hook("preAction", (_thisCmd, actionCmd) => {
|
|
@@ -3631,11 +4045,11 @@ config.addHelpText(
|
|
|
3631
4045
|
Examples:
|
|
3632
4046
|
$ sechroom config set baseUrl https://app.sechroom.ai/api prod (staging: https://staging.app.sechroom.ai/api)
|
|
3633
4047
|
$ sechroom config set tenant ocd
|
|
3634
|
-
$ sechroom config set --local tenant cli-smoke this dir + subdirs (.sechroom
|
|
4048
|
+
$ sechroom config set --local tenant cli-smoke this dir + subdirs (committed .sechroom.json)
|
|
3635
4049
|
$ sechroom config set clientId dyn-XXXX global-only escape hatch (no DCR endpoint)
|
|
3636
4050
|
$ sechroom config show --json`
|
|
3637
4051
|
);
|
|
3638
|
-
config.command("set <key> <value>").description("Set baseUrl | tenant | workspaceId | defaultProjectId | clientId (clientId is global-only)").option("--local", "Write
|
|
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) => {
|
|
3639
4053
|
if (opts.local) {
|
|
3640
4054
|
if (!["baseUrl", "tenant", "workspaceId", "defaultProjectId"].includes(key)) {
|
|
3641
4055
|
process.stderr.write(`--local supports only: baseUrl | tenant | workspaceId | defaultProjectId (clientId is global)
|
|
@@ -3694,6 +4108,7 @@ registerChat(program);
|
|
|
3694
4108
|
registerInit(program);
|
|
3695
4109
|
registerSetup(program);
|
|
3696
4110
|
registerOnboard(program);
|
|
4111
|
+
registerSweep(program);
|
|
3697
4112
|
registerSkills(program);
|
|
3698
4113
|
registerReset(program);
|
|
3699
4114
|
program.parseAsync().catch((err2) => {
|
package/package.json
CHANGED