@sechroom/cli 2026.6.12 → 2026.6.14

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 +408 -63
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -18,6 +18,7 @@ var CONFIG_FILE = join(CONFIG_DIR, "config.json");
18
18
  var TOKEN_FILE = join(CONFIG_DIR, "token.json");
19
19
  var LOCAL_CONFIG_NAME = ".sechroom.json";
20
20
  var DEFAULT_BASE_URL = "https://app.sechroom.ai/api";
21
+ var LOCAL_CONFIG_SCHEMA_VERSION = 2;
21
22
  function ensureDir() {
22
23
  if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
23
24
  }
@@ -78,7 +79,14 @@ function readLocalConfig() {
78
79
  if (!path) return {};
79
80
  try {
80
81
  const c = JSON.parse(readFileSync(path, "utf8"));
81
- return { baseUrl: c.baseUrl, tenant: c.tenant, path };
82
+ return {
83
+ schemaVersion: c.schemaVersion,
84
+ baseUrl: c.baseUrl,
85
+ tenant: c.tenant,
86
+ workspaceId: c.workspaceId,
87
+ defaultProjectId: c.defaultProjectId,
88
+ path
89
+ };
82
90
  } catch {
83
91
  return {};
84
92
  }
@@ -90,7 +98,8 @@ function writeLocalConfig(patch) {
90
98
  current = JSON.parse(readFileSync(path, "utf8"));
91
99
  } catch {
92
100
  }
93
- writeFileSync(path, JSON.stringify({ ...current, ...patch }, null, 2), { mode: 384 });
101
+ const next = { ...current, ...patch, schemaVersion: LOCAL_CONFIG_SCHEMA_VERSION };
102
+ writeFileSync(path, JSON.stringify(next, null, 2), { mode: 384 });
94
103
  return path;
95
104
  }
96
105
  function resolveConfig(flags) {
@@ -103,7 +112,9 @@ function resolveConfig(flags) {
103
112
  "No tenant set. The Sechroom API rejects untenanted requests (HTTP 400). Pass --tenant <id>, set SECHROOM_TENANT, run `sechroom config set tenant <id>`, or `sechroom config set --local tenant <id>` for this directory."
104
113
  );
105
114
  }
106
- return { baseUrl: baseUrl.replace(/\/$/, ""), tenant, clientId: persisted.clientId };
115
+ const workspaceId = process.env.SECHROOM_WORKSPACE ?? local.workspaceId ?? persisted.workspaceId ?? void 0;
116
+ const defaultProjectId = local.defaultProjectId ?? persisted.defaultProjectId ?? void 0;
117
+ return { baseUrl: baseUrl.replace(/\/$/, ""), tenant, workspaceId, defaultProjectId, clientId: persisted.clientId };
107
118
  }
108
119
  function describeConfig(flags) {
109
120
  const local = readLocalConfig();
@@ -121,6 +132,7 @@ function describeConfig(flags) {
121
132
  return {
122
133
  baseUrl: { value: baseUrl.value, source: baseUrl.source },
123
134
  tenant: pick(flags.tenant, process.env.SECHROOM_TENANT, local.tenant, g.tenant),
135
+ workspaceId: pick(void 0, process.env.SECHROOM_WORKSPACE, local.workspaceId, g.workspaceId),
124
136
  localPath: local.path
125
137
  };
126
138
  }
@@ -302,6 +314,7 @@ var style = {
302
314
  cyan: wrap(36, 39)
303
315
  };
304
316
  var ok = (s) => style.green(s);
317
+ var warn = (s) => style.yellow(s);
305
318
  var err = (s) => style.red(s);
306
319
  function active() {
307
320
  return !quiet && Boolean(process.stderr.isTTY);
@@ -1761,6 +1774,7 @@ Examples:
1761
1774
  }
1762
1775
 
1763
1776
  // src/setup/apply.ts
1777
+ import { createHash as createHash2 } from "crypto";
1764
1778
  import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync2 } from "fs";
1765
1779
  import { dirname as dirname2 } from "path";
1766
1780
 
@@ -1770,11 +1784,17 @@ var SectionType = {
1770
1784
  McpConfigToml: "mcp-config-toml",
1771
1785
  InstructionFile: "instruction-file",
1772
1786
  ProjectConfig: "project-config",
1773
- Verify: "verify"
1787
+ Verify: "verify",
1788
+ /** SBC-999 — workspace-pinned conventions, emitted only when the request
1789
+ * carried a workspaceId and that workspace has agent-setup-bundle memories. */
1790
+ WorkspaceConventions: "workspace-conventions"
1774
1791
  };
1775
1792
  async function fetchSetup(cfg) {
1776
1793
  const client = await makeClient(cfg);
1777
- const { data, error } = await client.GET("/operator-surface/setup", {});
1794
+ const { data, error } = await client.GET(
1795
+ "/operator-surface/setup",
1796
+ cfg.workspaceId ? { params: { query: { workspaceId: cfg.workspaceId } } } : {}
1797
+ );
1778
1798
  if (error) throw new Error(`GET /operator-surface/setup failed: ${JSON.stringify(error)}`);
1779
1799
  return data;
1780
1800
  }
