@sechroom/cli 2026.6.27 → 2026.6.28

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 +260 -44
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -16,12 +16,14 @@ 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
+ var ACCOUNTS_FILE = join(CONFIG_DIR, "accounts.json");
20
+ var DEFAULT_ACCOUNT = "default";
19
21
  var STATE_DIR_NAME = ".sechroom";
20
22
  var BASELINE_CONFIG_NAME = ".sechroom.json";
21
23
  var OVERRIDE_CONFIG_NAME = join(STATE_DIR_NAME, "config.json");
22
- var BINDING_FIELDS = ["schemaVersion", "baseUrl", "tenant", "workspaceId", "defaultProjectId"];
24
+ var BINDING_FIELDS = ["schemaVersion", "baseUrl", "tenant", "workspaceId", "defaultProjectId", "workspaces"];
23
25
  var DEFAULT_BASE_URL = "https://app.sechroom.ai/api";
24
- var LOCAL_CONFIG_SCHEMA_VERSION = 2;
26
+ var LOCAL_CONFIG_SCHEMA_VERSION = 3;
25
27
  function ensureDir() {
26
28
  if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
27
29
  }
@@ -44,23 +46,87 @@ function writeDcrClientId(baseUrl, clientId) {
44
46
  const p = readPersisted();
45
47
  writePersisted({ clientIds: { ...p.clientIds ?? {}, [baseUrl]: clientId } });
46
48
  }
