@sechroom/cli 2026.6.4 → 2026.6.6

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 (3) hide show
  1. package/README.md +17 -0
  2. package/dist/index.js +385 -56
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -100,6 +100,23 @@ sechroom lookup sechroom:mem_XXXX --json # namespaced form also resolves
100
100
  sechroom --json memory get mem_XXXX # agent-friendly
101
101
  ```
102
102
 
103
+ **Per-directory config.** A project dir can pin its own `tenant` + `baseUrl` in a local `.sechroom.json`, discovered by walking **up** from cwd (nearest wins, so any subdir inherits it). It overrides the global config — precedence: `--flag` > env > directory-local > global > default. `clientId` / auth state stays global.
104
+
105
+ ```bash
106
+ sechroom config set --local tenant cli-smoke # this dir + subdirs
107
+ sechroom config set --local baseUrl https://staging.app.sechroom.ai/api
108
+ sechroom config show # resolved values + which source won
109
+ ```
110
+
111
+ **Smoke testing.** There is a dedicated **`cli-smoke`** tenant on **both staging and prod** for exercising the CLI without touching real tenants — point at staging and use it:
112
+
113
+ ```bash
114
+ sechroom config set --local baseUrl https://staging.app.sechroom.ai/api
115
+ sechroom config set --local tenant cli-smoke
116
+ sechroom login
117
+ sechroom worklog append --text "cli smoke" --source claude-code-chris
118
+ ```
119
+
103
120
  Headless:
104
121
 
105
122
  ```bash
package/dist/index.js CHANGED
@@ -11,11 +11,12 @@ import open from "open";
11
11
 
12
12
  // src/config.ts
13
13
  import { homedir } from "os";
14
- import { join } from "path";
14
+ import { join, dirname } from "path";
15
15
  import { mkdirSync, readFileSync, writeFileSync, existsSync } 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 LOCAL_CONFIG_NAME = ".sechroom.json";
19
20
  var DEFAULT_BASE_URL = "https://app.sechroom.ai/api";
