@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.
- package/README.md +17 -0
- package/dist/index.js +119 -31
- 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,
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
972
|
-
|
|
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