47
- function readToken() {
48
- const envTok = process.env.SECHROOM_TOKEN;
49
- if (envTok) return { accessToken: envTok };
49
+ function readAccountsFile() {
50
50
  try {
51
- return JSON.parse(readFileSync(TOKEN_FILE, "utf8"));
51
+ const parsed = JSON.parse(readFileSync(ACCOUNTS_FILE, "utf8"));
52
+ if (parsed && typeof parsed === "object" && parsed.accounts) return parsed;
52
53
  } catch {
53
- return null;
54
54
  }
55
+ try {
56
+ const legacy = JSON.parse(readFileSync(TOKEN_FILE, "utf8"));
57
+ if (legacy?.accessToken) {
58
+ const migrated = {
59
+ accounts: {
60
+ [DEFAULT_ACCOUNT]: { ...legacy, baseUrl: readPersisted().baseUrl ?? DEFAULT_BASE_URL }
61
+ },
62
+ default: DEFAULT_ACCOUNT
63
+ };
64
+ writeAccountsFile(migrated);
65
+ rmSync(TOKEN_FILE, { force: true });
66
+ return migrated;
67
+ }
68
+ } catch {
69
+ }
70
+ return { accounts: {} };
55
71
  }
56
- function writeToken(tok) {
72
+ function writeAccountsFile(file) {
57
73
  ensureDir();
58
- writeFileSync(TOKEN_FILE, JSON.stringify(tok, null, 2), { mode: 384 });
74
+ writeFileSync(ACCOUNTS_FILE, JSON.stringify(file, null, 2), { mode: 384 });
75
+ }
76
+ function readAccount(alias) {
77
+ return readAccountsFile().accounts[alias] ?? null;
78
+ }
79
+ function writeAccount(alias, entry) {
80
+ const file = readAccountsFile();
81
+ file.accounts[alias] = entry;
82
+ if (!file.default) file.default = alias;
83
+ writeAccountsFile(file);
84
+ }
85
+ function removeAccount(alias) {
86
+ const file = readAccountsFile();
87
+ if (!(alias in file.accounts)) return false;
88
+ delete file.accounts[alias];
89
+ if (file.default === alias) file.default = Object.keys(file.accounts)[0];
90
+ writeAccountsFile(file);
91
+ return true;
92
+ }
93
+ function listAccounts() {
94
+ const file = readAccountsFile();
95
+ return Object.entries(file.accounts).map(([alias, entry]) => ({
96
+ alias,
97
+ entry,
98
+ isDefault: file.default === alias
99
+ }));
100
+ }
101
+ function setDefaultAccount(alias) {
102
+ const file = readAccountsFile();
103
+ if (!(alias in file.accounts)) throw new Error(`No account "${alias}". Run \`sechroom login --account ${alias}\` first.`);
104
+ file.default = alias;
105
+ writeAccountsFile(file);
106
+ }
107
+ function resolveAccountAlias(flagAccount) {
108
+ return flagAccount ?? process.env.SECHROOM_ACCOUNT ?? readLocalConfig().account ?? readAccountsFile().default ?? DEFAULT_ACCOUNT;
109
+ }
110
+ function writeLocalAccount(alias) {
111
+ const home = findConfigHome() ?? process.cwd();
112
+ const overridePath = join(home, OVERRIDE_CONFIG_NAME);
113
+ mkdirSync(dirname(overridePath), { recursive: true });
114
+ const current = readJsonConfig(overridePath) ?? {};
115
+ writeFileSync(
116
+ overridePath,
117
+ JSON.stringify({ ...current, account: alias }, null, 2),
118
+ { mode: 384 }
119
+ );
120
+ return overridePath;
121
+ }
122
+ function readToken() {
123
+ const envTok = process.env.SECHROOM_TOKEN;
124
+ if (envTok) return { accessToken: envTok };
125
+ return readAccount(resolveAccountAlias());
59
126
  }
60
127
  function clearToken() {
61
- if (!existsSync(TOKEN_FILE)) return void 0;
62
- rmSync(TOKEN_FILE);
63
- return TOKEN_FILE;
128
+ const alias = resolveAccountAlias();
129
+ return removeAccount(alias) ? `${ACCOUNTS_FILE} (account "${alias}")` : void 0;
64
130
  }
65
131
  function clearPersisted() {
66
132
  if (!existsSync(CONFIG_FILE)) return void 0;
@@ -95,11 +161,14 @@ function readLocalConfig() {
95
161
  tenant: merged.tenant,
96
162
  workspaceId: merged.workspaceId,
97
163
  defaultProjectId: merged.defaultProjectId,
98
- path: existsSync(baselinePath) ? baselinePath : overridePath
164
+ workspaces: merged.workspaces,
165
+ account: merged.account,
166
+ path: existsSync(baselinePath) ? baselinePath : overridePath,
167
+ home
99
168
  };
100
169
  }
101
- function writeLocalConfig(patch) {
102
- const home = findConfigHome() ?? process.cwd();
170
+ function writeLocalConfig(patch, opts) {
171
+ const home = (opts?.here ? process.cwd() : findConfigHome()) ?? process.cwd();
103
172
  const baselinePath = join(home, BASELINE_CONFIG_NAME);
104
173
  const overridePath = join(home, OVERRIDE_CONFIG_NAME);
105
174
  const current = readJsonConfig(baselinePath) ?? {};
@@ -117,6 +186,30 @@ function committedBindingPath(dir) {
117
186
  const p = join(dir, BASELINE_CONFIG_NAME);
118
187
  return existsSync(p) ? p : void 0;
119
188
  }
189
+ function selectWorkspaceBinding(local, explicitName) {
190
+ const bindings = local.workspaces ?? [];
191
+ if (explicitName) {
192
+ const hit = bindings.find((b) => b.name === explicitName);
193
+ if (!hit)
194
+ throw new Error(
195
+ `No workspace binding named "${explicitName}" in ${local.home ?? "this repo"}'s .sechroom.json` + (bindings.length > 0 ? ` (have: ${bindings.map((b) => b.name).join(", ")})` : "") + ". Add one with `sechroom workspace bind`."
196
+ );
197
+ return hit;
198
+ }
199
+ if (!local.home || bindings.length === 0) return void 0;
200
+ const rel = process.cwd().startsWith(local.home) ? process.cwd().slice(local.home.length).replace(/^[/\\]/, "") : "";
201
+ const norm = (p) => p.replace(/\/\*\*$/, "").replace(/[/\\]+$/, "");
202
+ let best;
203
+ for (const b of bindings) {
204
+ for (const raw of b.paths ?? []) {
205
+ const prefix = norm(raw);
206
+ if (prefix.length === 0) continue;
207
+ const matches = rel === prefix || rel.startsWith(prefix + "/");
208
+ if (matches && (!best || prefix.length > best.len)) best = { binding: b, len: prefix.length };
209
+ }
210
+ }
211
+ return best?.binding;
212
+ }
120
213
  function resolveConfig(flags) {
121
214
  const local = readLocalConfig();
122
215
  const persisted = readPersisted();
@@ -127,9 +220,11 @@ function resolveConfig(flags) {
127
220
  "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."
128
221
  );
129
222
  }
130
- const workspaceId = process.env.SECHROOM_WORKSPACE ?? local.workspaceId ?? persisted.workspaceId ?? void 0;
131
- const defaultProjectId = local.defaultProjectId ?? persisted.defaultProjectId ?? void 0;
132
- return { baseUrl: baseUrl.replace(/\/$/, ""), tenant, workspaceId, defaultProjectId, clientId: persisted.clientId };
223
+ const binding = selectWorkspaceBinding(local, flags.binding ?? process.env.SECHROOM_BINDING);
224
+ const workspaceId = process.env.SECHROOM_WORKSPACE ?? binding?.workspaceId ?? local.workspaceId ?? persisted.workspaceId ?? void 0;
225
+ const defaultProjectId = (binding ? binding.defaultProjectId : void 0) ?? local.defaultProjectId ?? persisted.defaultProjectId ?? void 0;
226
+ const account = resolveAccountAlias(flags.account);
227
+ return { baseUrl: baseUrl.replace(/\/$/, ""), tenant, account, workspaceId, defaultProjectId, clientId: persisted.clientId };
133
228
  }
134
229
  function describeConfig(flags) {
135
230
  const local = readLocalConfig();
@@ -144,10 +239,17 @@ function describeConfig(flags) {
144
239
  return { value: void 0, source: "unset" };
145
240
  };
146
241
  const baseUrl = pick(flags.baseUrl, process.env.SECHROOM_BASE_URL, local.baseUrl, g.baseUrl, DEFAULT_BASE_URL);
242
+ let binding;
243
+ try {
244
+ binding = selectWorkspaceBinding(local, flags.binding ?? process.env.SECHROOM_BINDING);
245
+ } catch {
246
+ binding = void 0;
247
+ }
248
+ const workspaceId = process.env.SECHROOM_WORKSPACE ? { value: process.env.SECHROOM_WORKSPACE, source: "env" } : binding ? { value: binding.workspaceId, source: `binding "${binding.name}"${localTag !== "local" ? ` (${local.path})` : ""}` } : pick(void 0, void 0, local.workspaceId, g.workspaceId);
147
249
  return {
148
250
  baseUrl: { value: baseUrl.value, source: baseUrl.source },
149
251
  tenant: pick(flags.tenant, process.env.SECHROOM_TENANT, local.tenant, g.tenant),
150
- workspaceId: pick(void 0, process.env.SECHROOM_WORKSPACE, local.workspaceId, g.workspaceId),
252
+ workspaceId,
151
253
  localPath: local.path
152
254
  };
153
255
  }
@@ -245,14 +347,25 @@ async function exchange(meta, clientId, code, verifier, redirectUri) {
245
347
  })
246
348
  });
