@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.
- package/README.md +17 -0
- package/dist/index.js +385 -56
- 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());
|
|
@@ -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
|
|
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
|
|
463
|
-
const
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
732
|
-
|
|
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