@sechroom/cli 2026.6.5 → 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 +119 -31
  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());
@@ -436,7 +494,7 @@ function registerLookup(program2) {
436
494
 
437
495
  // src/setup/apply.ts
438
496
  import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync2 } from "fs";
439
- import { dirname } from "path";
497
+ import { dirname as dirname2 } from "path";
440
498
 
441
499
  // src/setup/operator-surface.ts
442
500
  var SectionType = {
@@ -538,7 +596,7 @@ async function createOverride(cfg, template, personalWorkspaceId) {
538
596
  var BLOCK_BEGIN = "<!-- @sechroom/cli:begin (managed \u2014 re-run `sechroom setup agent-files` to refresh) -->";
539
597
  var BLOCK_END = "<!-- @sechroom/cli:end -->";
540
598
  function ensureDir2(path) {
541
- mkdirSync2(dirname(path), { recursive: true });
599
+ mkdirSync2(dirname2(path), { recursive: true });
542
600
  }
543
601
  function readOr(path, fallback) {
544
602
  try {
@@ -634,7 +692,7 @@ async function applyClient(cfg, setup, target, opts) {
634
692
  // src/setup/clients.ts
635
693
  import { existsSync as existsSync3 } from "fs";
636
694
  import { homedir as homedir2 } from "os";
637
- import { dirname as dirname2, join as join2 } from "path";
695
+ import { dirname as dirname3, join as join2 } from "path";
638
696
  function claudeDesktopConfigPath(home) {
639
697
  switch (process.platform) {
640
698
  case "darwin":
@@ -680,7 +738,7 @@ function detectInstalledClients(cwd) {
680
738
  const home = homedir2();
681
739
  const detected = [];
682
740
  if (existsSync3(join2(home, ".claude"))) detected.push("claude-code");
683
- if (existsSync3(dirname2(claudeDesktopConfigPath(home)))) detected.push("claude-desktop");
741
+ if (existsSync3(dirname3(claudeDesktopConfigPath(home)))) detected.push("claude-desktop");
684
742
  if (existsSync3(join2(home, ".codex"))) detected.push("codex");
685
743
  if (existsSync3(join2(home, ".cursor")) || existsSync3(join2(cwd, ".cursor"))) detected.push("cursor");
686
744
  return detected;
@@ -958,7 +1016,18 @@ program.command("login").description("Sign in via browser (OAuth auth-code + PKC
958
1016
  await login({ baseUrl: baseUrl.replace(/\/$/, ""), tenant: g.tenant ?? "" });
959
1017
  });
960
1018
  var config = program.command("config").description("Manage persisted CLI config");
961
- 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
+ }
962
1031
  if (!["baseUrl", "tenant", "clientId"].includes(key)) {
963
1032
  process.stderr.write(`unknown key: ${key} (expected baseUrl | tenant | clientId)
964
1033
  `);
@@ -968,8 +1037,27 @@ config.command("set <key> <value>").description("Set baseUrl | tenant | clientId
968
1037
  process.stdout.write(`set ${key}
969
1038
  `);
970
1039
  });
971
- config.command("show").description("Print persisted config").action(() => {
972
- 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
+ );
973
1061
  });
974
1062
  registerMemory(program);
975
1063
  registerWorklog(program);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sechroom/cli",
3
- "version": "2026.6.5",
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",