247
349
  if (!res.ok) throw new Error(`Token exchange failed (${res.status}): ${await res.text()}`);
248
- persistTokenResponse(await res.json());
350
+ return await res.json();
249
351
  }
250
- function persistTokenResponse(json) {
251
- const tok = json;
252
- writeToken({
352
+ function decodeEmail(accessToken) {
353
+ try {
354
+ const payload = accessToken.split(".")[1];
355
+ if (!payload) return void 0;
356
+ const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
357
+ return typeof claims.email === "string" ? claims.email : void 0;
358
+ } catch {
359
+ return void 0;
360
+ }
361
+ }
362
+ function persistTokenResponse(account, baseUrl, tok) {
363
+ writeAccount(account, {
364
+ baseUrl,
253
365
  accessToken: tok.access_token,
254
366
  refreshToken: tok.refresh_token,
255
- expiresAt: tok.expires_in ? Date.now() + tok.expires_in * 1e3 : void 0
367
+ expiresAt: tok.expires_in ? Date.now() + tok.expires_in * 1e3 : void 0,
368
+ email: decodeEmail(tok.access_token)
256
369
  });
257
370
  }
258
371
  async function login(cfg) {
@@ -276,14 +389,25 @@ ${authUrl}
276
389
  `);
277
390
  await open(authUrl.toString());
278
391
  const code = await loopback.code;
279
- await exchange(meta, clientId, code, verifier, loopback.redirectUri);
280
- process.stderr.write("Signed in. Token cached.\n");
392
+ const tok = await exchange(meta, clientId, code, verifier, loopback.redirectUri);
393
+ persistTokenResponse(cfg.account, cfg.baseUrl, tok);
394
+ const email = decodeEmail(tok.access_token);
395
+ process.stderr.write(`Signed in${email ? ` as ${email}` : ""} (account "${cfg.account}"). Token cached.
396
+ `);
281
397
  }
282
398
  async function requireToken(cfg) {
283
399
  if (process.env.SECHROOM_TOKEN) return process.env.SECHROOM_TOKEN;
284
- const cached = readToken();
400
+ const cached = readAccount(cfg.account);
285
401
  if (!cached?.accessToken) {
286
- throw new Error("Not signed in. Run `sechroom login` (or set SECHROOM_TOKEN for headless use).");
402
+ throw new Error(
403
+ `Not signed in (account "${cfg.account}"). Run \`sechroom login --account ${cfg.account}\` (or set SECHROOM_TOKEN for headless use).`
404
+ );
405
+ }
406
+ if (cached.baseUrl !== cfg.baseUrl) {
407
+ const matching = listAccounts().filter((a) => a.entry.baseUrl === cfg.baseUrl).map((a) => a.alias);
408
+ throw new Error(
409
+ `Account "${cfg.account}" is signed in to ${cached.baseUrl}, but this call targets ${cfg.baseUrl}. ` + (matching.length > 0 ? `Use --account ${matching[0]} (signed in there)` + (matching.length > 1 ? ` \u2014 or one of: ${matching.join(", ")}` : "") + ", or " : "") + `run \`sechroom login --account ${cfg.account}\` against it.`
410
+ );
287
411
  }
288
412
  const nearExpiry = cached.expiresAt !== void 0 && Date.now() > cached.expiresAt - 6e4;
289
413
  if (nearExpiry && cached.refreshToken) {
@@ -297,10 +421,10 @@ async function requireToken(cfg) {
297
421
  })
298
422
  });