@@ -1806,7 +1826,10 @@ async function fetchMemoryFields(cfg, id) {
1806
1826
  const client = await makeClient(cfg);
1807
1827
  const { data } = await client.GET("/memories/{memoryId}", { params: { path: { memoryId: id } } });
1808
1828
  const env = data;
1809
- return env?.item ?? env ?? null;
1829
+ const m = env?.item ?? env;
1830
+ if (!m) return null;
1831
+ const version = typeof m.currentVersion === "string" ? Number(m.currentVersion) : m.currentVersion;
1832
+ return { text: m.text, title: m.title, tags: m.tags, version: Number.isFinite(version) ? version : void 0 };
1810
1833
  }
1811
1834
  async function resolveInstruction(cfg, section, personalWorkspaceId) {
1812
1835
  const client = await makeClient(cfg);
@@ -1830,14 +1853,28 @@ async function resolveInstruction(cfg, section, personalWorkspaceId) {
1830
1853
  if (ovrHits.length > 0) {
1831
1854
  const override = await fetchMemoryFields(cfg, ovrHits[0].id);
1832
1855
  if (typeof override?.text === "string" && override.text.length > 0) {
1833
- return { title: override.title ?? template.title ?? artifact.title, body: override.text, source: "override", templateId, templateTags };
1856
+ return { title: override.title ?? template.title ?? artifact.title, body: override.text, source: "override", templateId, templateTags, sourceRef: `${ovrHits[0].id}@v${override.version ?? 1}` };
1834
1857
  }
1835
1858
  }
1836
1859
  }
1837
- return { title: template.title ?? artifact.title, body: template.text, source: "template", templateId, templateTags };
1860
+ return { title: template.title ?? artifact.title, body: template.text, source: "template", templateId, templateTags, sourceRef: `${templateId}@v${template.version ?? 1}` };
1838
1861
  }
1839
1862
  return null;
1840
1863
  }