20
21
  function ensureDir() {
21
22
  if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
@@ -32,6 +33,13 @@ function writePersisted(patch) {
32
33
  const next = { ...readPersisted(), ...patch };
33
34
  writeFileSync(CONFIG_FILE, JSON.stringify(next, null, 2), { mode: 384 });
34
35
  }
36
+ function readDcrClientId(baseUrl) {
37
+ return readPersisted().clientIds?.[baseUrl];
38
+ }
39
+ function writeDcrClientId(baseUrl, clientId) {
40
+ const p = readPersisted();
41
+ writePersisted({ clientIds: { ...p.clientIds ?? {}, [baseUrl]: clientId } });
42
+ }
35
43
  function readToken() {
36
44
  const envTok = process.env.SECHROOM_TOKEN;
37
45
  if (envTok) return { accessToken: envTok };
@@ -45,17 +53,67 @@ function writeToken(tok) {
45
53
  ensureDir();
46
54
  writeFileSync(TOKEN_FILE, JSON.stringify(tok, null, 2), { mode: 384 });
47
55
  }
56
+ function findLocalConfigPath(start = process.cwd()) {
57
+ let dir = start;
58
+ for (; ; ) {
59
+ const candidate = join(dir, LOCAL_CONFIG_NAME);
60
+ if (existsSync(candidate)) return candidate;
61
+ const parent = dirname(dir);
62
+ if (parent === dir) return void 0;
63
+ dir = parent;
64
+ }
65
+ }
66
+ function readLocalConfig() {
67
+ const path = findLocalConfigPath();
68
+ if (!path) return {};
69
+ try {
70
+ const c = JSON.parse(readFileSync(path, "utf8"));
71
+ return { baseUrl: c.baseUrl, tenant: c.tenant, path };
72
+ } catch {
73
+ return {};
74
+ }
75
+ }
76
+ function writeLocalConfig(patch) {
77
+ const path = findLocalConfigPath() ?? join(process.cwd(), LOCAL_CONFIG_NAME);
78
+ let current = {};
79
+ try {
80
+ current = JSON.parse(readFileSync(path, "utf8"));
81
+ } catch {
82
+ }
83
+ writeFileSync(path, JSON.stringify({ ...current, ...patch }, null, 2), { mode: 384 });
84
+ return path;
85
+ }
48
86
  function resolveConfig(flags) {
87
+ const local = readLocalConfig();
49
88
  const persisted = readPersisted();
50
- const baseUrl = flags.baseUrl ?? process.env.SECHROOM_BASE_URL ?? persisted.baseUrl ?? DEFAULT_BASE_URL;
51
- const tenant = flags.tenant ?? process.env.SECHROOM_TENANT ?? persisted.tenant ?? "";
89
+ const baseUrl = flags.baseUrl ?? process.env.SECHROOM_BASE_URL ?? local.baseUrl ?? persisted.baseUrl ?? DEFAULT_BASE_URL;
90
+ const tenant = flags.tenant ?? process.env.SECHROOM_TENANT ?? local.tenant ?? persisted.tenant ?? "";
52
91
  if (!tenant) {
53
92
  throw new Error(
54
- "No tenant set. The Sechroom API rejects untenanted requests (HTTP 400). Pass --tenant <id>, set SECHROOM_TENANT, or run `sechroom config set tenant <id>`."
93
+ "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."
55
94
  );
56
95
  }
57
96
  return { baseUrl: baseUrl.replace(/\/$/, ""), tenant, clientId: persisted.clientId };
58
97
  }
98
+ function describeConfig(flags) {
99
+ const local = readLocalConfig();
100
+ const g = readPersisted();
101
+ const localTag = local.path ? `local (${local.path})` : "local";
102
+ const pick = (flag, env, l, gl, def) => {
103
+ if (flag) return { value: flag, source: "flag" };
104
+ if (env) return { value: env, source: "env" };
105
+ if (l) return { value: l, source: localTag };
106
+ if (gl) return { value: gl, source: "global" };
107
+ if (def) return { value: def, source: "default" };
108
+ return { value: void 0, source: "unset" };
109
+ };
110
+ const baseUrl = pick(flags.baseUrl, process.env.SECHROOM_BASE_URL, local.baseUrl, g.baseUrl, DEFAULT_BASE_URL);
111
+ return {
112
+ baseUrl: { value: baseUrl.value, source: baseUrl.source },
113
+ tenant: pick(flags.tenant, process.env.SECHROOM_TENANT, local.tenant, g.tenant),
114
+ localPath: local.path
115
+ };
116
+ }
59
117
 
60
118
  // src/auth.ts
61
119
  var SCOPES = "openid email profile";
@@ -72,26 +130,26 @@ async function discover(baseUrl) {
72
130
  if (!res.ok) throw new Error(`AS discovery failed (${res.status}) at ${baseUrl}`);
73
131
  return await res.json();
74
132
  }
75
- async function ensureClientId(meta) {
76
- const cached = readPersisted().clientId;
77
- if (cached) return cached;
78
- if (!meta.registration_endpoint) {
79
- throw new Error(
80
- "Server advertises no registration_endpoint and no client_id is cached. Pre-register a client and run `sechroom config set clientId <id>`."
81
- );
133
+ async function ensureClientId(meta, baseUrl) {
134
+ if (meta.registration_endpoint) {
135
+ const res = await fetch(meta.registration_endpoint, {
136
+ method: "POST",
137
+ headers: { "content-type": "application/json" },
138
+ body: JSON.stringify({
139
+ client_name: "sechroom-cli",
140
+ redirect_uris: CANDIDATE_PORTS.map(redirectUriFor)
141
+ })
142
+ });
143
+ if (!res.ok) throw new Error(`Dynamic client registration failed (${res.status}): ${await res.text()}`);
144
+ const reg = await res.json();
145
+ writeDcrClientId(baseUrl, reg.client_id);
146
+ return reg.client_id;
82
147
  }
83
- const res = await fetch(meta.registration_endpoint, {
84
- method: "POST",
85
- headers: { "content-type": "application/json" },
86
- body: JSON.stringify({
87
- client_name: "sechroom-cli",
88
- redirect_uris: CANDIDATE_PORTS.map(redirectUriFor)
89
- })
90
- });
91
- if (!res.ok) throw new Error(`Dynamic client registration failed (${res.status}): ${await res.text()}`);
92
- const reg = await res.json();
93
- writePersisted({ clientId: reg.client_id });
94
- return reg.client_id;
148
+ const fallback = readPersisted().clientId ?? readDcrClientId(baseUrl);
149
+ if (fallback) return fallback;
150
+ throw new Error(
151
+ "Server advertises no registration_endpoint and no client_id is configured. Pre-register a client and run `sechroom config set clientId <id>`."
152
+ );
95
153
  }
96
154
  function startLoopback(state) {
97
155
  return new Promise((resolveOuter, rejectOuter) => {
@@ -162,7 +220,7 @@ function persistTokenResponse(json) {
162
220
  }
163
221
  async function login(cfg) {
164
222
  const meta = await discover(cfg.baseUrl);
165
- const clientId = await ensureClientId(meta);
223
+ const clientId = await ensureClientId(meta, cfg.baseUrl);
166
224
  const state = b64url(randomBytes(16));
167
225
  const verifier = b64url(randomBytes(32));
168
226
  const challenge = b64url(createHash("sha256").update(verifier).digest());
@@ -255,6 +313,37 @@ function spinner(text) {
255
313
  stop: clear
256
314
  };
257
315
  }
316
+ function canPrompt() {
317
+ return !quiet && Boolean(process.stdin.isTTY) && Boolean(process.stderr.isTTY);
318
+ }
319
+ async function promptYesNo(question) {
320
+ if (!canPrompt()) return false;
321
+ const { createInterface } = await import("readline");
322
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
323
+ try {
324
+ const answer = await new Promise((resolve) => {
325
+ rl.question(`${question} [y/N] `, resolve);
326
+ });
327
+ return /^y(es)?$/i.test(answer.trim());
328
+ } finally {
329
+ rl.close();
330
+ }
331
+ }
332
+ async function promptText(question, def) {
333
+ if (!canPrompt()) return def ?? "";
334
+ const { createInterface } = await import("readline");
335
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
336
+ try {
337
+ const suffix = def ? ` [${def}]` : "";
338
+ const answer = await new Promise((resolve) => {
339
+ rl.question(`${question}${suffix} `, resolve);
340
+ });
341
+ const trimmed = answer.trim();
342
+ return trimmed.length > 0 ? trimmed : def ?? "";
343
+ } finally {
344
+ rl.close();
345
+ }
346
+ }
258
347
  async function withSpinner(text, fn) {
259
348
  const s = spinner(text);
260
349
  try {
@@ -405,7 +494,7 @@ function registerLookup(program2) {
405
494
 
406
495
  // src/setup/apply.ts
407
496
  import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync2 } from "fs";
408
- import { dirname } from "path";
497
+ import { dirname as dirname2 } from "path";
409
498
 
410
499
  // src/setup/operator-surface.ts
411
500
  var SectionType = {
@@ -440,43 +529,74 @@ function parseTagArtifactId(id) {
440
529
  const tags = id.slice("tag:".length).split(",").map((t) => t.trim()).filter((t) => t.length > 0);
441
530
  return tags.length > 0 ? tags : null;
442
531
  }
443
- async function resolveInstructionBody(cfg, section) {
532
+ async function getPersonalWorkspaceId(cfg) {
533
+ const client = await makeClient(cfg);
534
+ const { data } = await client.GET("/me/personal-workspace", {});
535
+ return data?.workspaceId ?? null;
536
+ }
537
+ async function fetchMemoryFields(cfg, id) {
538
+ const client = await makeClient(cfg);
539
+ const { data } = await client.GET("/memories/{memoryId}", { params: { path: { memoryId: id } } });
540
+ const env = data;
541
+ return env?.item ?? env ?? null;
542
+ }
543
+ async function resolveInstruction(cfg, section, personalWorkspaceId) {
444
544
  const client = await makeClient(cfg);
445
545
  for (const artifact of section.artifacts) {
446
546
  const tags = parseTagArtifactId(artifact.id);
447
547
  if (!tags) continue;
448
548
  const { data } = await client.POST("/memories/search", {
449
- body: {
450
- query: null,
451
- textQuery: null,
452
- semanticQuery: artifact.title ?? "role instruction template",
453
- hybrid: true,
454
- limit: 1,
455
- includeArchived: false,
456
- includeSystem: false,
457
- tags
458
- }
549
+ body: { query: null, textQuery: null, semanticQuery: artifact.title ?? "role instruction template", hybrid: true, limit: 1, includeArchived: false, includeSystem: false, tags }
459
550
  });
460
551
  const hits = data ?? [];
461
552
  if (hits.length === 0) continue;
462
- const memId = hits[0].id;
463
- const { data: memEnvelope } = await client.GET("/memories/{memoryId}", {
464
- params: { path: { memoryId: memId } }
465
- });
466
- const env = memEnvelope;
467
- const body = env?.item?.text ?? env?.text;
468
- if (typeof body === "string" && body.length > 0) {
469
- return { title: env?.item?.title ?? env?.title ?? artifact.title, body };
553
+ const templateId = hits[0].id;
554
+ const template = await fetchMemoryFields(cfg, templateId);
555
+ if (typeof template?.text !== "string" || template.text.length === 0) continue;
556
+ const templateTags = template.tags ?? tags;
557
+ if (personalWorkspaceId) {
558
+ const { data: ovr } = await client.POST("/memories/search", {
559
+ body: { query: null, textQuery: null, semanticQuery: "role override", hybrid: true, limit: 1, includeArchived: false, includeSystem: false, tags: ["sechroom:role:override", `sechroom:template-ref:${templateId}`], owner: { type: "Workspace", id: personalWorkspaceId } }
560
+ });
561
+ const ovrHits = ovr ?? [];
562
+ if (ovrHits.length > 0) {
563
+ const override = await fetchMemoryFields(cfg, ovrHits[0].id);
564
+ if (typeof override?.text === "string" && override.text.length > 0) {
565
+ return { title: override.title ?? template.title ?? artifact.title, body: override.text, source: "override", templateId, templateTags };
566
+ }
567
+ }
470
568
  }
569
+ return { title: template.title ?? artifact.title, body: template.text, source: "template", templateId, templateTags };
471
570
  }
472
571
  return null;
473
572
  }
573
+ async function createOverride(cfg, template, personalWorkspaceId) {
574
+ const client = await makeClient(cfg);
575
+ const overrideTags = template.templateTags.filter(
576
+ (t) => t !== "sechroom:role:template" && !t.startsWith("sechroom:bundle:") && !t.startsWith("sechroom:template-ref:")
577
+ );
578
+ overrideTags.push("sechroom:role:override", `sechroom:template-ref:${template.templateId}`);
579
+ const { error } = await client.POST("/memories", {
580
+ body: {
581
+ text: template.body,
582
+ type: "reference",
583
+ content: "{}",
584
+ confidence: 1,
585
+ source: "cli-agent-instructions-customize",
586
+ archetype: "Document",
587
+ title: template.title ?? null,
588
+ tags: overrideTags,
589
+ owner: { type: "Workspace", id: personalWorkspaceId }
590
+ }
591
+ });
592
+ if (error) throw new Error(`creating personal copy failed: ${JSON.stringify(error)}`);
593
+ }
474
594
 
475
595
  // src/setup/apply.ts
476
596
  var BLOCK_BEGIN = "<!-- @sechroom/cli:begin (managed \u2014 re-run `sechroom setup agent-files` to refresh) -->";
477
597
  var BLOCK_END = "<!-- @sechroom/cli:end -->";
478
598
  function ensureDir2(path) {
479
- mkdirSync2(dirname(path), { recursive: true });
599
+ mkdirSync2(dirname2(path), { recursive: true });
480
600
  }
481
601
  function readOr(path, fallback) {
482
602
  try {
@@ -557,11 +677,12 @@ async function applyClient(cfg, setup, target, opts) {
557
677
  if (!section) {
558
678
  actions.push({ kind: "instruction", path: target.instruction.path, status: "skipped", note: `no instruction-file section on surface '${target.instruction.surfaceKey}'` });
559
679
  } else {
560
- const resolved = await resolveInstructionBody(cfg, section);
680
+ const resolved = await resolveInstruction(cfg, section, opts.personalWorkspaceId);
561
681
  if (!resolved) {
562
682
  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`" });
563
683
  } else {
564
- actions.push(writeInstructionBlock(target.instruction.path, resolved.body, opts.dryRun));
684
+ const action = writeInstructionBlock(target.instruction.path, resolved.body, opts.dryRun);
685
+ actions.push(resolved.source === "override" ? { ...action, note: "your personal copy" } : action);
565
686
  }
566
687
  }
567
688
  }
@@ -569,8 +690,9 @@ async function applyClient(cfg, setup, target, opts) {
569
690
  }
570
691
 
571
692
  // src/setup/clients.ts
693
+ import { existsSync as existsSync3 } from "fs";
572
694
  import { homedir as homedir2 } from "os";
573
- import { join as join2 } from "path";
695
+ import { dirname as dirname3, join as join2 } from "path";
574
696
  function claudeDesktopConfigPath(home) {
575
697
  switch (process.platform) {
576
698
  case "darwin":
@@ -612,8 +734,49 @@ function clientTargets(cwd) {
612
734
  }
613
735
  var ALL_CLIENT_KEYS = ["claude-code", "claude-desktop", "codex", "cursor"];
614
736
  var DEFAULT_CLIENT_KEY = "claude-code";
737
+ function detectInstalledClients(cwd) {
738
+ const home = homedir2();
739
+ const detected = [];
740
+ if (existsSync3(join2(home, ".claude"))) detected.push("claude-code");
741
+ if (existsSync3(dirname3(claudeDesktopConfigPath(home)))) detected.push("claude-desktop");
742
+ if (existsSync3(join2(home, ".codex"))) detected.push("codex");
743
+ if (existsSync3(join2(home, ".cursor")) || existsSync3(join2(cwd, ".cursor"))) detected.push("cursor");
744
+ return detected;
745
+ }
615
746
 
616
747
  // src/commands/setup.ts
748
+ function copyChoice(opts) {
749
+ return opts.copy === true ? "yes" : opts.copy === false ? "no" : "ask";
750
+ }
751
+ async function maybeOfferCopies(cfg, setup, targets, keys, personalWorkspaceId, choice) {
752
+ if (!personalWorkspaceId || choice === "no") return;
753
+ const seen = /* @__PURE__ */ new Set();
754
+ for (const key of keys) {
755
+ const instr = targets[key]?.instruction;
756
+ if (!instr || seen.has(instr.surfaceKey)) continue;
757
+ seen.add(instr.surfaceKey);
758
+ const section = findSection(findSurface(setup, instr.surfaceKey), SectionType.InstructionFile);
759
+ if (!section) continue;
760
+ const resolved = await resolveInstruction(cfg, section, personalWorkspaceId);
761
+ if (!resolved || resolved.source === "override") continue;
762
+ let make = choice === "yes";
763
+ if (choice === "ask") {
764
+ process.stderr.write(
765
+ `
766
+ The ${instr.surfaceKey} agent instructions are the shared template.
767
+ Make a personal copy to tailor them for your workflow \u2014 your agent uses your
768
+ version, the shared template stays clean, and you can discard back anytime.
769
+ `
770
+ );
771
+ make = await promptYesNo("Make a personal copy to customise?");
772
+ }
773
+ if (make) {
774
+ await createOverride(cfg, resolved, personalWorkspaceId);
775
+ process.stderr.write(`\u2713 personal copy created for ${instr.surfaceKey} \u2014 edit it on the Agent setup page or via the API.
776
+ `);
777
+ }
778
+ }
779
+ }
617
780
  function resolveClientKeys(raw) {
618
781
  const targets = clientTargets(process.cwd());
619
782
  if (raw === "all") return [...ALL_CLIENT_KEYS];
@@ -636,19 +799,24 @@ ${client.label} (${client.key}):
636
799
  }
637
800
  }
638
801
  function registerInit(program2) {
639
- program2.command("init").description("Wire this project for sechroom: write MCP config + agent instruction files from the server's setup descriptors").option("--client <list>", `comma-separated clients (${ALL_CLIENT_KEYS.join(", ")}) or 'all'`, DEFAULT_CLIENT_KEY).option("--dry-run", "print what would be written without writing", false).option("--mcp-only", "only write MCP config (skip agent files)", false).option("--agent-files-only", "only write agent instruction files (skip MCP config)", false).action(async (opts, cmd) => {
802
+ program2.command("init").description("Wire this project for sechroom: write MCP config + agent instruction files from the server's setup descriptors").option("--client <list>", `comma-separated clients (${ALL_CLIENT_KEYS.join(", ")}) or 'all'`, DEFAULT_CLIENT_KEY).option("--dry-run", "print what would be written without writing", false).option("--mcp-only", "only write MCP config (skip agent files)", false).option("--agent-files-only", "only write agent instruction files (skip MCP config)", false).option("--copy", "make a personal copy of the agent instructions you can edit (default: prompt on a TTY, else skip)").action(async (opts, cmd) => {
640
803
  const cfg = resolveConfig(cmd.optsWithGlobals());
641
804
  const setup = await withSpinner("Fetching setup descriptors", () => fetchSetup(cfg));
642
805
  const targets = clientTargets(process.cwd());
643
806
  const keys = resolveClientKeys(opts.client);
644
807
  const json = cmd.optsWithGlobals().json;
808
+ const personalWorkspaceId = await getPersonalWorkspaceId(cfg);
809
+ if (!opts.dryRun && !opts.mcpOnly) {
810
+ await maybeOfferCopies(cfg, setup, targets, keys, personalWorkspaceId, copyChoice(opts));
811
+ }
645
812
  const result = [];
646
813
  for (const key of keys) {
647
814
  const target = targets[key];
648
815
  const actions = await applyClient(cfg, setup, target, {
649
816
  dryRun: Boolean(opts.dryRun),
650
817
  mcp: !opts.agentFilesOnly,
651
- agentFiles: !opts.mcpOnly
818
+ agentFiles: !opts.mcpOnly,
819
+ personalWorkspaceId
652
820
  });
653
821
  result.push({ client: key, actions });
654
822
  if (!json) printActions(target, actions);
@@ -675,8 +843,8 @@ function registerSetup(program2) {
675
843
  setup.command("mcp <client>").description(`Write only the MCP config for a client (${ALL_CLIENT_KEYS.join(", ")})`).option("--dry-run", "print what would be written without writing", false).action(async (client, opts, cmd) => {
676
844
  await runSingle(client, cmd, { dryRun: Boolean(opts.dryRun), mcp: true, agentFiles: false });
677
845
  });
678
- setup.command("agent-files <client>").description(`Write only the agent instruction file for a client (${ALL_CLIENT_KEYS.join(", ")})`).option("--dry-run", "print what would be written without writing", false).action(async (client, opts, cmd) => {
679
- await runSingle(client, cmd, { dryRun: Boolean(opts.dryRun), mcp: false, agentFiles: true });
846
+ setup.command("agent-files <client>").description(`Write only the agent instruction file for a client (${ALL_CLIENT_KEYS.join(", ")})`).option("--dry-run", "print what would be written without writing", false).option("--copy", "make a personal copy you can edit (default: prompt on a TTY, else skip)").action(async (client, opts, cmd) => {
847
+ await runSingle(client, cmd, { dryRun: Boolean(opts.dryRun), mcp: false, agentFiles: true, copy: opts.copy });
680
848
  });
681
849
  }
682
850
  async function runSingle(client, cmd, opts) {
@@ -685,7 +853,16 @@ async function runSingle(client, cmd, opts) {
685
853
  const target = targets[client];
686
854
  if (!target) fail(`unknown client '${client}'. Known: ${ALL_CLIENT_KEYS.join(", ")}.`);
687
855
  const setupData = await withSpinner("Fetching setup descriptors", () => fetchSetup(cfg));
688
- const actions = await applyClient(cfg, setupData, target, opts);
856
+ const personalWorkspaceId = await getPersonalWorkspaceId(cfg);
857
+ if (opts.agentFiles && !opts.dryRun) {
858
+ await maybeOfferCopies(cfg, setupData, targets, [client], personalWorkspaceId, copyChoice(opts));
859
+ }
860
+ const actions = await applyClient(cfg, setupData, target, {
861
+ dryRun: opts.dryRun,
862
+ mcp: opts.mcp,
863
+ agentFiles: opts.agentFiles,
864
+ personalWorkspaceId
865
+ });
689
866
  const json = cmd.optsWithGlobals().json;
690
867
  if (json) {
691
868
  emit({ dryRun: opts.dryRun, client, actions }, true);
@@ -695,6 +872,127 @@ async function runSingle(client, cmd, opts) {
695
872
  }
696
873
  }
697
874
 
875
+ // src/commands/onboard.ts
876
+ var DEFAULT_BASE_URL2 = "https://app.sechroom.ai/api";
877
+ function systemTimezone() {
878
+ try {
879
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
880
+ } catch {
881
+ return "UTC";
882
+ }
883
+ }
884
+ async function ensureConfig(g, yes) {
885
+ const persisted = readPersisted();
886
+ let baseUrl = g.baseUrl ?? process.env.SECHROOM_BASE_URL ?? persisted.baseUrl ?? DEFAULT_BASE_URL2;
887
+ let tenant = g.tenant ?? process.env.SECHROOM_TENANT ?? persisted.tenant ?? "";
888
+ if (canPrompt() && !yes) {
889
+ baseUrl = await promptText("Sechroom API base URL?", baseUrl);
890
+ tenant = await promptText("Tenant id?", tenant || void 0);
891
+ }
892
+ baseUrl = baseUrl.replace(/\/$/, "");
893
+ if (!tenant) {
894
+ fail(
895
+ "No tenant set. Pass --tenant <id>, set SECHROOM_TENANT, or run `sechroom config set tenant <id>` \u2014 the API rejects untenanted requests (HTTP 400)."
896
+ );
897
+ }
898
+ writePersisted({ baseUrl, tenant });
899
+ return { baseUrl, tenant, clientId: persisted.clientId };
900
+ }
901
+ async function ensureAuth(cfg, yes) {
902
+ if (process.env.SECHROOM_TOKEN) return;
903
+ const cached = readToken();
904
+ const usable = Boolean(cached?.accessToken) && (cached.expiresAt === void 0 || Date.now() < cached.expiresAt - 6e4 || Boolean(cached.refreshToken));
905
+ if (usable) return;
906
+ if (!canPrompt() || yes) {
907
+ fail("Not signed in. Run `sechroom login` first, or set SECHROOM_TOKEN for headless use.");
908
+ }
909
+ process.stderr.write("\nNot signed in \u2014 opening the browser to authenticate.\n");
910
+ await login(cfg);
911
+ }
912
+ async function ensureTimezone(cfg, opts) {
913
+ const client = await makeClient(cfg);
914
+ const { data, error } = await client.GET("/me/profile", {});
915
+ if (error) return { timezone: null, action: "skipped", note: "could not read profile" };
916
+ const current = data?.effectiveTimezone;
917
+ if (current && current.trim().length > 0) return { timezone: current, action: "already-set" };
918
+ const system = systemTimezone();
919
+ let tz = system;
920
+ if (canPrompt() && !opts.yes) {
921
+ tz = await promptText("Your timezone (IANA, e.g. Europe/London)?", system);
922
+ } else if (!opts.yes) {
923
+ return {
924
+ timezone: null,
925
+ action: "skipped",
926
+ note: "no timezone set \u2014 re-run interactively or pass --yes to adopt the system timezone"
927
+ };
928
+ }
929
+ if (!tz) return { timezone: null, action: "skipped", note: "no timezone provided" };
930
+ if (opts.dryRun) return { timezone: tz, action: "dry-run" };
931
+ const { error: putErr } = await client.PUT("/me/profile", {
932
+ body: { displayName: null, photoUrl: null, bio: null, timezone: tz }
933
+ });
934
+ if (putErr) return { timezone: tz, action: "skipped", note: `update failed: ${JSON.stringify(putErr)}` };
935
+ return { timezone: tz, action: "set" };
936
+ }
937
+ async function chooseClients(clientFlag, yes, cwd) {
938
+ if (clientFlag) return resolveClientKeys(clientFlag);
939
+ const detected = detectInstalledClients(cwd);
940
+ const fallback = (detected.length > 0 ? detected : [DEFAULT_CLIENT_KEY]).join(",");
941
+ if (!canPrompt() || yes) return resolveClientKeys(fallback);
942
+ process.stderr.write(
943
+ `
944
+ Available clients: ${ALL_CLIENT_KEYS.join(", ")}
945
+ ` + (detected.length > 0 ? `Detected on this machine: ${detected.join(", ")}
946
+ ` : "No clients auto-detected.\n")
947
+ );
948
+ const answer = await promptText("Which to wire? (comma-separated, or 'all')", fallback);
949
+ return resolveClientKeys(answer);
950
+ }
951
+ function registerOnboard(program2) {
952
+ 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("--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)", false).action(async (opts, cmd) => {
953
+ const g = cmd.optsWithGlobals();
954
+ const json = Boolean(g.json);
955
+ const yes = Boolean(opts.yes);
956
+ const dryRun = Boolean(opts.dryRun);
957
+ const cfg = await ensureConfig(g, yes);
958
+ await ensureAuth(cfg, yes);
959
+ const tz = await ensureTimezone(cfg, { yes, dryRun });
960
+ if (!json && tz.action !== "already-set") {
961
+ const line = tz.action === "set" ? `\u2713 timezone set to ${tz.timezone}
962
+ ` : tz.action === "dry-run" ? `(dry run \u2014 would set timezone to ${tz.timezone})
963
+ ` : `timezone not set \u2014 ${tz.note}
964
+ `;
965
+ process.stderr.write(line);
966
+ }
967
+ const keys = await chooseClients(opts.client, yes, process.cwd());
968
+ const setup = await withSpinner("Fetching setup descriptors", () => fetchSetup(cfg));
969
+ const targets = clientTargets(process.cwd());
970
+ const personalWorkspaceId = await getPersonalWorkspaceId(cfg);
971
+ if (!dryRun) {
972
+ await maybeOfferCopies(cfg, setup, targets, keys, personalWorkspaceId, copyChoice(opts));
973
+ }
974
+ const result = [];
975
+ for (const key of keys) {
976
+ const target = targets[key];
977
+ const actions = await applyClient(cfg, setup, target, {
978
+ dryRun,
979
+ mcp: true,
980
+ agentFiles: true,
981
+ personalWorkspaceId
982
+ });
983
+ result.push({ client: key, actions });
984
+ if (!json) printActions(target, actions);
985
+ }
986
+ if (json) {
987
+ emit({ dryRun, baseUrl: cfg.baseUrl, tenant: cfg.tenant, timezone: tz, clients: result }, true);
988
+ return;
989
+ }
990
+ process.stdout.write(
991
+ dryRun ? "\n(dry run \u2014 nothing written)\n" : "\nDone. Restart your AI client (or reload MCP) to pick up the new config.\n"
992
+ );
993
+ });
994
+ }
995
+
698
996
  // src/index.ts
699
997
  function resolveVersion() {
700
998
  try {
@@ -718,7 +1016,18 @@ program.command("login").description("Sign in via browser (OAuth auth-code + PKC
718
1016
  await login({ baseUrl: baseUrl.replace(/\/$/, ""), tenant: g.tenant ?? "" });
719
1017
  });
720
1018
  var config = program.command("config").description("Manage persisted CLI config");
721
- config.command("set <key> <value>").description("Set baseUrl | tenant | clientId").action((key, value) => {
1019
+ 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) => {
1020
+ if (opts.local) {
1021
+ if (!["baseUrl", "tenant"].includes(key)) {
1022
+ process.stderr.write(`--local supports only: baseUrl | tenant (clientId is global)
1023
+ `);
1024
+ process.exit(1);
1025
+ }
1026
+ const path = writeLocalConfig({ [key]: value });
1027
+ process.stdout.write(`set ${key} (local: ${path})
1028
+ `);
1029
+ return;
1030
+ }
722
1031
  if (!["baseUrl", "tenant", "clientId"].includes(key)) {
723
1032
  process.stderr.write(`unknown key: ${key} (expected baseUrl | tenant | clientId)
724
1033
  `);
@@ -728,14 +1037,34 @@ config.command("set <key> <value>").description("Set baseUrl | tenant | clientId
728
1037
  process.stdout.write(`set ${key}
729
1038
  `);
730
1039
  });
731
- config.command("show").description("Print persisted config").action(() => {
732
- process.stdout.write(JSON.stringify(readPersisted(), null, 2) + "\n");
1040
+ config.command("show").description("Print resolved config + sources (flag > env > local > global > default)").action((_opts, cmd) => {
1041
+ const g = cmd.optsWithGlobals();
1042
+ const d = describeConfig({ baseUrl: g.baseUrl, tenant: g.tenant });
1043
+ if (g.json) {
1044
+ process.stdout.write(
1045
+ JSON.stringify({
1046
+ resolved: { baseUrl: d.baseUrl, tenant: d.tenant },
1047
+ global: readPersisted(),
1048
+ local: readLocalConfig()
1049
+ }) + "\n"
1050
+ );
1051
+ return;
1052
+ }
1053
+ process.stdout.write(
1054
+ `baseUrl: ${d.baseUrl.value} [${d.baseUrl.source}]
1055
+ tenant: ${d.tenant.value ?? "(unset)"} [${d.tenant.source}]
1056
+
1057
+ global: ${JSON.stringify(readPersisted())}
1058
+ local: ${d.localPath ?? "(none)"} ${JSON.stringify(readLocalConfig())}
1059
+ `
1060
+ );
733
1061
  });
734
1062
  registerMemory(program);
735
1063
  registerWorklog(program);
736
1064
  registerLookup(program);
737
1065
  registerInit(program);
738
1066
  registerSetup(program);
1067
+ registerOnboard(program);
739
1068
  program.parseAsync().catch((err) => {
740
1069
  process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}
741
1070
  `);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sechroom/cli",
3
- "version": "2026.6.4",
3
+ "version": "2026.6.6",
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",