299
423
  if (res.ok) {
300
- persistTokenResponse(await res.json());
301
- return readToken().accessToken;
424
+ persistTokenResponse(cfg.account, cfg.baseUrl, await res.json());
425
+ return readAccount(cfg.account).accessToken;
302
426
  }
303
- throw new Error("Session expired and refresh failed. Run `sechroom login` again.");
427
+ throw new Error(`Session expired and refresh failed. Run \`sechroom login --account ${cfg.account}\` again.`);
304
428
  }
305
429
  return cached.accessToken;
306
430
  }
@@ -545,6 +669,62 @@ function fail(error) {
545
669
  process.exit(1);
546
670
  }
547
671
 
672
+ // src/commands/accounts.ts
673
+ function registerAccounts(program2) {
674
+ const accounts = program2.command("accounts").description("Manage signed-in identities (multi-account)");
675
+ accounts.addHelpText(
676
+ "after",
677
+ `
678
+ Examples:
679
+ $ sechroom login --account work sign in a second identity under the "work" alias
680
+ $ sechroom accounts list see every signed-in identity + which is active here
681
+ $ sechroom accounts use work make "work" the machine default
682
+ $ sechroom accounts use work --local pin "work" for THIS directory only (gitignored)
683
+ $ sechroom logout --account work sign the "work" identity out`
684
+ );
685
+ accounts.command("list").description("List signed-in accounts (alias, email, server, expiry)").action((_opts, cmd) => {
686
+ const active2 = resolveAccountAlias(cmd.optsWithGlobals().account);
687
+ const rows = listAccounts().map(({ alias, entry, isDefault }) => ({
688
+ alias,
689
+ email: entry.email,
690
+ baseUrl: entry.baseUrl,
691
+ expiresAt: entry.expiresAt ? new Date(entry.expiresAt).toISOString() : void 0,
692
+ isDefault,
693
+ isActiveHere: alias === active2
694
+ }));
695
+ if (cmd.optsWithGlobals().json) return emit({ accounts: rows, active: active2 }, true);
696
+ if (rows.length === 0) {
697
+ console.log(style.dim("No accounts signed in. Run `sechroom login` (optionally --account <alias>)."));
698
+ return;
699
+ }
700
+ for (const r of rows) {
701
+ const markers = [r.isActiveHere ? style.green("active") : "", r.isDefault ? style.dim("default") : ""].filter(Boolean).join(" ");
702
+ console.log(
703
+ `${r.alias.padEnd(16)} ${(r.email ?? "\u2014").padEnd(28)} ${r.baseUrl} ${markers}`
704
+ );
705
+ }
706
+ });
707
+ accounts.command("use <alias>").description("Select the account used for calls (machine default, or --local for this directory)").option("--local", "pin for this directory only (gitignored .sechroom/config.json)").action((alias, opts, cmd) => {
708
+ const json = cmd.optsWithGlobals().json;
709
+ if (opts.local) {
710
+ const path = writeLocalAccount(alias);
711
+ if (json) return emit({ account: alias, scope: "local", path }, true);
712
+ console.log(style.green(`Account "${alias}" pinned for this directory (${path}).`));
713
+ return;
714
+ }
715
+ setDefaultAccount(alias);
716
+ if (json) return emit({ account: alias, scope: "global" }, true);
717
+ console.log(style.green(`Account "${alias}" is now the machine default.`));
718
+ });
719
+ accounts.command("remove <alias>").description("Forget an account's cached token (local removal \u2014 does not revoke at the server)").action((alias, _opts, cmd) => {
720
+ const removed = removeAccount(alias);
721
+ if (cmd.optsWithGlobals().json) return emit({ removed: removed ? [alias] : [] }, true);
722
+ console.log(
723
+ removed ? style.green(`Account "${alias}" removed.`) : style.dim(`No account "${alias}".`)
724
+ );
725
+ });
726
+ }
727
+
548
728
  // src/commands/memory.ts