1864
+ async function resolveWorkspaceConventions(cfg, section) {
1865
+ const parts = [];
1866
+ const refs = [];
1867
+ for (const artifact of section.artifacts) {
1868
+ if (parseTagArtifactId(artifact.id)) continue;
1869
+ const mem = await fetchMemoryFields(cfg, artifact.id);
1870
+ if (typeof mem?.text === "string" && mem.text.trim().length > 0) {
1871
+ parts.push(mem.text.trim());
1872
+ refs.push(`${artifact.id}@v${mem.version ?? 1}`);
1873
+ }
1874
+ }
1875
+ if (parts.length === 0) return null;
1876
+ return { body: parts.join("\n\n---\n\n"), refs };
1877
+ }
1841
1878
  async function createOverride(cfg, template, personalWorkspaceId) {
1842
1879
  const client = await makeClient(cfg);
1843
1880
  const overrideTags = template.templateTags.filter(
@@ -1861,8 +1898,57 @@ async function createOverride(cfg, template, personalWorkspaceId) {
1861
1898
  }
1862
1899
 
1863
1900
  // src/setup/apply.ts
1864
- var BLOCK_BEGIN = "<!-- @sechroom/cli:begin (managed \u2014 re-run `sechroom setup agent-files` to refresh) -->";
1865
- var BLOCK_END = "<!-- @sechroom/cli:end -->";
1901
+ var MARKER_BEGIN = "<!-- @sechroom/cli:begin";
1902
+ var MARKER_END = "<!-- @sechroom/cli:end";
1903
+ function normalizeBody(s) {
1904
+ return s.replace(/\r\n/g, "\n").trim();
1905
+ }
1906
+ function bodySha256(body) {
1907
+ return createHash2("sha256").update(normalizeBody(body), "utf8").digest("hex");
1908
+ }
1909
+ function renderBlock(write) {
1910
+ const body = normalizeBody(write.body);
1911
+ const attrs = [`block=${write.block}`];
1912
+ if (write.source) attrs.push(`source=${write.source}`);
1913
+ attrs.push(`sha256=${bodySha256(body)}`);
1914
+ return `${MARKER_BEGIN} ${attrs.join(" ")} -->
1915
+ ${body}
1916
+ ${MARKER_END} block=${write.block} -->
1917
+ `;
1918
+ }
1919
+ function keyedBlockRe(block) {
1920
+ const b = escapeRe(block);
1921
+ return new RegExp(
1922
+ `${escapeRe(MARKER_BEGIN)}[^\\n]*?\\bblock=${b}\\b[^\\n]*?-->\\n[\\s\\S]*?${escapeRe(MARKER_END)}[^\\n]*?\\bblock=${b}\\b[^\\n]*?-->\\n?`
1923
+ );
1924
+ }
1925
+ function legacyBlockRe() {
1926
+ return new RegExp(
1927
+ `${escapeRe(MARKER_BEGIN)}(?:(?!block=)[^\\n])*?-->\\n[\\s\\S]*?${escapeRe(MARKER_END)}(?:(?!block=)[^\\n])*?-->\\n?`
1928
+ );
1929
+ }
1930
+ function parseAttrs(beginLine) {
1931
+ const attrs = {};
1932
+ for (const m of beginLine.matchAll(/(\w+)=(\S+)/g)) attrs[m[1]] = m[2];
1933
+ return attrs;
1934
+ }
1935
+ function innerBody(segment) {
1936
+ const firstNl = segment.indexOf("\n");
1937
+ const endIdx = segment.lastIndexOf(MARKER_END);
1938
+ return segment.slice(firstNl + 1, endIdx).replace(/\n$/, "");
1939
+ }
1940
+ function parseManagedBlock(content, block) {
1941
+ const keyed = content.match(keyedBlockRe(block));
1942
+ if (keyed) {
1943
+ const attrs = parseAttrs(keyed[0].slice(0, keyed[0].indexOf("\n")));
1944
+ return { block, source: attrs.source ?? null, sha256: attrs.sha256 ?? null, body: innerBody(keyed[0]) };
1945
+ }
1946
+ if (block === "role-template") {
1947
+ const legacy = content.match(legacyBlockRe());
1948
+ if (legacy) return { block, source: null, sha256: null, body: innerBody(legacy[0]) };
1949
+ }
1950
+ return null;
1951
+ }
1866
1952
  function ensureDir2(path) {
1867
1953
  mkdirSync2(dirname2(path), { recursive: true });
1868
1954
  }
@@ -1901,32 +1987,74 @@ function mergeCodexToml(path, snippet, dryRun) {
1901
1987
  writeFileSync2(path, next, { mode: 384 });
1902
1988
  return { kind: "mcp", path, status: existed ? "merged" : "created" };
1903
1989
  }
1904
- function writeInstructionBlock(path, body, dryRun) {
1905
- const block = `${BLOCK_BEGIN}
1906
- ${body.trim()}
1907
- ${BLOCK_END}
1908
- `;
1990
+ function writeInstructionBlock(path, write, dryRun) {
1909
1991
  const existed = existsSync2(path);
1910
- const current = readOr(path, "");
1911
- let next;
1912
- const re = new RegExp(`${escapeRe(BLOCK_BEGIN)}[\\s\\S]*?${escapeRe(BLOCK_END)}\\n?`);
1913
- if (re.test(current)) {
1914
- next = current.replace(re, block);
1915
- } else {
1916
- next = current.trim().length > 0 ? `${current.trimEnd()}
1917
-
1918
- ${block}` : block;
1919
- }
1992
+ const next = computeBlockFile(readOr(path, ""), write);
1920
1993
  if (dryRun) return { kind: "instruction", path, status: "dry-run" };
1921
1994
  ensureDir2(path);
1922
1995
  writeFileSync2(path, next);
1923
1996
  return { kind: "instruction", path, status: existed ? "merged" : "created" };
1924
1997
  }
1998
+ function computeBlockFile(current, write) {
1999
+ const rendered = renderBlock(write);
2000
+ const keyed = keyedBlockRe(write.block);
2001
+ if (keyed.test(current)) return current.replace(keyed, rendered);
2002
+ if (write.block === "role-template" && legacyBlockRe().test(current)) {
2003
+ return current.replace(legacyBlockRe(), rendered);
2004
+ }
2005
+ return current.trim().length > 0 ? `${current.trimEnd()}
2006
+
2007
+ ${rendered}` : rendered;
2008
+ }
2009
+ function evaluateBlock(content, block, serverBody) {
2010
+ const onDisk = parseManagedBlock(content, block);
2011
+ if (!onDisk) return "absent";
2012
+ const actual = bodySha256(onDisk.body);
2013
+ if (onDisk.sha256 && actual !== onDisk.sha256) return "drift";
2014
+ return actual === bodySha256(serverBody) ? "current" : "stale";
2015
+ }
2016
+ function applyBlock(path, write, mode, dryRun) {
2017
+ const current = readOr(path, "");
2018
+ const state = evaluateBlock(current, write.block, write.body);
2019
+ if (mode === "check") {
2020
+ return {
2021
+ kind: "instruction",
2022
+ path,
2023
+ status: state === "current" ? "current" : "skipped",
2024
+ eval: state,
2025
+ note: state === "current" ? void 0 : `would ${state === "absent" ? "write" : "refresh"} (${state})`
2026
+ };
2027
+ }
2028
+ if (state === "current") {
2029
+ return { kind: "instruction", path, status: "current", eval: "current" };
2030
+ }
2031
+ if (state === "drift" && mode !== "force") {
2032
+ const proposedPath = `${path}.proposed`;
2033
+ const next = computeBlockFile(current, write);
2034
+ if (!dryRun) {
2035
+ ensureDir2(proposedPath);
2036
+ writeFileSync2(proposedPath, next);
2037
+ }
2038
+ return {
2039
+ kind: "instruction",
2040
+ path,
2041
+ status: "skipped",
2042
+ eval: "drift",
2043
+ proposedPath,
2044
+ note: `local edits \u2014 wrote ${proposedPath} (original left untouched)`
2045
+ };
2046
+ }
2047
+ const action = writeInstructionBlock(path, write, dryRun);
2048
+ const note = state === "stale" ? "refreshed" : state === "drift" ? "overwrote local edits" : void 0;
2049
+ return { ...action, eval: state, note: note ?? action.note };
2050
+ }
1925
2051
  function escapeRe(s) {
1926
2052
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1927
2053
  }
1928
2054
  async function applyClient(cfg, setup, target, opts) {
1929
2055
  const actions = [];
2056
+ const mode = opts.mode ?? "apply";
2057
+ const dryRun = opts.dryRun || mode === "check";
1930
2058
  if (opts.mcp && target.mcp) {
1931
2059
  const surface = findSurface(setup, target.mcp.surfaceKey);
1932
2060
  const section = findSection(surface, target.mcp.sectionType);
@@ -1935,7 +2063,7 @@ async function applyClient(cfg, setup, target, opts) {
1935
2063
  actions.push({ kind: "mcp", path: target.mcp.path, status: "skipped", note: `no ${target.mcp.sectionType} section on surface '${target.mcp.surfaceKey}'` });
1936
2064
  } else {
1937
2065
  actions.push(
1938
- target.mcp.format === "toml" ? mergeCodexToml(target.mcp.path, snippet, opts.dryRun) : mergeMcpJson(target.mcp.path, snippet, opts.dryRun)
2066
+ target.mcp.format === "toml" ? mergeCodexToml(target.mcp.path, snippet, dryRun) : mergeMcpJson(target.mcp.path, snippet, dryRun)
1939
2067
  );
1940
2068
  }
1941
2069
  }
@@ -1949,8 +2077,26 @@ async function applyClient(cfg, setup, target, opts) {
1949
2077
  if (!resolved) {
1950
2078
  actions.push({ kind: "instruction", path: target.instruction.path, status: "skipped", note: "no role template found in this tenant \u2014 install the SEM Starter bundle, then re-run `sechroom setup agent-files`" });
1951
2079
  } else {
1952
- const action = writeInstructionBlock(target.instruction.path, resolved.body, opts.dryRun);
1953
- actions.push(resolved.source === "override" ? { ...action, note: "your personal copy" } : action);
2080
+ const action = applyBlock(
2081
+ target.instruction.path,
2082
+ { block: "role-template", body: resolved.body, source: resolved.sourceRef },
2083
+ mode,
2084
+ opts.dryRun
2085
+ );
2086
+ actions.push(resolved.source === "override" && action.status !== "current" ? { ...action, note: action.note ?? "your personal copy" } : action);
2087
+ }
2088
+ }
2089
+ const conventionsSection = findSection(surface, SectionType.WorkspaceConventions);
2090
+ if (conventionsSection) {
2091
+ const conventions = await resolveWorkspaceConventions(cfg, conventionsSection);
2092
+ if (conventions) {
2093
+ const action = applyBlock(
2094
+ target.instruction.path,
2095
+ { block: "workspace-conventions", body: conventions.body, source: `workspace:${cfg.workspaceId ?? ""}` },
2096
+ mode,
2097
+ opts.dryRun
2098
+ );
2099
+ actions.push(action.status === "current" ? action : { ...action, note: action.note ?? `workspace conventions (${conventions.refs.length})` });
1954
2100
  }
1955
2101
  }
1956
2102
  }
@@ -2284,20 +2430,169 @@ function systemTimezone() {
2284
2430
  return "UTC";
2285
2431
  }
2286
2432
  }
2287
- async function ensureConfig(g, opts) {
2433
+ function editDistance(a, b) {
2434
+ const m = a.length;
2435
+ const n = b.length;
2436
+ if (m === 0) return n;
2437
+ if (n === 0) return m;
2438
+ let prev = Array.from({ length: n + 1 }, (_, j) => j);
2439
+ for (let i = 1; i <= m; i++) {
2440
+ const curr = [i];
2441
+ for (let j = 1; j <= n; j++) {
2442
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
2443
+ curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
2444
+ }
2445
+ prev = curr;
2446
+ }
2447
+ return prev[n];
2448
+ }
2449
+ function namesCollide(a, b) {
2450
+ const x = a.trim().toLowerCase();
2451
+ const y = b.trim().toLowerCase();
2452
+ return x === y || editDistance(x, y) <= 1;
2453
+ }
2454
+ function workspacePath(ws, byId) {
2455
+ const parts = [];
2456
+ const seen = /* @__PURE__ */ new Set();
2457
+ let cur = ws;
2458
+ while (cur && !seen.has(cur.id)) {
2459
+ seen.add(cur.id);
2460
+ parts.unshift(cur.name);
2461
+ cur = cur.parentId ? byId.get(cur.parentId) : void 0;
2462
+ }
2463
+ return parts.join(" / ");
2464
+ }
2465
+ function resolveBaseUrl(g) {
2288
2466
  const persisted = readPersisted();
2289
2467
  const local = readLocalConfig();
2290
- let baseUrl = g.baseUrl ?? process.env.SECHROOM_BASE_URL ?? local.baseUrl ?? persisted.baseUrl ?? DEFAULT_BASE_URL2;
2291
- let tenant = g.tenant ?? process.env.SECHROOM_TENANT ?? local.tenant ?? persisted.tenant ?? "";
2292
- if (canPrompt() && !opts.yes) {
2293
- tenant = await promptText("Tenant id?", tenant || void 0);
2468
+ const baseUrl = g.baseUrl ?? process.env.SECHROOM_BASE_URL ?? local.baseUrl ?? persisted.baseUrl ?? DEFAULT_BASE_URL2;
2469
+ return baseUrl.replace(/\/$/, "");
2470
+ }
2471
+ async function fetchWorkspaces(client) {
2472
+ const { data, error } = await client.GET("/workspaces", { params: { query: { includeArchived: false } } });
2473
+ if (error) throw new Error(`Couldn't list your workspaces: ${JSON.stringify(error)}`);
2474
+ const rows = data ?? [];
2475
+ return rows.map((r) => r.item ?? r).filter((w) => Boolean(w?.id && w?.name)).map((w) => ({ id: w.id, name: w.name, parentId: w.parentId ?? null }));
2476
+ }
2477
+ async function lookupWorkspace(client, id) {
2478
+ const { data, error } = await client.GET("/workspaces/{workspaceId}", { params: { path: { workspaceId: id } } });
2479
+ if (error) return null;
2480
+ const env = data;
2481
+ const w = env?.item ?? env;
2482
+ return w?.id ? { id: w.id, name: w.name ?? id, parentId: w.parentId ?? null } : null;
2483
+ }
2484
+ async function warnIfProjectStray(client, projectId, workspaceId, json) {
2485
+ const { data, error } = await client.GET("/projects/{projectId}", { params: { path: { projectId } } });
2486
+ if (error) return;
2487
+ const env = data;
2488
+ const owner = env?.item?.workspaceId;
2489
+ if (owner && owner !== workspaceId && !json) {
2490
+ process.stderr.write(
2491
+ `${warn("\u26A0")} defaultProjectId ${style.dim(projectId)} belongs to a different workspace (${style.dim(owner)}), not ${style.dim(workspaceId)} \u2014 leaving it as-is.
2492
+ `
2493
+ );
2294
2494
  }
2295
- baseUrl = baseUrl.replace(/\/$/, "");
2296
- if (!tenant) {
2297
- fail(
2298
- "No tenant set. Pass --tenant <id>, set SECHROOM_TENANT, or run `sechroom config set tenant <id>` \u2014 the API rejects untenanted requests (HTTP 400)."
2495
+ }
2496
+ async function pickWorkspace(client) {
2497
+ const all = await withSpinner("Listing your workspaces", () => fetchWorkspaces(client));
2498
+ if (all.length === 0) {
2499
+ process.stderr.write(`no workspaces found \u2014 skipping workspace binding (you can set it later with \`sechroom config set --local workspaceId <id>\`)
2500
+ `);
2501
+ return void 0;
2502
+ }
2503
+ const byId = new Map(all.map((w) => [w.id, w]));
2504
+ let pool = all;
2505
+ if (all.length > 12) {
2506
+ const q = (await promptText(`Filter ${all.length} workspaces (substring, Enter to list all)?`, "")).trim().toLowerCase();
2507
+ if (q) {
2508
+ const hits = all.filter((w) => `${w.name} ${workspacePath(w, byId)}`.toLowerCase().includes(q));
2509
+ if (hits.length > 0) pool = hits;
2510
+ else process.stderr.write(`no match for "${q}" \u2014 listing all
2511
+ `);
2512
+ }
2513
+ }
2514
+ const SKIP = "__skip__";
2515
+ const choices = [
2516
+ ...pool.slice().sort((a, b) => workspacePath(a, byId).localeCompare(workspacePath(b, byId))).map((w) => ({ label: workspacePath(w, byId), value: w.id, hint: w.id })),
2517
+ { label: style.dim("skip \u2014 don't bind a workspace"), value: SKIP, hint: void 0 }
2518
+ ];
2519
+ const chosen = await promptSelect("Bind this directory to a workspace:", choices, SKIP);
2520
+ if (chosen === SKIP) return void 0;
2521
+ const picked = byId.get(chosen);
2522
+ const collisions = all.filter((w) => w.id !== picked.id && namesCollide(w.name, picked.name));
2523
+ if (collisions.length > 0) {
2524
+ process.stderr.write(
2525
+ `${warn("\u26A0")} ${collisions.length} other workspace(s) have a similar name to ${style.cyan(workspacePath(picked, byId))}:
2526
+ ` + collisions.map((w) => ` ${style.dim(workspacePath(w, byId))} ${style.dim(`(${w.id})`)}`).join("\n") + `
2527
+ You picked ${style.dim(picked.id)} \u2014 re-run \`sechroom config set --local workspaceId <id>\` if that's wrong.
2528
+ `
2299
2529
  );
2300
2530
  }
2531
+ return chosen;
2532
+ }
2533
+ async function resolveWorkspaceBinding(client, existing, opts) {
2534
+ if (opts.workspace) {
2535
+ const found = await lookupWorkspace(client, opts.workspace);
2536
+ if (!found && !opts.json) {
2537
+ process.stderr.write(
2538
+ `${warn("\u26A0")} workspace ${style.dim(opts.workspace)} not found (or you lack access) \u2014 binding it anyway.
2539
+ `
2540
+ );
2541
+ }
2542
+ return opts.workspace;
2543
+ }
2544
+ if (existing) return existing;
2545
+ if (!canPrompt() || opts.yes) return void 0;
2546
+ return pickWorkspace(client);
2547
+ }
2548
+ async function ensureTenant(baseUrl, g, opts) {
2549
+ const persisted = readPersisted();
2550
+ const local = readLocalConfig();
2551
+ let tenant = g.tenant ?? process.env.SECHROOM_TENANT ?? local.tenant ?? persisted.tenant ?? "";
2552
+ if (!tenant) {
2553
+ const client = await makeClient({ baseUrl, tenant: "", clientId: persisted.clientId });
2554
+ const { data, error } = await client.GET("/auth/me/tenants", {});
2555
+ if (error) {
2556
+ fail(`Couldn't list your tenants: ${JSON.stringify(error)}. Pass --tenant <id> to skip this.`);
2557
+ }
2558
+ const tenants = data?.tenants ?? [];
2559
+ if (tenants.length === 0) {
2560
+ fail(
2561
+ "You're signed in, but your account isn't a member of any tenant yet. Ask an admin to add you (or create one in the app), then re-run \u2014 or pass --tenant <id>."
2562
+ );
2563
+ } else if (tenants.length === 1) {
2564
+ tenant = tenants[0].key;
2565
+ if (!opts.json) {
2566
+ process.stderr.write(
2567
+ `${ok("\u2713")} using your tenant ${style.cyan(tenants[0].label)} ${style.dim(`(${tenant})`)}
2568
+ `
2569
+ );
2570
+ }
2571
+ } else if (canPrompt() && !opts.yes) {
2572
+ tenant = await promptSelect(
2573
+ "You belong to several tenants \u2014 pick one:",
2574
+ tenants.map((t) => ({ label: t.label, value: t.key, hint: t.key })),
2575
+ data?.defaultTenantKey ?? tenants[0].key
2576
+ );
2577
+ } else {
2578
+ tenant = data?.defaultTenantKey ?? tenants[0].key;
2579
+ if (!opts.json) {
2580
+ process.stderr.write(
2581
+ `using tenant ${tenant} (${tenants.length} available \u2014 pass --tenant to choose another)
2582
+ `
2583
+ );
2584
+ }
2585
+ }
2586
+ }
2587
+ const existingWorkspace = local.workspaceId ?? persisted.workspaceId ?? void 0;
2588
+ const wsClient = await makeClient({ baseUrl, tenant, clientId: persisted.clientId });
2589
+ const workspaceId = await resolveWorkspaceBinding(wsClient, existingWorkspace, {
2590
+ yes: opts.yes,
2591
+ json: opts.json,
2592
+ workspace: opts.workspace
2593
+ });
2594
+ const defaultProjectId = local.defaultProjectId ?? persisted.defaultProjectId ?? void 0;
2595
+ if (defaultProjectId && workspaceId) await warnIfProjectStray(wsClient, defaultProjectId, workspaceId, opts.json);
2301
2596
  let storeLocal = Boolean(opts.local);
2302
2597
  if (!opts.local && canPrompt() && !opts.yes) {
2303
2598
  storeLocal = await promptSelect(
@@ -2309,16 +2604,23 @@ async function ensureConfig(g, opts) {
2309
2604
  local.path ? "local" : "global"
2310
2605
  ) === "local";
2311
2606
  }
2312
- if (storeLocal) {
2313
- const path = writeLocalConfig({ baseUrl, tenant });
2314
- if (!opts.json) process.stderr.write(`${ok("\u2713")} config saved to ${path} (directory-local)
2607
+ if (opts.persist !== false) {
2608
+ const patch = { baseUrl, tenant, ...workspaceId ? { workspaceId } : {} };
2609
+ if (storeLocal) {
2610
+ const path = writeLocalConfig(patch);
2611
+ if (!opts.json) process.stderr.write(`${ok("\u2713")} config saved to ${path} (directory-local)
2315
2612
  `);
2316
- } else {
2317
- writePersisted({ baseUrl, tenant });
2318
- if (!opts.json) process.stderr.write(`${ok("\u2713")} config saved globally (~/.config/sechroom/config.json)
2613
+ } else {
2614
+ writePersisted(patch);
2615
+ if (!opts.json) process.stderr.write(`${ok("\u2713")} config saved globally (~/.config/sechroom/config.json)
2616
+ `);
2617
+ }
2618
+ if (workspaceId && !existingWorkspace && !opts.json) {
2619
+ process.stderr.write(`${ok("\u2713")} bound to workspace ${style.dim(workspaceId)}
2319
2620
  `);
2621
+ }
2320
2622
  }
2321
- return { baseUrl, tenant, clientId: persisted.clientId };
2623
+ return { baseUrl, tenant, workspaceId, defaultProjectId, clientId: persisted.clientId };
2322
2624
  }
2323
2625
  async function ensureAuth(cfg, yes) {
2324
2626
  if (process.env.SECHROOM_TOKEN) return;
@@ -2373,7 +2675,7 @@ async function chooseClients(clientFlag, yes, cwd) {
2373
2675
  return picks.length > 0 ? picks : preselected;
2374
2676
  }
2375
2677
  function registerOnboard(program2) {
2376
- 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.json instead of the global config", false).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("-y, --yes", "non-interactive: accept defaults (system timezone, detected clients, global config, full wire)", false).addHelpText(
2678
+ 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.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(
2377
2679
  "after",
2378
2680
  `
2379
2681
  Examples:
@@ -2381,16 +2683,22 @@ Examples:
2381
2683
  $ sechroom onboard --cli-only just the CLI \u2014 no .mcp.json, no agent files
2382
2684
  $ sechroom onboard --no-mcp agent instructions only, skip MCP config
2383
2685
  $ sechroom onboard --local save tenant + base URL to ./.sechroom.json
2686
+ $ sechroom onboard --workspace wsp_XX bind this directory to a workspace (no pick prompt)
2687
+ $ sechroom onboard --refresh refresh out-of-date instruction blocks in place
2688
+ $ sechroom onboard --check CI/pre-commit: nonzero exit if instructions are out of date
2384
2689
  $ sechroom onboard --yes non-interactive: defaults + global config + full wire
2385
2690
  $ sechroom onboard --client all --dry-run preview wiring every client, write nothing`
2386
2691
  ).action(async (opts, cmd) => {
2387
2692
  const g = cmd.optsWithGlobals();
2388
2693
  const json = Boolean(g.json);
2389
- const yes = Boolean(opts.yes);
2390
2694
  const dryRun = Boolean(opts.dryRun);
2391
- const cfg = await ensureConfig(g, { yes, json, local: Boolean(opts.local) });
2392
- await ensureAuth(cfg, yes);
2393
- const tz = await ensureTimezone(cfg, { yes, dryRun });
2695
+ const mode = opts.check ? "check" : opts.force ? "force" : "apply";
2696
+ const check = mode === "check";
2697
+ const yes = Boolean(opts.yes) || check;
2698
+ const baseUrl = resolveBaseUrl(g);
2699
+ await ensureAuth({ baseUrl, tenant: "", clientId: readPersisted().clientId }, yes);
2700
+ const cfg = await ensureTenant(baseUrl, g, { yes, json, local: Boolean(opts.local), workspace: opts.workspace, persist: !check });
2701
+ const tz = await ensureTimezone(cfg, { yes, dryRun: dryRun || check });
2394
2702
  if (!json && tz.action !== "already-set") {
2395
2703
  const line = tz.action === "set" ? `${ok("\u2713")} timezone set to ${tz.timezone}
2396
2704
  ` : tz.action === "dry-run" ? `(dry run \u2014 would set timezone to ${tz.timezone})
@@ -2401,7 +2709,7 @@ Examples:
2401
2709
  const wire = await chooseWire(opts, yes);
2402
2710
  if (wire === "cli-only") {
2403
2711
  if (json) {
2404
- emit({ dryRun, baseUrl: cfg.baseUrl, tenant: cfg.tenant, timezone: tz, wire, clients: [] }, true);
2712
+ emit({ dryRun, baseUrl: cfg.baseUrl, tenant: cfg.tenant, workspaceId: cfg.workspaceId ?? null, timezone: tz, wire, clients: [] }, true);
2405
2713
  return;
2406
2714
  }
2407
2715
  process.stdout.write(
@@ -2416,7 +2724,7 @@ Try: ${style.cyan('sechroom memory search "..."')} or ${style.cyan("sechroom -
2416
2724
  const setup = await withSpinner("Fetching setup descriptors", () => fetchSetup(cfg));
2417
2725
  const targets = clientTargets(process.cwd());
2418
2726
  const personalWorkspaceId = await getPersonalWorkspaceId(cfg);
2419
- if (!dryRun) {
2727
+ if (!dryRun && !check) {
2420
2728
  await maybeOfferCopies(cfg, setup, targets, keys, personalWorkspaceId, copyChoice(opts));
2421
2729
  }
2422
2730
  const writeMcp = wire === "full";
@@ -2427,20 +2735,56 @@ Try: ${style.cyan('sechroom memory search "..."')} or ${style.cyan("sechroom -
2427
2735
  dryRun,
2428
2736
  mcp: writeMcp,
2429
2737
  agentFiles: true,
2430
- personalWorkspaceId
2738
+ personalWorkspaceId,
2739
+ mode
2431
2740
  });
2432
2741
  result.push({ client: key, actions });
2433
- if (!json) printActions(target, actions);
2742
+ if (!json && !check) printActions(target, actions);
2743
+ }
2744
+ const evalCounts = { current: 0, stale: 0, drift: 0, absent: 0 };
2745
+ for (const { actions } of result) for (const a of actions) if (a.eval) evalCounts[a.eval]++;
2746
+ const wouldChange = evalCounts.stale + evalCounts.drift + evalCounts.absent;
2747
+ if (check) {
2748
+ if (json) {
2749
+ emit({ check: true, baseUrl: cfg.baseUrl, tenant: cfg.tenant, workspaceId: cfg.workspaceId ?? null, eval: evalCounts, wouldChange, clients: result }, true);
2750
+ } else if (wouldChange === 0) {
2751
+ process.stdout.write(`${ok("\u2713")} all instruction blocks are up to date.
2752
+ `);
2753
+ } else {
2754
+ const bits = [];
2755
+ if (evalCounts.stale) bits.push(`${evalCounts.stale} out of date`);
2756
+ if (evalCounts.drift) bits.push(`${evalCounts.drift} with local edits`);
2757
+ if (evalCounts.absent) bits.push(`${evalCounts.absent} not yet written`);
2758
+ process.stderr.write(
2759
+ `${warn("\u26A0")} ${wouldChange} instruction block(s) would change: ${bits.join(", ")}. Run ${style.cyan("sechroom onboard --refresh")}.
2760
+ `
2761
+ );
2762
+ }
2763
+ process.exit(wouldChange === 0 ? 0 : 1);
2434
2764
  }
2435
2765
  if (!json && !dryRun) {
2436
2766
  await maybeOfferSkills(cfg, personalWorkspaceId, { yes, dryRun, surface: "claude-code" });
2437
2767
  }
2438
2768
  if (json) {
2439
- emit({ dryRun, baseUrl: cfg.baseUrl, tenant: cfg.tenant, timezone: tz, wire, clients: result }, true);
2769
+ emit({ dryRun, baseUrl: cfg.baseUrl, tenant: cfg.tenant, workspaceId: cfg.workspaceId ?? null, timezone: tz, wire, eval: evalCounts, clients: result }, true);
2440
2770
  return;
2441
2771
  }
2772
+ if (!dryRun && evalCounts.stale) {
2773
+ process.stderr.write(`${style.cyan("\u21BB")} refreshed ${evalCounts.stale} section(s) the server had moved
2774
+ `);
2775
+ }
2776
+ if (!dryRun && evalCounts.drift) {
2777
+ process.stderr.write(
2778
+ mode === "force" ? `${warn("\u26A0")} overwrote ${evalCounts.drift} section(s) that had local edits (--force)
2779
+ ` : `${warn("\u26A0")} ${evalCounts.drift} section(s) have local edits \u2014 wrote a .proposed file alongside (original untouched). Review + merge, or re-run with ${style.cyan("--force")}.
2780
+ `
2781
+ );
2782
+ }
2783
+ const wroteSomething = result.some(({ actions }) => actions.some((a) => a.status === "created" || a.status === "merged"));
2442
2784
  process.stdout.write(
2443
- dryRun ? "\n(dry run \u2014 nothing written)\n" : writeMcp ? `
2785
+ dryRun ? "\n(dry run \u2014 nothing written)\n" : !wroteSomething ? `
2786
+ ${style.bold("Done.")} Everything's already up to date.
2787
+ ` : writeMcp ? `
2444
2788
  ${style.bold("Done.")} Restart your AI client (or reload MCP) to pick up the new config.
2445
2789
  ` : `
2446
2790
  ${style.bold("Done.")} Agent instructions written (no MCP config).
@@ -2821,10 +3165,10 @@ Examples:
2821
3165
  $ sechroom config set clientId dyn-XXXX global-only escape hatch (no DCR endpoint)
2822
3166
  $ sechroom config show --json`
2823
3167
  );
2824
- config.command("set <key> <value>").description("Set baseUrl | tenant | clientId (clientId is global-only)").option("--local", "Write to the directory-local .sechroom.json (nearest up the tree, else cwd) instead of the global config").action((key, value, opts) => {
3168
+ config.command("set <key> <value>").description("Set baseUrl | tenant | workspaceId | defaultProjectId | clientId (clientId is global-only)").option("--local", "Write to the directory-local .sechroom.json (nearest up the tree, else cwd) instead of the global config").action((key, value, opts) => {
2825
3169
  if (opts.local) {
2826
- if (!["baseUrl", "tenant"].includes(key)) {
2827
- process.stderr.write(`--local supports only: baseUrl | tenant (clientId is global)
3170
+ if (!["baseUrl", "tenant", "workspaceId", "defaultProjectId"].includes(key)) {
3171
+ process.stderr.write(`--local supports only: baseUrl | tenant | workspaceId | defaultProjectId (clientId is global)
2828
3172
  `);
2829
3173
  process.exit(1);
2830
3174
  }
@@ -2833,8 +3177,8 @@ config.command("set <key> <value>").description("Set baseUrl | tenant | clientId
2833
3177
  `);
2834
3178
  return;
2835
3179
  }
2836
- if (!["baseUrl", "tenant", "clientId"].includes(key)) {
2837
- process.stderr.write(`unknown key: ${key} (expected baseUrl | tenant | clientId)
3180
+ if (!["baseUrl", "tenant", "clientId", "workspaceId", "defaultProjectId"].includes(key)) {
3181
+ process.stderr.write(`unknown key: ${key} (expected baseUrl | tenant | workspaceId | defaultProjectId | clientId)
2838
3182
  `);
2839
3183
  process.exit(1);
2840
3184
  }
@@ -2848,7 +3192,7 @@ config.command("show").description("Print resolved config + sources (flag > env
2848
3192
  if (g.json) {
2849
3193
  process.stdout.write(
2850
3194
  JSON.stringify({
2851
- resolved: { baseUrl: d.baseUrl, tenant: d.tenant },
3195
+ resolved: { baseUrl: d.baseUrl, tenant: d.tenant, workspaceId: d.workspaceId },
2852
3196
  global: readPersisted(),
2853
3197
  local: readLocalConfig()
2854
3198
  }) + "\n"
@@ -2856,8 +3200,9 @@ config.command("show").description("Print resolved config + sources (flag > env
2856
3200
  return;
2857
3201
  }
2858
3202
  process.stdout.write(
2859
- `baseUrl: ${d.baseUrl.value} [${d.baseUrl.source}]
2860
- tenant: ${d.tenant.value ?? "(unset)"} [${d.tenant.source}]
3203
+ `baseUrl: ${d.baseUrl.value} [${d.baseUrl.source}]
3204
+ tenant: ${d.tenant.value ?? "(unset)"} [${d.tenant.source}]
3205
+ workspaceId: ${d.workspaceId.value ?? "(unset)"} [${d.workspaceId.source}]
2861
3206
 
2862
3207
  global: ${JSON.stringify(readPersisted())}
2863
3208
  local: ${d.localPath ?? "(none)"} ${JSON.stringify(readLocalConfig())}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sechroom/cli",
3
- "version": "2026.6.12",
3
+ "version": "2026.6.14",
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",