549
729
  function registerMemory(program2) {
550
730
  const memory = program2.command("memory").description("Create, read, and search memories");
@@ -1022,6 +1202,26 @@ Examples:
1022
1202
  // src/commands/workspace.ts
1023
1203
  function registerWorkspace(program2) {
1024
1204
  const workspace = program2.command("workspace").description("Create, browse, and manage workspaces");
1205
+ workspace.command("bind <workspaceId>").description("Bind a workspace to this repo (.sechroom.json). With --name it becomes a NAMED v3 binding (multi-workspace); --paths scopes it to subtrees.").option("--name <name>", "binding name (enables multi-workspace; selectable via --binding / path match)").option("--paths <csv>", "comma-separated directory prefixes (relative to the repo root) this binding covers, e.g. frontend,docs").option("--default-project <id>", "informational default project for this binding").option("--here", "write the binding file at THIS directory even when a parent already carries one").action((workspaceId, opts, cmd) => {
1206
+ const json = cmd.optsWithGlobals().json;
1207
+ if (!opts.name) {
1208
+ const path2 = writeLocalConfig({ workspaceId, ...opts.defaultProject ? { defaultProjectId: opts.defaultProject } : {} }, { here: Boolean(opts.here) });
1209
+ if (json) return emit({ workspaceId, path: path2 }, true);
1210
+ console.log(style.green(`Workspace ${workspaceId} bound (${path2}).`));
1211
+ return;
1212
+ }
1213
+ const local = readLocalConfig();
1214
+ const bindings = (local.workspaces ?? []).filter((b) => b.name !== opts.name);
1215
+ bindings.push({
1216
+ name: opts.name,
1217
+ workspaceId,
1218
+ ...opts.defaultProject ? { defaultProjectId: opts.defaultProject } : {},
1219
+ ...opts.paths ? { paths: String(opts.paths).split(",").map((p) => p.trim()).filter(Boolean) } : {}
1220
+ });
1221
+ const path = writeLocalConfig({ workspaces: bindings }, { here: Boolean(opts.here) });
1222
+ if (json) return emit({ binding: opts.name, workspaceId, paths: opts.paths ?? null, path }, true);
1223
+ console.log(style.green(`Binding "${opts.name}" \u2192 ${workspaceId} saved (${path}).`));
1224
+ });
1025
1225
  workspace.addHelpText(
1026
1226
  "after",
1027
1227
  `
@@ -3179,7 +3379,7 @@ async function ensureTenant(baseUrl, g, opts) {
3179
3379
  const local = readLocalConfig();
3180
3380
  let tenant = g.tenant ?? process.env.SECHROOM_TENANT ?? local.tenant ?? persisted.tenant ?? "";
3181
3381
  if (!tenant) {
3182
- const client = await makeClient({ baseUrl, tenant: "", clientId: persisted.clientId });
3382
+ const client = await makeClient({ baseUrl, tenant: "", account: resolveAccountAlias(), clientId: persisted.clientId });
3183
3383
  const { data, error } = await client.GET("/auth/me/tenants", {});
3184
3384
  if (error) {
3185
3385
  fail(`Couldn't list your tenants: ${JSON.stringify(error)}. Pass --tenant <id> to skip this.`);
@@ -3214,7 +3414,7 @@ async function ensureTenant(baseUrl, g, opts) {
3214
3414
  }
3215
3415
  }
3216
3416
  const existingWorkspace = local.workspaceId ?? persisted.workspaceId ?? void 0;
3217
- const wsClient = await makeClient({ baseUrl, tenant, clientId: persisted.clientId });
3417
+ const wsClient = await makeClient({ baseUrl, tenant, account: resolveAccountAlias(), clientId: persisted.clientId });
3218
3418
  const workspaceId = await resolveWorkspaceBinding(wsClient, existingWorkspace, {
3219
3419
  yes: opts.yes,
3220
3420
  json: opts.json,
@@ -3236,7 +3436,7 @@ async function ensureTenant(baseUrl, g, opts) {
3236
3436
  if (opts.persist !== false) {
3237
3437
  const patch = { baseUrl, tenant, ...workspaceId ? { workspaceId } : {} };
3238
3438
  if (storeLocal) {
3239
- const path = writeLocalConfig(patch);
3439
+ const path = writeLocalConfig(patch, { here: Boolean(opts.here) });
3240
3440
  if (!opts.json) process.stderr.write(`${ok("\u2713")} config saved to ${path} (directory-local)
3241
3441
  `);
3242
3442
  } else {
@@ -3249,7 +3449,7 @@ async function ensureTenant(baseUrl, g, opts) {
3249
3449
  `);
3250
3450
  }
3251
3451
  }
3252
- return { baseUrl, tenant, workspaceId, defaultProjectId, clientId: persisted.clientId };
3452
+ return { baseUrl, tenant, account: resolveAccountAlias(), workspaceId, defaultProjectId, clientId: persisted.clientId };
3253
3453
  }
3254
3454
  async function ensureAuth(cfg, yes) {
3255
3455
  if (process.env.SECHROOM_TOKEN) return;
@@ -3406,7 +3606,7 @@ async function runRecurse(cfg, g, opts) {
3406
3606
  summarizeFanout(results, { dryRun });
3407
3607
  }
3408
3608
  function registerOnboard(program2) {
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(
3609
+ 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("--here", "with --local: write the binding at THIS directory even when a parent already carries one \u2014 binds a subtree (e.g. a monorepo's frontend/) to its own workspace", 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(
3410
3610
  "after",
3411
3611
  `
3412
3612
  Examples:
@@ -3432,13 +3632,13 @@ Examples:
3432
3632
  if (opts.designLane) process.env.SECHROOM_DESIGN_LANE = opts.designLane;
3433
3633
  if (opts.recurse) {
3434
3634
  const baseUrl2 = resolveBaseUrl(g);
3435
- await ensureAuth({ baseUrl: baseUrl2, tenant: "", clientId: readPersisted().clientId }, yes);
3635
+ await ensureAuth({ baseUrl: baseUrl2, tenant: "", account: resolveAccountAlias(), clientId: readPersisted().clientId }, yes);
3436
3636
  const cfg2 = await ensureTenant(baseUrl2, g, { yes: true, json, persist: false });
3437
3637
  await runRecurse(cfg2, g, { yes, dryRun, json, lane: opts.lane, designLane: opts.designLane });
3438
3638
  return;
3439
3639
  }
3440
3640
  const baseUrl = resolveBaseUrl(g);
3441
- await ensureAuth({ baseUrl, tenant: "", clientId: readPersisted().clientId }, yes);
3641
+ await ensureAuth({ baseUrl, tenant: "", account: resolveAccountAlias(), clientId: readPersisted().clientId }, yes);
3442
3642
  const cfg = await ensureTenant(baseUrl, g, { yes, json, local: Boolean(opts.local), workspace: opts.workspace, persist: !check });
3443
3643
  const tz = await ensureTimezone(cfg, { yes, dryRun: dryRun || check });
3444
3644
  if (!json && tz.action !== "already-set") {
@@ -3935,9 +4135,23 @@ function removeMaterialisedSkills(dir) {
3935
4135
  return removed;
3936
4136
  }
3937
4137
  function registerReset(program2) {
3938
- program2.command("logout").description("Sign out \u2014 remove the cached (global) auth token").action((_opts, cmd) => {
4138
+ program2.command("logout").description("Sign out \u2014 remove the active account's cached token (--account <alias> for a specific one, --all for every account)").option("--account <alias>", "sign a specific account out").option("--all", "sign every account out").action((opts, cmd) => {
4139
+ const json = cmd.optsWithGlobals().json;
4140
+ if (opts.all) {
4141
+ const removed2 = listAccounts().map((a) => a.alias);
4142
+ for (const alias of removed2) removeAccount(alias);
4143
+ if (json) return emit({ removed: removed2 }, true);
4144
+ console.log(removed2.length > 0 ? style.green(`Signed out ${removed2.length} account(s): ${removed2.join(", ")}.`) : style.dim("Already signed out (no accounts)."));
4145
+ return;
4146
+ }
4147
+ if (opts.account) {
4148
+ const ok2 = removeAccount(opts.account);
4149
+ if (json) return emit({ removed: ok2 ? [opts.account] : [] }, true);
4150
+ console.log(ok2 ? style.green(`Signed out account "${opts.account}".`) : style.dim(`No account "${opts.account}".`));
4151
+ return;
4152
+ }
3939
4153
  const removed = clearToken();
3940
- if (cmd.optsWithGlobals().json) return emit({ removed: removed ? [removed] : [] }, true);
4154
+ if (json) return emit({ removed: removed ? [removed] : [] }, true);
3941
4155
  console.log(
3942
4156
  removed ? style.green("Signed out \u2014 auth token removed.") : style.dim("Already signed out (no token).")
3943
4157
  );
@@ -3999,7 +4213,7 @@ function resolveVersion() {
3999
4213
  }
4000
4214
  }
4001
4215
  var program = new Command();
4002
- program.name("sechroom").description("Sechroom CLI \u2014 thin generated client over the Sechroom HTTP API. An agent/human surface alongside MCP.").version(resolveVersion()).option("--base-url <url>", "API base URL (overrides config / SECHROOM_BASE_URL)").option("--tenant <tenant>", "Tenant id (required by the API; overrides config / SECHROOM_TENANT)").option("--json", "Emit compact JSON (for scripts and agents)", false);
4216
+ program.name("sechroom").description("Sechroom CLI \u2014 thin generated client over the Sechroom HTTP API. An agent/human surface alongside MCP.").version(resolveVersion()).option("--base-url <url>", "API base URL (overrides config / SECHROOM_BASE_URL)").option("--tenant <tenant>", "Tenant id (required by the API; overrides config / SECHROOM_TENANT)").option("--binding <name>", "Named workspace binding from .sechroom.json `workspaces` (overrides path auto-selection / SECHROOM_BINDING)").option("--json", "Emit compact JSON (for scripts and agents)", false);
4003
4217
  program.addHelpText(
4004
4218
  "after",
4005
4219
  `
@@ -4032,11 +4246,12 @@ Examples:
4032
4246
  $ sechroom login sign in to the configured base URL + tenant
4033
4247
  $ sechroom login --base-url https://staging.app.sechroom.ai/api
4034
4248
  $ export SECHROOM_TOKEN=<bearer> headless: skip login entirely (CI / agents)`
4035
- ).action(async (_opts, cmd) => {
4249
+ ).option("--account <alias>", "sign in under a named account alias (multi-account; default: the active alias)").action(async (opts, cmd) => {
4036
4250
  const g = cmd.optsWithGlobals();
4037
4251
  const persisted = readPersisted();
4038
4252
  const baseUrl = g.baseUrl ?? process.env.SECHROOM_BASE_URL ?? persisted.baseUrl ?? "https://app.sechroom.ai/api";
4039
- await login({ baseUrl: baseUrl.replace(/\/$/, ""), tenant: g.tenant ?? "" });
4253
+ const account = resolveAccountAlias(opts.account);
4254
+ await login({ baseUrl: baseUrl.replace(/\/$/, ""), tenant: g.tenant ?? "", account });
4040
4255
  });
4041
4256
  var config = program.command("config").description("Manage persisted CLI config");
4042
4257
  config.addHelpText(
@@ -4072,7 +4287,7 @@ config.command("set <key> <value>").description("Set baseUrl | tenant | workspac
4072
4287
  });
4073
4288
  config.command("show").description("Print resolved config + sources (flag > env > local > global > default)").action((_opts, cmd) => {
4074
4289
  const g = cmd.optsWithGlobals();
4075
- const d = describeConfig({ baseUrl: g.baseUrl, tenant: g.tenant });
4290
+ const d = describeConfig({ baseUrl: g.baseUrl, tenant: g.tenant, binding: g.binding });
4076
4291
  if (g.json) {
4077
4292
  process.stdout.write(
4078
4293
  JSON.stringify({
@@ -4093,6 +4308,7 @@ local: ${d.localPath ?? "(none)"} ${JSON.stringify(readLocalConfig())}
4093
4308
  `
4094
4309
  );
4095
4310
  });
4311
+ registerAccounts(program);
4096
4312
  registerMemory(program);
4097
4313
  registerWorklog(program);
4098
4314
  registerLookup(program);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sechroom/cli",
3
- "version": "2026.6.27",
3
+ "version": "2026.6.28",
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",