@sechroom/cli 2026.6.4 → 2026.6.5-rc.a010619f
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 +75 -1
- package/dist/index.js +3668 -293
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { readFileSync as
|
|
4
|
+
import { readFileSync as readFileSync8 } from "fs";
|
|
5
5
|
import { Command } from "commander";
|
|
6
6
|
|
|
7
7
|
// src/auth.ts
|
|
@@ -11,12 +11,17 @@ import open from "open";
|
|
|
11
11
|
|
|
12
12
|
// src/config.ts
|
|
13
13
|
import { homedir } from "os";
|
|
14
|
-
import { join } from "path";
|
|
15
|
-
import { mkdirSync, readFileSync, writeFileSync, existsSync } from "fs";
|
|
14
|
+
import { join, dirname } from "path";
|
|
15
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } from "fs";
|
|
16
16
|
var CONFIG_DIR = join(homedir(), ".config", "sechroom");
|
|
17
17
|
var CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
18
18
|
var TOKEN_FILE = join(CONFIG_DIR, "token.json");
|
|
19
|
+
var STATE_DIR_NAME = ".sechroom";
|
|
20
|
+
var BASELINE_CONFIG_NAME = ".sechroom.json";
|
|
21
|
+
var OVERRIDE_CONFIG_NAME = join(STATE_DIR_NAME, "config.json");
|
|
22
|
+
var BINDING_FIELDS = ["schemaVersion", "baseUrl", "tenant", "workspaceId", "defaultProjectId"];
|
|
19
23
|
var DEFAULT_BASE_URL = "https://app.sechroom.ai/api";
|
|
24
|
+
var LOCAL_CONFIG_SCHEMA_VERSION = 2;
|
|
20
25
|
function ensureDir() {
|
|
21
26
|
if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
|
|
22
27
|
}
|
|
@@ -32,6 +37,13 @@ function writePersisted(patch) {
|
|
|
32
37
|
const next = { ...readPersisted(), ...patch };
|
|
33
38
|
writeFileSync(CONFIG_FILE, JSON.stringify(next, null, 2), { mode: 384 });
|
|
34
39
|
}
|
|
40
|
+
function readDcrClientId(baseUrl) {
|
|
41
|
+
return readPersisted().clientIds?.[baseUrl];
|
|
42
|
+
}
|
|
43
|
+
function writeDcrClientId(baseUrl, clientId) {
|
|
44
|
+
const p = readPersisted();
|
|
45
|
+
writePersisted({ clientIds: { ...p.clientIds ?? {}, [baseUrl]: clientId } });
|
|
46
|
+
}
|
|
35
47
|
function readToken() {
|
|
36
48
|
const envTok = process.env.SECHROOM_TOKEN;
|
|
37
49
|
if (envTok) return { accessToken: envTok };
|
|
@@ -45,16 +57,99 @@ function writeToken(tok) {
|
|
|
45
57
|
ensureDir();
|
|
46
58
|
writeFileSync(TOKEN_FILE, JSON.stringify(tok, null, 2), { mode: 384 });
|
|
47
59
|
}
|
|
60
|
+
function clearToken() {
|
|
61
|
+
if (!existsSync(TOKEN_FILE)) return void 0;
|
|
62
|
+
rmSync(TOKEN_FILE);
|
|
63
|
+
return TOKEN_FILE;
|
|
64
|
+
}
|
|
65
|
+
function clearPersisted() {
|
|
66
|
+
if (!existsSync(CONFIG_FILE)) return void 0;
|
|
67
|
+
rmSync(CONFIG_FILE);
|
|
68
|
+
return CONFIG_FILE;
|
|
69
|
+
}
|
|
70
|
+
function readJsonConfig(path) {
|
|
71
|
+
try {
|
|
72
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
73
|
+
} catch {
|
|
74
|
+
return void 0;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function findConfigHome(start = process.cwd()) {
|
|
78
|
+
let dir = start;
|
|
79
|
+
for (; ; ) {
|
|
80
|
+
if (existsSync(join(dir, BASELINE_CONFIG_NAME)) || existsSync(join(dir, OVERRIDE_CONFIG_NAME))) return dir;
|
|
81
|
+
const parent = dirname(dir);
|
|
82
|
+
if (parent === dir) return void 0;
|
|
83
|
+
dir = parent;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function readLocalConfig() {
|
|
87
|
+
const home = findConfigHome();
|
|
88
|
+
if (!home) return {};
|
|
89
|
+
const baselinePath = join(home, BASELINE_CONFIG_NAME);
|
|
90
|
+
const overridePath = join(home, OVERRIDE_CONFIG_NAME);
|
|
91
|
+
const merged = { ...readJsonConfig(baselinePath) ?? {}, ...readJsonConfig(overridePath) ?? {} };
|
|
92
|
+
return {
|
|
93
|
+
schemaVersion: merged.schemaVersion,
|
|
94
|
+
baseUrl: merged.baseUrl,
|
|
95
|
+
tenant: merged.tenant,
|
|
96
|
+
workspaceId: merged.workspaceId,
|
|
97
|
+
defaultProjectId: merged.defaultProjectId,
|
|
98
|
+
path: existsSync(baselinePath) ? baselinePath : overridePath
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
function writeLocalConfig(patch) {
|
|
102
|
+
const home = findConfigHome() ?? process.cwd();
|
|
103
|
+
const baselinePath = join(home, BASELINE_CONFIG_NAME);
|
|
104
|
+
const overridePath = join(home, OVERRIDE_CONFIG_NAME);
|
|
105
|
+
const current = readJsonConfig(baselinePath) ?? {};
|
|
106
|
+
const next = { ...current, ...patch, schemaVersion: LOCAL_CONFIG_SCHEMA_VERSION };
|
|
107
|
+
writeFileSync(baselinePath, JSON.stringify(next, null, 2), { mode: 420 });
|
|
108
|
+
const override = readJsonConfig(overridePath);
|
|
109
|
+
if (override) {
|
|
110
|
+
for (const f of BINDING_FIELDS) delete override[f];
|
|
111
|
+
if (Object.keys(override).length === 0) rmSync(overridePath, { force: true });
|
|
112
|
+
else writeFileSync(overridePath, JSON.stringify(override, null, 2), { mode: 384 });
|
|
113
|
+
}
|
|
114
|
+
return baselinePath;
|
|
115
|
+
}
|
|
116
|
+
function committedBindingPath(dir) {
|
|
117
|
+
const p = join(dir, BASELINE_CONFIG_NAME);
|
|
118
|
+
return existsSync(p) ? p : void 0;
|
|
119
|
+
}
|
|
48
120
|
function resolveConfig(flags) {
|
|
121
|
+
const local = readLocalConfig();
|
|
49
122
|
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 ?? "";
|
|
123
|
+
const baseUrl = flags.baseUrl ?? process.env.SECHROOM_BASE_URL ?? local.baseUrl ?? persisted.baseUrl ?? DEFAULT_BASE_URL;
|
|
124
|
+
const tenant = flags.tenant ?? process.env.SECHROOM_TENANT ?? local.tenant ?? persisted.tenant ?? "";
|
|
52
125
|
if (!tenant) {
|
|
53
126
|
throw new Error(
|
|
54
|
-
"No tenant set. The Sechroom API rejects untenanted requests (HTTP 400). Pass --tenant <id>, set SECHROOM_TENANT,
|
|
127
|
+
"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
128
|
);
|
|
56
129
|
}
|
|
57
|
-
|
|
130
|
+
const workspaceId = process.env.SECHROOM_WORKSPACE ?? local.workspaceId ?? persisted.workspaceId ?? void 0;
|
|
131
|
+
const defaultProjectId = local.defaultProjectId ?? persisted.defaultProjectId ?? void 0;
|
|
132
|
+
return { baseUrl: baseUrl.replace(/\/$/, ""), tenant, workspaceId, defaultProjectId, clientId: persisted.clientId };
|
|
133
|
+
}
|
|
134
|
+
function describeConfig(flags) {
|
|
135
|
+
const local = readLocalConfig();
|
|
136
|
+
const g = readPersisted();
|
|
137
|
+
const localTag = local.path ? `local (${local.path})` : "local";
|
|
138
|
+
const pick = (flag, env, l, gl, def) => {
|
|
139
|
+
if (flag) return { value: flag, source: "flag" };
|
|
140
|
+
if (env) return { value: env, source: "env" };
|
|
141
|
+
if (l) return { value: l, source: localTag };
|
|
142
|
+
if (gl) return { value: gl, source: "global" };
|
|
143
|
+
if (def) return { value: def, source: "default" };
|
|
144
|
+
return { value: void 0, source: "unset" };
|
|
145
|
+
};
|
|
146
|
+
const baseUrl = pick(flags.baseUrl, process.env.SECHROOM_BASE_URL, local.baseUrl, g.baseUrl, DEFAULT_BASE_URL);
|
|
147
|
+
return {
|
|
148
|
+
baseUrl: { value: baseUrl.value, source: baseUrl.source },
|
|
149
|
+
tenant: pick(flags.tenant, process.env.SECHROOM_TENANT, local.tenant, g.tenant),
|
|
150
|
+
workspaceId: pick(void 0, process.env.SECHROOM_WORKSPACE, local.workspaceId, g.workspaceId),
|
|
151
|
+
localPath: local.path
|
|
152
|
+
};
|
|
58
153
|
}
|
|
59
154
|
|
|
60
155
|
// src/auth.ts
|
|
@@ -72,26 +167,26 @@ async function discover(baseUrl) {
|
|
|
72
167
|
if (!res.ok) throw new Error(`AS discovery failed (${res.status}) at ${baseUrl}`);
|
|
73
168
|
return await res.json();
|
|
74
169
|
}
|
|
75
|
-
async function ensureClientId(meta) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
170
|
+
async function ensureClientId(meta, baseUrl) {
|
|
171
|
+
if (meta.registration_endpoint) {
|
|
172
|
+
const res = await fetch(meta.registration_endpoint, {
|
|
173
|
+
method: "POST",
|
|
174
|
+
headers: { "content-type": "application/json" },
|
|
175
|
+
body: JSON.stringify({
|
|
176
|
+
client_name: "sechroom-cli",
|
|
177
|
+
redirect_uris: CANDIDATE_PORTS.map(redirectUriFor)
|
|
178
|
+
})
|
|
179
|
+
});
|
|
180
|
+
if (!res.ok) throw new Error(`Dynamic client registration failed (${res.status}): ${await res.text()}`);
|
|
181
|
+
const reg = await res.json();
|
|
182
|
+
writeDcrClientId(baseUrl, reg.client_id);
|
|
183
|
+
return reg.client_id;
|
|
82
184
|
}
|
|
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;
|
|
185
|
+
const fallback = readPersisted().clientId ?? readDcrClientId(baseUrl);
|
|
186
|
+
if (fallback) return fallback;
|
|
187
|
+
throw new Error(
|
|
188
|
+
"Server advertises no registration_endpoint and no client_id is configured. Pre-register a client and run `sechroom config set clientId <id>`."
|
|
189
|
+
);
|
|
95
190
|
}
|
|
96
191
|
function startLoopback(state) {
|
|
97
192
|
return new Promise((resolveOuter, rejectOuter) => {
|
|
@@ -115,13 +210,13 @@ function startLoopback(state) {
|
|
|
115
210
|
}
|
|
116
211
|
const got = url.searchParams.get("code");
|
|
117
212
|
const gotState = url.searchParams.get("state");
|
|
118
|
-
const
|
|
119
|
-
res.writeHead(
|
|
213
|
+
const err2 = url.searchParams.get("error");
|
|
214
|
+
res.writeHead(err2 ? 400 : 200, { "content-type": "text/html" });
|
|
120
215
|
res.end(
|
|
121
|
-
|
|
216
|
+
err2 ? `<h3>Authorization failed: ${err2}</h3>` : "<h3>Signed in to Sechroom.</h3><p>Return to the terminal.</p>"
|
|
122
217
|
);
|
|
123
218
|
server.close();
|
|
124
|
-
if (
|
|
219
|
+
if (err2) return rejectCode(new Error(`Authorization error: ${err2}`));
|
|
125
220
|
if (!got) return rejectCode(new Error("No code in callback."));
|
|
126
221
|
if (gotState !== state) return rejectCode(new Error("State mismatch \u2014 possible CSRF."));
|
|
127
222
|
resolveCode(got);
|
|
@@ -162,7 +257,7 @@ function persistTokenResponse(json) {
|
|
|
162
257
|
}
|
|
163
258
|
async function login(cfg) {
|
|
164
259
|
const meta = await discover(cfg.baseUrl);
|
|
165
|
-
const clientId = await ensureClientId(meta);
|
|
260
|
+
const clientId = await ensureClientId(meta, cfg.baseUrl);
|
|
166
261
|
const state = b64url(randomBytes(16));
|
|
167
262
|
const verifier = b64url(randomBytes(32));
|
|
168
263
|
const challenge = b64url(createHash("sha256").update(verifier).digest());
|
|
@@ -219,6 +314,23 @@ var quiet = false;
|
|
|
219
314
|
function setQuiet(q) {
|
|
220
315
|
quiet = q;
|
|
221
316
|
}
|
|
317
|
+
function colorOn() {
|
|
318
|
+
return !quiet && !process.env.NO_COLOR && process.env.FORCE_COLOR !== "0" && Boolean(process.stdout.isTTY);
|
|
319
|
+
}
|
|
320
|
+
function wrap(open2, close) {
|
|
321
|
+
return (s) => colorOn() ? `\x1B[${open2}m${s}\x1B[${close}m` : String(s);
|
|
322
|
+
}
|
|
323
|
+
var style = {
|
|
324
|
+
bold: wrap(1, 22),
|
|
325
|
+
dim: wrap(2, 22),
|
|
326
|
+
red: wrap(31, 39),
|
|
327
|
+
green: wrap(32, 39),
|
|
328
|
+
yellow: wrap(33, 39),
|
|
329
|
+
cyan: wrap(36, 39)
|
|
330
|
+
};
|
|
331
|
+
var ok = (s) => style.green(s);
|
|
332
|
+
var warn = (s) => style.yellow(s);
|
|
333
|
+
var err = (s) => style.red(s);
|
|
222
334
|
function active() {
|
|
223
335
|
return !quiet && Boolean(process.stderr.isTTY);
|
|
224
336
|
}
|
|
@@ -244,26 +356,130 @@ function spinner(text) {
|
|
|
244
356
|
return {
|
|
245
357
|
succeed(t) {
|
|
246
358
|
clear();
|
|
247
|
-
process.stderr.write(
|
|
359
|
+
process.stderr.write(`${ok("\u2713")} ${t ?? text}
|
|
248
360
|
`);
|
|
249
361
|
},
|
|
250
362
|
fail(t) {
|
|
251
363
|
clear();
|
|
252
|
-
process.stderr.write(
|
|
364
|
+
process.stderr.write(`${err("\u2717")} ${t ?? text}
|
|
253
365
|
`);
|
|
254
366
|
},
|
|
255
367
|
stop: clear
|
|
256
368
|
};
|
|
257
369
|
}
|
|
370
|
+
function canPrompt() {
|
|
371
|
+
return !quiet && Boolean(process.stdin.isTTY) && Boolean(process.stderr.isTTY);
|
|
372
|
+
}
|
|
373
|
+
async function promptYesNo(question) {
|
|
374
|
+
if (!canPrompt()) return false;
|
|
375
|
+
const { createInterface } = await import("readline");
|
|
376
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
377
|
+
try {
|
|
378
|
+
const answer = await new Promise((resolve3) => {
|
|
379
|
+
rl.question(`${question} [y/N] `, resolve3);
|
|
380
|
+
});
|
|
381
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
382
|
+
} finally {
|
|
383
|
+
rl.close();
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
async function promptText(question, def) {
|
|
387
|
+
if (!canPrompt()) return def ?? "";
|
|
388
|
+
const { createInterface } = await import("readline");
|
|
389
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
390
|
+
try {
|
|
391
|
+
const suffix = def ? ` [${def}]` : "";
|
|
392
|
+
const answer = await new Promise((resolve3) => {
|
|
393
|
+
rl.question(`${question}${suffix} `, resolve3);
|
|
394
|
+
});
|
|
395
|
+
const trimmed = answer.trim();
|
|
396
|
+
return trimmed.length > 0 ? trimmed : def ?? "";
|
|
397
|
+
} finally {
|
|
398
|
+
rl.close();
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
async function promptSelect(question, choices, def) {
|
|
402
|
+
if (choices.length === 0) throw new Error("promptSelect: no choices");
|
|
403
|
+
const defIdx = Math.max(
|
|
404
|
+
0,
|
|
405
|
+
def !== void 0 ? choices.findIndex((c) => c.value === def) : 0
|
|
406
|
+
);
|
|
407
|
+
if (!canPrompt()) return choices[defIdx].value;
|
|
408
|
+
const { createInterface } = await import("readline");
|
|
409
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
410
|
+
try {
|
|
411
|
+
process.stderr.write(`${style.bold(question)}
|
|
412
|
+
`);
|
|
413
|
+
choices.forEach((c, i) => {
|
|
414
|
+
const marker = i === defIdx ? style.cyan("\u203A") : " ";
|
|
415
|
+
const hint = c.hint ? ` ${style.dim(`\u2014 ${c.hint}`)}` : "";
|
|
416
|
+
process.stderr.write(` ${marker} ${style.bold(String(i + 1))}. ${c.label}${hint}
|
|
417
|
+
`);
|
|
418
|
+
});
|
|
419
|
+
const answer = await new Promise((resolve3) => {
|
|
420
|
+
rl.question(`Choose ${style.dim(`[${defIdx + 1}]`)} `, resolve3);
|
|
421
|
+
});
|
|
422
|
+
const trimmed = answer.trim();
|
|
423
|
+
if (!trimmed) return choices[defIdx].value;
|
|
424
|
+
const n = Number(trimmed);
|
|
425
|
+
if (Number.isInteger(n) && n >= 1 && n <= choices.length) return choices[n - 1].value;
|
|
426
|
+
const byLabel = choices.find(
|
|
427
|
+
(c) => c.label.toLowerCase().startsWith(trimmed.toLowerCase())
|
|
428
|
+
);
|
|
429
|
+
return byLabel ? byLabel.value : choices[defIdx].value;
|
|
430
|
+
} finally {
|
|
431
|
+
rl.close();
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
async function promptMultiSelect(question, choices, preselected = []) {
|
|
435
|
+
if (choices.length === 0) return [];
|
|
436
|
+
const pre = (v) => preselected.includes(v);
|
|
437
|
+
const preValues = () => choices.filter((c) => pre(c.value)).map((c) => c.value);
|
|
438
|
+
if (!canPrompt()) return preValues();
|
|
439
|
+
const { createInterface } = await import("readline");
|
|
440
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
441
|
+
try {
|
|
442
|
+
process.stderr.write(
|
|
443
|
+
`${style.bold(question)} ${style.dim("(numbers or 'all', comma-separated; Enter keeps \u25C9)")}
|
|
444
|
+
`
|
|
445
|
+
);
|
|
446
|
+
choices.forEach((c, i) => {
|
|
447
|
+
const box = pre(c.value) ? style.cyan("\u25C9") : "\u25CB";
|
|
448
|
+
const hint = c.hint ? ` ${style.dim(`\u2014 ${c.hint}`)}` : "";
|
|
449
|
+
process.stderr.write(` ${box} ${style.bold(String(i + 1))}. ${c.label}${hint}
|
|
450
|
+
`);
|
|
451
|
+
});
|
|
452
|
+
const answer = await new Promise((resolve3) => {
|
|
453
|
+
rl.question(`Select ${style.dim("[Enter = \u25C9]")} `, resolve3);
|
|
454
|
+
});
|
|
455
|
+
const trimmed = answer.trim().toLowerCase();
|
|
456
|
+
if (!trimmed) return preValues();
|
|
457
|
+
if (trimmed === "all") return choices.map((c) => c.value);
|
|
458
|
+
const picks = [];
|
|
459
|
+
for (const tok of trimmed.split(",").map((t) => t.trim()).filter(Boolean)) {
|
|
460
|
+
const n = Number(tok);
|
|
461
|
+
if (Number.isInteger(n) && n >= 1 && n <= choices.length) {
|
|
462
|
+
picks.push(choices[n - 1].value);
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
const byLabel = choices.find((c) => c.label.toLowerCase().startsWith(tok));
|
|
466
|
+
if (byLabel) picks.push(byLabel.value);
|
|
467
|
+
}
|
|
468
|
+
const uniq = [...new Set(picks)];
|
|
469
|
+
return uniq.length > 0 ? uniq : preValues();
|
|
470
|
+
} finally {
|
|
471
|
+
rl.close();
|
|
472
|
+
}
|
|
473
|
+
}
|
|
258
474
|
async function withSpinner(text, fn) {
|
|
259
475
|
const s = spinner(text);
|
|
260
476
|
try {
|
|
261
477
|
const result = await fn();
|
|
262
478
|
s.succeed();
|
|
263
479
|
return result;
|
|
264
|
-
} catch (
|
|
480
|
+
} catch (err2) {
|
|
265
481
|
s.fail();
|
|
266
|
-
throw
|
|
482
|
+
throw err2;
|
|
267
483
|
}
|
|
268
484
|
}
|
|
269
485
|
|
|
@@ -275,6 +491,7 @@ async function makeClient(cfg) {
|
|
|
275
491
|
onRequest({ request }) {
|
|
276
492
|
request.headers.set("authorization", `Bearer ${token}`);
|
|
277
493
|
request.headers.set("tenant", cfg.tenant);
|
|
494
|
+
request.headers.set("x-sechroom-surface", "cli");
|
|
278
495
|
return request;
|
|
279
496
|
}
|
|
280
497
|
});
|
|
@@ -287,18 +504,36 @@ function emit(data, json) {
|
|
|
287
504
|
process.stdout.write(JSON.stringify(data, null, 2) + "\n");
|
|
288
505
|
}
|
|
289
506
|
}
|
|
507
|
+
function publicUrl(url) {
|
|
508
|
+
return url.replace(/^https?:\/\/localhost:5012/, "https://sechroom.yi.ocd.codes");
|
|
509
|
+
}
|
|
510
|
+
function resolveViewUrl(baseUrl, url) {
|
|
511
|
+
if (!url) return void 0;
|
|
512
|
+
if (/^https?:\/\//i.test(url)) return publicUrl(url);
|
|
513
|
+
const origin = baseUrl.replace(/\/api\/?$/i, "").replace(/\/+$/, "");
|
|
514
|
+
return publicUrl(`${origin}${url.startsWith("/") ? url : `/${url}`}`);
|
|
515
|
+
}
|
|
516
|
+
function emitAction(summary, data, json) {
|
|
517
|
+
if (json) {
|
|
518
|
+
process.stdout.write(JSON.stringify(data) + "\n");
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
process.stdout.write(`${ok("\u2713")} ${summary}
|
|
522
|
+
`);
|
|
523
|
+
}
|
|
290
524
|
async function runApi(label, fn) {
|
|
291
525
|
const s = spinner(label);
|
|
292
526
|
let res;
|
|
293
527
|
try {
|
|
294
528
|
res = await fn();
|
|
295
|
-
} catch (
|
|
529
|
+
} catch (err2) {
|
|
296
530
|
s.fail();
|
|
297
|
-
fail(
|
|
531
|
+
fail(err2);
|
|
298
532
|
}
|
|
299
|
-
|
|
533
|
+
const httpFailed = res.response !== void 0 && !res.response.ok;
|
|
534
|
+
if (res.error !== void 0 && res.error !== null || httpFailed) {
|
|
300
535
|
s.fail();
|
|
301
|
-
fail(res.error);
|
|
536
|
+
fail(res.error ?? (res.response ? `HTTP ${res.response.status} ${res.response.statusText}`.trim() : "request failed"));
|
|
302
537
|
}
|
|
303
538
|
s.succeed();
|
|
304
539
|
return res.data;
|
|
@@ -313,6 +548,22 @@ function fail(error) {
|
|
|
313
548
|
// src/commands/memory.ts
|
|
314
549
|
function registerMemory(program2) {
|
|
315
550
|
const memory = program2.command("memory").description("Create, read, and search memories");
|
|
551
|
+
memory.addHelpText(
|
|
552
|
+
"after",
|
|
553
|
+
`
|
|
554
|
+
Examples:
|
|
555
|
+
$ sechroom memory create --text "first note" --type reference --tag idea --tag cli
|
|
556
|
+
$ sechroom memory create --text "filed note" --owner-type Workspace --owner-id wsp_XXXX
|
|
557
|
+
$ sechroom memory search "rate limiting" --limit 5 --tag kind:decision
|
|
558
|
+
$ sechroom memory search "auth flow" --workspace wsp_XXXX --json
|
|
559
|
+
$ sechroom memory get mem_XXXX --json
|
|
560
|
+
$ sechroom memory edit-text mem_XXXX --old "teh" --new "the" --replace-all
|
|
561
|
+
$ sechroom memory edit-text-batch mem_XXXX --edit "foo=>bar" --edit "baz=>qux"
|
|
562
|
+
$ sechroom memory move mem_XXXX --owner-type Workspace --owner-id wsp_XXXX
|
|
563
|
+
$ sechroom memory archive mem_XXXX
|
|
564
|
+
$ sechroom memory list-archived --workspace wsp_XXXX --json
|
|
565
|
+
$ sechroom memory tags --json`
|
|
566
|
+
);
|
|
316
567
|
memory.command("create").description("Create a memory (POST /memories)").requiredOption("--text <text>", "Memory body text").option("--type <type>", "Memory type", "reference").option("--title <title>", "Optional title").option("--tag <tag...>", "Tags (repeatable)").option("--owner-type <ownerType>", "Workspace | Project | Unfiled", "Unfiled").option("--owner-id <ownerId>", "Owner id (required for Workspace/Project)").option("--source <source>", "Source / lane stamp", "cli").option("--confidence <n>", "Confidence 0..1", "1.0").action(async (opts, cmd) => {
|
|
317
568
|
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
318
569
|
const unfiled = String(opts.ownerType).toLowerCase() === "unfiled";
|
|
@@ -332,7 +583,10 @@ function registerMemory(program2) {
|
|
|
332
583
|
}
|
|
333
584
|
});
|
|
334
585
|
});
|
|
335
|
-
|
|
586
|
+
const titlePart = opts.title ? ` ${style.dim(`"${opts.title}"`)}` : "";
|
|
587
|
+
const view = resolveViewUrl(cfg.baseUrl, data.url);
|
|
588
|
+
const urlPart = view ? ` ${style.dim("\u2192")} ${view}` : "";
|
|
589
|
+
emitAction(`created memory ${style.bold(data.id)}${titlePart}${urlPart}`, data, cmd.optsWithGlobals().json);
|
|
336
590
|
});
|
|
337
591
|
memory.command("get <memoryId>").description("Fetch a memory by id (GET /memories/{memoryId})").action(async (memoryId, _opts, cmd) => {
|
|
338
592
|
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
@@ -362,11 +616,213 @@ function registerMemory(program2) {
|
|
|
362
616
|
});
|
|
363
617
|
emit(data, cmd.optsWithGlobals().json);
|
|
364
618
|
});
|
|
619
|
+
memory.command("edit-text <memoryId>").description("Find/replace one substring (POST /memories/{memoryId}/edit-text)").requiredOption("--old <text>", "Text to find").requiredOption("--new <text>", "Replacement text").option("--replace-all", "Replace every occurrence (default: first only)", false).option("--regenerate-filing", "Re-run filing after the edit", false).option("--source <source>", "Source / lane stamp", "cli").action(async (memoryId, opts, cmd) => {
|
|
620
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
621
|
+
const data = await runApi("Editing memory text", async () => {
|
|
622
|
+
const client = await makeClient(cfg);
|
|
623
|
+
return client.POST("/memories/{memoryId}/edit-text", {
|
|
624
|
+
params: { path: { memoryId } },
|
|
625
|
+
body: {
|
|
626
|
+
memoryId,
|
|
627
|
+
oldText: opts.old,
|
|
628
|
+
newText: opts.new,
|
|
629
|
+
replaceAll: Boolean(opts.replaceAll),
|
|
630
|
+
regenerateFiling: Boolean(opts.regenerateFiling),
|
|
631
|
+
source: opts.source
|
|
632
|
+
}
|
|
633
|
+
});
|
|
634
|
+
});
|
|
635
|
+
emitAction(`edited ${style.bold(memoryId)} \u2192 v${style.bold(String(data.version))}`, data, cmd.optsWithGlobals().json);
|
|
636
|
+
});
|
|
637
|
+
memory.command("edit-text-batch <memoryId>").description("Apply many find/replace edits (POST /memories/{memoryId}/edit-text-batch)").requiredOption("--edit <old=>new...>", "Edit as 'old=>new' (repeatable)").option("--replace-all", "Apply replaceAll to every edit", false).option("--regenerate-filing", "Re-run filing after the edits", false).option("--source <source>", "Source / lane stamp", "cli").action(async (memoryId, opts, cmd) => {
|
|
638
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
639
|
+
const replaceAll = Boolean(opts.replaceAll);
|
|
640
|
+
const edits = opts.edit.map((spec) => {
|
|
641
|
+
const idx = spec.indexOf("=>");
|
|
642
|
+
if (idx < 0) {
|
|
643
|
+
process.stderr.write(`error: --edit must be 'old=>new', got: ${spec}
|
|
644
|
+
`);
|
|
645
|
+
process.exit(1);
|
|
646
|
+
}
|
|
647
|
+
return { oldText: spec.slice(0, idx), newText: spec.slice(idx + 2), replaceAll };
|
|
648
|
+
});
|
|
649
|
+
const data = await runApi("Applying batch edits", async () => {
|
|
650
|
+
const client = await makeClient(cfg);
|
|
651
|
+
return client.POST("/memories/{memoryId}/edit-text-batch", {
|
|
652
|
+
params: { path: { memoryId } },
|
|
653
|
+
body: {
|
|
654
|
+
memoryId,
|
|
655
|
+
edits,
|
|
656
|
+
regenerateFiling: Boolean(opts.regenerateFiling),
|
|
657
|
+
source: opts.source
|
|
658
|
+
}
|
|
659
|
+
});
|
|
660
|
+
});
|
|
661
|
+
emitAction(
|
|
662
|
+
`applied ${style.bold(String(data.editsApplied))} edit(s) \u2192 v${style.bold(String(data.version))}`,
|
|
663
|
+
data,
|
|
664
|
+
cmd.optsWithGlobals().json
|
|
665
|
+
);
|
|
666
|
+
});
|
|
667
|
+
memory.command("archive <memoryId>").description("Archive a memory (POST /memories/{memoryId}/archive)").option("--source <source>", "Source / lane stamp", "cli").action(async (memoryId, opts, cmd) => {
|
|
668
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
669
|
+
const data = await runApi("Archiving memory", async () => {
|
|
670
|
+
const client = await makeClient(cfg);
|
|
671
|
+
return client.POST("/memories/{memoryId}/archive", {
|
|
672
|
+
params: { path: { memoryId } },
|
|
673
|
+
body: { source: opts.source }
|
|
674
|
+
});
|
|
675
|
+
});
|
|
676
|
+
emitAction(`archived ${style.bold(memoryId)}`, data, cmd.optsWithGlobals().json);
|
|
677
|
+
});
|
|
678
|
+
memory.command("restore <memoryId>").description("Restore an archived memory (POST /memories/{memoryId}/restore)").option("--source <source>", "Source / lane stamp", "cli").action(async (memoryId, opts, cmd) => {
|
|
679
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
680
|
+
const data = await runApi("Restoring memory", async () => {
|
|
681
|
+
const client = await makeClient(cfg);
|
|
682
|
+
return client.POST("/memories/{memoryId}/restore", {
|
|
683
|
+
params: { path: { memoryId } },
|
|
684
|
+
body: { source: opts.source }
|
|
685
|
+
});
|
|
686
|
+
});
|
|
687
|
+
emitAction(`restored ${style.bold(memoryId)}`, data, cmd.optsWithGlobals().json);
|
|
688
|
+
});
|
|
689
|
+
memory.command("move <memoryId>").description("Move a memory to a new owner (POST /memories/{memoryId}/move)").requiredOption("--owner-type <ownerType>", "Unfiled | Workspace | Project | Candidate").option("--owner-id <ownerId>", "Owner id (required unless Unfiled)").option("--source <source>", "Source / lane stamp", "cli").action(async (memoryId, opts, cmd) => {
|
|
690
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
691
|
+
const data = await runApi("Moving memory", async () => {
|
|
692
|
+
const client = await makeClient(cfg);
|
|
693
|
+
return client.POST("/memories/{memoryId}/move", {
|
|
694
|
+
params: { path: { memoryId } },
|
|
695
|
+
body: {
|
|
696
|
+
to: {
|
|
697
|
+
type: opts.ownerType,
|
|
698
|
+
id: String(opts.ownerId ?? "")
|
|
699
|
+
},
|
|
700
|
+
source: opts.source
|
|
701
|
+
}
|
|
702
|
+
});
|
|
703
|
+
});
|
|
704
|
+
emitAction(`moved ${style.bold(memoryId)} \u2192 ${opts.ownerType}`, data, cmd.optsWithGlobals().json);
|
|
705
|
+
});
|
|
706
|
+
memory.command("list-archived").description("List archived memories (GET /memories/archived)").option("--workspace <workspaceId>", "Scope to a workspace").option("--project <projectId>", "Scope to a project").option("--page <n>", "Page number").option("--page-size <n>", "Page size").action(async (opts, cmd) => {
|
|
707
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
708
|
+
const data = await runApi("Listing archived memories", async () => {
|
|
709
|
+
const client = await makeClient(cfg);
|
|
710
|
+
return client.GET("/memories/archived", {
|
|
711
|
+
params: {
|
|
712
|
+
query: {
|
|
713
|
+
...opts.workspace ? { workspaceId: opts.workspace } : {},
|
|
714
|
+
...opts.project ? { projectId: opts.project } : {},
|
|
715
|
+
...opts.page ? { page: Number(opts.page) } : {},
|
|
716
|
+
...opts.pageSize ? { pageSize: Number(opts.pageSize) } : {}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
});
|
|
721
|
+
emit(data, cmd.optsWithGlobals().json);
|
|
722
|
+
});
|
|
723
|
+
memory.command("versions <memoryId>").description("List a memory's versions (GET /memories/{memoryId}/versions)").action(async (memoryId, _opts, cmd) => {
|
|
724
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
725
|
+
const data = await runApi("Fetching versions", async () => {
|
|
726
|
+
const client = await makeClient(cfg);
|
|
727
|
+
return client.GET("/memories/{memoryId}/versions", { params: { path: { memoryId } } });
|
|
728
|
+
});
|
|
729
|
+
emit(data, cmd.optsWithGlobals().json);
|
|
730
|
+
});
|
|
731
|
+
memory.command("revert <memoryId>").description("Revert a memory to an earlier version (POST /memories/{memoryId}/revert)").requiredOption("--from-version <n>", "Version to revert from").requiredOption("--text <text>", "Reverted text (the target version's text)").requiredOption("--content <content>", "Reverted content JSON (the target version's content)").option("--source <source>", "Source / lane stamp", "cli").action(async (memoryId, opts, cmd) => {
|
|
732
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
733
|
+
const data = await runApi("Reverting memory", async () => {
|
|
734
|
+
const client = await makeClient(cfg);
|
|
735
|
+
return client.POST("/memories/{memoryId}/revert", {
|
|
736
|
+
params: { path: { memoryId } },
|
|
737
|
+
body: {
|
|
738
|
+
fromVersion: Number(opts.fromVersion),
|
|
739
|
+
revertedContent: opts.content,
|
|
740
|
+
revertedText: opts.text,
|
|
741
|
+
source: opts.source
|
|
742
|
+
}
|
|
743
|
+
});
|
|
744
|
+
});
|
|
745
|
+
emitAction(`reverted ${style.bold(memoryId)} from v${opts.fromVersion}`, data, cmd.optsWithGlobals().json);
|
|
746
|
+
});
|
|
747
|
+
memory.command("owners").description("List owners with memory counts (GET /memories/owners)").option("--include-archived", "Include archived memories in counts", false).action(async (opts, cmd) => {
|
|
748
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
749
|
+
const data = await runApi("Fetching owners", async () => {
|
|
750
|
+
const client = await makeClient(cfg);
|
|
751
|
+
return client.GET("/memories/owners", {
|
|
752
|
+
params: { query: { includeArchived: Boolean(opts.includeArchived) } }
|
|
753
|
+
});
|
|
754
|
+
});
|
|
755
|
+
emit(data, cmd.optsWithGlobals().json);
|
|
756
|
+
});
|
|
757
|
+
memory.command("tags").description("List tags with memory counts (GET /memories/tags)").option("--include-archived", "Include archived memories in counts", false).action(async (opts, cmd) => {
|
|
758
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
759
|
+
const data = await runApi("Fetching tags", async () => {
|
|
760
|
+
const client = await makeClient(cfg);
|
|
761
|
+
return client.GET("/memories/tags", {
|
|
762
|
+
params: { query: { includeArchived: Boolean(opts.includeArchived) } }
|
|
763
|
+
});
|
|
764
|
+
});
|
|
765
|
+
emit(data, cmd.optsWithGlobals().json);
|
|
766
|
+
});
|
|
767
|
+
memory.command("types").description("List types with memory counts (GET /memories/types)").option("--include-archived", "Include archived memories in counts", false).action(async (opts, cmd) => {
|
|
768
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
769
|
+
const data = await runApi("Fetching types", async () => {
|
|
770
|
+
const client = await makeClient(cfg);
|
|
771
|
+
return client.GET("/memories/types", {
|
|
772
|
+
params: { query: { includeArchived: Boolean(opts.includeArchived) } }
|
|
773
|
+
});
|
|
774
|
+
});
|
|
775
|
+
emit(data, cmd.optsWithGlobals().json);
|
|
776
|
+
});
|
|
777
|
+
memory.command("sum-tokens").description("Sum token counts across memories (POST /memories/sum-tokens)").option("--id <memoryId...>", "Memory id(s) to sum (repeatable)").option("--snapshot <snapshotId>", "Sum a continuity snapshot's load set").action(async (opts, cmd) => {
|
|
778
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
779
|
+
const data = await runApi("Summing tokens", async () => {
|
|
780
|
+
const client = await makeClient(cfg);
|
|
781
|
+
return client.POST("/memories/sum-tokens", {
|
|
782
|
+
body: {
|
|
783
|
+
ids: opts.id ?? null,
|
|
784
|
+
snapshotId: opts.snapshot ?? null
|
|
785
|
+
}
|
|
786
|
+
});
|
|
787
|
+
});
|
|
788
|
+
emit(data, cmd.optsWithGlobals().json);
|
|
789
|
+
});
|
|
790
|
+
memory.command("similar <memoryId>").description("Find similar memories (GET /memories/{memoryId}/similar)").option("--limit <n>", "Max results").option("--threshold <n>", "Minimum similarity threshold").action(async (memoryId, opts, cmd) => {
|
|
791
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
792
|
+
const data = await runApi("Finding similar memories", async () => {
|
|
793
|
+
const client = await makeClient(cfg);
|
|
794
|
+
return client.GET("/memories/{memoryId}/similar", {
|
|
795
|
+
params: {
|
|
796
|
+
path: { memoryId },
|
|
797
|
+
query: {
|
|
798
|
+
...opts.limit ? { limit: Number(opts.limit) } : {},
|
|
799
|
+
...opts.threshold ? { threshold: Number(opts.threshold) } : {}
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
});
|
|
803
|
+
});
|
|
804
|
+
emit(data, cmd.optsWithGlobals().json);
|
|
805
|
+
});
|
|
806
|
+
memory.command("by-url <url>").description("Resolve a memory by its canonical URL (GET /memories/by-url)").action(async (url, _opts, cmd) => {
|
|
807
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
808
|
+
const data = await runApi("Resolving memory by URL", async () => {
|
|
809
|
+
const client = await makeClient(cfg);
|
|
810
|
+
return client.GET("/memories/by-url", { params: { query: { url } } });
|
|
811
|
+
});
|
|
812
|
+
emit(data, cmd.optsWithGlobals().json);
|
|
813
|
+
});
|
|
365
814
|
}
|
|
366
815
|
|
|
367
816
|
// src/commands/worklog.ts
|
|
368
817
|
function registerWorklog(program2) {
|
|
369
818
|
const worklog = program2.command("worklog").description("Append to the daily work log");
|
|
819
|
+
worklog.addHelpText(
|
|
820
|
+
"after",
|
|
821
|
+
`
|
|
822
|
+
Examples:
|
|
823
|
+
$ sechroom worklog append --text "shipped CLI help + onboarding scope; PR #1430"
|
|
824
|
+
$ sechroom worklog append --text "smoke passed" --source claude-code-chris --title "CLI smoke"`
|
|
825
|
+
);
|
|
370
826
|
worklog.command("append").description("Append a work-log entry (POST /operator-surface/work-log/append)").requiredOption("--text <text>", "Entry body (short bullets / pointers) \u2014 the bullet").option("--source <source>", "Lane stamp (e.g. claude-code-chris) \u2014 laneId", "cli").option("--workspace <workspaceId>", "Target work-log workspace (default: caller's daily log)").option("--title <title>", "Optional entry title").action(async (opts, cmd) => {
|
|
371
827
|
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
372
828
|
const data = await runApi("Appending work-log entry", async () => {
|
|
@@ -380,7 +836,7 @@ function registerWorklog(program2) {
|
|
|
380
836
|
}
|
|
381
837
|
});
|
|
382
838
|
});
|
|
383
|
-
|
|
839
|
+
emitAction(`appended work-log entry ${style.bold(data.memoryId)}`, data, cmd.optsWithGlobals().json);
|
|
384
840
|
});
|
|
385
841
|
}
|
|
386
842
|
|
|
@@ -388,6 +844,14 @@ function registerWorklog(program2) {
|
|
|
388
844
|
function registerLookup(program2) {
|
|
389
845
|
program2.command("lookup <id>").description(
|
|
390
846
|
"Resolve a sechroom id to its kind, title, and view URL (mem_\u2026/wsp_\u2026/prj_\u2026, unprefixed, or sechroom:<id>)"
|
|
847
|
+
).addHelpText(
|
|
848
|
+
"after",
|
|
849
|
+
`
|
|
850
|
+
Examples:
|
|
851
|
+
$ sechroom lookup mem_XXXX a memory -> kind / title / view URL
|
|
852
|
+
$ sechroom lookup wsp_XXXX a workspace
|
|
853
|
+
$ sechroom lookup sechroom:mem_XXXX namespaced / portable form also resolves
|
|
854
|
+
$ sechroom lookup mem_XXXX --json`
|
|
391
855
|
).action(async (id, _opts, cmd) => {
|
|
392
856
|
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
393
857
|
const data = await runApi(`Resolving ${id}`, async () => {
|
|
@@ -403,303 +867,3131 @@ function registerLookup(program2) {
|
|
|
403
867
|
});
|
|
404
868
|
}
|
|
405
869
|
|
|
406
|
-
// src/
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
if (!section) return null;
|
|
432
|
-
for (const step of section.steps) {
|
|
433
|
-
if (step.copyValue) return step.copyValue;
|
|
434
|
-
if (step.codeSnippet) return step.codeSnippet;
|
|
435
|
-
}
|
|
436
|
-
return null;
|
|
437
|
-
}
|
|
438
|
-
function parseTagArtifactId(id) {
|
|
439
|
-
if (!id.startsWith("tag:")) return null;
|
|
440
|
-
const tags = id.slice("tag:".length).split(",").map((t) => t.trim()).filter((t) => t.length > 0);
|
|
441
|
-
return tags.length > 0 ? tags : null;
|
|
442
|
-
}
|
|
443
|
-
async function resolveInstructionBody(cfg, section) {
|
|
444
|
-
const client = await makeClient(cfg);
|
|
445
|
-
for (const artifact of section.artifacts) {
|
|
446
|
-
const tags = parseTagArtifactId(artifact.id);
|
|
447
|
-
if (!tags) continue;
|
|
448
|
-
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
|
-
}
|
|
870
|
+
// src/commands/relationships.ts
|
|
871
|
+
function registerRelationships(program2) {
|
|
872
|
+
const relationship = program2.command("relationship").description("Create, list, and manage memory relationships + suggestions");
|
|
873
|
+
relationship.addHelpText(
|
|
874
|
+
"after",
|
|
875
|
+
`
|
|
876
|
+
Examples:
|
|
877
|
+
$ sechroom relationship create mem_FROM mem_TO --type Reference
|
|
878
|
+
$ sechroom relationship list mem_XXXX --direction Outbound --json
|
|
879
|
+
$ sechroom relationship delete rel_XXXX
|
|
880
|
+
$ sechroom relationship suggest mem_XXXX --limit 5
|
|
881
|
+
$ sechroom relationship suggestions --status Pending --memory mem_XXXX
|
|
882
|
+
$ sechroom relationship suggestion accept rsg_XXXX`
|
|
883
|
+
);
|
|
884
|
+
relationship.command("create <fromMemoryId> <toMemoryId>").description("Create a relationship (POST /memories/{memoryId}/relationships)").option("--type <type>", "Relationship type (Reference, Related, Parent, Child, Follows, \u2026)", "Reference").action(async (fromMemoryId, toMemoryId, opts, cmd) => {
|
|
885
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
886
|
+
const data = await runApi("Creating relationship", async () => {
|
|
887
|
+
const client = await makeClient(cfg);
|
|
888
|
+
return client.POST("/memories/{memoryId}/relationships", {
|
|
889
|
+
params: { path: { memoryId: fromMemoryId } },
|
|
890
|
+
body: {
|
|
891
|
+
toMemoryId,
|
|
892
|
+
type: opts.type
|
|
893
|
+
}
|
|
894
|
+
});
|
|
459
895
|
});
|
|
460
|
-
const
|
|
461
|
-
|
|
462
|
-
const
|
|
463
|
-
|
|
464
|
-
|
|
896
|
+
const inversePart = data.inverseId ? ` ${style.dim(`(inverse ${data.inverseId})`)}` : "";
|
|
897
|
+
const view = resolveViewUrl(cfg.baseUrl, data.url);
|
|
898
|
+
const urlPart = view ? ` ${style.dim("\u2192")} ${view}` : "";
|
|
899
|
+
emitAction(
|
|
900
|
+
`created relationship ${style.bold(data.id)} ${style.dim(`${fromMemoryId} \u2192 ${toMemoryId}`)}${inversePart}${urlPart}`,
|
|
901
|
+
data,
|
|
902
|
+
cmd.optsWithGlobals().json
|
|
903
|
+
);
|
|
904
|
+
});
|
|
905
|
+
relationship.command("list <memoryId>").description("List a memory's relationships (GET /memories/{memoryId}/relationships)").option("--direction <direction>", "Both | Outbound | Inbound").option("--include-deleted", "Include deleted relationships", false).option("--page <n>", "Page number").option("--page-size <n>", "Page size").action(async (memoryId, opts, cmd) => {
|
|
906
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
907
|
+
const data = await runApi("Listing relationships", async () => {
|
|
908
|
+
const client = await makeClient(cfg);
|
|
909
|
+
return client.GET("/memories/{memoryId}/relationships", {
|
|
910
|
+
params: {
|
|
911
|
+
path: { memoryId },
|
|
912
|
+
query: {
|
|
913
|
+
...opts.direction ? { direction: opts.direction } : {},
|
|
914
|
+
...opts.includeDeleted ? { includeDeleted: true } : {},
|
|
915
|
+
...opts.page ? { page: Number(opts.page) } : {},
|
|
916
|
+
...opts.pageSize ? { pageSize: Number(opts.pageSize) } : {}
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
});
|
|
465
920
|
});
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
}
|
|
921
|
+
emit(data, cmd.optsWithGlobals().json);
|
|
922
|
+
});
|
|
923
|
+
relationship.command("delete <id>").description("Delete a relationship (DELETE /relationships/{id})").action(async (id, _opts, cmd) => {
|
|
924
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
925
|
+
const data = await runApi("Deleting relationship", async () => {
|
|
926
|
+
const client = await makeClient(cfg);
|
|
927
|
+
return client.DELETE("/relationships/{id}", {
|
|
928
|
+
params: { path: { id } },
|
|
929
|
+
body: {}
|
|
930
|
+
});
|
|
931
|
+
});
|
|
932
|
+
emitAction(`deleted relationship ${style.bold(id)}`, data, cmd.optsWithGlobals().json);
|
|
933
|
+
});
|
|
934
|
+
relationship.command("suggest <memoryId>").description("Generate relationship suggestions for a memory (POST /memories/{memoryId}/suggest-relationships)").option("--limit <n>", "Max suggestions to generate").action(async (memoryId, opts, cmd) => {
|
|
935
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
936
|
+
const data = await runApi("Suggesting relationships", async () => {
|
|
937
|
+
const client = await makeClient(cfg);
|
|
938
|
+
return client.POST("/memories/{memoryId}/suggest-relationships", {
|
|
939
|
+
params: { path: { memoryId } },
|
|
940
|
+
body: {
|
|
941
|
+
limit: opts.limit ? Number(opts.limit) : null
|
|
942
|
+
}
|
|
943
|
+
});
|
|
944
|
+
});
|
|
945
|
+
emitAction(
|
|
946
|
+
`suggested ${style.bold(String(data.suggested))} for ${memoryId} ${style.dim(`(considered ${data.considered}, suppressed ${data.suppressed})`)}`,
|
|
947
|
+
data,
|
|
948
|
+
cmd.optsWithGlobals().json
|
|
949
|
+
);
|
|
950
|
+
});
|
|
951
|
+
relationship.command("suggestions").description("List relationship suggestions (GET /relationship-suggestions)").option("--memory <memoryId>", "Filter to a memory").option(
|
|
952
|
+
"--status <status>",
|
|
953
|
+
"Pending | Accepted | EditedAndAccepted | Rejected | Superseded | Deferred | Invalidated"
|
|
954
|
+
).option("--page <n>", "Page number").option("--page-size <n>", "Page size").action(async (opts, cmd) => {
|
|
955
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
956
|
+
const data = await runApi("Listing suggestions", async () => {
|
|
957
|
+
const client = await makeClient(cfg);
|
|
958
|
+
return client.GET("/relationship-suggestions", {
|
|
959
|
+
params: {
|
|
960
|
+
query: {
|
|
961
|
+
...opts.memory ? { memoryId: opts.memory } : {},
|
|
962
|
+
...opts.status ? {
|
|
963
|
+
status: opts.status
|
|
964
|
+
} : {},
|
|
965
|
+
...opts.page ? { page: Number(opts.page) } : {},
|
|
966
|
+
...opts.pageSize ? { pageSize: Number(opts.pageSize) } : {}
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
});
|
|
970
|
+
});
|
|
971
|
+
emit(data, cmd.optsWithGlobals().json);
|
|
972
|
+
});
|
|
973
|
+
const suggestion = relationship.command("suggestion").description("Inspect and decide on a single relationship suggestion");
|
|
974
|
+
suggestion.command("get <id>").description("Fetch a suggestion by id (GET /relationship-suggestions/{id})").action(async (id, _opts, cmd) => {
|
|
975
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
976
|
+
const data = await runApi("Fetching suggestion", async () => {
|
|
977
|
+
const client = await makeClient(cfg);
|
|
978
|
+
return client.GET("/relationship-suggestions/{id}", { params: { path: { id } } });
|
|
979
|
+
});
|
|
980
|
+
emit(data, cmd.optsWithGlobals().json);
|
|
981
|
+
});
|
|
982
|
+
suggestion.command("accept <id>").description("Accept a suggestion (POST /relationship-suggestions/{id}/accept)").action(async (id, _opts, cmd) => {
|
|
983
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
984
|
+
const data = await runApi("Accepting suggestion", async () => {
|
|
985
|
+
const client = await makeClient(cfg);
|
|
986
|
+
return client.POST("/relationship-suggestions/{id}/accept", {
|
|
987
|
+
params: { path: { id } },
|
|
988
|
+
body: {}
|
|
989
|
+
});
|
|
990
|
+
});
|
|
991
|
+
emitAction(`accepted suggestion ${style.bold(id)}`, data, cmd.optsWithGlobals().json);
|
|
992
|
+
});
|
|
993
|
+
suggestion.command("reject <id>").description("Reject a suggestion (POST /relationship-suggestions/{id}/reject)").option("--reason <reason>", "Why it's being rejected").option("--reason-code <code>", "Structured reason code").action(async (id, opts, cmd) => {
|
|
994
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
995
|
+
const data = await runApi("Rejecting suggestion", async () => {
|
|
996
|
+
const client = await makeClient(cfg);
|
|
997
|
+
return client.POST("/relationship-suggestions/{id}/reject", {
|
|
998
|
+
params: { path: { id } },
|
|
999
|
+
body: {
|
|
1000
|
+
reason: opts.reason ?? null,
|
|
1001
|
+
...opts.reasonCode ? { reasonCode: opts.reasonCode } : {}
|
|
1002
|
+
}
|
|
1003
|
+
});
|
|
1004
|
+
});
|
|
1005
|
+
emitAction(`rejected suggestion ${style.bold(id)}`, data, cmd.optsWithGlobals().json);
|
|
1006
|
+
});
|
|
1007
|
+
suggestion.command("defer <id>").description("Defer a suggestion (POST /relationship-suggestions/{id}/defer)").option("--until <iso>", "Defer until this ISO date-time (omit to defer indefinitely)").action(async (id, opts, cmd) => {
|
|
1008
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
1009
|
+
const data = await runApi("Deferring suggestion", async () => {
|
|
1010
|
+
const client = await makeClient(cfg);
|
|
1011
|
+
return client.POST("/relationship-suggestions/{id}/defer", {
|
|
1012
|
+
params: { path: { id } },
|
|
1013
|
+
body: {
|
|
1014
|
+
until: opts.until ?? null
|
|
1015
|
+
}
|
|
1016
|
+
});
|
|
1017
|
+
});
|
|
1018
|
+
emitAction(`deferred suggestion ${style.bold(id)}`, data, cmd.optsWithGlobals().json);
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// src/commands/workspace.ts
|
|
1023
|
+
function registerWorkspace(program2) {
|
|
1024
|
+
const workspace = program2.command("workspace").description("Create, browse, and manage workspaces");
|
|
1025
|
+
workspace.addHelpText(
|
|
1026
|
+
"after",
|
|
1027
|
+
`
|
|
1028
|
+
Examples:
|
|
1029
|
+
$ sechroom workspace create --name "My Workspace" --parent wsp_XXXX
|
|
1030
|
+
$ sechroom workspace list --include-archived --json
|
|
1031
|
+
$ sechroom workspace get wsp_XXXX --json
|
|
1032
|
+
$ sechroom workspace rename wsp_XXXX --name "Renamed"
|
|
1033
|
+
$ sechroom workspace move wsp_XXXX --parent wsp_YYYY
|
|
1034
|
+
$ sechroom workspace feed wsp_XXXX --limit 20 --cascade`
|
|
1035
|
+
);
|
|
1036
|
+
workspace.command("create").description("Create a workspace (POST /workspaces)").requiredOption("--name <name>", "Workspace name").option("--description <description>", "Optional description").option("--parent <parentId>", "Parent workspace id (omit for a top-level workspace)").action(async (opts, cmd) => {
|
|
1037
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
1038
|
+
const data = await runApi("Creating workspace", async () => {
|
|
1039
|
+
const client = await makeClient(cfg);
|
|
1040
|
+
return client.POST("/workspaces", {
|
|
1041
|
+
body: {
|
|
1042
|
+
name: opts.name,
|
|
1043
|
+
description: opts.description ?? null,
|
|
1044
|
+
parentId: opts.parent ?? null
|
|
1045
|
+
}
|
|
1046
|
+
});
|
|
1047
|
+
});
|
|
1048
|
+
const view = resolveViewUrl(cfg.baseUrl, data.url);
|
|
1049
|
+
const urlPart = view ? ` ${style.dim("\u2192")} ${view}` : "";
|
|
1050
|
+
emitAction(
|
|
1051
|
+
`created workspace ${style.bold(data.id)} ${style.dim(`"${opts.name}"`)}${urlPart}`,
|
|
1052
|
+
data,
|
|
1053
|
+
cmd.optsWithGlobals().json
|
|
1054
|
+
);
|
|
1055
|
+
});
|
|
1056
|
+
workspace.command("list").description("List the caller's workspaces (GET /workspaces)").option("--include-archived", "Include archived workspaces", false).action(async (opts, cmd) => {
|
|
1057
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
1058
|
+
const data = await runApi("Listing workspaces", async () => {
|
|
1059
|
+
const client = await makeClient(cfg);
|
|
1060
|
+
return client.GET("/workspaces", {
|
|
1061
|
+
params: { query: { includeArchived: Boolean(opts.includeArchived) } }
|
|
1062
|
+
});
|
|
1063
|
+
});
|
|
1064
|
+
emit(data, cmd.optsWithGlobals().json);
|
|
1065
|
+
});
|
|
1066
|
+
workspace.command("get <workspaceId>").description("Fetch a workspace by id (GET /workspaces/{workspaceId})").action(async (workspaceId, _opts, cmd) => {
|
|
1067
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
1068
|
+
const data = await runApi("Fetching workspace", async () => {
|
|
1069
|
+
const client = await makeClient(cfg);
|
|
1070
|
+
return client.GET("/workspaces/{workspaceId}", { params: { path: { workspaceId } } });
|
|
1071
|
+
});
|
|
1072
|
+
emit(data, cmd.optsWithGlobals().json);
|
|
1073
|
+
});
|
|
1074
|
+
workspace.command("rename <workspaceId>").description("Rename a workspace (PUT /workspaces/{workspaceId}/rename)").requiredOption("--name <name>", "New workspace name").action(async (workspaceId, opts, cmd) => {
|
|
1075
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
1076
|
+
const data = await runApi("Renaming workspace", async () => {
|
|
1077
|
+
const client = await makeClient(cfg);
|
|
1078
|
+
return client.PUT("/workspaces/{workspaceId}/rename", {
|
|
1079
|
+
params: { path: { workspaceId } },
|
|
1080
|
+
body: { newName: opts.name }
|
|
1081
|
+
});
|
|
1082
|
+
});
|
|
1083
|
+
emitAction(
|
|
1084
|
+
`renamed workspace ${style.bold(workspaceId)} ${style.dim(`\u2192 "${opts.name}"`)}`,
|
|
1085
|
+
data,
|
|
1086
|
+
cmd.optsWithGlobals().json
|
|
1087
|
+
);
|
|
1088
|
+
});
|
|
1089
|
+
workspace.command("describe <workspaceId>").description("Set a workspace description (PUT /workspaces/{workspaceId}/description)").requiredOption("--description <description>", "New description").action(async (workspaceId, opts, cmd) => {
|
|
1090
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
1091
|
+
const data = await runApi("Updating description", async () => {
|
|
1092
|
+
const client = await makeClient(cfg);
|
|
1093
|
+
return client.PUT("/workspaces/{workspaceId}/description", {
|
|
1094
|
+
params: { path: { workspaceId } },
|
|
1095
|
+
body: { newDescription: opts.description }
|
|
1096
|
+
});
|
|
1097
|
+
});
|
|
1098
|
+
emitAction(
|
|
1099
|
+
`updated description for workspace ${style.bold(workspaceId)}`,
|
|
1100
|
+
data,
|
|
1101
|
+
cmd.optsWithGlobals().json
|
|
1102
|
+
);
|
|
1103
|
+
});
|
|
1104
|
+
workspace.command("move <workspaceId>").description("Move a workspace under a new parent (POST /workspaces/{workspaceId}/move)").option("--parent <parentId>", "New parent workspace id (omit to move to top level)").action(async (workspaceId, opts, cmd) => {
|
|
1105
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
1106
|
+
const data = await runApi("Moving workspace", async () => {
|
|
1107
|
+
const client = await makeClient(cfg);
|
|
1108
|
+
return client.POST("/workspaces/{workspaceId}/move", {
|
|
1109
|
+
params: { path: { workspaceId } },
|
|
1110
|
+
body: { newParentId: opts.parent ?? null }
|
|
1111
|
+
});
|
|
1112
|
+
});
|
|
1113
|
+
const target = opts.parent ? `under ${style.bold(opts.parent)}` : "to top level";
|
|
1114
|
+
emitAction(
|
|
1115
|
+
`moved workspace ${style.bold(workspaceId)} ${style.dim(target)}`,
|
|
1116
|
+
data,
|
|
1117
|
+
cmd.optsWithGlobals().json
|
|
1118
|
+
);
|
|
1119
|
+
});
|
|
1120
|
+
workspace.command("archive <workspaceId>").description("Archive a workspace (POST /workspaces/{workspaceId}/archive)").action(async (workspaceId, _opts, cmd) => {
|
|
1121
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
1122
|
+
const data = await runApi("Archiving workspace", async () => {
|
|
1123
|
+
const client = await makeClient(cfg);
|
|
1124
|
+
return client.POST("/workspaces/{workspaceId}/archive", {
|
|
1125
|
+
params: { path: { workspaceId } },
|
|
1126
|
+
body: {}
|
|
1127
|
+
});
|
|
1128
|
+
});
|
|
1129
|
+
emitAction(`archived workspace ${style.bold(workspaceId)}`, data, cmd.optsWithGlobals().json);
|
|
1130
|
+
});
|
|
1131
|
+
workspace.command("restore <workspaceId>").description("Restore an archived workspace (POST /workspaces/{workspaceId}/restore)").action(async (workspaceId, _opts, cmd) => {
|
|
1132
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
1133
|
+
const data = await runApi("Restoring workspace", async () => {
|
|
1134
|
+
const client = await makeClient(cfg);
|
|
1135
|
+
return client.POST("/workspaces/{workspaceId}/restore", {
|
|
1136
|
+
params: { path: { workspaceId } },
|
|
1137
|
+
body: {}
|
|
1138
|
+
});
|
|
1139
|
+
});
|
|
1140
|
+
emitAction(`restored workspace ${style.bold(workspaceId)}`, data, cmd.optsWithGlobals().json);
|
|
1141
|
+
});
|
|
1142
|
+
workspace.command("feed <workspaceId>").description("List a workspace's memory feed (GET /workspaces/{workspaceId}/memories/feed)").option("--limit <n>", "Max results", "20").option("--cursor <cursor>", "Paging cursor from a prior page").option("--cascade", "Cascade into descendant workspaces", false).option("--include-projects", "Include the workspace's projects", false).option("--include-archived", "Include archived memories", false).option("--query <query>", "Filter the feed by text").option("--tag <tag>", "Filter tags (comma-separated)").action(async (workspaceId, opts, cmd) => {
|
|
1143
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
1144
|
+
const data = await runApi("Fetching feed", async () => {
|
|
1145
|
+
const client = await makeClient(cfg);
|
|
1146
|
+
return client.GET("/workspaces/{workspaceId}/memories/feed", {
|
|
1147
|
+
params: {
|
|
1148
|
+
path: { workspaceId },
|
|
1149
|
+
query: {
|
|
1150
|
+
limit: Number(opts.limit),
|
|
1151
|
+
cascadeWorkspaces: Boolean(opts.cascade),
|
|
1152
|
+
includeProjects: Boolean(opts.includeProjects),
|
|
1153
|
+
includeArchived: Boolean(opts.includeArchived),
|
|
1154
|
+
...opts.cursor ? { cursor: opts.cursor } : {},
|
|
1155
|
+
...opts.query ? { query: opts.query } : {},
|
|
1156
|
+
...opts.tag ? { filterTags: opts.tag } : {}
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
});
|
|
1160
|
+
});
|
|
1161
|
+
emit(data, cmd.optsWithGlobals().json);
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// src/commands/project.ts
|
|
1166
|
+
function registerProject(program2) {
|
|
1167
|
+
const project = program2.command("project").description("Create, browse, and manage projects");
|
|
1168
|
+
project.addHelpText(
|
|
1169
|
+
"after",
|
|
1170
|
+
`
|
|
1171
|
+
Examples:
|
|
1172
|
+
$ sechroom project create --workspace wsp_XXXX --name "Onboarding" --slug onboarding
|
|
1173
|
+
$ sechroom project list --workspace wsp_XXXX --status Active
|
|
1174
|
+
$ sechroom project get prj_XXXX --json
|
|
1175
|
+
$ sechroom project rename prj_XXXX --name "New Name" --slug new-name
|
|
1176
|
+
$ sechroom project status prj_XXXX --status Completed`
|
|
1177
|
+
);
|
|
1178
|
+
project.command("create").description("Create a project (POST /projects)").requiredOption("--workspace <workspaceId>", "Owning workspace id").requiredOption("--name <name>", "Project name").requiredOption("--slug <slug>", "URL slug").option("--description <description>", "Optional description").option("--parent <parentProjectId>", "Optional parent project id").action(async (opts, cmd) => {
|
|
1179
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
1180
|
+
const data = await runApi("Creating project", async () => {
|
|
1181
|
+
const client = await makeClient(cfg);
|
|
1182
|
+
return client.POST("/projects", {
|
|
1183
|
+
body: {
|
|
1184
|
+
workspaceId: opts.workspace,
|
|
1185
|
+
name: opts.name,
|
|
1186
|
+
slug: opts.slug,
|
|
1187
|
+
description: opts.description ?? null,
|
|
1188
|
+
parentProjectId: opts.parent ?? null
|
|
1189
|
+
}
|
|
1190
|
+
});
|
|
1191
|
+
});
|
|
1192
|
+
const view = resolveViewUrl(cfg.baseUrl, data.url);
|
|
1193
|
+
const urlPart = view ? ` ${style.dim("\u2192")} ${view}` : "";
|
|
1194
|
+
emitAction(`created project ${style.bold(data.id)}${urlPart}`, data, cmd.optsWithGlobals().json);
|
|
1195
|
+
});
|
|
1196
|
+
project.command("list").description("List projects (GET /projects)").option("--workspace <workspaceId>", "Scope to a workspace").option("--status <status>", "Draft | Active | OnHold | Completed | Cancelled").option("--include-archived", "Include archived projects", false).action(async (opts, cmd) => {
|
|
1197
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
1198
|
+
const data = await runApi("Listing projects", async () => {
|
|
1199
|
+
const client = await makeClient(cfg);
|
|
1200
|
+
return client.GET("/projects", {
|
|
1201
|
+
params: {
|
|
1202
|
+
query: {
|
|
1203
|
+
...opts.workspace ? { workspaceId: opts.workspace } : {},
|
|
1204
|
+
...opts.status ? { status: opts.status } : {},
|
|
1205
|
+
...opts.includeArchived ? { includeArchived: true } : {}
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
});
|
|
1209
|
+
});
|
|
1210
|
+
emit(data, cmd.optsWithGlobals().json);
|
|
1211
|
+
});
|
|
1212
|
+
project.command("get <projectId>").description("Fetch a project by id (GET /projects/{projectId})").action(async (projectId, _opts, cmd) => {
|
|
1213
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
1214
|
+
const data = await runApi("Fetching project", async () => {
|
|
1215
|
+
const client = await makeClient(cfg);
|
|
1216
|
+
return client.GET("/projects/{projectId}", { params: { path: { projectId } } });
|
|
1217
|
+
});
|
|
1218
|
+
emit(data, cmd.optsWithGlobals().json);
|
|
1219
|
+
});
|
|
1220
|
+
project.command("rename <projectId>").description("Rename a project (PUT /projects/{projectId}/rename)").requiredOption("--name <name>", "New project name").requiredOption("--slug <slug>", "New URL slug").action(async (projectId, opts, cmd) => {
|
|
1221
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
1222
|
+
const data = await runApi("Renaming project", async () => {
|
|
1223
|
+
const client = await makeClient(cfg);
|
|
1224
|
+
return client.PUT("/projects/{projectId}/rename", {
|
|
1225
|
+
params: { path: { projectId } },
|
|
1226
|
+
body: { newName: opts.name, newSlug: opts.slug }
|
|
1227
|
+
});
|
|
1228
|
+
});
|
|
1229
|
+
emitAction(`renamed project ${style.bold(projectId)} \u2192 ${style.bold(opts.name)}`, data, cmd.optsWithGlobals().json);
|
|
1230
|
+
});
|
|
1231
|
+
project.command("describe <projectId>").description("Set a project's description (PUT /projects/{projectId}/description)").requiredOption("--description <description>", "New description (empty string to clear)").action(async (projectId, opts, cmd) => {
|
|
1232
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
1233
|
+
const data = await runApi("Updating description", async () => {
|
|
1234
|
+
const client = await makeClient(cfg);
|
|
1235
|
+
return client.PUT("/projects/{projectId}/description", {
|
|
1236
|
+
params: { path: { projectId } },
|
|
1237
|
+
body: { newDescription: opts.description }
|
|
1238
|
+
});
|
|
1239
|
+
});
|
|
1240
|
+
emitAction(`updated description on project ${style.bold(projectId)}`, data, cmd.optsWithGlobals().json);
|
|
1241
|
+
});
|
|
1242
|
+
project.command("move <projectId>").description("Move a project to another workspace/parent (POST /projects/{projectId}/move)").requiredOption("--workspace <toWorkspaceId>", "Destination workspace id").option("--parent <toParentId>", "Destination parent project id").action(async (projectId, opts, cmd) => {
|
|
1243
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
1244
|
+
const data = await runApi("Moving project", async () => {
|
|
1245
|
+
const client = await makeClient(cfg);
|
|
1246
|
+
return client.POST("/projects/{projectId}/move", {
|
|
1247
|
+
params: { path: { projectId } },
|
|
1248
|
+
body: { toWorkspaceId: opts.workspace, toParentId: opts.parent ?? null }
|
|
1249
|
+
});
|
|
1250
|
+
});
|
|
1251
|
+
emitAction(`moved project ${style.bold(projectId)} \u2192 ${style.bold(opts.workspace)}`, data, cmd.optsWithGlobals().json);
|
|
1252
|
+
});
|
|
1253
|
+
project.command("status <projectId>").description("Set a project's status (POST /projects/{projectId}/status)").requiredOption("--status <status>", "Draft | Active | OnHold | Completed | Cancelled").action(async (projectId, opts, cmd) => {
|
|
1254
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
1255
|
+
const data = await runApi("Setting status", async () => {
|
|
1256
|
+
const client = await makeClient(cfg);
|
|
1257
|
+
return client.POST("/projects/{projectId}/status", {
|
|
1258
|
+
params: { path: { projectId } },
|
|
1259
|
+
body: {
|
|
1260
|
+
status: opts.status
|
|
1261
|
+
}
|
|
1262
|
+
});
|
|
1263
|
+
});
|
|
1264
|
+
emitAction(`set project ${style.bold(projectId)} status \u2192 ${style.bold(opts.status)}`, data, cmd.optsWithGlobals().json);
|
|
1265
|
+
});
|
|
1266
|
+
project.command("victory-conditions <projectId>").description("Set a project's victory conditions (PUT /projects/{projectId}/victory-conditions)").requiredOption("--conditions <conditions>", "Victory conditions text (empty string to clear)").action(async (projectId, opts, cmd) => {
|
|
1267
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
1268
|
+
const data = await runApi("Updating victory conditions", async () => {
|
|
1269
|
+
const client = await makeClient(cfg);
|
|
1270
|
+
return client.PUT("/projects/{projectId}/victory-conditions", {
|
|
1271
|
+
params: { path: { projectId } },
|
|
1272
|
+
body: { victoryConditions: opts.conditions }
|
|
1273
|
+
});
|
|
1274
|
+
});
|
|
1275
|
+
emitAction(`updated victory conditions on project ${style.bold(projectId)}`, data, cmd.optsWithGlobals().json);
|
|
1276
|
+
});
|
|
1277
|
+
project.command("archive <projectId>").description("Archive a project (POST /projects/{projectId}/archive)").action(async (projectId, _opts, cmd) => {
|
|
1278
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
1279
|
+
const data = await runApi("Archiving project", async () => {
|
|
1280
|
+
const client = await makeClient(cfg);
|
|
1281
|
+
return client.POST("/projects/{projectId}/archive", {
|
|
1282
|
+
params: { path: { projectId } },
|
|
1283
|
+
body: {}
|
|
1284
|
+
});
|
|
1285
|
+
});
|
|
1286
|
+
emitAction(`archived project ${style.bold(projectId)}`, data, cmd.optsWithGlobals().json);
|
|
1287
|
+
});
|
|
1288
|
+
project.command("restore <projectId>").description("Restore an archived project (POST /projects/{projectId}/restore)").action(async (projectId, _opts, cmd) => {
|
|
1289
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
1290
|
+
const data = await runApi("Restoring project", async () => {
|
|
1291
|
+
const client = await makeClient(cfg);
|
|
1292
|
+
return client.POST("/projects/{projectId}/restore", {
|
|
1293
|
+
params: { path: { projectId } },
|
|
1294
|
+
body: {}
|
|
1295
|
+
});
|
|
1296
|
+
});
|
|
1297
|
+
emitAction(`restored project ${style.bold(projectId)}`, data, cmd.optsWithGlobals().json);
|
|
1298
|
+
});
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
// src/commands/filing.ts
|
|
1302
|
+
function registerFiling(program2) {
|
|
1303
|
+
const filing = program2.command("filing").description("Review and act on filing suggestions");
|
|
1304
|
+
filing.addHelpText(
|
|
1305
|
+
"after",
|
|
1306
|
+
`
|
|
1307
|
+
Examples:
|
|
1308
|
+
$ sechroom filing suggestions --status Pending --page-size 20
|
|
1309
|
+
$ sechroom filing get fsg_XXXX --json
|
|
1310
|
+
$ sechroom filing preview --memory-id mem_XXXX
|
|
1311
|
+
$ sechroom filing accept fsg_XXXX
|
|
1312
|
+
$ sechroom filing reject fsg_XXXX --reason "wrong workspace"
|
|
1313
|
+
$ sechroom filing edit-and-accept fsg_XXXX --target-kind Workspace --existing-target-id wsp_XXXX`
|
|
1314
|
+
);
|
|
1315
|
+
filing.command("suggestions").description("List filing suggestions (GET /filing/suggestions)").option("--memory-id <memoryId>", "Filter to a single memory's suggestions").option(
|
|
1316
|
+
"--status <status>",
|
|
1317
|
+
"Generating | Pending | Accepted | Rejected | EditedAndAccepted | Deferred | Invalidated"
|
|
1318
|
+
).option("--page <n>", "Page number").option("--page-size <n>", "Page size").action(async (opts, cmd) => {
|
|
1319
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
1320
|
+
const data = await runApi("Listing filing suggestions", async () => {
|
|
1321
|
+
const client = await makeClient(cfg);
|
|
1322
|
+
return client.GET("/filing/suggestions", {
|
|
1323
|
+
params: {
|
|
1324
|
+
query: {
|
|
1325
|
+
...opts.memoryId ? { memoryId: opts.memoryId } : {},
|
|
1326
|
+
...opts.status ? { status: opts.status } : {},
|
|
1327
|
+
...opts.page ? { page: Number(opts.page) } : {},
|
|
1328
|
+
...opts.pageSize ? { pageSize: Number(opts.pageSize) } : {}
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
});
|
|
1332
|
+
});
|
|
1333
|
+
emit(data, cmd.optsWithGlobals().json);
|
|
1334
|
+
});
|
|
1335
|
+
filing.command("get <id>").description("Fetch a filing suggestion by id (GET /filing/suggestions/{id})").action(async (id, _opts, cmd) => {
|
|
1336
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
1337
|
+
const data = await runApi("Fetching filing suggestion", async () => {
|
|
1338
|
+
const client = await makeClient(cfg);
|
|
1339
|
+
return client.GET("/filing/suggestions/{id}", { params: { path: { id } } });
|
|
1340
|
+
});
|
|
1341
|
+
emit(data, cmd.optsWithGlobals().json);
|
|
1342
|
+
});
|
|
1343
|
+
filing.command("preview").description("Preview a filing suggestion for a memory id or ad-hoc shape (POST /filing/suggestions/preview)").option("--memory-id <memoryId>", "Preview filing for an existing memory").option("--text <text>", "Ad-hoc memory body text (instead of --memory-id)").option("--title <title>", "Ad-hoc memory title").option("--tag <tag...>", "Ad-hoc memory tags (repeatable)").option("--type <type>", "Ad-hoc memory type", "reference").option("--scope-workspace <workspaceId>", "Scope the preview to a workspace").action(async (opts, cmd) => {
|
|
1344
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
1345
|
+
const memory = opts.text ? {
|
|
1346
|
+
text: opts.text,
|
|
1347
|
+
title: opts.title ?? null,
|
|
1348
|
+
tags: opts.tag ?? null,
|
|
1349
|
+
type: opts.type
|
|
1350
|
+
} : null;
|
|
1351
|
+
const data = await runApi("Previewing filing suggestion", async () => {
|
|
1352
|
+
const client = await makeClient(cfg);
|
|
1353
|
+
return client.POST("/filing/suggestions/preview", {
|
|
1354
|
+
body: {
|
|
1355
|
+
memoryId: opts.memoryId ?? null,
|
|
1356
|
+
memory,
|
|
1357
|
+
scopeWorkspaceId: opts.scopeWorkspace ?? null
|
|
1358
|
+
}
|
|
1359
|
+
});
|
|
1360
|
+
});
|
|
1361
|
+
emit(data, cmd.optsWithGlobals().json);
|
|
1362
|
+
});
|
|
1363
|
+
filing.command("accept <id>").description("Accept a filing suggestion (POST /filing/suggestions/{id}/accept)").action(async (id, _opts, cmd) => {
|
|
1364
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
1365
|
+
const data = await runApi("Accepting filing suggestion", async () => {
|
|
1366
|
+
const client = await makeClient(cfg);
|
|
1367
|
+
return client.POST("/filing/suggestions/{id}/accept", { params: { path: { id } }, body: {} });
|
|
1368
|
+
});
|
|
1369
|
+
emitAction(`accepted filing suggestion ${style.bold(id)}`, data, cmd.optsWithGlobals().json);
|
|
1370
|
+
});
|
|
1371
|
+
filing.command("reject <id>").description("Reject a filing suggestion (POST /filing/suggestions/{id}/reject)").option("--reason <reason>", "Why the suggestion was rejected").option("--reason-code <code>", "Structured reason code").action(async (id, opts, cmd) => {
|
|
1372
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
1373
|
+
const data = await runApi("Rejecting filing suggestion", async () => {
|
|
1374
|
+
const client = await makeClient(cfg);
|
|
1375
|
+
return client.POST("/filing/suggestions/{id}/reject", {
|
|
1376
|
+
params: { path: { id } },
|
|
1377
|
+
body: {
|
|
1378
|
+
reason: opts.reason ?? null,
|
|
1379
|
+
...opts.reasonCode ? { reasonCode: opts.reasonCode } : {}
|
|
1380
|
+
}
|
|
1381
|
+
});
|
|
1382
|
+
});
|
|
1383
|
+
emitAction(`rejected filing suggestion ${style.bold(id)}`, data, cmd.optsWithGlobals().json);
|
|
1384
|
+
});
|
|
1385
|
+
filing.command("defer <id>").description("Defer a filing suggestion (POST /filing/suggestions/{id}/defer)").option("--until <iso>", "Defer until an ISO-8601 timestamp (defaults to indefinite)").action(async (id, opts, cmd) => {
|
|
1386
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
1387
|
+
const data = await runApi("Deferring filing suggestion", async () => {
|
|
1388
|
+
const client = await makeClient(cfg);
|
|
1389
|
+
return client.POST("/filing/suggestions/{id}/defer", {
|
|
1390
|
+
params: { path: { id } },
|
|
1391
|
+
body: { until: opts.until ?? null }
|
|
1392
|
+
});
|
|
1393
|
+
});
|
|
1394
|
+
emitAction(`deferred filing suggestion ${style.bold(id)}`, data, cmd.optsWithGlobals().json);
|
|
1395
|
+
});
|
|
1396
|
+
filing.command("edit-and-accept <id>").description("Override the target then accept (POST /filing/suggestions/{id}/edit-and-accept)").option("--target-kind <kind>", "Workspace | Project").option("--existing-target-id <id>", "File into an existing workspace/project").option("--new-name <name>", "Create a new target with this name").option("--new-description <text>", "Description for the new target").option("--new-parent-workspace <workspaceId>", "Parent workspace for a new project").option("--memory-id <memoryId...>", "Override the memory set (repeatable)").action(async (id, opts, cmd) => {
|
|
1397
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
1398
|
+
const data = await runApi("Editing and accepting filing suggestion", async () => {
|
|
1399
|
+
const client = await makeClient(cfg);
|
|
1400
|
+
return client.POST("/filing/suggestions/{id}/edit-and-accept", {
|
|
1401
|
+
params: { path: { id } },
|
|
1402
|
+
body: {
|
|
1403
|
+
targetKind: opts.targetKind ?? null,
|
|
1404
|
+
existingTargetId: opts.existingTargetId ?? null,
|
|
1405
|
+
newName: opts.newName ?? null,
|
|
1406
|
+
newDescription: opts.newDescription ?? null,
|
|
1407
|
+
newParentWorkspaceId: opts.newParentWorkspace ?? null,
|
|
1408
|
+
overrideMemoryIds: opts.memoryId ?? null
|
|
1409
|
+
}
|
|
1410
|
+
});
|
|
1411
|
+
});
|
|
1412
|
+
emitAction(`edited & accepted filing suggestion ${style.bold(id)}`, data, cmd.optsWithGlobals().json);
|
|
1413
|
+
});
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
// src/commands/continuity.ts
|
|
1417
|
+
function registerContinuity(program2) {
|
|
1418
|
+
const continuity = program2.command("continuity").description("Continuity snapshots: checkpoint and resume work");
|
|
1419
|
+
continuity.addHelpText(
|
|
1420
|
+
"after",
|
|
1421
|
+
`
|
|
1422
|
+
Examples:
|
|
1423
|
+
$ sechroom continuity snapshot-create --lane claude-code-chris --scope loop-spec \\
|
|
1424
|
+
--objective "ship slice 9" --state "tests green, PR open" \\
|
|
1425
|
+
--last-action "raised PR #1425" --next-action "watch for merge" \\
|
|
1426
|
+
--resume-instruction "load csn id, resume at step 10"
|
|
1427
|
+
$ sechroom continuity snapshots --scope loop-spec
|
|
1428
|
+
$ sechroom continuity snapshot-get csn_XXXX --json
|
|
1429
|
+
$ sechroom continuity resume-me --max-artifacts 20
|
|
1430
|
+
$ sechroom continuity changed-since --since 2026-06-01T00:00:00Z
|
|
1431
|
+
$ sechroom continuity grant csn_XXXX --grantee usr_XXXX`
|
|
1432
|
+
);
|
|
1433
|
+
continuity.command("snapshot-create").description("Create a continuity snapshot (POST /continuity/snapshots)").requiredOption("--lane <laneId>", "Lane id (e.g. claude-code-chris)").requiredOption("--scope <scope>", "Snapshot scope (e.g. loop-spec)").requiredOption("--objective <text>", "Current objective").requiredOption("--state <text>", "Current state").requiredOption("--last-action <text>", "Last meaningful action").requiredOption("--next-action <text>", "Next intended action").requiredOption("--resume-instruction <text>", "Resume instruction").option("--constraint <text...>", "Active constraints (repeatable)").option("--question <text...>", "Open questions (repeatable)").option("--surface-marker <text...>", "Surface markers (repeatable)").option("--artifact <id...>", "Relevant artifact ids (repeatable)").option("--confidence <n>", "Confidence 0..1").action(async (opts, cmd) => {
|
|
1434
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
1435
|
+
const data = await runApi("Creating snapshot", async () => {
|
|
1436
|
+
const client = await makeClient(cfg);
|
|
1437
|
+
return client.POST("/continuity/snapshots", {
|
|
1438
|
+
body: {
|
|
1439
|
+
laneId: opts.lane,
|
|
1440
|
+
scope: opts.scope,
|
|
1441
|
+
currentObjective: opts.objective,
|
|
1442
|
+
currentState: opts.state,
|
|
1443
|
+
lastMeaningfulAction: opts.lastAction,
|
|
1444
|
+
nextIntendedAction: opts.nextAction,
|
|
1445
|
+
resumeInstruction: opts.resumeInstruction,
|
|
1446
|
+
activeConstraints: opts.constraint ?? null,
|
|
1447
|
+
openQuestions: opts.question ?? null,
|
|
1448
|
+
surfaceMarkers: opts.surfaceMarker ?? null,
|
|
1449
|
+
relevantArtifactIds: opts.artifact ?? null,
|
|
1450
|
+
confidence: opts.confidence != null ? Number(opts.confidence) : null
|
|
1451
|
+
}
|
|
1452
|
+
});
|
|
1453
|
+
});
|
|
1454
|
+
emitAction(`created snapshot ${style.bold(data.snapshotId)}`, data, cmd.optsWithGlobals().json);
|
|
1455
|
+
});
|
|
1456
|
+
continuity.command("snapshot-get <id>").description("Fetch a snapshot by id (GET /continuity/snapshots/{id})").action(async (id, _opts, cmd) => {
|
|
1457
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
1458
|
+
const data = await runApi("Fetching snapshot", async () => {
|
|
1459
|
+
const client = await makeClient(cfg);
|
|
1460
|
+
return client.GET("/continuity/snapshots/{id}", { params: { path: { id } } });
|
|
1461
|
+
});
|
|
1462
|
+
emit(data, cmd.optsWithGlobals().json);
|
|
1463
|
+
});
|
|
1464
|
+
continuity.command("snapshots").description("List the caller's own snapshots (GET /me/continuity/snapshots)").option("--scope <scope>", "Filter by scope").option("--lane <laneId>", "Filter by lane id").action(async (opts, cmd) => {
|
|
1465
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
1466
|
+
const data = await runApi("Listing snapshots", async () => {
|
|
1467
|
+
const client = await makeClient(cfg);
|
|
1468
|
+
return client.GET("/me/continuity/snapshots", {
|
|
1469
|
+
params: {
|
|
1470
|
+
query: {
|
|
1471
|
+
...opts.scope ? { scope: opts.scope } : {},
|
|
1472
|
+
...opts.lane ? { laneId: opts.lane } : {}
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
});
|
|
1476
|
+
});
|
|
1477
|
+
emit(data, cmd.optsWithGlobals().json);
|
|
1478
|
+
});
|
|
1479
|
+
continuity.command("resume-me").description("Resume the caller's own lane (POST /continuity/resume/me)").option("--workspace <workspaceId>", "Scope to a workspace").option("--max-artifacts <n>", "Cap artifacts in the bundle").option("--changed-since <iso>", "Only include changes since this ISO timestamp").option("--looking-at-myself", "Include the caller's own changes", false).action(async (opts, cmd) => {
|
|
1480
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
1481
|
+
const data = await runApi("Resuming", async () => {
|
|
1482
|
+
const client = await makeClient(cfg);
|
|
1483
|
+
return client.POST("/continuity/resume/me", {
|
|
1484
|
+
body: {
|
|
1485
|
+
workspaceId: opts.workspace ?? null,
|
|
1486
|
+
maxArtifacts: opts.maxArtifacts != null ? Number(opts.maxArtifacts) : null,
|
|
1487
|
+
includeLookingAtMyself: opts.lookingAtMyself ? true : null,
|
|
1488
|
+
changedSince: opts.changedSince ?? null
|
|
1489
|
+
}
|
|
1490
|
+
});
|
|
1491
|
+
});
|
|
1492
|
+
emit(data, cmd.optsWithGlobals().json);
|
|
1493
|
+
});
|
|
1494
|
+
continuity.command("resume-lane <laneId>").description("Resume a specific lane (POST /continuity/resume/lane)").option("--workspace <workspaceId>", "Scope to a workspace").option("--max-artifacts <n>", "Cap artifacts in the bundle").option("--changed-since <iso>", "Only include changes since this ISO timestamp").option("--looking-at-myself", "Include the caller's own changes", false).action(async (laneId, opts, cmd) => {
|
|
1495
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
1496
|
+
const data = await runApi("Resuming lane", async () => {
|
|
1497
|
+
const client = await makeClient(cfg);
|
|
1498
|
+
return client.POST("/continuity/resume/lane", {
|
|
1499
|
+
body: {
|
|
1500
|
+
laneId,
|
|
1501
|
+
workspaceId: opts.workspace ?? null,
|
|
1502
|
+
maxArtifacts: opts.maxArtifacts != null ? Number(opts.maxArtifacts) : null,
|
|
1503
|
+
includeLookingAtMyself: opts.lookingAtMyself ? true : null,
|
|
1504
|
+
changedSince: opts.changedSince ?? null
|
|
1505
|
+
}
|
|
1506
|
+
});
|
|
1507
|
+
});
|
|
1508
|
+
emit(data, cmd.optsWithGlobals().json);
|
|
1509
|
+
});
|
|
1510
|
+
continuity.command("changed-since").description("What changed since a timestamp (POST /continuity/changed-since)").requiredOption("--since <iso>", "ISO-8601 timestamp to compare against").option("--looking-at-myself", "Include the caller's own changes", false).action(async (opts, cmd) => {
|
|
1511
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
1512
|
+
const data = await runApi("Computing changes", async () => {
|
|
1513
|
+
const client = await makeClient(cfg);
|
|
1514
|
+
return client.POST("/continuity/changed-since", {
|
|
1515
|
+
body: {
|
|
1516
|
+
since: opts.since,
|
|
1517
|
+
includeLookingAtMyself: opts.lookingAtMyself ? true : null
|
|
1518
|
+
}
|
|
1519
|
+
});
|
|
1520
|
+
});
|
|
1521
|
+
emit(data, cmd.optsWithGlobals().json);
|
|
1522
|
+
});
|
|
1523
|
+
continuity.command("load-set").description("Derive the active load set (POST /continuity/load-set/derive)").option("--workspace <workspaceId>", "Scope to a workspace").option("--max-artifacts <n>", "Cap artifacts in the load set").option("--looking-at-myself", "Include the caller's own changes", false).action(async (opts, cmd) => {
|
|
1524
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
1525
|
+
const data = await runApi("Deriving load set", async () => {
|
|
1526
|
+
const client = await makeClient(cfg);
|
|
1527
|
+
return client.POST("/continuity/load-set/derive", {
|
|
1528
|
+
body: {
|
|
1529
|
+
workspaceId: opts.workspace ?? null,
|
|
1530
|
+
maxArtifacts: opts.maxArtifacts != null ? Number(opts.maxArtifacts) : null,
|
|
1531
|
+
includeLookingAtMyself: opts.lookingAtMyself ? true : null
|
|
1532
|
+
}
|
|
1533
|
+
});
|
|
1534
|
+
});
|
|
1535
|
+
emit(data, cmd.optsWithGlobals().json);
|
|
1536
|
+
});
|
|
1537
|
+
continuity.command("grant <snapshotId>").description("Grant another operator read access (POST /continuity/snapshots/{snapshotId}/grants)").requiredOption("--grantee <userId>", "Sechroom user id being granted read access").option("--source <source>", "Permission-set source kind", "TenantRole").option("--source-id <sourceId>", "Permission-set source id (e.g. a tenant role)", "viewer").option("--valid-from <iso>", "Optional ISO-8601 grant start").option("--valid-to <iso>", "Optional ISO-8601 grant expiry").action(async (snapshotId, opts, cmd) => {
|
|
1538
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
1539
|
+
const data = await runApi("Minting grant", async () => {
|
|
1540
|
+
const client = await makeClient(cfg);
|
|
1541
|
+
return client.POST("/continuity/snapshots/{snapshotId}/grants", {
|
|
1542
|
+
params: { path: { snapshotId } },
|
|
1543
|
+
body: {
|
|
1544
|
+
userId: opts.grantee,
|
|
1545
|
+
kind: "Allow",
|
|
1546
|
+
source: opts.source,
|
|
1547
|
+
sourceId: opts.sourceId,
|
|
1548
|
+
...opts.validFrom ? { validFrom: opts.validFrom } : {},
|
|
1549
|
+
...opts.validTo ? { validTo: opts.validTo } : {}
|
|
1550
|
+
}
|
|
1551
|
+
});
|
|
1552
|
+
});
|
|
1553
|
+
emitAction(
|
|
1554
|
+
`granted ${style.bold(data.userId)} read on ${style.bold(snapshotId)} ${style.dim(`(grant ${data.grantId})`)}`,
|
|
1555
|
+
data,
|
|
1556
|
+
cmd.optsWithGlobals().json
|
|
1557
|
+
);
|
|
1558
|
+
});
|
|
1559
|
+
continuity.command("revoke-grant <snapshotId> <grantId>").description("Revoke a grant (DELETE /continuity/snapshots/{snapshotId}/grants/{grantId})").action(async (snapshotId, grantId, _opts, cmd) => {
|
|
1560
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
1561
|
+
const data = await runApi("Revoking grant", async () => {
|
|
1562
|
+
const client = await makeClient(cfg);
|
|
1563
|
+
return client.DELETE("/continuity/snapshots/{snapshotId}/grants/{grantId}", {
|
|
1564
|
+
params: { path: { snapshotId, grantId } },
|
|
1565
|
+
body: {}
|
|
1566
|
+
});
|
|
1567
|
+
});
|
|
1568
|
+
emitAction(
|
|
1569
|
+
`revoked grant ${style.bold(grantId)} on ${style.bold(snapshotId)}`,
|
|
1570
|
+
data,
|
|
1571
|
+
cmd.optsWithGlobals().json
|
|
1572
|
+
);
|
|
1573
|
+
});
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
// src/commands/hook.ts
|
|
1577
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
1578
|
+
import { homedir as homedir3 } from "os";
|
|
1579
|
+
import { delimiter, dirname as dirname4, join as join4 } from "path";
|
|
1580
|
+
|
|
1581
|
+
// src/sem.ts
|
|
1582
|
+
import { basename as basename2, dirname as dirname2, join as join2 } from "path";
|
|
1583
|
+
import { appendFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
1584
|
+
var SEM_FILE = join2(".sechroom", "lane.json");
|
|
1585
|
+
var LEGACY_SEM_FILE = ".sem";
|
|
1586
|
+
var STATE_DIR_NAME2 = ".sechroom";
|
|
1587
|
+
function localSemPath(cwd = process.cwd()) {
|
|
1588
|
+
return join2(cwd, SEM_FILE);
|
|
1589
|
+
}
|
|
1590
|
+
function resolveSemPathForRead(start = process.cwd()) {
|
|
1591
|
+
let dir = start;
|
|
1592
|
+
while (true) {
|
|
1593
|
+
const candidate = join2(dir, SEM_FILE);
|
|
1594
|
+
if (existsSync2(candidate)) return candidate;
|
|
1595
|
+
const legacy = join2(dir, LEGACY_SEM_FILE);
|
|
1596
|
+
if (existsSync2(legacy)) return legacy;
|
|
1597
|
+
const parent = dirname2(dir);
|
|
1598
|
+
if (parent === dir) return void 0;
|
|
1599
|
+
dir = parent;
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
function parseSem(text) {
|
|
1603
|
+
const out = {};
|
|
1604
|
+
for (const raw of text.split("\n")) {
|
|
1605
|
+
const line = raw.trim();
|
|
1606
|
+
if (!line || line.startsWith("#")) continue;
|
|
1607
|
+
const eq = line.indexOf("=");
|
|
1608
|
+
if (eq === -1) continue;
|
|
1609
|
+
const key = line.slice(0, eq).trim();
|
|
1610
|
+
const value = line.slice(eq + 1).trim();
|
|
1611
|
+
if (key) out[key] = value;
|
|
1612
|
+
}
|
|
1613
|
+
return out;
|
|
1614
|
+
}
|
|
1615
|
+
function serializeSem(values) {
|
|
1616
|
+
return JSON.stringify(values, null, 2) + "\n";
|
|
1617
|
+
}
|
|
1618
|
+
function readSem(path) {
|
|
1619
|
+
const p = path ?? resolveSemPathForRead();
|
|
1620
|
+
if (!p || !existsSync2(p)) return void 0;
|
|
1621
|
+
const text = readFileSync2(p, "utf8");
|
|
1622
|
+
const values = basename2(p) === LEGACY_SEM_FILE ? parseSem(text) : parseLaneJson(text);
|
|
1623
|
+
return { path: p, values };
|
|
1624
|
+
}
|
|
1625
|
+
function readLocalSemValues(cwd = process.cwd()) {
|
|
1626
|
+
const next = join2(cwd, SEM_FILE);
|
|
1627
|
+
if (existsSync2(next)) return readSem(next)?.values ?? {};
|
|
1628
|
+
const legacy = join2(cwd, LEGACY_SEM_FILE);
|
|
1629
|
+
if (existsSync2(legacy)) return readSem(legacy)?.values ?? {};
|
|
1630
|
+
return {};
|
|
1631
|
+
}
|
|
1632
|
+
function parseLaneJson(text) {
|
|
1633
|
+
try {
|
|
1634
|
+
const parsed = JSON.parse(text);
|
|
1635
|
+
const out = {};
|
|
1636
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
1637
|
+
if (typeof v === "string") out[k] = v;
|
|
1638
|
+
}
|
|
1639
|
+
return out;
|
|
1640
|
+
} catch {
|
|
1641
|
+
return {};
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
var STATE_DIR_IGNORE = `${STATE_DIR_NAME2}/`;
|
|
1645
|
+
function writeSem(values, path = localSemPath()) {
|
|
1646
|
+
mkdirSync2(dirname2(path), { recursive: true });
|
|
1647
|
+
writeFileSync2(path, serializeSem(values));
|
|
1648
|
+
ensureSemIgnored(path);
|
|
1649
|
+
return path;
|
|
1650
|
+
}
|
|
1651
|
+
function ignoresSem(content) {
|
|
1652
|
+
return content.split("\n").some((line) => {
|
|
1653
|
+
const t = line.trim();
|
|
1654
|
+
return t === STATE_DIR_NAME2 || t === STATE_DIR_IGNORE || t === `/${STATE_DIR_NAME2}` || t === `/${STATE_DIR_IGNORE}` || t === `**/${STATE_DIR_NAME2}` || t === `**/${STATE_DIR_IGNORE}`;
|
|
1655
|
+
});
|
|
1656
|
+
}
|
|
1657
|
+
function inGitRepo(startDir) {
|
|
1658
|
+
let dir = startDir;
|
|
1659
|
+
for (; ; ) {
|
|
1660
|
+
if (existsSync2(join2(dir, ".git"))) return true;
|
|
1661
|
+
const parent = dirname2(dir);
|
|
1662
|
+
if (parent === dir) return false;
|
|
1663
|
+
dir = parent;
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
function resolveGitignoreTarget(startDir) {
|
|
1667
|
+
let dir = startDir;
|
|
1668
|
+
for (; ; ) {
|
|
1669
|
+
const gi = join2(dir, ".gitignore");
|
|
1670
|
+
if (existsSync2(gi)) return { path: gi, exists: true };
|
|
1671
|
+
const parent = dirname2(dir);
|
|
1672
|
+
if (existsSync2(join2(dir, ".git")) || parent === dir) {
|
|
1673
|
+
return { path: join2(startDir, ".gitignore"), exists: false };
|
|
1674
|
+
}
|
|
1675
|
+
dir = parent;
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
function ensureSemIgnored(semPath) {
|
|
1679
|
+
try {
|
|
1680
|
+
const checkoutDir = dirname2(dirname2(semPath));
|
|
1681
|
+
if (!inGitRepo(checkoutDir)) return;
|
|
1682
|
+
const target = resolveGitignoreTarget(checkoutDir);
|
|
1683
|
+
if (target.exists) {
|
|
1684
|
+
const content = readFileSync2(target.path, "utf8");
|
|
1685
|
+
if (ignoresSem(content)) return;
|
|
1686
|
+
const sep = content.length === 0 || content.endsWith("\n") ? "" : "\n";
|
|
1687
|
+
appendFileSync(target.path, `${sep}${STATE_DIR_IGNORE}
|
|
1688
|
+
`);
|
|
1689
|
+
} else {
|
|
1690
|
+
writeFileSync2(target.path, `${STATE_DIR_IGNORE}
|
|
1691
|
+
`);
|
|
1692
|
+
}
|
|
1693
|
+
} catch {
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
// src/setup/clients.ts
|
|
1698
|
+
import { existsSync as existsSync3 } from "fs";
|
|
1699
|
+
import { homedir as homedir2 } from "os";
|
|
1700
|
+
import { dirname as dirname3, join as join3 } from "path";
|
|
1701
|
+
|
|
1702
|
+
// src/setup/operator-surface.ts
|
|
1703
|
+
var SectionType = {
|
|
1704
|
+
McpConfig: "mcp-config",
|
|
1705
|
+
McpConfigToml: "mcp-config-toml",
|
|
1706
|
+
InstructionFile: "instruction-file",
|
|
1707
|
+
ProjectConfig: "project-config",
|
|
1708
|
+
Verify: "verify",
|
|
1709
|
+
/** SBC-999 — workspace-pinned conventions, emitted only when the request
|
|
1710
|
+
* carried a workspaceId and that workspace has agent-setup-bundle memories. */
|
|
1711
|
+
WorkspaceConventions: "workspace-conventions"
|
|
1712
|
+
};
|
|
1713
|
+
async function fetchSetup(cfg) {
|
|
1714
|
+
const client = await makeClient(cfg);
|
|
1715
|
+
const { data, error } = await client.GET(
|
|
1716
|
+
"/operator-surface/setup",
|
|
1717
|
+
cfg.workspaceId ? { params: { query: { workspaceId: cfg.workspaceId } } } : {}
|
|
1718
|
+
);
|
|
1719
|
+
if (error) throw new Error(`GET /operator-surface/setup failed: ${JSON.stringify(error)}`);
|
|
1720
|
+
return data;
|
|
1721
|
+
}
|
|
1722
|
+
function findSurface(setup, surfaceKey) {
|
|
1723
|
+
return setup.surfaces.find((s) => s.surfaceKey === surfaceKey);
|
|
1724
|
+
}
|
|
1725
|
+
function findSection(surface, sectionType) {
|
|
1726
|
+
return surface?.sections.find((s) => s.sectionType === sectionType);
|
|
1727
|
+
}
|
|
1728
|
+
function sectionSnippet(section) {
|
|
1729
|
+
if (!section) return null;
|
|
1730
|
+
for (const step of section.steps) {
|
|
1731
|
+
if (step.copyValue) return step.copyValue;
|
|
1732
|
+
if (step.codeSnippet) return step.codeSnippet;
|
|
1733
|
+
}
|
|
1734
|
+
return null;
|
|
1735
|
+
}
|
|
1736
|
+
function parseTagArtifactId(id) {
|
|
1737
|
+
if (!id.startsWith("tag:")) return null;
|
|
1738
|
+
const tags = id.slice("tag:".length).split(",").map((t) => t.trim()).filter((t) => t.length > 0);
|
|
1739
|
+
return tags.length > 0 ? tags : null;
|
|
1740
|
+
}
|
|
1741
|
+
async function getPersonalWorkspaceId(cfg) {
|
|
1742
|
+
const client = await makeClient(cfg);
|
|
1743
|
+
const { data } = await client.GET("/me/personal-workspace", {});
|
|
1744
|
+
return data?.workspaceId ?? null;
|
|
1745
|
+
}
|
|
1746
|
+
async function fetchMemoryFields(cfg, id) {
|
|
1747
|
+
const client = await makeClient(cfg);
|
|
1748
|
+
const { data } = await client.GET("/memories/{memoryId}", { params: { path: { memoryId: id } } });
|
|
1749
|
+
const env = data;
|
|
1750
|
+
const m = env?.item ?? env;
|
|
1751
|
+
if (!m) return null;
|
|
1752
|
+
const version = typeof m.currentVersion === "string" ? Number(m.currentVersion) : m.currentVersion;
|
|
1753
|
+
return { text: m.text, title: m.title, tags: m.tags, version: Number.isFinite(version) ? version : void 0 };
|
|
1754
|
+
}
|
|
1755
|
+
async function resolveInstruction(cfg, section, personalWorkspaceId) {
|
|
1756
|
+
const client = await makeClient(cfg);
|
|
1757
|
+
for (const artifact of section.artifacts) {
|
|
1758
|
+
const tags = parseTagArtifactId(artifact.id);
|
|
1759
|
+
if (!tags) continue;
|
|
1760
|
+
const { data } = await client.POST("/memories/search", {
|
|
1761
|
+
body: { query: null, textQuery: null, semanticQuery: null, hybrid: false, limit: 1, includeArchived: false, includeSystem: false, tags }
|
|
1762
|
+
});
|
|
1763
|
+
const hits = data ?? [];
|
|
1764
|
+
if (hits.length === 0) continue;
|
|
1765
|
+
const templateId = hits[0].id;
|
|
1766
|
+
const template = await fetchMemoryFields(cfg, templateId);
|
|
1767
|
+
if (typeof template?.text !== "string" || template.text.length === 0) continue;
|
|
1768
|
+
const templateTags = template.tags ?? tags;
|
|
1769
|
+
if (personalWorkspaceId) {
|
|
1770
|
+
const { data: ovr } = await client.POST("/memories/search", {
|
|
1771
|
+
body: { query: null, textQuery: null, semanticQuery: null, hybrid: false, limit: 1, includeArchived: false, includeSystem: false, tags: ["sechroom:role:override", `sechroom:template-ref:${templateId}`], owner: { type: "Workspace", id: personalWorkspaceId } }
|
|
1772
|
+
});
|
|
1773
|
+
const ovrHits = ovr ?? [];
|
|
1774
|
+
if (ovrHits.length > 0) {
|
|
1775
|
+
const override = await fetchMemoryFields(cfg, ovrHits[0].id);
|
|
1776
|
+
if (typeof override?.text === "string" && override.text.length > 0) {
|
|
1777
|
+
return { title: override.title ?? template.title ?? artifact.title, body: override.text, source: "override", templateId, templateTags, sourceRef: `${ovrHits[0].id}@v${override.version ?? 1}` };
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
return { title: template.title ?? artifact.title, body: template.text, source: "template", templateId, templateTags, sourceRef: `${templateId}@v${template.version ?? 1}` };
|
|
1782
|
+
}
|
|
1783
|
+
return null;
|
|
1784
|
+
}
|
|
1785
|
+
async function resolveWorkspaceConventions(cfg, section) {
|
|
1786
|
+
const parts = [];
|
|
1787
|
+
const refs = [];
|
|
1788
|
+
for (const artifact of section.artifacts) {
|
|
1789
|
+
if (parseTagArtifactId(artifact.id)) continue;
|
|
1790
|
+
const mem = await fetchMemoryFields(cfg, artifact.id);
|
|
1791
|
+
if (typeof mem?.text === "string" && mem.text.trim().length > 0) {
|
|
1792
|
+
parts.push(mem.text.trim());
|
|
1793
|
+
refs.push(`${artifact.id}@v${mem.version ?? 1}`);
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
if (parts.length === 0) return null;
|
|
1797
|
+
return { body: parts.join("\n\n---\n\n"), refs };
|
|
1798
|
+
}
|
|
1799
|
+
async function createOverride(cfg, template, personalWorkspaceId) {
|
|
1800
|
+
const client = await makeClient(cfg);
|
|
1801
|
+
const overrideTags = template.templateTags.filter(
|
|
1802
|
+
(t) => t !== "sechroom:role:template" && !t.startsWith("sechroom:bundle:") && !t.startsWith("sechroom:template-ref:")
|
|
1803
|
+
);
|
|
1804
|
+
overrideTags.push("sechroom:role:override", `sechroom:template-ref:${template.templateId}`);
|
|
1805
|
+
const { error } = await client.POST("/memories", {
|
|
1806
|
+
body: {
|
|
1807
|
+
text: template.body,
|
|
1808
|
+
type: "reference",
|
|
1809
|
+
content: "{}",
|
|
1810
|
+
confidence: 1,
|
|
1811
|
+
source: "cli-agent-instructions-customize",
|
|
1812
|
+
archetype: "Document",
|
|
1813
|
+
title: template.title ?? null,
|
|
1814
|
+
tags: overrideTags,
|
|
1815
|
+
owner: { type: "Workspace", id: personalWorkspaceId }
|
|
1816
|
+
}
|
|
1817
|
+
});
|
|
1818
|
+
if (error) throw new Error(`creating personal copy failed: ${JSON.stringify(error)}`);
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
// src/setup/clients.ts
|
|
1822
|
+
function claudeDesktopConfigPath(home) {
|
|
1823
|
+
switch (process.platform) {
|
|
1824
|
+
case "darwin":
|
|
1825
|
+
return join3(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
1826
|
+
case "win32":
|
|
1827
|
+
return join3(process.env.APPDATA ?? join3(home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
|
|
1828
|
+
default:
|
|
1829
|
+
return join3(home, ".config", "Claude", "claude_desktop_config.json");
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
function clientTargets(cwd) {
|
|
1833
|
+
const home = homedir2();
|
|
1834
|
+
return {
|
|
1835
|
+
"claude-code": {
|
|
1836
|
+
key: "claude-code",
|
|
1837
|
+
label: "Claude Code",
|
|
1838
|
+
mcp: { surfaceKey: "claude-code", sectionType: SectionType.McpConfig, path: join3(cwd, ".mcp.json"), format: "json" },
|
|
1839
|
+
instruction: { surfaceKey: "claude-code", path: join3(cwd, "CLAUDE.md") }
|
|
1840
|
+
},
|
|
1841
|
+
"claude-desktop": {
|
|
1842
|
+
key: "claude-desktop",
|
|
1843
|
+
label: "Claude Desktop",
|
|
1844
|
+
mcp: { surfaceKey: "claude-desktop", sectionType: SectionType.McpConfig, path: claudeDesktopConfigPath(home), format: "json" },
|
|
1845
|
+
instruction: { surfaceKey: "claude-desktop", path: join3(home, ".claude", "CLAUDE.md") }
|
|
1846
|
+
},
|
|
1847
|
+
codex: {
|
|
1848
|
+
key: "codex",
|
|
1849
|
+
label: "Codex CLI",
|
|
1850
|
+
mcp: { surfaceKey: "chatgpt", sectionType: SectionType.McpConfigToml, path: join3(home, ".codex", "config.toml"), format: "toml" },
|
|
1851
|
+
instruction: { surfaceKey: "chatgpt", path: join3(cwd, "AGENTS.md") }
|
|
1852
|
+
},
|
|
1853
|
+
cursor: {
|
|
1854
|
+
key: "cursor",
|
|
1855
|
+
label: "Cursor",
|
|
1856
|
+
mcp: { surfaceKey: "claude-code", sectionType: SectionType.McpConfig, path: join3(cwd, ".cursor", "mcp.json"), format: "json" },
|
|
1857
|
+
instruction: { surfaceKey: "chatgpt", path: join3(cwd, "AGENTS.md") }
|
|
1858
|
+
}
|
|
1859
|
+
};
|
|
1860
|
+
}
|
|
1861
|
+
var ALL_CLIENT_KEYS = ["claude-code", "claude-desktop", "codex", "cursor"];
|
|
1862
|
+
var DEFAULT_CLIENT_KEY = "claude-code";
|
|
1863
|
+
function detectInstalledClients(cwd) {
|
|
1864
|
+
const home = homedir2();
|
|
1865
|
+
const detected = [];
|
|
1866
|
+
if (existsSync3(join3(home, ".claude"))) detected.push("claude-code");
|
|
1867
|
+
if (existsSync3(dirname3(claudeDesktopConfigPath(home)))) detected.push("claude-desktop");
|
|
1868
|
+
if (existsSync3(join3(home, ".codex"))) detected.push("codex");
|
|
1869
|
+
if (existsSync3(join3(home, ".cursor")) || existsSync3(join3(cwd, ".cursor"))) detected.push("cursor");
|
|
1870
|
+
return detected;
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
// src/commands/hook.ts
|
|
1874
|
+
async function readStdin() {
|
|
1875
|
+
if (process.stdin.isTTY) return "";
|
|
1876
|
+
const chunks = [];
|
|
1877
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
1878
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
1879
|
+
}
|
|
1880
|
+
function parseHookInput(raw) {
|
|
1881
|
+
if (!raw.trim()) return {};
|
|
1882
|
+
try {
|
|
1883
|
+
return JSON.parse(raw);
|
|
1884
|
+
} catch {
|
|
1885
|
+
return {};
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
function resolveLane(flagLane, cwd) {
|
|
1889
|
+
if (flagLane) return flagLane;
|
|
1890
|
+
const env = process.env.SECHROOM_LANE;
|
|
1891
|
+
if (env) return env;
|
|
1892
|
+
const start = cwd ?? process.cwd();
|
|
1893
|
+
const sem = readSem(resolveSemPathForRead(start));
|
|
1894
|
+
return sem?.values["code-lane"];
|
|
1895
|
+
}
|
|
1896
|
+
var INTENT_FILE = join4(".sechroom", "continuity.json");
|
|
1897
|
+
function resolveIntentPath(start) {
|
|
1898
|
+
let dir = start;
|
|
1899
|
+
for (; ; ) {
|
|
1900
|
+
const candidate = join4(dir, INTENT_FILE);
|
|
1901
|
+
if (existsSync4(candidate)) return candidate;
|
|
1902
|
+
const parent = dirname4(dir);
|
|
1903
|
+
if (parent === dir) return void 0;
|
|
1904
|
+
dir = parent;
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
function readIntent(start) {
|
|
1908
|
+
const path = resolveIntentPath(start);
|
|
1909
|
+
if (!path) return void 0;
|
|
1910
|
+
try {
|
|
1911
|
+
return JSON.parse(readFileSync3(path, "utf8"));
|
|
1912
|
+
} catch {
|
|
1913
|
+
return void 0;
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
function hasRequiredIntent(i) {
|
|
1917
|
+
return Boolean(
|
|
1918
|
+
i.objective?.trim() && i.state?.trim() && i.lastAction?.trim() && i.nextAction?.trim() && i.resumeInstruction?.trim()
|
|
1919
|
+
);
|
|
1920
|
+
}
|
|
1921
|
+
function formatContext(bundle, lane) {
|
|
1922
|
+
const s = bundle?.latestSnapshot;
|
|
1923
|
+
if (!s) return null;
|
|
1924
|
+
const lines = [];
|
|
1925
|
+
lines.push(`[sechroom continuity \u2014 resumed lane ${lane}]`);
|
|
1926
|
+
if (s.currentObjective) lines.push(`Objective: ${s.currentObjective}`);
|
|
1927
|
+
if (s.currentState) lines.push(`State: ${s.currentState}`);
|
|
1928
|
+
if (s.lastMeaningfulAction) lines.push(`Last action: ${s.lastMeaningfulAction}`);
|
|
1929
|
+
if (s.nextIntendedAction) lines.push(`Next: ${s.nextIntendedAction}`);
|
|
1930
|
+
if (s.resumeInstruction) lines.push(`Resume: ${s.resumeInstruction}`);
|
|
1931
|
+
const constraints = s.activeConstraints ?? [];
|
|
1932
|
+
if (constraints.length) {
|
|
1933
|
+
lines.push("Active constraints:");
|
|
1934
|
+
for (const c of constraints) lines.push(` - ${c}`);
|
|
1935
|
+
}
|
|
1936
|
+
const questions = s.openQuestions ?? [];
|
|
1937
|
+
if (questions.length) {
|
|
1938
|
+
lines.push("Open questions:");
|
|
1939
|
+
for (const q of questions) lines.push(` - ${q}`);
|
|
1940
|
+
}
|
|
1941
|
+
const artifacts = s.relevantArtifactIds ?? [];
|
|
1942
|
+
if (artifacts.length) lines.push(`Relevant artifacts: ${artifacts.join(", ")}`);
|
|
1943
|
+
const marker = [s.id, s.createdAt].filter(Boolean).join(" @ ");
|
|
1944
|
+
if (marker) lines.push(`(snapshot ${marker})`);
|
|
1945
|
+
return lines.join("\n");
|
|
1946
|
+
}
|
|
1947
|
+
function emitSessionStart(additionalContext) {
|
|
1948
|
+
process.stdout.write(
|
|
1949
|
+
JSON.stringify({
|
|
1950
|
+
hookSpecificOutput: {
|
|
1951
|
+
hookEventName: "SessionStart",
|
|
1952
|
+
additionalContext
|
|
1953
|
+
}
|
|
1954
|
+
}) + "\n"
|
|
1955
|
+
);
|
|
1956
|
+
}
|
|
1957
|
+
var HOOK_COMMANDS = {
|
|
1958
|
+
SessionStart: "sechroom hook session-start",
|
|
1959
|
+
PreCompact: "sechroom hook pre-compact"
|
|
1960
|
+
};
|
|
1961
|
+
var HOOK_EVENTS = ["SessionStart", "PreCompact"];
|
|
1962
|
+
function hasHookCommand(config2, event, command) {
|
|
1963
|
+
const groups = config2.hooks?.[event] ?? [];
|
|
1964
|
+
return groups.some((g) => (g.hooks ?? []).some((h) => h.type === "command" && h.command === command));
|
|
1965
|
+
}
|
|
1966
|
+
function mergeHooks(config2) {
|
|
1967
|
+
config2.hooks ??= {};
|
|
1968
|
+
let added = 0;
|
|
1969
|
+
for (const event of HOOK_EVENTS) {
|
|
1970
|
+
const command = HOOK_COMMANDS[event];
|
|
1971
|
+
if (hasHookCommand(config2, event, command)) continue;
|
|
1972
|
+
const groups = config2.hooks[event] ??= [];
|
|
1973
|
+
groups.push({ hooks: [{ type: "command", command }] });
|
|
1974
|
+
added += 1;
|
|
1975
|
+
}
|
|
1976
|
+
return added;
|
|
1977
|
+
}
|
|
1978
|
+
function readJsonConfig2(path) {
|
|
1979
|
+
if (!existsSync4(path)) return {};
|
|
1980
|
+
const raw = readFileSync3(path, "utf8");
|
|
1981
|
+
if (!raw.trim()) return {};
|
|
1982
|
+
return JSON.parse(raw);
|
|
1983
|
+
}
|
|
1984
|
+
function installHooksJson(path, dryRun) {
|
|
1985
|
+
const existed = existsSync4(path) && readFileSync3(path, "utf8").trim().length > 0;
|
|
1986
|
+
const config2 = readJsonConfig2(path);
|
|
1987
|
+
const added = mergeHooks(config2);
|
|
1988
|
+
if (added === 0 && existed) return { path, status: "current" };
|
|
1989
|
+
if (!dryRun) {
|
|
1990
|
+
mkdirSync3(dirname4(path), { recursive: true });
|
|
1991
|
+
writeFileSync3(path, JSON.stringify(config2, null, 2) + "\n");
|
|
1992
|
+
}
|
|
1993
|
+
return { path, status: existed ? "merged" : "created" };
|
|
1994
|
+
}
|
|
1995
|
+
function ensureCodexFeaturesHooks(content) {
|
|
1996
|
+
const lines = content.split("\n");
|
|
1997
|
+
const headerIdx = lines.findIndex((l) => l.trim() === "[features]");
|
|
1998
|
+
if (headerIdx === -1) {
|
|
1999
|
+
const base = content.length === 0 || content.endsWith("\n") ? content : content + "\n";
|
|
2000
|
+
return { next: base + "\n[features]\nhooks = true\n", changed: true };
|
|
2001
|
+
}
|
|
2002
|
+
for (let i = headerIdx + 1; i < lines.length; i += 1) {
|
|
2003
|
+
const trimmed = lines[i].trim();
|
|
2004
|
+
if (trimmed.startsWith("[") && trimmed.endsWith("]")) break;
|
|
2005
|
+
const m = lines[i].match(/^(\s*)hooks(\s*)=(\s*)(.*)$/);
|
|
2006
|
+
if (!m) continue;
|
|
2007
|
+
const value = m[4].replace(/\s*#.*$/, "").trim();
|
|
2008
|
+
if (value === "true") return { next: content, changed: false };
|
|
2009
|
+
lines[i] = `${m[1]}hooks${m[2]}=${m[3]}true`;
|
|
2010
|
+
return { next: lines.join("\n"), changed: true };
|
|
2011
|
+
}
|
|
2012
|
+
lines.splice(headerIdx + 1, 0, "hooks = true");
|
|
2013
|
+
return { next: lines.join("\n"), changed: true };
|
|
2014
|
+
}
|
|
2015
|
+
function installCodexFeatureFlag(path, dryRun) {
|
|
2016
|
+
const existed = existsSync4(path);
|
|
2017
|
+
const content = existed ? readFileSync3(path, "utf8") : "";
|
|
2018
|
+
const { next, changed } = ensureCodexFeaturesHooks(content);
|
|
2019
|
+
if (!changed) return { path, status: "current" };
|
|
2020
|
+
if (!dryRun) {
|
|
2021
|
+
mkdirSync3(dirname4(path), { recursive: true });
|
|
2022
|
+
writeFileSync3(path, next);
|
|
2023
|
+
}
|
|
2024
|
+
return { path, status: existed ? "merged" : "created" };
|
|
2025
|
+
}
|
|
2026
|
+
function resolveSurfaces(surface, cwd) {
|
|
2027
|
+
if (surface === "claude") return ["claude"];
|
|
2028
|
+
if (surface === "codex") return ["codex"];
|
|
2029
|
+
if (surface === "both") return ["claude", "codex"];
|
|
2030
|
+
if (surface) throw new Error(`--surface must be one of claude | codex | both (got '${surface}')`);
|
|
2031
|
+
const surfaces = detectHookSurfaces(cwd);
|
|
2032
|
+
return surfaces.length > 0 ? surfaces : ["claude", "codex"];
|
|
2033
|
+
}
|
|
2034
|
+
function describe(result, dryRun) {
|
|
2035
|
+
if (result.status === "current") return ` \u2713 ${result.path} (already configured)`;
|
|
2036
|
+
const verb = dryRun ? "would" : result.status === "created" ? "created" : "updated";
|
|
2037
|
+
return ` \u2713 ${result.path} (${dryRun ? `${verb} ${result.status === "created" ? "create" : "update"}` : verb})`;
|
|
2038
|
+
}
|
|
2039
|
+
var HOOK_SURFACE_LABEL = {
|
|
2040
|
+
claude: "Claude Code",
|
|
2041
|
+
codex: "Codex"
|
|
2042
|
+
};
|
|
2043
|
+
function installHookSurfaces(surfaces, opts) {
|
|
2044
|
+
const out = [];
|
|
2045
|
+
for (const surface of surfaces) {
|
|
2046
|
+
if (surface === "claude") {
|
|
2047
|
+
const path = opts.local ? join4(opts.cwd, ".claude", "settings.json") : join4(opts.home, ".claude", "settings.json");
|
|
2048
|
+
out.push({ surface, results: [installHooksJson(path, opts.dryRun)] });
|
|
2049
|
+
} else {
|
|
2050
|
+
const hooksJson = installHooksJson(join4(opts.home, ".codex", "hooks.json"), opts.dryRun);
|
|
2051
|
+
const featureFlag = installCodexFeatureFlag(join4(opts.home, ".codex", "config.toml"), opts.dryRun);
|
|
2052
|
+
out.push({ surface, results: [hooksJson, featureFlag] });
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
return out;
|
|
2056
|
+
}
|
|
2057
|
+
function detectHookSurfaces(cwd) {
|
|
2058
|
+
const detected = detectInstalledClients(cwd);
|
|
2059
|
+
const surfaces = [];
|
|
2060
|
+
if (detected.includes("claude-code")) surfaces.push("claude");
|
|
2061
|
+
if (detected.includes("codex")) surfaces.push("codex");
|
|
2062
|
+
return surfaces;
|
|
2063
|
+
}
|
|
2064
|
+
function isSechroomOnPath() {
|
|
2065
|
+
const pathEnv = process.env.PATH ?? "";
|
|
2066
|
+
if (!pathEnv) return false;
|
|
2067
|
+
const names = process.platform === "win32" ? ["sechroom.cmd", "sechroom.exe", "sechroom.bat", "sechroom"] : ["sechroom"];
|
|
2068
|
+
for (const dir of pathEnv.split(delimiter)) {
|
|
2069
|
+
if (!dir) continue;
|
|
2070
|
+
for (const name of names) {
|
|
2071
|
+
if (existsSync4(join4(dir, name))) return true;
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
return false;
|
|
2075
|
+
}
|
|
2076
|
+
function warnIfSechroomNotOnPath(write = (s) => void process.stderr.write(s)) {
|
|
2077
|
+
if (isSechroomOnPath()) return false;
|
|
2078
|
+
write(
|
|
2079
|
+
"\n\u26A0 `sechroom` isn't on your PATH. The hooks run a bare `sechroom hook \u2026` command\n when your agent fires them, so a non-global install (npx / local) will fail at\n that point. Install globally so the command resolves:\n npm i -g @sechroom/cli\n"
|
|
2080
|
+
);
|
|
2081
|
+
return true;
|
|
2082
|
+
}
|
|
2083
|
+
function registerHook(program2) {
|
|
2084
|
+
const hook = program2.command("hook").description("Agent-lifecycle hook adapter (Claude Code / Codex) \u2014 bridges hooks to continuity");
|
|
2085
|
+
hook.addHelpText(
|
|
2086
|
+
"after",
|
|
2087
|
+
`
|
|
2088
|
+
Examples:
|
|
2089
|
+
# SessionStart (load): inject the lane's latest snapshot as context.
|
|
2090
|
+
$ echo '{"hook_event_name":"SessionStart","cwd":"'"$PWD"'"}' | sechroom hook session-start
|
|
2091
|
+
# PreCompact (save): snapshot from ./${INTENT_FILE} before the agent compacts.
|
|
2092
|
+
$ echo '{"hook_event_name":"PreCompact","cwd":"'"$PWD"'"}' | sechroom hook pre-compact
|
|
2093
|
+
# Wire both hooks into the installed surface(s)' config (no hand-editing):
|
|
2094
|
+
$ sechroom hook install auto-detect Claude Code / Codex
|
|
2095
|
+
$ sechroom hook install --surface codex Codex only
|
|
2096
|
+
$ sechroom hook install --local --dry-run preview the project .claude/settings.json
|
|
2097
|
+
|
|
2098
|
+
Lane source (high -> low): --lane > SECHROOM_LANE > ./.sem code-lane (D-binding-5).
|
|
2099
|
+
Fail-soft: no lane / no auth / no-or-partial intent file / API error -> exit 0, never blocks.`
|
|
2100
|
+
);
|
|
2101
|
+
hook.command("session-start").description("Resume the checkout's lane and emit continuity context for a SessionStart hook").option("--lane <laneId>", "Override the resolved lane (else SECHROOM_LANE, else ./.sem code-lane)").option("--surface <surface>", "Target surface: claude | codex (output is identical for session-start)", "claude").option("--max-artifacts <n>", "Cap artifacts in the resume bundle").action(async (opts, cmd) => {
|
|
2102
|
+
try {
|
|
2103
|
+
const raw = await readStdin();
|
|
2104
|
+
const input = parseHookInput(raw);
|
|
2105
|
+
const lane = resolveLane(opts.lane, input.cwd);
|
|
2106
|
+
if (!lane) return process.exit(0);
|
|
2107
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
2108
|
+
const client = await makeClient(cfg);
|
|
2109
|
+
const { data } = await client.POST("/continuity/resume/lane", {
|
|
2110
|
+
body: {
|
|
2111
|
+
laneId: lane,
|
|
2112
|
+
workspaceId: null,
|
|
2113
|
+
maxArtifacts: opts.maxArtifacts != null ? Number(opts.maxArtifacts) : null,
|
|
2114
|
+
includeLookingAtMyself: null,
|
|
2115
|
+
changedSince: null
|
|
2116
|
+
}
|
|
2117
|
+
});
|
|
2118
|
+
const context = formatContext(data, lane);
|
|
2119
|
+
if (context) emitSessionStart(context);
|
|
2120
|
+
return process.exit(0);
|
|
2121
|
+
} catch {
|
|
2122
|
+
return process.exit(0);
|
|
2123
|
+
}
|
|
2124
|
+
});
|
|
2125
|
+
hook.command("pre-compact").description("Save a continuity snapshot from the agent-maintained intent file on a PreCompact hook").option("--lane <laneId>", "Override the resolved lane (else SECHROOM_LANE, else ./.sem code-lane)").option("--scope <scope>", "Snapshot scope (else the intent file's `scope`, else 'compaction')").option("--surface <surface>", "Target surface: claude | codex (lifecycle-only on both)", "claude").action(async (opts, cmd) => {
|
|
2126
|
+
try {
|
|
2127
|
+
const raw = await readStdin();
|
|
2128
|
+
const input = parseHookInput(raw);
|
|
2129
|
+
const cwd = input.cwd ?? process.cwd();
|
|
2130
|
+
const lane = resolveLane(opts.lane, input.cwd);
|
|
2131
|
+
if (!lane) return process.exit(0);
|
|
2132
|
+
const intent = readIntent(cwd);
|
|
2133
|
+
if (!intent || !hasRequiredIntent(intent)) return process.exit(0);
|
|
2134
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
2135
|
+
const client = await makeClient(cfg);
|
|
2136
|
+
await client.POST("/continuity/snapshots", {
|
|
2137
|
+
body: {
|
|
2138
|
+
laneId: lane,
|
|
2139
|
+
scope: opts.scope ?? intent.scope ?? "compaction",
|
|
2140
|
+
currentObjective: intent.objective,
|
|
2141
|
+
currentState: intent.state,
|
|
2142
|
+
lastMeaningfulAction: intent.lastAction,
|
|
2143
|
+
nextIntendedAction: intent.nextAction,
|
|
2144
|
+
resumeInstruction: intent.resumeInstruction,
|
|
2145
|
+
activeConstraints: intent.constraints ?? null,
|
|
2146
|
+
openQuestions: intent.questions ?? null,
|
|
2147
|
+
surfaceMarkers: intent.surfaceMarkers ?? null,
|
|
2148
|
+
relevantArtifactIds: intent.artifacts ?? null,
|
|
2149
|
+
confidence: intent.confidence ?? null,
|
|
2150
|
+
// Compaction is infrequent, so the FR-051 clobber guard doesn't bite;
|
|
2151
|
+
// Acknowledge lets a within-window checkpoint land on the lane.
|
|
2152
|
+
concurrentSessionPolicy: "Acknowledge"
|
|
2153
|
+
}
|
|
2154
|
+
});
|
|
2155
|
+
return process.exit(0);
|
|
2156
|
+
} catch {
|
|
2157
|
+
return process.exit(0);
|
|
2158
|
+
}
|
|
2159
|
+
});
|
|
2160
|
+
hook.command("install").description("Wire the session-start + pre-compact hooks into Claude Code and/or Codex config").option("--surface <surface>", "Target surface: claude | codex | both (default: auto-detect installed surfaces)").option("--local", "Claude Code only: write <cwd>/.claude/settings.json instead of ~/.claude/settings.json").option("--dry-run", "Print what would change; write nothing").action((opts) => {
|
|
2161
|
+
const dryRun = Boolean(opts.dryRun);
|
|
2162
|
+
const cwd = process.cwd();
|
|
2163
|
+
let surfaces;
|
|
2164
|
+
try {
|
|
2165
|
+
surfaces = resolveSurfaces(opts.surface, cwd);
|
|
2166
|
+
} catch (err2) {
|
|
2167
|
+
process.stderr.write(`${err2.message}
|
|
2168
|
+
`);
|
|
2169
|
+
return process.exit(2);
|
|
2170
|
+
}
|
|
2171
|
+
const results = [];
|
|
2172
|
+
try {
|
|
2173
|
+
const installed = installHookSurfaces(surfaces, { dryRun, local: opts.local, cwd, home: homedir3() });
|
|
2174
|
+
for (const { surface, results: surfaceResults } of installed) {
|
|
2175
|
+
process.stdout.write(`${HOOK_SURFACE_LABEL[surface]}:
|
|
2176
|
+
`);
|
|
2177
|
+
for (const r of surfaceResults) {
|
|
2178
|
+
results.push(r);
|
|
2179
|
+
process.stdout.write(describe(r, dryRun) + "\n");
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
} catch (err2) {
|
|
2183
|
+
process.stderr.write(`hook install failed: ${err2.message}
|
|
2184
|
+
`);
|
|
2185
|
+
return process.exit(1);
|
|
2186
|
+
}
|
|
2187
|
+
if (dryRun) {
|
|
2188
|
+
process.stdout.write("\n(dry run \u2014 no files were written.)\n");
|
|
2189
|
+
} else if (results.every((r) => r.status === "current")) {
|
|
2190
|
+
process.stdout.write("\nAlready up to date \u2014 nothing to change.\n");
|
|
2191
|
+
} else {
|
|
2192
|
+
process.stdout.write("\nRestart (or reload) your agent for the hooks to take effect.\n");
|
|
2193
|
+
}
|
|
2194
|
+
warnIfSechroomNotOnPath();
|
|
2195
|
+
return process.exit(0);
|
|
2196
|
+
});
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
// src/commands/account.ts
|
|
2200
|
+
function registerId(program2) {
|
|
2201
|
+
const id = program2.command("id").description("Allocate human-authored id sequences (FR-*, D-*)");
|
|
2202
|
+
id.addHelpText(
|
|
2203
|
+
"after",
|
|
2204
|
+
`
|
|
2205
|
+
Examples:
|
|
2206
|
+
$ sechroom id next FR sechroom allocate the next FR-sechroom-NNN id
|
|
2207
|
+
$ sechroom id next D Backend allocate the next D-Backend-NNN id
|
|
2208
|
+
$ sechroom id peek FR sechroom inspect the sequence without consuming
|
|
2209
|
+
$ sechroom id peek FR sechroom --json`
|
|
2210
|
+
);
|
|
2211
|
+
id.command("next <namespaceKind> <scope>").description("Allocate the next id in a sequence (POST /id-registry/allocate)").action(async (namespaceKind, scope, _opts, cmd) => {
|
|
2212
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
2213
|
+
const data = await runApi("Allocating id", async () => {
|
|
2214
|
+
const client = await makeClient(cfg);
|
|
2215
|
+
return client.POST("/id-registry/allocate", {
|
|
2216
|
+
body: { namespaceKind, scope, clientNonce: null }
|
|
2217
|
+
});
|
|
2218
|
+
});
|
|
2219
|
+
emitAction(`allocated ${style.bold(data.id)} ${style.dim(`(seq ${data.seq})`)}`, data, cmd.optsWithGlobals().json);
|
|
2220
|
+
});
|
|
2221
|
+
id.command("peek <namespaceKind> <scope>").description("Inspect a sequence without consuming (GET /id-registry/state)").action(async (namespaceKind, scope, _opts, cmd) => {
|
|
2222
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
2223
|
+
const data = await runApi("Peeking id sequence", async () => {
|
|
2224
|
+
const client = await makeClient(cfg);
|
|
2225
|
+
return client.GET("/id-registry/state", {
|
|
2226
|
+
params: { query: { namespaceKind, scope } }
|
|
2227
|
+
});
|
|
2228
|
+
});
|
|
2229
|
+
emit(data, cmd.optsWithGlobals().json);
|
|
2230
|
+
});
|
|
2231
|
+
}
|
|
2232
|
+
function registerAccount(program2) {
|
|
2233
|
+
const account = program2.command("account").description("Your profile, feeds, and review queue");
|
|
2234
|
+
account.addHelpText(
|
|
2235
|
+
"after",
|
|
2236
|
+
`
|
|
2237
|
+
Examples:
|
|
2238
|
+
$ sechroom account profile
|
|
2239
|
+
$ sechroom account set-profile --display-name "Chris" --timezone "Europe/London"
|
|
2240
|
+
$ sechroom account feed --limit 20
|
|
2241
|
+
$ sechroom account reviews --status Pending
|
|
2242
|
+
$ sechroom account lookup-batch mem_XXXX wsp_YYYY --json`
|
|
2243
|
+
);
|
|
2244
|
+
account.command("profile").description("Show your resolved profile (GET /me/profile)").action(async (_opts, cmd) => {
|
|
2245
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
2246
|
+
const data = await runApi("Fetching profile", async () => {
|
|
2247
|
+
const client = await makeClient(cfg);
|
|
2248
|
+
return client.GET("/me/profile");
|
|
2249
|
+
});
|
|
2250
|
+
emit(data, cmd.optsWithGlobals().json);
|
|
2251
|
+
});
|
|
2252
|
+
account.command("set-profile").description("Update your profile (PUT /me/profile)").option("--display-name <displayName>", "Display name").option("--timezone <timezone>", "IANA timezone (e.g. Europe/London)").option("--bio <bio>", "Short bio").option("--photo-url <photoUrl>", "Avatar / photo URL").action(async (opts, cmd) => {
|
|
2253
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
2254
|
+
const data = await runApi("Updating profile", async () => {
|
|
2255
|
+
const client = await makeClient(cfg);
|
|
2256
|
+
return client.PUT("/me/profile", {
|
|
2257
|
+
body: {
|
|
2258
|
+
displayName: opts.displayName ?? null,
|
|
2259
|
+
timezone: opts.timezone ?? null,
|
|
2260
|
+
bio: opts.bio ?? null,
|
|
2261
|
+
photoUrl: opts.photoUrl ?? null
|
|
2262
|
+
}
|
|
2263
|
+
});
|
|
2264
|
+
});
|
|
2265
|
+
emitAction("updated profile", data, cmd.optsWithGlobals().json);
|
|
2266
|
+
});
|
|
2267
|
+
account.command("feed").description("Your recent memory feed (GET /me/memories/feed)").option("--limit <n>", "Max results", "20").option("--cursor <cursor>", "Opaque paging cursor").option("--query <query>", "Free-text filter").option("--filter-tags <tags>", "Comma-separated tag filter").option("--include-archived", "Include archived memories", false).option("--include-text", "Include memory body text", false).action(async (opts, cmd) => {
|
|
2268
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
2269
|
+
const data = await runApi("Fetching feed", async () => {
|
|
2270
|
+
const client = await makeClient(cfg);
|
|
2271
|
+
return client.GET("/me/memories/feed", {
|
|
2272
|
+
params: {
|
|
2273
|
+
query: {
|
|
2274
|
+
limit: Number(opts.limit),
|
|
2275
|
+
includeArchived: Boolean(opts.includeArchived),
|
|
2276
|
+
includeText: Boolean(opts.includeText),
|
|
2277
|
+
...opts.cursor ? { cursor: opts.cursor } : {},
|
|
2278
|
+
...opts.query ? { query: opts.query } : {},
|
|
2279
|
+
...opts.filterTags ? { filterTags: opts.filterTags } : {}
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
});
|
|
2283
|
+
});
|
|
2284
|
+
emit(data, cmd.optsWithGlobals().json);
|
|
2285
|
+
});
|
|
2286
|
+
account.command("reviews").description("Your review queue (GET /reviews)").option("--status <status>", "Pending | Resolved | Empty").option("--scope-kind <scopeKind>", "Memory | Project | Workspace").option("--scope-target-id <id>", "Scope target id").option("--limit <n>", "Max results", "20").action(async (opts, cmd) => {
|
|
2287
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
2288
|
+
const data = await runApi("Fetching reviews", async () => {
|
|
2289
|
+
const client = await makeClient(cfg);
|
|
2290
|
+
return client.GET("/reviews", {
|
|
2291
|
+
params: {
|
|
2292
|
+
query: {
|
|
2293
|
+
limit: Number(opts.limit),
|
|
2294
|
+
...opts.status ? { status: opts.status } : {},
|
|
2295
|
+
...opts.scopeKind ? { scopeKind: opts.scopeKind } : {},
|
|
2296
|
+
...opts.scopeTargetId ? { scopeTargetId: opts.scopeTargetId } : {}
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
});
|
|
2300
|
+
});
|
|
2301
|
+
emit(data, cmd.optsWithGlobals().json);
|
|
2302
|
+
});
|
|
2303
|
+
account.command("review-get <reviewId>").description("Fetch one review bundle (GET /reviews/{reviewId})").action(async (reviewId, _opts, cmd) => {
|
|
2304
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
2305
|
+
const data = await runApi("Fetching review", async () => {
|
|
2306
|
+
const client = await makeClient(cfg);
|
|
2307
|
+
return client.GET("/reviews/{reviewId}", { params: { path: { reviewId } } });
|
|
2308
|
+
});
|
|
2309
|
+
emit(data, cmd.optsWithGlobals().json);
|
|
2310
|
+
});
|
|
2311
|
+
account.command("review-accept <reviewId>").description("Accept a review bundle (POST /reviews/{reviewId}/accept)").action(async (reviewId, _opts, cmd) => {
|
|
2312
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
2313
|
+
const data = await runApi("Accepting review", async () => {
|
|
2314
|
+
const client = await makeClient(cfg);
|
|
2315
|
+
return client.POST("/reviews/{reviewId}/accept", {
|
|
2316
|
+
params: { path: { reviewId } },
|
|
2317
|
+
body: { decisions: {} }
|
|
2318
|
+
});
|
|
2319
|
+
});
|
|
2320
|
+
emitAction(`accepted review ${style.bold(reviewId)}`, data, cmd.optsWithGlobals().json);
|
|
2321
|
+
});
|
|
2322
|
+
account.command("lookup-batch <ids...>").description("Resolve many ids at once (POST /lookup/batch)").action(async (ids, _opts, cmd) => {
|
|
2323
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
2324
|
+
const data = await runApi(`Resolving ${ids.length} ids`, async () => {
|
|
2325
|
+
const client = await makeClient(cfg);
|
|
2326
|
+
return client.POST("/lookup/batch", { body: { ids } });
|
|
2327
|
+
});
|
|
2328
|
+
emit(data, cmd.optsWithGlobals().json);
|
|
2329
|
+
});
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
// src/commands/chat.ts
|
|
2333
|
+
function registerChat(program2) {
|
|
2334
|
+
const chat = program2.command("chat").description("Send and read Slack / Discord channel messages").option("--surface <surface>", "slack | discord", "slack");
|
|
2335
|
+
chat.addHelpText(
|
|
2336
|
+
"after",
|
|
2337
|
+
`
|
|
2338
|
+
Examples:
|
|
2339
|
+
$ sechroom chat send C0123456789 "deploy is green" --surface slack
|
|
2340
|
+
$ sechroom chat send 987654321098765432 "deploy is green" --surface discord --guild 123456789012345678
|
|
2341
|
+
$ sechroom chat send C0123456789 "lgtm" --surface slack --as user --parent 1718049600.123456
|
|
2342
|
+
$ sechroom chat messages --surface slack
|
|
2343
|
+
$ sechroom chat replies 1718049600.123456 --surface slack
|
|
2344
|
+
$ sechroom chat stop-tracking 1718049600.123456 --surface slack`
|
|
2345
|
+
);
|
|
2346
|
+
chat.command("send <channelId> <text>").description("Send a message to a channel (POST /chat/channel-messages/{surface})").option("--guild <guildId>", "Discord guild snowflake \u2014 required for --surface discord").option("--memory <memoryId>", "Attach a sechroom memory id").option("--no-track", "Don't capture replies to this message").option("--parent <parentMessage>", "Thread under a parent (Slack thread_ts / Discord message id)").option("--source <source>", "Source / lane stamp (renders an attribution footer)", "cli").option("--as <as>", "Slack only: 'bot' (default) or 'user' (your linked Slack identity)", "bot").action(async (channelId, text, opts, cmd) => {
|
|
2347
|
+
const { surface, ...globals } = cmd.optsWithGlobals();
|
|
2348
|
+
const json = Boolean(cmd.optsWithGlobals().json);
|
|
2349
|
+
const cfg = resolveConfig(globals);
|
|
2350
|
+
const data = await runApi("Sending message", async () => {
|
|
2351
|
+
const client = await makeClient(cfg);
|
|
2352
|
+
return client.POST("/chat/channel-messages/{surface}", {
|
|
2353
|
+
params: { path: { surface: String(surface) } },
|
|
2354
|
+
body: {
|
|
2355
|
+
channelId,
|
|
2356
|
+
text,
|
|
2357
|
+
guildId: opts.guild ?? null,
|
|
2358
|
+
attachedMemoryId: opts.memory ?? null,
|
|
2359
|
+
trackReplies: opts.track,
|
|
2360
|
+
parentMessage: opts.parent ?? null,
|
|
2361
|
+
source: opts.source,
|
|
2362
|
+
as: opts.as
|
|
2363
|
+
}
|
|
2364
|
+
});
|
|
2365
|
+
});
|
|
2366
|
+
if (!data.ok) {
|
|
2367
|
+
if (json) {
|
|
2368
|
+
emit(data, true);
|
|
2369
|
+
} else {
|
|
2370
|
+
process.stderr.write(
|
|
2371
|
+
`${err("\u2717")} send failed: ${data.upstreamError ?? "error"}${data.errorDescription ? ` \u2014 ${data.errorDescription}` : ""}
|
|
2372
|
+
`
|
|
2373
|
+
);
|
|
2374
|
+
}
|
|
2375
|
+
process.exit(1);
|
|
2376
|
+
}
|
|
2377
|
+
const idPart = data.persistedId ? ` ${style.dim(`(${data.persistedId})`)}` : "";
|
|
2378
|
+
emitAction(`sent to ${surface} ${style.bold(channelId)}${idPart}`, data, json);
|
|
2379
|
+
});
|
|
2380
|
+
chat.command("messages").description("List recent channel messages (GET /chat/channel-messages/{surface})").action(async (_opts, cmd) => {
|
|
2381
|
+
const { surface, ...globals } = cmd.optsWithGlobals();
|
|
2382
|
+
const cfg = resolveConfig(globals);
|
|
2383
|
+
const data = await runApi("Fetching messages", async () => {
|
|
2384
|
+
const client = await makeClient(cfg);
|
|
2385
|
+
return client.GET("/chat/channel-messages/{surface}", {
|
|
2386
|
+
params: { path: { surface: String(surface) } }
|
|
2387
|
+
});
|
|
2388
|
+
});
|
|
2389
|
+
emit(data, cmd.optsWithGlobals().json);
|
|
2390
|
+
});
|
|
2391
|
+
chat.command("replies <messageId>").description("List thread replies for a message (GET /chat/channel-messages/by-id/{id}/replies)").action(async (messageId, _opts, cmd) => {
|
|
2392
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
2393
|
+
const data = await runApi("Fetching replies", async () => {
|
|
2394
|
+
const client = await makeClient(cfg);
|
|
2395
|
+
return client.GET("/chat/channel-messages/by-id/{id}/replies", {
|
|
2396
|
+
params: { path: { id: messageId } }
|
|
2397
|
+
});
|
|
2398
|
+
});
|
|
2399
|
+
emit(data, cmd.optsWithGlobals().json);
|
|
2400
|
+
});
|
|
2401
|
+
chat.command("stop-tracking <messageId>").description("Stop watching a message for replies (POST .../by-id/{id}/stop-tracking-replies)").action(async (messageId, _opts, cmd) => {
|
|
2402
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
2403
|
+
const data = await runApi("Stopping reply tracking", async () => {
|
|
2404
|
+
const client = await makeClient(cfg);
|
|
2405
|
+
return client.POST("/chat/channel-messages/by-id/{id}/stop-tracking-replies", {
|
|
2406
|
+
params: { path: { id: messageId } },
|
|
2407
|
+
body: {}
|
|
2408
|
+
});
|
|
2409
|
+
});
|
|
2410
|
+
emitAction(`stopped tracking replies on ${style.bold(messageId)}`, data, cmd.optsWithGlobals().json);
|
|
2411
|
+
});
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
// src/setup/apply.ts
|
|
2415
|
+
import { createHash as createHash2 } from "crypto";
|
|
2416
|
+
import { mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync5 } from "fs";
|
|
2417
|
+
import { dirname as dirname5 } from "path";
|
|
2418
|
+
var MARKER_BEGIN = "<!-- @sechroom/cli:begin";
|
|
2419
|
+
var MARKER_END = "<!-- @sechroom/cli:end";
|
|
2420
|
+
function normalizeBody(s) {
|
|
2421
|
+
return s.replace(/\r\n/g, "\n").trim();
|
|
2422
|
+
}
|
|
2423
|
+
function bodySha256(body) {
|
|
2424
|
+
return createHash2("sha256").update(normalizeBody(body), "utf8").digest("hex");
|
|
2425
|
+
}
|
|
2426
|
+
function renderBlock(write) {
|
|
2427
|
+
const body = normalizeBody(write.body);
|
|
2428
|
+
const attrs = [`block=${write.block}`];
|
|
2429
|
+
if (write.source) attrs.push(`source=${write.source}`);
|
|
2430
|
+
attrs.push(`sha256=${bodySha256(body)}`);
|
|
2431
|
+
return `${MARKER_BEGIN} ${attrs.join(" ")} -->
|
|
2432
|
+
${body}
|
|
2433
|
+
${MARKER_END} block=${write.block} -->
|
|
2434
|
+
`;
|
|
2435
|
+
}
|
|
2436
|
+
function keyedBlockRe(block) {
|
|
2437
|
+
const b = escapeRe(block);
|
|
2438
|
+
return new RegExp(
|
|
2439
|
+
`${escapeRe(MARKER_BEGIN)}[^\\n]*?\\bblock=${b}\\b[^\\n]*?-->\\n[\\s\\S]*?${escapeRe(MARKER_END)}[^\\n]*?\\bblock=${b}\\b[^\\n]*?-->\\n?`
|
|
2440
|
+
);
|
|
2441
|
+
}
|
|
2442
|
+
function legacyBlockRe() {
|
|
2443
|
+
return new RegExp(
|
|
2444
|
+
`${escapeRe(MARKER_BEGIN)}(?:(?!block=)[^\\n])*?-->\\n[\\s\\S]*?${escapeRe(MARKER_END)}(?:(?!block=)[^\\n])*?-->\\n?`
|
|
2445
|
+
);
|
|
2446
|
+
}
|
|
2447
|
+
function parseAttrs(beginLine) {
|
|
2448
|
+
const attrs = {};
|
|
2449
|
+
for (const m of beginLine.matchAll(/(\w+)=(\S+)/g)) attrs[m[1]] = m[2];
|
|
2450
|
+
return attrs;
|
|
2451
|
+
}
|
|
2452
|
+
function innerBody(segment) {
|
|
2453
|
+
const firstNl = segment.indexOf("\n");
|
|
2454
|
+
const endIdx = segment.lastIndexOf(MARKER_END);
|
|
2455
|
+
return segment.slice(firstNl + 1, endIdx).replace(/\n$/, "");
|
|
2456
|
+
}
|
|
2457
|
+
function parseManagedBlock(content, block) {
|
|
2458
|
+
const keyed = content.match(keyedBlockRe(block));
|
|
2459
|
+
if (keyed) {
|
|
2460
|
+
const attrs = parseAttrs(keyed[0].slice(0, keyed[0].indexOf("\n")));
|
|
2461
|
+
return { block, source: attrs.source ?? null, sha256: attrs.sha256 ?? null, body: innerBody(keyed[0]) };
|
|
2462
|
+
}
|
|
2463
|
+
if (block === "role-template") {
|
|
2464
|
+
const legacy = content.match(legacyBlockRe());
|
|
2465
|
+
if (legacy) return { block, source: null, sha256: null, body: innerBody(legacy[0]) };
|
|
2466
|
+
}
|
|
2467
|
+
return null;
|
|
2468
|
+
}
|
|
2469
|
+
function ensureDir2(path) {
|
|
2470
|
+
mkdirSync4(dirname5(path), { recursive: true });
|
|
2471
|
+
}
|
|
2472
|
+
function readOr(path, fallback) {
|
|
2473
|
+
try {
|
|
2474
|
+
return readFileSync4(path, "utf8");
|
|
2475
|
+
} catch {
|
|
2476
|
+
return fallback;
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
function mergeMcpJson(path, snippet, dryRun) {
|
|
2480
|
+
const incoming = JSON.parse(snippet);
|
|
2481
|
+
const existed = existsSync5(path);
|
|
2482
|
+
let current = {};
|
|
2483
|
+
if (existed) {
|
|
2484
|
+
try {
|
|
2485
|
+
current = JSON.parse(readFileSync4(path, "utf8"));
|
|
2486
|
+
} catch {
|
|
2487
|
+
return { kind: "mcp", path, status: "skipped", note: "existing file isn't valid JSON \u2014 left untouched" };
|
|
2488
|
+
}
|
|
2489
|
+
}
|
|
2490
|
+
current.mcpServers = { ...current.mcpServers ?? {}, ...incoming.mcpServers ?? {} };
|
|
2491
|
+
if (dryRun) return { kind: "mcp", path, status: "dry-run" };
|
|
2492
|
+
ensureDir2(path);
|
|
2493
|
+
writeFileSync4(path, JSON.stringify(current, null, 2) + "\n", { mode: 384 });
|
|
2494
|
+
return { kind: "mcp", path, status: existed ? "merged" : "created" };
|
|
2495
|
+
}
|
|
2496
|
+
function mergeCodexToml(path, snippet, dryRun) {
|
|
2497
|
+
const existed = existsSync5(path);
|
|
2498
|
+
let body = readOr(path, "");
|
|
2499
|
+
body = body.replace(/(^|\n)\[mcp_servers\.sechroom\][^[]*/, "\n").replace(/\n{3,}/g, "\n\n");
|
|
2500
|
+
const trimmed = body.trim();
|
|
2501
|
+
const next = (trimmed.length > 0 ? trimmed + "\n\n" : "") + snippet.trim() + "\n";
|
|
2502
|
+
if (dryRun) return { kind: "mcp", path, status: "dry-run" };
|
|
2503
|
+
ensureDir2(path);
|
|
2504
|
+
writeFileSync4(path, next, { mode: 384 });
|
|
2505
|
+
return { kind: "mcp", path, status: existed ? "merged" : "created" };
|
|
2506
|
+
}
|
|
2507
|
+
function writeInstructionBlock(path, write, dryRun) {
|
|
2508
|
+
const existed = existsSync5(path);
|
|
2509
|
+
const next = computeBlockFile(readOr(path, ""), write);
|
|
2510
|
+
if (dryRun) return { kind: "instruction", path, status: "dry-run" };
|
|
2511
|
+
ensureDir2(path);
|
|
2512
|
+
writeFileSync4(path, next);
|
|
2513
|
+
return { kind: "instruction", path, status: existed ? "merged" : "created" };
|
|
2514
|
+
}
|
|
2515
|
+
function computeBlockFile(current, write) {
|
|
2516
|
+
const rendered = renderBlock(write);
|
|
2517
|
+
const keyed = keyedBlockRe(write.block);
|
|
2518
|
+
if (keyed.test(current)) return current.replace(keyed, rendered);
|
|
2519
|
+
if (write.block === "role-template" && legacyBlockRe().test(current)) {
|
|
2520
|
+
return current.replace(legacyBlockRe(), rendered);
|
|
2521
|
+
}
|
|
2522
|
+
return current.trim().length > 0 ? `${current.trimEnd()}
|
|
2523
|
+
|
|
2524
|
+
${rendered}` : rendered;
|
|
2525
|
+
}
|
|
2526
|
+
function evaluateBlock(content, block, serverBody) {
|
|
2527
|
+
const onDisk = parseManagedBlock(content, block);
|
|
2528
|
+
if (!onDisk) return "absent";
|
|
2529
|
+
const actual = bodySha256(onDisk.body);
|
|
2530
|
+
if (onDisk.sha256 && actual !== onDisk.sha256) return "drift";
|
|
2531
|
+
return actual === bodySha256(serverBody) ? "current" : "stale";
|
|
2532
|
+
}
|
|
2533
|
+
function applyBlock(path, write, mode, dryRun) {
|
|
2534
|
+
const current = readOr(path, "");
|
|
2535
|
+
const state = evaluateBlock(current, write.block, write.body);
|
|
2536
|
+
if (mode === "check") {
|
|
2537
|
+
return {
|
|
2538
|
+
kind: "instruction",
|
|
2539
|
+
path,
|
|
2540
|
+
status: state === "current" ? "current" : "skipped",
|
|
2541
|
+
eval: state,
|
|
2542
|
+
note: state === "current" ? void 0 : `would ${state === "absent" ? "write" : "refresh"} (${state})`
|
|
2543
|
+
};
|
|
2544
|
+
}
|
|
2545
|
+
if (state === "current") {
|
|
2546
|
+
return { kind: "instruction", path, status: "current", eval: "current" };
|
|
2547
|
+
}
|
|
2548
|
+
if (state === "drift" && mode !== "force") {
|
|
2549
|
+
const proposedPath = `${path}.proposed`;
|
|
2550
|
+
const next = computeBlockFile(current, write);
|
|
2551
|
+
if (!dryRun) {
|
|
2552
|
+
ensureDir2(proposedPath);
|
|
2553
|
+
writeFileSync4(proposedPath, next);
|
|
2554
|
+
}
|
|
2555
|
+
return {
|
|
2556
|
+
kind: "instruction",
|
|
2557
|
+
path,
|
|
2558
|
+
status: "skipped",
|
|
2559
|
+
eval: "drift",
|
|
2560
|
+
proposedPath,
|
|
2561
|
+
note: `local edits \u2014 wrote ${proposedPath} (original left untouched)`
|
|
2562
|
+
};
|
|
2563
|
+
}
|
|
2564
|
+
const action = writeInstructionBlock(path, write, dryRun);
|
|
2565
|
+
const note = state === "stale" ? "refreshed" : state === "drift" ? "overwrote local edits" : void 0;
|
|
2566
|
+
return { ...action, eval: state, note: note ?? action.note };
|
|
2567
|
+
}
|
|
537
2568
|
function escapeRe(s) {
|
|
538
2569
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
539
2570
|
}
|
|
540
|
-
async function applyClient(cfg, setup, target, opts) {
|
|
541
|
-
const actions = [];
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
const
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
actions.push(
|
|
550
|
-
|
|
2571
|
+
async function applyClient(cfg, setup, target, opts) {
|
|
2572
|
+
const actions = [];
|
|
2573
|
+
const mode = opts.mode ?? "apply";
|
|
2574
|
+
const dryRun = opts.dryRun || mode === "check";
|
|
2575
|
+
if (opts.mcp && target.mcp) {
|
|
2576
|
+
const surface = findSurface(setup, target.mcp.surfaceKey);
|
|
2577
|
+
const section = findSection(surface, target.mcp.sectionType);
|
|
2578
|
+
const snippet = sectionSnippet(section);
|
|
2579
|
+
if (!snippet) {
|
|
2580
|
+
actions.push({ kind: "mcp", path: target.mcp.path, status: "skipped", note: `no ${target.mcp.sectionType} section on surface '${target.mcp.surfaceKey}'` });
|
|
2581
|
+
} else {
|
|
2582
|
+
actions.push(
|
|
2583
|
+
target.mcp.format === "toml" ? mergeCodexToml(target.mcp.path, snippet, dryRun) : mergeMcpJson(target.mcp.path, snippet, dryRun)
|
|
2584
|
+
);
|
|
2585
|
+
}
|
|
2586
|
+
}
|
|
2587
|
+
if (opts.agentFiles && target.instruction) {
|
|
2588
|
+
const surface = findSurface(setup, target.instruction.surfaceKey);
|
|
2589
|
+
const section = findSection(surface, SectionType.InstructionFile);
|
|
2590
|
+
if (!section) {
|
|
2591
|
+
actions.push({ kind: "instruction", path: target.instruction.path, status: "skipped", note: `no instruction-file section on surface '${target.instruction.surfaceKey}'` });
|
|
2592
|
+
} else {
|
|
2593
|
+
const resolved = await resolveInstruction(cfg, section, opts.personalWorkspaceId);
|
|
2594
|
+
if (!resolved) {
|
|
2595
|
+
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`" });
|
|
2596
|
+
} else {
|
|
2597
|
+
const action = applyBlock(
|
|
2598
|
+
target.instruction.path,
|
|
2599
|
+
{ block: "role-template", body: resolved.body, source: resolved.sourceRef },
|
|
2600
|
+
mode,
|
|
2601
|
+
opts.dryRun
|
|
2602
|
+
);
|
|
2603
|
+
actions.push(resolved.source === "override" && action.status !== "current" ? { ...action, note: action.note ?? "your personal copy" } : action);
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
const conventionsSection = findSection(surface, SectionType.WorkspaceConventions);
|
|
2607
|
+
if (conventionsSection) {
|
|
2608
|
+
const conventions = await resolveWorkspaceConventions(cfg, conventionsSection);
|
|
2609
|
+
if (conventions) {
|
|
2610
|
+
const action = applyBlock(
|
|
2611
|
+
target.instruction.path,
|
|
2612
|
+
{ block: "workspace-conventions", body: conventions.body, source: `workspace:${cfg.workspaceId ?? ""}` },
|
|
2613
|
+
mode,
|
|
2614
|
+
opts.dryRun
|
|
2615
|
+
);
|
|
2616
|
+
actions.push(action.status === "current" ? action : { ...action, note: action.note ?? `workspace conventions (${conventions.refs.length})` });
|
|
2617
|
+
}
|
|
2618
|
+
}
|
|
2619
|
+
}
|
|
2620
|
+
return actions;
|
|
2621
|
+
}
|
|
2622
|
+
|
|
2623
|
+
// src/setup/hooks-offer.ts
|
|
2624
|
+
import { homedir as homedir4 } from "os";
|
|
2625
|
+
async function maybeOfferHooks(opts) {
|
|
2626
|
+
if (opts.dryRun) return;
|
|
2627
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
2628
|
+
const surfaces = detectHookSurfaces(cwd);
|
|
2629
|
+
if (surfaces.length === 0) return;
|
|
2630
|
+
const names = surfaces.map((s) => HOOK_SURFACE_LABEL[s]).join(" + ");
|
|
2631
|
+
process.stderr.write(
|
|
2632
|
+
`
|
|
2633
|
+
Sechroom can wire continuity lifecycle hooks into ${style.bold(names)} so your agent
|
|
2634
|
+
auto-resumes where you left off and checkpoints working state before compacting.
|
|
2635
|
+
`
|
|
2636
|
+
);
|
|
2637
|
+
const install = opts.yes ? true : canPrompt() ? await promptYesNo(`Install the continuity hooks for ${names}?`) : false;
|
|
2638
|
+
if (!install) return;
|
|
2639
|
+
try {
|
|
2640
|
+
const installed = installHookSurfaces(surfaces, { dryRun: false, cwd, home: homedir4() });
|
|
2641
|
+
let changed = false;
|
|
2642
|
+
for (const { surface, results } of installed) {
|
|
2643
|
+
for (const r of results) {
|
|
2644
|
+
if (r.status !== "current") changed = true;
|
|
2645
|
+
const verb = r.status === "current" ? "already configured" : r.status === "created" ? "created" : "updated";
|
|
2646
|
+
process.stderr.write(`${style.green("\u2713")} ${HOOK_SURFACE_LABEL[surface]}: ${r.path} (${verb})
|
|
2647
|
+
`);
|
|
2648
|
+
}
|
|
2649
|
+
}
|
|
2650
|
+
if (changed) {
|
|
2651
|
+
process.stderr.write(`${style.dim("Restart (or reload) your agent for the hooks to take effect.")}
|
|
2652
|
+
`);
|
|
2653
|
+
}
|
|
2654
|
+
warnIfSechroomNotOnPath();
|
|
2655
|
+
} catch (err2) {
|
|
2656
|
+
process.stderr.write(`${style.dim(`(skipped hook install: ${err2.message})`)}
|
|
2657
|
+
`);
|
|
2658
|
+
}
|
|
2659
|
+
}
|
|
2660
|
+
|
|
2661
|
+
// src/setup/skills-offer.ts
|
|
2662
|
+
import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "fs";
|
|
2663
|
+
import { homedir as homedir5 } from "os";
|
|
2664
|
+
import { join as join5 } from "path";
|
|
2665
|
+
|
|
2666
|
+
// src/setup/lane-pin.ts
|
|
2667
|
+
var CODE_LANE_PREFIX_BY_CLIENT = {
|
|
2668
|
+
"claude-code": "claude-code",
|
|
2669
|
+
"claude-desktop": "claude-code",
|
|
2670
|
+
cursor: "claude-code",
|
|
2671
|
+
codex: "codex"
|
|
2672
|
+
};
|
|
2673
|
+
var CLIENT_PRIORITY = ["claude-code", "claude-desktop", "cursor", "codex"];
|
|
2674
|
+
function handleFromDisplayName(name) {
|
|
2675
|
+
if (!name) return void 0;
|
|
2676
|
+
const localPart = name.trim().split("@")[0] ?? "";
|
|
2677
|
+
const first = localPart.split(/[\s._-]+/)[0]?.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
2678
|
+
return first || void 0;
|
|
2679
|
+
}
|
|
2680
|
+
function codeLanePrefix(clients) {
|
|
2681
|
+
for (const c of CLIENT_PRIORITY) if (clients.includes(c)) return CODE_LANE_PREFIX_BY_CLIENT[c];
|
|
2682
|
+
return "claude-code";
|
|
2683
|
+
}
|
|
2684
|
+
async function inferLanes(cfg, clients) {
|
|
2685
|
+
let wf;
|
|
2686
|
+
let profile;
|
|
2687
|
+
try {
|
|
2688
|
+
const client = await makeClient(cfg);
|
|
2689
|
+
[wf, profile] = await Promise.all([
|
|
2690
|
+
client.GET("/me/workflow-preferences", {}).then((r) => r.data).catch(() => void 0),
|
|
2691
|
+
client.GET("/me/profile", {}).then((r) => r.data).catch(() => void 0)
|
|
2692
|
+
]);
|
|
2693
|
+
} catch {
|
|
2694
|
+
}
|
|
2695
|
+
const handle = handleFromDisplayName(profile?.effectiveDisplayName);
|
|
2696
|
+
const prefix = codeLanePrefix(clients ?? ["claude-code"]);
|
|
2697
|
+
return {
|
|
2698
|
+
code: process.env.SECHROOM_CODE_LANE ?? wf?.defaultCodeLane ?? (handle ? `${prefix}-${handle}` : void 0),
|
|
2699
|
+
design: process.env.SECHROOM_DESIGN_LANE ?? wf?.defaultDesignLane ?? (handle ? `claude-design-${handle}` : void 0)
|
|
2700
|
+
};
|
|
2701
|
+
}
|
|
2702
|
+
function writePin(code, design) {
|
|
2703
|
+
const values = {};
|
|
2704
|
+
if (code) values["code-lane"] = code;
|
|
2705
|
+
if (design) values["design-lane"] = design;
|
|
2706
|
+
if (Object.keys(values).length === 0) return;
|
|
2707
|
+
const target = writeSem(values);
|
|
2708
|
+
process.stderr.write(`${ok("\u2713")} lane pin written \u2192 ${target} ${style.dim("(./.sechroom/lane.json, git-ignored)")}
|
|
2709
|
+
`);
|
|
2710
|
+
}
|
|
2711
|
+
async function ensureLanePin(cfg, opts) {
|
|
2712
|
+
if (opts.dryRun) return;
|
|
2713
|
+
if (readSem()) return;
|
|
2714
|
+
const { code: codeGuess, design: designGuess } = await inferLanes(cfg, opts.clients);
|
|
2715
|
+
if (!canPrompt() || opts.yes) {
|
|
2716
|
+
if (opts.yes && (codeGuess || designGuess)) writePin(codeGuess, designGuess);
|
|
2717
|
+
return;
|
|
2718
|
+
}
|
|
2719
|
+
if (codeGuess || designGuess) {
|
|
2720
|
+
process.stderr.write(
|
|
2721
|
+
`
|
|
2722
|
+
I can pin this checkout's lane so operator skills + the continuity hook resolve your identity:
|
|
2723
|
+
`
|
|
2724
|
+
);
|
|
2725
|
+
if (codeGuess) process.stderr.write(` ${style.dim("code-lane")} = ${style.cyan(codeGuess)}
|
|
2726
|
+
`);
|
|
2727
|
+
if (designGuess) process.stderr.write(` ${style.dim("design-lane")} = ${style.cyan(designGuess)}
|
|
2728
|
+
`);
|
|
2729
|
+
if (await promptYesNo("Pin these?")) {
|
|
2730
|
+
writePin(codeGuess, designGuess);
|
|
2731
|
+
return;
|
|
2732
|
+
}
|
|
2733
|
+
}
|
|
2734
|
+
const code = await promptText("Code-lane id (e.g. claude-code-you, blank to skip)?", codeGuess ?? "");
|
|
2735
|
+
const design = await promptText("Design-lane id (e.g. claude-design-you, blank to skip)?", designGuess ?? "");
|
|
2736
|
+
if (!code && !design) {
|
|
2737
|
+
process.stderr.write(
|
|
2738
|
+
` ${style.dim("skipped \u2014 set later with")} ${style.cyan("sechroom skills set-lane --code-lane \u2026 --design-lane \u2026")}
|
|
2739
|
+
`
|
|
2740
|
+
);
|
|
2741
|
+
return;
|
|
2742
|
+
}
|
|
2743
|
+
writePin(code || void 0, design || void 0);
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2746
|
+
// src/setup/skills-offer.ts
|
|
2747
|
+
var ROLE_TAG = "sechroom:role:skill-template";
|
|
2748
|
+
function tagValue(tags, prefix) {
|
|
2749
|
+
return tags.find((t) => t.startsWith(prefix))?.slice(prefix.length);
|
|
2750
|
+
}
|
|
2751
|
+
async function maybeOfferSkills(cfg, personalWorkspaceId, opts) {
|
|
2752
|
+
if (!personalWorkspaceId || opts.dryRun) return;
|
|
2753
|
+
const surface = opts.surface ?? "claude-code";
|
|
2754
|
+
let rows = [];
|
|
2755
|
+
try {
|
|
2756
|
+
const client = await makeClient(cfg);
|
|
2757
|
+
const feed = await client.GET("/workspaces/{workspaceId}/memories/feed", {
|
|
2758
|
+
params: {
|
|
2759
|
+
path: { workspaceId: personalWorkspaceId },
|
|
2760
|
+
query: { limit: 200, cascadeWorkspaces: true, includeText: true }
|
|
2761
|
+
}
|
|
2762
|
+
}).then((r) => r.data).catch(() => void 0);
|
|
2763
|
+
rows = feed?.results ?? feed?.Results ?? [];
|
|
2764
|
+
} catch {
|
|
2765
|
+
return;
|
|
2766
|
+
}
|
|
2767
|
+
const skills = rows.map((r) => r.item ?? r).filter((m) => {
|
|
2768
|
+
const tags = m.tags ?? m.Tags ?? [];
|
|
2769
|
+
return tags.includes(ROLE_TAG) && tagValue(tags, "target:") === surface;
|
|
2770
|
+
});
|
|
2771
|
+
if (skills.length === 0) return;
|
|
2772
|
+
const byName = /* @__PURE__ */ new Map();
|
|
2773
|
+
for (const m of skills) {
|
|
2774
|
+
const name = tagValue(m.tags ?? m.Tags ?? [], "skill:");
|
|
2775
|
+
if (name) byName.set(name, m);
|
|
2776
|
+
}
|
|
2777
|
+
const names = [...byName.keys()].sort();
|
|
2778
|
+
if (names.length === 0) return;
|
|
2779
|
+
process.stderr.write(
|
|
2780
|
+
`
|
|
2781
|
+
Found ${style.bold(String(names.length))} operator skill(s) installed in your workspace: ${names.join(", ")}.
|
|
2782
|
+
`
|
|
2783
|
+
);
|
|
2784
|
+
const dir = join5(homedir5(), ".claude", "skills");
|
|
2785
|
+
const materialise = opts.yes ? true : canPrompt() ? await promptYesNo(`Write them to ${dir}/ so ${surface} can use them?`) : false;
|
|
2786
|
+
if (!materialise) return;
|
|
2787
|
+
const written = [];
|
|
2788
|
+
for (const [name, m] of byName) {
|
|
2789
|
+
const body = m.text ?? m.Text ?? "";
|
|
2790
|
+
mkdirSync5(join5(dir, name), { recursive: true });
|
|
2791
|
+
writeFileSync5(join5(dir, name, "SKILL.md"), body.endsWith("\n") ? body : body + "\n");
|
|
2792
|
+
written.push(name);
|
|
2793
|
+
}
|
|
2794
|
+
process.stderr.write(`${style.green("\u2713")} wrote ${written.length} skill(s) to ${dir}
|
|
2795
|
+
`);
|
|
2796
|
+
await ensureLanePin(cfg, { yes: opts.yes, dryRun: opts.dryRun, clients: [surface] });
|
|
2797
|
+
}
|
|
2798
|
+
|
|
2799
|
+
// src/commands/setup.ts
|
|
2800
|
+
function copyChoice(opts) {
|
|
2801
|
+
return opts.copy === true ? "yes" : opts.copy === false ? "no" : "ask";
|
|
2802
|
+
}
|
|
2803
|
+
async function maybeOfferCopies(cfg, setup, targets, keys, personalWorkspaceId, choice) {
|
|
2804
|
+
if (!personalWorkspaceId || choice === "no") return;
|
|
2805
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2806
|
+
for (const key of keys) {
|
|
2807
|
+
const instr = targets[key]?.instruction;
|
|
2808
|
+
if (!instr || seen.has(instr.surfaceKey)) continue;
|
|
2809
|
+
seen.add(instr.surfaceKey);
|
|
2810
|
+
const section = findSection(findSurface(setup, instr.surfaceKey), SectionType.InstructionFile);
|
|
2811
|
+
if (!section) continue;
|
|
2812
|
+
const resolved = await resolveInstruction(cfg, section, personalWorkspaceId);
|
|
2813
|
+
if (!resolved || resolved.source === "override") continue;
|
|
2814
|
+
let make = choice === "yes";
|
|
2815
|
+
if (choice === "ask") {
|
|
2816
|
+
process.stderr.write(
|
|
2817
|
+
`
|
|
2818
|
+
The ${instr.surfaceKey} agent instructions are the shared template.
|
|
2819
|
+
Make a personal copy to tailor them for your workflow \u2014 your agent uses your
|
|
2820
|
+
version, the shared template stays clean, and you can discard back anytime.
|
|
2821
|
+
`
|
|
2822
|
+
);
|
|
2823
|
+
make = await promptYesNo("Make a personal copy to customise?");
|
|
2824
|
+
}
|
|
2825
|
+
if (make) {
|
|
2826
|
+
await createOverride(cfg, resolved, personalWorkspaceId);
|
|
2827
|
+
process.stderr.write(`\u2713 personal copy created for ${instr.surfaceKey} \u2014 edit it on the Agent setup page or via the API.
|
|
2828
|
+
`);
|
|
2829
|
+
}
|
|
2830
|
+
}
|
|
2831
|
+
}
|
|
2832
|
+
function resolveClientKeys(raw) {
|
|
2833
|
+
const targets = clientTargets(process.cwd());
|
|
2834
|
+
if (raw === "all") return [...ALL_CLIENT_KEYS];
|
|
2835
|
+
const keys = raw.split(",").map((k) => k.trim()).filter(Boolean);
|
|
2836
|
+
for (const k of keys) {
|
|
2837
|
+
if (!targets[k]) {
|
|
2838
|
+
fail(`unknown client '${k}'. Known: ${ALL_CLIENT_KEYS.join(", ")}, or 'all'.`);
|
|
2839
|
+
}
|
|
2840
|
+
}
|
|
2841
|
+
return keys;
|
|
2842
|
+
}
|
|
2843
|
+
function printActions(client, actions) {
|
|
2844
|
+
process.stdout.write(`
|
|
2845
|
+
${client.label} (${client.key}):
|
|
2846
|
+
`);
|
|
2847
|
+
for (const a of actions) {
|
|
2848
|
+
const tag = a.status === "skipped" ? "skip" : a.status;
|
|
2849
|
+
process.stdout.write(` [${tag}] ${a.kind}: ${a.path}${a.note ? ` \u2014 ${a.note}` : ""}
|
|
2850
|
+
`);
|
|
2851
|
+
}
|
|
2852
|
+
}
|
|
2853
|
+
function registerInit(program2) {
|
|
2854
|
+
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)").addHelpText(
|
|
2855
|
+
"after",
|
|
2856
|
+
`
|
|
2857
|
+
Examples:
|
|
2858
|
+
$ sechroom init Claude Code (default): ./.mcp.json + ./CLAUDE.md
|
|
2859
|
+
$ sechroom init --client all claude-code, claude-desktop, codex, cursor
|
|
2860
|
+
$ sechroom init --client codex,cursor
|
|
2861
|
+
$ sechroom init --mcp-only just the MCP config (skip agent files)
|
|
2862
|
+
$ sechroom init --dry-run --json preview the writes, change nothing`
|
|
2863
|
+
).action(async (opts, cmd) => {
|
|
2864
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
2865
|
+
const setup = await withSpinner("Fetching setup descriptors", () => fetchSetup(cfg));
|
|
2866
|
+
const targets = clientTargets(process.cwd());
|
|
2867
|
+
const keys = resolveClientKeys(opts.client);
|
|
2868
|
+
const json = cmd.optsWithGlobals().json;
|
|
2869
|
+
const personalWorkspaceId = await getPersonalWorkspaceId(cfg);
|
|
2870
|
+
if (!opts.dryRun && !opts.mcpOnly) {
|
|
2871
|
+
await maybeOfferCopies(cfg, setup, targets, keys, personalWorkspaceId, copyChoice(opts));
|
|
2872
|
+
}
|
|
2873
|
+
const result = [];
|
|
2874
|
+
for (const key of keys) {
|
|
2875
|
+
const target = targets[key];
|
|
2876
|
+
const actions = await applyClient(cfg, setup, target, {
|
|
2877
|
+
dryRun: Boolean(opts.dryRun),
|
|
2878
|
+
mcp: !opts.agentFilesOnly,
|
|
2879
|
+
agentFiles: !opts.mcpOnly,
|
|
2880
|
+
personalWorkspaceId
|
|
2881
|
+
});
|
|
2882
|
+
result.push({ client: key, actions });
|
|
2883
|
+
if (!json) printActions(target, actions);
|
|
2884
|
+
}
|
|
2885
|
+
if (!json && !opts.dryRun && !opts.mcpOnly) {
|
|
2886
|
+
await maybeOfferSkills(cfg, personalWorkspaceId, { yes: false, dryRun: Boolean(opts.dryRun), surface: "claude-code" });
|
|
2887
|
+
}
|
|
2888
|
+
if (!json && !opts.dryRun && !opts.mcpOnly) {
|
|
2889
|
+
await maybeOfferHooks({ yes: false, dryRun: Boolean(opts.dryRun), cwd: process.cwd() });
|
|
2890
|
+
}
|
|
2891
|
+
if (json) {
|
|
2892
|
+
emit({ dryRun: Boolean(opts.dryRun), clients: result }, true);
|
|
2893
|
+
return;
|
|
2894
|
+
}
|
|
2895
|
+
const first = targets[keys[0]];
|
|
2896
|
+
const surface = findSurface(setup, first.mcp?.surfaceKey ?? first.instruction?.surfaceKey ?? "");
|
|
2897
|
+
const verify = findSection(surface, SectionType.Verify);
|
|
2898
|
+
if (verify?.description) {
|
|
2899
|
+
process.stdout.write(`
|
|
2900
|
+
Next \u2014 verify: ${verify.description}
|
|
2901
|
+
`);
|
|
2902
|
+
}
|
|
2903
|
+
process.stdout.write(
|
|
2904
|
+
opts.dryRun ? "\n(dry run \u2014 nothing written)\n" : "\nDone. Restart your AI client (or reload MCP) to pick up the new config.\n"
|
|
2905
|
+
);
|
|
2906
|
+
});
|
|
2907
|
+
}
|
|
2908
|
+
function registerSetup(program2) {
|
|
2909
|
+
const setup = program2.command("setup").description("Granular onboarding steps (init runs these together)");
|
|
2910
|
+
setup.command("mcp <clients...>").description(`Write only the MCP config for one or more clients (${ALL_CLIENT_KEYS.join(", ")}, or 'all')`).option("--dry-run", "print what would be written without writing", false).addHelpText("after", "\nExamples:\n $ sechroom setup mcp codex\n $ sechroom setup mcp claude-code codex\n $ sechroom setup mcp all").action(async (clients, opts, cmd) => {
|
|
2911
|
+
await runClients(clients, cmd, { dryRun: Boolean(opts.dryRun), mcp: true, agentFiles: false });
|
|
2912
|
+
});
|
|
2913
|
+
setup.command("agent-files <clients...>").description(`Write only the agent instruction file(s) for one or more clients (${ALL_CLIENT_KEYS.join(", ")}, or 'all')`).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)").addHelpText("after", "\nExamples:\n $ sechroom setup agent-files claude-code CLAUDE.md\n $ sechroom setup agent-files claude-code codex CLAUDE.md + AGENTS.md in one run\n $ sechroom setup agent-files all").action(async (clients, opts, cmd) => {
|
|
2914
|
+
await runClients(clients, cmd, { dryRun: Boolean(opts.dryRun), mcp: false, agentFiles: true, copy: opts.copy });
|
|
2915
|
+
});
|
|
2916
|
+
}
|
|
2917
|
+
async function runClients(clients, cmd, opts) {
|
|
2918
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
2919
|
+
const targets = clientTargets(process.cwd());
|
|
2920
|
+
const keys = resolveClientKeys(clients.join(","));
|
|
2921
|
+
const setupData = await withSpinner("Fetching setup descriptors", () => fetchSetup(cfg));
|
|
2922
|
+
const personalWorkspaceId = await getPersonalWorkspaceId(cfg);
|
|
2923
|
+
if (opts.agentFiles && !opts.dryRun) {
|
|
2924
|
+
await maybeOfferCopies(cfg, setupData, targets, keys, personalWorkspaceId, copyChoice(opts));
|
|
2925
|
+
}
|
|
2926
|
+
const json = cmd.optsWithGlobals().json;
|
|
2927
|
+
const result = [];
|
|
2928
|
+
for (const key of keys) {
|
|
2929
|
+
const target = targets[key];
|
|
2930
|
+
const actions = await applyClient(cfg, setupData, target, {
|
|
2931
|
+
dryRun: opts.dryRun,
|
|
2932
|
+
mcp: opts.mcp,
|
|
2933
|
+
agentFiles: opts.agentFiles,
|
|
2934
|
+
personalWorkspaceId
|
|
2935
|
+
});
|
|
2936
|
+
result.push({ client: key, actions });
|
|
2937
|
+
if (!json) printActions(target, actions);
|
|
2938
|
+
}
|
|
2939
|
+
if (json) {
|
|
2940
|
+
emit({ dryRun: opts.dryRun, clients: result }, true);
|
|
2941
|
+
return;
|
|
2942
|
+
}
|
|
2943
|
+
process.stdout.write(opts.dryRun ? "\n(dry run \u2014 nothing written)\n" : "\nDone.\n");
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2946
|
+
// src/commands/onboard.ts
|
|
2947
|
+
import { existsSync as existsSync7 } from "fs";
|
|
2948
|
+
import { join as join7 } from "path";
|
|
2949
|
+
|
|
2950
|
+
// src/commands/fanout.ts
|
|
2951
|
+
import { spawnSync } from "child_process";
|
|
2952
|
+
import { existsSync as existsSync6, readFileSync as readFileSync5, readdirSync, statSync } from "fs";
|
|
2953
|
+
import { isAbsolute, join as join6, resolve } from "path";
|
|
2954
|
+
var ICON = {
|
|
2955
|
+
refresh: "\u21BB",
|
|
2956
|
+
bind: "+",
|
|
2957
|
+
"skip-missing": "\u2013",
|
|
2958
|
+
"skip-unbound": "\u26A0"
|
|
2959
|
+
};
|
|
2960
|
+
function resolveChildDir(path, root) {
|
|
2961
|
+
return isAbsolute(path) ? path : resolve(root, path);
|
|
2962
|
+
}
|
|
2963
|
+
function discoverChildren(root) {
|
|
2964
|
+
let names;
|
|
2965
|
+
try {
|
|
2966
|
+
names = readdirSync(root);
|
|
2967
|
+
} catch {
|
|
2968
|
+
return [];
|
|
2969
|
+
}
|
|
2970
|
+
const out = [];
|
|
2971
|
+
for (const name of names.sort()) {
|
|
2972
|
+
if (name.startsWith(".") || name === "node_modules") continue;
|
|
2973
|
+
const dir = join6(root, name);
|
|
2974
|
+
try {
|
|
2975
|
+
if (!statSync(dir).isDirectory()) continue;
|
|
2976
|
+
} catch {
|
|
2977
|
+
continue;
|
|
2978
|
+
}
|
|
2979
|
+
if (existsSync6(join6(dir, ".git")) || committedBindingPath(dir)) out.push(name);
|
|
2980
|
+
}
|
|
2981
|
+
return out;
|
|
2982
|
+
}
|
|
2983
|
+
function readManifest(path) {
|
|
2984
|
+
if (!existsSync6(path)) return null;
|
|
2985
|
+
let parsed;
|
|
2986
|
+
try {
|
|
2987
|
+
parsed = JSON.parse(readFileSync5(path, "utf8"));
|
|
2988
|
+
} catch (err2) {
|
|
2989
|
+
throw new Error(`couldn't parse ${path}: ${err2 instanceof Error ? err2.message : String(err2)}`);
|
|
2990
|
+
}
|
|
2991
|
+
return Array.isArray(parsed.repos) ? parsed.repos.filter((r) => r && typeof r.path === "string") : [];
|
|
2992
|
+
}
|
|
2993
|
+
function passthroughGlobals(g) {
|
|
2994
|
+
const out = [];
|
|
2995
|
+
if (g.baseUrl) out.push("--base-url", g.baseUrl);
|
|
2996
|
+
if (g.tenant) out.push("--tenant", g.tenant);
|
|
2997
|
+
return out;
|
|
2998
|
+
}
|
|
2999
|
+
function runChildren(plans, o) {
|
|
3000
|
+
const { globals, dryRun, json } = o;
|
|
3001
|
+
const results = [];
|
|
3002
|
+
for (const plan of plans) {
|
|
3003
|
+
const runs = plan.argv.length > 0;
|
|
3004
|
+
const argv = runs ? [...globals, ...plan.argv] : [];
|
|
3005
|
+
if (!json) {
|
|
3006
|
+
process.stderr.write(` ${ICON[plan.disposition]} ${style.cyan(plan.label)} ${style.dim(plan.reason)}
|
|
3007
|
+
`);
|
|
3008
|
+
if (runs && dryRun) process.stderr.write(` ${style.dim(`would run: sechroom ${argv.join(" ")}`)}
|
|
3009
|
+
`);
|
|
3010
|
+
}
|
|
3011
|
+
let exitCode = runs ? 0 : null;
|
|
3012
|
+
if (runs && !dryRun) {
|
|
3013
|
+
const res = spawnSync(process.execPath, [process.argv[1], ...argv], {
|
|
3014
|
+
cwd: plan.dir,
|
|
3015
|
+
stdio: json ? "ignore" : "inherit"
|
|
3016
|
+
});
|
|
3017
|
+
exitCode = res.status;
|
|
3018
|
+
if (!json) {
|
|
3019
|
+
process.stderr.write(
|
|
3020
|
+
exitCode === 0 ? ` ${ok("\u2713")} ${style.dim("onboard ok")}
|
|
3021
|
+
` : ` ${warn("\u2717")} ${style.dim(`onboard exited ${exitCode ?? "signal"}`)}
|
|
3022
|
+
`
|
|
3023
|
+
);
|
|
3024
|
+
}
|
|
3025
|
+
}
|
|
3026
|
+
results.push({
|
|
3027
|
+
path: plan.label,
|
|
3028
|
+
dir: plan.dir,
|
|
3029
|
+
disposition: plan.disposition,
|
|
3030
|
+
ran: runs && !dryRun,
|
|
3031
|
+
exitCode,
|
|
3032
|
+
reason: plan.reason
|
|
3033
|
+
});
|
|
3034
|
+
}
|
|
3035
|
+
return results;
|
|
3036
|
+
}
|
|
3037
|
+
function summarizeFanout(results, o) {
|
|
3038
|
+
const ran = results.filter((r) => r.ran);
|
|
3039
|
+
const failed = ran.filter((r) => r.exitCode !== 0);
|
|
3040
|
+
const skipped = results.filter((r) => r.disposition.startsWith("skip"));
|
|
3041
|
+
const wouldRun = results.filter((r) => !r.disposition.startsWith("skip"));
|
|
3042
|
+
const tally = (o.dryRun ? [wouldRun.length ? `${wouldRun.length} would onboard` : null, skipped.length ? `${skipped.length} would skip` : null] : [
|
|
3043
|
+
ran.length ? `${ran.length - failed.length}/${ran.length} onboarded` : null,
|
|
3044
|
+
skipped.length ? `${skipped.length} skipped` : null,
|
|
3045
|
+
failed.length ? `${failed.length} failed` : null
|
|
3046
|
+
]).filter(Boolean).join(", ");
|
|
3047
|
+
process.stderr.write(`
|
|
3048
|
+
${failed.length ? warn("\u26A0") : ok("\u2713")} ${tally || "nothing to do"}${o.dryRun ? style.dim(" (dry run)") : ""}
|
|
3049
|
+
`);
|
|
3050
|
+
if (failed.length) process.exit(1);
|
|
3051
|
+
}
|
|
3052
|
+
|
|
3053
|
+
// src/commands/onboard.ts
|
|
3054
|
+
var DEFAULT_BASE_URL2 = "https://app.sechroom.ai/api";
|
|
3055
|
+
function systemTimezone() {
|
|
3056
|
+
try {
|
|
3057
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
3058
|
+
} catch {
|
|
3059
|
+
return "UTC";
|
|
3060
|
+
}
|
|
3061
|
+
}
|
|
3062
|
+
function editDistance(a, b) {
|
|
3063
|
+
const m = a.length;
|
|
3064
|
+
const n = b.length;
|
|
3065
|
+
if (m === 0) return n;
|
|
3066
|
+
if (n === 0) return m;
|
|
3067
|
+
let prev = Array.from({ length: n + 1 }, (_, j) => j);
|
|
3068
|
+
for (let i = 1; i <= m; i++) {
|
|
3069
|
+
const curr = [i];
|
|
3070
|
+
for (let j = 1; j <= n; j++) {
|
|
3071
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
3072
|
+
curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
|
|
3073
|
+
}
|
|
3074
|
+
prev = curr;
|
|
3075
|
+
}
|
|
3076
|
+
return prev[n];
|
|
3077
|
+
}
|
|
3078
|
+
function namesCollide(a, b) {
|
|
3079
|
+
const x = a.trim().toLowerCase();
|
|
3080
|
+
const y = b.trim().toLowerCase();
|
|
3081
|
+
return x === y || editDistance(x, y) <= 1;
|
|
3082
|
+
}
|
|
3083
|
+
function workspacePath(ws, byId) {
|
|
3084
|
+
const parts = [];
|
|
3085
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3086
|
+
let cur = ws;
|
|
3087
|
+
while (cur && !seen.has(cur.id)) {
|
|
3088
|
+
seen.add(cur.id);
|
|
3089
|
+
parts.unshift(cur.name);
|
|
3090
|
+
cur = cur.parentId ? byId.get(cur.parentId) : void 0;
|
|
3091
|
+
}
|
|
3092
|
+
return parts.join(" / ");
|
|
3093
|
+
}
|
|
3094
|
+
function resolveBaseUrl(g) {
|
|
3095
|
+
const persisted = readPersisted();
|
|
3096
|
+
const local = readLocalConfig();
|
|
3097
|
+
const baseUrl = g.baseUrl ?? process.env.SECHROOM_BASE_URL ?? local.baseUrl ?? persisted.baseUrl ?? DEFAULT_BASE_URL2;
|
|
3098
|
+
return baseUrl.replace(/\/$/, "");
|
|
3099
|
+
}
|
|
3100
|
+
async function fetchWorkspaces(client) {
|
|
3101
|
+
const { data, error } = await client.GET("/workspaces", { params: { query: { includeArchived: false } } });
|
|
3102
|
+
if (error) throw new Error(`Couldn't list your workspaces: ${JSON.stringify(error)}`);
|
|
3103
|
+
const rows = data ?? [];
|
|
3104
|
+
return rows.map((r) => r.item ?? r).filter((w) => Boolean(w?.id && w?.name)).map((w) => ({ id: w.id, name: w.name, parentId: w.parentId ?? null }));
|
|
3105
|
+
}
|
|
3106
|
+
async function lookupWorkspace(client, id) {
|
|
3107
|
+
const { data, error } = await client.GET("/workspaces/{workspaceId}", { params: { path: { workspaceId: id } } });
|
|
3108
|
+
if (error) return null;
|
|
3109
|
+
const env = data;
|
|
3110
|
+
const w = env?.item ?? env;
|
|
3111
|
+
return w?.id ? { id: w.id, name: w.name ?? id, parentId: w.parentId ?? null } : null;
|
|
3112
|
+
}
|
|
3113
|
+
async function warnIfProjectStray(client, projectId, workspaceId, json) {
|
|
3114
|
+
const { data, error } = await client.GET("/projects/{projectId}", { params: { path: { projectId } } });
|
|
3115
|
+
if (error) return;
|
|
3116
|
+
const env = data;
|
|
3117
|
+
const owner = env?.item?.workspaceId;
|
|
3118
|
+
if (owner && owner !== workspaceId && !json) {
|
|
3119
|
+
process.stderr.write(
|
|
3120
|
+
`${warn("\u26A0")} defaultProjectId ${style.dim(projectId)} belongs to a different workspace (${style.dim(owner)}), not ${style.dim(workspaceId)} \u2014 leaving it as-is.
|
|
3121
|
+
`
|
|
3122
|
+
);
|
|
3123
|
+
}
|
|
3124
|
+
}
|
|
3125
|
+
async function pickWorkspace(client, promptLabel = "Bind this directory to a workspace:") {
|
|
3126
|
+
const all = await withSpinner("Listing your workspaces", () => fetchWorkspaces(client));
|
|
3127
|
+
if (all.length === 0) {
|
|
3128
|
+
process.stderr.write(`no workspaces found \u2014 skipping workspace binding (you can set it later with \`sechroom config set --local workspaceId <id>\`)
|
|
3129
|
+
`);
|
|
3130
|
+
return void 0;
|
|
3131
|
+
}
|
|
3132
|
+
const byId = new Map(all.map((w) => [w.id, w]));
|
|
3133
|
+
let pool = all;
|
|
3134
|
+
if (all.length > 12) {
|
|
3135
|
+
const q = (await promptText(`Filter ${all.length} workspaces (substring, Enter to list all)?`, "")).trim().toLowerCase();
|
|
3136
|
+
if (q) {
|
|
3137
|
+
const hits = all.filter((w) => `${w.name} ${workspacePath(w, byId)}`.toLowerCase().includes(q));
|
|
3138
|
+
if (hits.length > 0) pool = hits;
|
|
3139
|
+
else process.stderr.write(`no match for "${q}" \u2014 listing all
|
|
3140
|
+
`);
|
|
3141
|
+
}
|
|
3142
|
+
}
|
|
3143
|
+
const SKIP = "__skip__";
|
|
3144
|
+
const choices = [
|
|
3145
|
+
...pool.slice().sort((a, b) => workspacePath(a, byId).localeCompare(workspacePath(b, byId))).map((w) => ({ label: workspacePath(w, byId), value: w.id, hint: w.id })),
|
|
3146
|
+
{ label: style.dim("skip \u2014 don't bind a workspace"), value: SKIP, hint: void 0 }
|
|
3147
|
+
];
|
|
3148
|
+
const chosen = await promptSelect(promptLabel, choices, SKIP);
|
|
3149
|
+
if (chosen === SKIP) return void 0;
|
|
3150
|
+
const picked = byId.get(chosen);
|
|
3151
|
+
const collisions = all.filter((w) => w.id !== picked.id && namesCollide(w.name, picked.name));
|
|
3152
|
+
if (collisions.length > 0) {
|
|
3153
|
+
process.stderr.write(
|
|
3154
|
+
`${warn("\u26A0")} ${collisions.length} other workspace(s) have a similar name to ${style.cyan(workspacePath(picked, byId))}:
|
|
3155
|
+
` + collisions.map((w) => ` ${style.dim(workspacePath(w, byId))} ${style.dim(`(${w.id})`)}`).join("\n") + `
|
|
3156
|
+
You picked ${style.dim(picked.id)} \u2014 re-run \`sechroom config set --local workspaceId <id>\` if that's wrong.
|
|
3157
|
+
`
|
|
3158
|
+
);
|
|
3159
|
+
}
|
|
3160
|
+
return chosen;
|
|
3161
|
+
}
|
|
3162
|
+
async function resolveWorkspaceBinding(client, existing, opts) {
|
|
3163
|
+
if (opts.workspace) {
|
|
3164
|
+
const found = await lookupWorkspace(client, opts.workspace);
|
|
3165
|
+
if (!found && !opts.json) {
|
|
3166
|
+
process.stderr.write(
|
|
3167
|
+
`${warn("\u26A0")} workspace ${style.dim(opts.workspace)} not found (or you lack access) \u2014 binding it anyway.
|
|
3168
|
+
`
|
|
551
3169
|
);
|
|
552
3170
|
}
|
|
3171
|
+
return opts.workspace;
|
|
553
3172
|
}
|
|
554
|
-
if (
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
3173
|
+
if (existing) return existing;
|
|
3174
|
+
if (!canPrompt() || opts.yes) return void 0;
|
|
3175
|
+
return pickWorkspace(client);
|
|
3176
|
+
}
|
|
3177
|
+
async function ensureTenant(baseUrl, g, opts) {
|
|
3178
|
+
const persisted = readPersisted();
|
|
3179
|
+
const local = readLocalConfig();
|
|
3180
|
+
let tenant = g.tenant ?? process.env.SECHROOM_TENANT ?? local.tenant ?? persisted.tenant ?? "";
|
|
3181
|
+
if (!tenant) {
|
|
3182
|
+
const client = await makeClient({ baseUrl, tenant: "", clientId: persisted.clientId });
|
|
3183
|
+
const { data, error } = await client.GET("/auth/me/tenants", {});
|
|
3184
|
+
if (error) {
|
|
3185
|
+
fail(`Couldn't list your tenants: ${JSON.stringify(error)}. Pass --tenant <id> to skip this.`);
|
|
3186
|
+
}
|
|
3187
|
+
const tenants = data?.tenants ?? [];
|
|
3188
|
+
if (tenants.length === 0) {
|
|
3189
|
+
fail(
|
|
3190
|
+
"You're signed in, but your account isn't a member of any tenant yet. Ask an admin to add you (or create one in the app), then re-run \u2014 or pass --tenant <id>."
|
|
3191
|
+
);
|
|
3192
|
+
} else if (tenants.length === 1) {
|
|
3193
|
+
tenant = tenants[0].key;
|
|
3194
|
+
if (!opts.json) {
|
|
3195
|
+
process.stderr.write(
|
|
3196
|
+
`${ok("\u2713")} using your tenant ${style.cyan(tenants[0].label)} ${style.dim(`(${tenant})`)}
|
|
3197
|
+
`
|
|
3198
|
+
);
|
|
3199
|
+
}
|
|
3200
|
+
} else if (canPrompt() && !opts.yes) {
|
|
3201
|
+
tenant = await promptSelect(
|
|
3202
|
+
"You belong to several tenants \u2014 pick one:",
|
|
3203
|
+
tenants.map((t) => ({ label: t.label, value: t.key, hint: t.key })),
|
|
3204
|
+
data?.defaultTenantKey ?? tenants[0].key
|
|
3205
|
+
);
|
|
559
3206
|
} else {
|
|
560
|
-
|
|
561
|
-
if (!
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
3207
|
+
tenant = data?.defaultTenantKey ?? tenants[0].key;
|
|
3208
|
+
if (!opts.json) {
|
|
3209
|
+
process.stderr.write(
|
|
3210
|
+
`using tenant ${tenant} (${tenants.length} available \u2014 pass --tenant to choose another)
|
|
3211
|
+
`
|
|
3212
|
+
);
|
|
565
3213
|
}
|
|
566
3214
|
}
|
|
567
3215
|
}
|
|
568
|
-
|
|
3216
|
+
const existingWorkspace = local.workspaceId ?? persisted.workspaceId ?? void 0;
|
|
3217
|
+
const wsClient = await makeClient({ baseUrl, tenant, clientId: persisted.clientId });
|
|
3218
|
+
const workspaceId = await resolveWorkspaceBinding(wsClient, existingWorkspace, {
|
|
3219
|
+
yes: opts.yes,
|
|
3220
|
+
json: opts.json,
|
|
3221
|
+
workspace: opts.workspace
|
|
3222
|
+
});
|
|
3223
|
+
const defaultProjectId = local.defaultProjectId ?? persisted.defaultProjectId ?? void 0;
|
|
3224
|
+
if (defaultProjectId && workspaceId) await warnIfProjectStray(wsClient, defaultProjectId, workspaceId, opts.json);
|
|
3225
|
+
let storeLocal = Boolean(opts.local);
|
|
3226
|
+
if (!opts.local && canPrompt() && !opts.yes) {
|
|
3227
|
+
storeLocal = await promptSelect(
|
|
3228
|
+
"Where should this tenant + base URL be saved?",
|
|
3229
|
+
[
|
|
3230
|
+
{ label: "Globally", value: "global", hint: "all projects on this machine" },
|
|
3231
|
+
{ label: "This directory", value: "local", hint: ".sechroom.json \u2014 committed, project + subdirs" }
|
|
3232
|
+
],
|
|
3233
|
+
local.path ? "local" : "global"
|
|
3234
|
+
) === "local";
|
|
3235
|
+
}
|
|
3236
|
+
if (opts.persist !== false) {
|
|
3237
|
+
const patch = { baseUrl, tenant, ...workspaceId ? { workspaceId } : {} };
|
|
3238
|
+
if (storeLocal) {
|
|
3239
|
+
const path = writeLocalConfig(patch);
|
|
3240
|
+
if (!opts.json) process.stderr.write(`${ok("\u2713")} config saved to ${path} (directory-local)
|
|
3241
|
+
`);
|
|
3242
|
+
} else {
|
|
3243
|
+
writePersisted(patch);
|
|
3244
|
+
if (!opts.json) process.stderr.write(`${ok("\u2713")} config saved globally (~/.config/sechroom/config.json)
|
|
3245
|
+
`);
|
|
3246
|
+
}
|
|
3247
|
+
if (workspaceId && !existingWorkspace && !opts.json) {
|
|
3248
|
+
process.stderr.write(`${ok("\u2713")} bound to workspace ${style.dim(workspaceId)}
|
|
3249
|
+
`);
|
|
3250
|
+
}
|
|
3251
|
+
}
|
|
3252
|
+
return { baseUrl, tenant, workspaceId, defaultProjectId, clientId: persisted.clientId };
|
|
569
3253
|
}
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
return join2(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
578
|
-
case "win32":
|
|
579
|
-
return join2(process.env.APPDATA ?? join2(home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
|
|
580
|
-
default:
|
|
581
|
-
return join2(home, ".config", "Claude", "claude_desktop_config.json");
|
|
3254
|
+
async function ensureAuth(cfg, yes) {
|
|
3255
|
+
if (process.env.SECHROOM_TOKEN) return;
|
|
3256
|
+
const cached = readToken();
|
|
3257
|
+
const usable = Boolean(cached?.accessToken) && (cached.expiresAt === void 0 || Date.now() < cached.expiresAt - 6e4 || Boolean(cached.refreshToken));
|
|
3258
|
+
if (usable) return;
|
|
3259
|
+
if (!canPrompt() || yes) {
|
|
3260
|
+
fail("Not signed in. Run `sechroom login` first, or set SECHROOM_TOKEN for headless use.");
|
|
582
3261
|
}
|
|
3262
|
+
process.stderr.write("\nNot signed in \u2014 opening the browser to authenticate.\n");
|
|
3263
|
+
await login(cfg);
|
|
583
3264
|
}
|
|
584
|
-
function
|
|
585
|
-
const
|
|
3265
|
+
async function ensureTimezone(cfg, opts) {
|
|
3266
|
+
const client = await makeClient(cfg);
|
|
3267
|
+
const { data, error } = await client.GET("/me/profile", {});
|
|
3268
|
+
if (error) return { timezone: null, action: "skipped", note: "could not read profile" };
|
|
3269
|
+
const current = data?.effectiveTimezone;
|
|
3270
|
+
if (current && current.trim().length > 0) return { timezone: current, action: "already-set" };
|
|
3271
|
+
const system = systemTimezone();
|
|
3272
|
+
let tz = system;
|
|
3273
|
+
if (canPrompt() && !opts.yes) {
|
|
3274
|
+
tz = await promptText("Your timezone (IANA, e.g. Europe/London)?", system);
|
|
3275
|
+
} else if (!opts.yes) {
|
|
3276
|
+
return {
|
|
3277
|
+
timezone: null,
|
|
3278
|
+
action: "skipped",
|
|
3279
|
+
note: "no timezone set \u2014 re-run interactively or pass --yes to adopt the system timezone"
|
|
3280
|
+
};
|
|
3281
|
+
}
|
|
3282
|
+
if (!tz) return { timezone: null, action: "skipped", note: "no timezone provided" };
|
|
3283
|
+
if (opts.dryRun) return { timezone: tz, action: "dry-run" };
|
|
3284
|
+
const { error: putErr } = await client.PUT("/me/profile", {
|
|
3285
|
+
body: { displayName: null, photoUrl: null, bio: null, timezone: tz }
|
|
3286
|
+
});
|
|
3287
|
+
if (putErr) return { timezone: tz, action: "skipped", note: `update failed: ${JSON.stringify(putErr)}` };
|
|
3288
|
+
return { timezone: tz, action: "set" };
|
|
3289
|
+
}
|
|
3290
|
+
async function chooseClients(clientFlag, yes, cwd) {
|
|
3291
|
+
if (clientFlag) return resolveClientKeys(clientFlag);
|
|
3292
|
+
const detected = detectInstalledClients(cwd);
|
|
3293
|
+
const preselected = detected.length > 0 ? detected : [DEFAULT_CLIENT_KEY];
|
|
3294
|
+
if (!canPrompt() || yes) return preselected;
|
|
3295
|
+
const picks = await promptMultiSelect(
|
|
3296
|
+
"Which AI clients should I wire?",
|
|
3297
|
+
ALL_CLIENT_KEYS.map((k) => ({
|
|
3298
|
+
label: k,
|
|
3299
|
+
value: k,
|
|
3300
|
+
hint: detected.includes(k) ? "detected" : void 0
|
|
3301
|
+
})),
|
|
3302
|
+
preselected
|
|
3303
|
+
);
|
|
3304
|
+
return picks.length > 0 ? picks : preselected;
|
|
3305
|
+
}
|
|
3306
|
+
async function planRecurseChild(entry, root, client, opts) {
|
|
3307
|
+
const dir = resolveChildDir(entry.path, root);
|
|
3308
|
+
if (!existsSync7(dir)) {
|
|
3309
|
+
return { label: entry.path, dir, disposition: "skip-missing", argv: [], reason: "directory does not exist" };
|
|
3310
|
+
}
|
|
3311
|
+
if (existsSync7(join7(dir, ".sechroom.json"))) {
|
|
3312
|
+
return {
|
|
3313
|
+
label: entry.path,
|
|
3314
|
+
dir,
|
|
3315
|
+
disposition: "refresh",
|
|
3316
|
+
argv: ["onboard", "--refresh", "--yes"],
|
|
3317
|
+
reason: "bound (committed .sechroom.json) \u2014 refresh in place"
|
|
3318
|
+
};
|
|
3319
|
+
}
|
|
3320
|
+
if (entry.workspaceId) {
|
|
3321
|
+
return {
|
|
3322
|
+
label: entry.path,
|
|
3323
|
+
dir,
|
|
3324
|
+
disposition: "bind",
|
|
3325
|
+
argv: ["onboard", "--yes", "--local", "--workspace", entry.workspaceId],
|
|
3326
|
+
reason: `unbound \u2014 bind to ${entry.workspaceId}`
|
|
3327
|
+
};
|
|
3328
|
+
}
|
|
3329
|
+
if (opts.dryRun) {
|
|
3330
|
+
return { label: entry.path, dir, disposition: "bind", argv: ["onboard", "--yes", "--local", "--workspace", "<prompt>"], reason: "unbound \u2014 would prompt for a workspace" };
|
|
3331
|
+
}
|
|
3332
|
+
if (opts.yes || !canPrompt()) {
|
|
3333
|
+
return { label: entry.path, dir, disposition: "skip-unbound", argv: [], reason: "unbound + no workspace (run interactively, or add it to ./.sechroom/repos.json)" };
|
|
3334
|
+
}
|
|
3335
|
+
process.stderr.write(`
|
|
3336
|
+
${style.bold(entry.path)} ${style.dim("is not bound yet.")}
|
|
3337
|
+
`);
|
|
3338
|
+
const ws = await pickWorkspace(client, `Bind ${style.cyan(entry.path)} to a workspace:`);
|
|
3339
|
+
if (!ws) {
|
|
3340
|
+
return { label: entry.path, dir, disposition: "skip-unbound", argv: [], reason: "unbound \u2014 no workspace chosen (skipped)" };
|
|
3341
|
+
}
|
|
586
3342
|
return {
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
},
|
|
593
|
-
"claude-desktop": {
|
|
594
|
-
key: "claude-desktop",
|
|
595
|
-
label: "Claude Desktop",
|
|
596
|
-
mcp: { surfaceKey: "claude-desktop", sectionType: SectionType.McpConfig, path: claudeDesktopConfigPath(home), format: "json" },
|
|
597
|
-
instruction: { surfaceKey: "claude-desktop", path: join2(home, ".claude", "CLAUDE.md") }
|
|
598
|
-
},
|
|
599
|
-
codex: {
|
|
600
|
-
key: "codex",
|
|
601
|
-
label: "Codex CLI",
|
|
602
|
-
mcp: { surfaceKey: "chatgpt", sectionType: SectionType.McpConfigToml, path: join2(home, ".codex", "config.toml"), format: "toml" },
|
|
603
|
-
instruction: { surfaceKey: "chatgpt", path: join2(cwd, "AGENTS.md") }
|
|
604
|
-
},
|
|
605
|
-
cursor: {
|
|
606
|
-
key: "cursor",
|
|
607
|
-
label: "Cursor",
|
|
608
|
-
mcp: { surfaceKey: "claude-code", sectionType: SectionType.McpConfig, path: join2(cwd, ".cursor", "mcp.json"), format: "json" },
|
|
609
|
-
instruction: { surfaceKey: "chatgpt", path: join2(cwd, "AGENTS.md") }
|
|
610
|
-
}
|
|
3343
|
+
label: entry.path,
|
|
3344
|
+
dir,
|
|
3345
|
+
disposition: "bind",
|
|
3346
|
+
argv: ["onboard", "--yes", "--local", "--workspace", ws],
|
|
3347
|
+
reason: `unbound \u2014 bind to ${ws}`
|
|
611
3348
|
};
|
|
612
3349
|
}
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
3350
|
+
async function resolveFanoutLane(cfg, opts) {
|
|
3351
|
+
let code = opts.lane ?? process.env.SECHROOM_CODE_LANE;
|
|
3352
|
+
let design = opts.designLane ?? process.env.SECHROOM_DESIGN_LANE;
|
|
3353
|
+
if (!code || !design) {
|
|
3354
|
+
const clients = detectInstalledClients(process.cwd());
|
|
3355
|
+
const inferred = await inferLanes(cfg, clients.length ? clients : void 0);
|
|
3356
|
+
code = code ?? inferred.code;
|
|
3357
|
+
design = design ?? inferred.design;
|
|
3358
|
+
}
|
|
3359
|
+
if (!opts.lane && !opts.yes && !opts.dryRun && canPrompt() && (code || design)) {
|
|
3360
|
+
process.stderr.write(`
|
|
3361
|
+
This fan-out will pin the same lane in every repo:
|
|
3362
|
+
`);
|
|
3363
|
+
if (code) process.stderr.write(` ${style.dim("code-lane")} = ${style.cyan(code)}
|
|
3364
|
+
`);
|
|
3365
|
+
if (design) process.stderr.write(` ${style.dim("design-lane")} = ${style.cyan(design)}
|
|
3366
|
+
`);
|
|
3367
|
+
if (!await promptYesNo("Use this lane for all repos?")) {
|
|
3368
|
+
code = await promptText("Code-lane id (blank = let each repo infer)?", code ?? "") || void 0;
|
|
3369
|
+
design = await promptText("Design-lane id (blank = skip)?", design ?? "") || void 0;
|
|
624
3370
|
}
|
|
625
3371
|
}
|
|
626
|
-
|
|
3372
|
+
if (code) process.env.SECHROOM_CODE_LANE = code;
|
|
3373
|
+
else delete process.env.SECHROOM_CODE_LANE;
|
|
3374
|
+
if (design) process.env.SECHROOM_DESIGN_LANE = design;
|
|
3375
|
+
else delete process.env.SECHROOM_DESIGN_LANE;
|
|
3376
|
+
return { code, design };
|
|
627
3377
|
}
|
|
628
|
-
function
|
|
629
|
-
|
|
630
|
-
|
|
3378
|
+
async function runRecurse(cfg, g, opts) {
|
|
3379
|
+
const { yes, dryRun, json } = opts;
|
|
3380
|
+
const root = process.cwd();
|
|
3381
|
+
const manifestPath = join7(root, ".sechroom", "repos.json");
|
|
3382
|
+
const fromManifest = readManifest(manifestPath);
|
|
3383
|
+
const entries = fromManifest ?? discoverChildren(root).map((path) => ({ path }));
|
|
3384
|
+
const sourceLabel = fromManifest ? `manifest ${manifestPath}` : `auto-discovered under ${root}`;
|
|
3385
|
+
if (entries.length === 0) {
|
|
3386
|
+
if (json) process.stdout.write(JSON.stringify({ recurse: true, root, repos: [] }) + "\n");
|
|
3387
|
+
else process.stderr.write(`${warn("\u26A0")} no child repos found ${fromManifest ? `in ${manifestPath}` : `under ${root}`} \u2014 nothing to do.
|
|
631
3388
|
`);
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
3389
|
+
return;
|
|
3390
|
+
}
|
|
3391
|
+
if (!json) {
|
|
3392
|
+
process.stderr.write(`${style.bold("onboard --recurse")} ${style.dim(`(${entries.length} repo${entries.length === 1 ? "" : "s"} from ${sourceLabel})`)}
|
|
635
3393
|
`);
|
|
636
3394
|
}
|
|
3395
|
+
const lane = await resolveFanoutLane(cfg, { lane: opts.lane, designLane: opts.designLane, yes, dryRun });
|
|
3396
|
+
if (!json && lane.code) process.stderr.write(`${ok("\u2713")} lane ${style.cyan(lane.code)}${lane.design ? ` ${style.dim(`/ ${lane.design}`)}` : ""} for every repo
|
|
3397
|
+
`);
|
|
3398
|
+
const client = await makeClient(cfg);
|
|
3399
|
+
const plans = [];
|
|
3400
|
+
for (const entry of entries) plans.push(await planRecurseChild(entry, root, client, { yes, dryRun }));
|
|
3401
|
+
const results = runChildren(plans, { globals: passthroughGlobals(g), dryRun, json });
|
|
3402
|
+
if (json) {
|
|
3403
|
+
process.stdout.write(JSON.stringify({ recurse: true, root, dryRun, repos: results }) + "\n");
|
|
3404
|
+
return;
|
|
3405
|
+
}
|
|
3406
|
+
summarizeFanout(results, { dryRun });
|
|
637
3407
|
}
|
|
638
|
-
function
|
|
639
|
-
program2.command("
|
|
640
|
-
|
|
3408
|
+
function registerOnboard(program2) {
|
|
3409
|
+
program2.command("onboard").description("Guided first-run setup: configure, sign in, set timezone, detect clients, and wire this project").option("--recurse", "orchestration-root mode: onboard every child repo under this dir (auto-discovered, or from ./.sechroom/repos.json) \u2014 refreshes bound repos, prompts a workspace per new one", false).option("--lane <id>", "set the code-lane (substrate source identity) explicitly instead of inferring it; with --recurse it's used for every child repo").option("--design-lane <id>", "set the design-lane explicitly (substrate-authoring identity); with --recurse applies to every child").option("--client <list>", `comma-separated clients (${ALL_CLIENT_KEYS.join(", ")}) or 'all' (default: auto-detected)`).option("--local", "save the binding (tenant + base URL + workspace) to a committed .sechroom.json in this repo instead of the global config", false).option("--workspace <id>", "bind this directory to a workspace (skips the interactive workspace pick)").option("--cli-only", "configure the CLI only \u2014 don't wire any AI client (no MCP config, no agent files)", false).option("--no-mcp", "skip the MCP server config (.mcp.json etc.); still write the agent instruction files").option("--copy", "make a personal copy of the agent instructions you can edit (default: prompt on a TTY, else skip)").option("--dry-run", "walk through without writing files or changing the profile", false).option("--refresh", "re-fetch descriptors and refresh any out-of-date managed blocks (local edits preserved to .proposed)", false).option("--force", "rewrite every managed block, overwriting local edits inside the markers (content outside untouched)", false).option("--check", "report whether anything would change and exit (0 = all current, 1 = stale/drift/absent); writes nothing", false).option("-y, --yes", "non-interactive: accept defaults (system timezone, detected clients, global config, full wire)", false).addHelpText(
|
|
3410
|
+
"after",
|
|
3411
|
+
`
|
|
3412
|
+
Examples:
|
|
3413
|
+
$ sechroom onboard guided, interactive (asks where to save config + how to wire)
|
|
3414
|
+
$ sechroom onboard --cli-only just the CLI \u2014 no .mcp.json, no agent files
|
|
3415
|
+
$ sechroom onboard --no-mcp agent instructions only, skip MCP config
|
|
3416
|
+
$ sechroom onboard --local save tenant + base URL to a committed ./.sechroom.json
|
|
3417
|
+
$ sechroom onboard --workspace wsp_XX bind this directory to a workspace (no pick prompt)
|
|
3418
|
+
$ sechroom onboard --recurse orchestration root: onboard every child repo under this dir
|
|
3419
|
+
$ sechroom onboard --recurse --lane claude-code-you pin one lane across every repo in the tree
|
|
3420
|
+
$ sechroom onboard --refresh refresh out-of-date instruction blocks in place
|
|
3421
|
+
$ sechroom onboard --check CI/pre-commit: nonzero exit if instructions are out of date
|
|
3422
|
+
$ sechroom onboard --yes non-interactive: defaults + global config + full wire
|
|
3423
|
+
$ sechroom onboard --client all --dry-run preview wiring every client, write nothing`
|
|
3424
|
+
).action(async (opts, cmd) => {
|
|
3425
|
+
const g = cmd.optsWithGlobals();
|
|
3426
|
+
const json = Boolean(g.json);
|
|
3427
|
+
const dryRun = Boolean(opts.dryRun);
|
|
3428
|
+
const mode = opts.check ? "check" : opts.force ? "force" : "apply";
|
|
3429
|
+
const check = mode === "check";
|
|
3430
|
+
const yes = Boolean(opts.yes) || check;
|
|
3431
|
+
if (opts.lane) process.env.SECHROOM_CODE_LANE = opts.lane;
|
|
3432
|
+
if (opts.designLane) process.env.SECHROOM_DESIGN_LANE = opts.designLane;
|
|
3433
|
+
if (opts.recurse) {
|
|
3434
|
+
const baseUrl2 = resolveBaseUrl(g);
|
|
3435
|
+
await ensureAuth({ baseUrl: baseUrl2, tenant: "", clientId: readPersisted().clientId }, yes);
|
|
3436
|
+
const cfg2 = await ensureTenant(baseUrl2, g, { yes: true, json, persist: false });
|
|
3437
|
+
await runRecurse(cfg2, g, { yes, dryRun, json, lane: opts.lane, designLane: opts.designLane });
|
|
3438
|
+
return;
|
|
3439
|
+
}
|
|
3440
|
+
const baseUrl = resolveBaseUrl(g);
|
|
3441
|
+
await ensureAuth({ baseUrl, tenant: "", clientId: readPersisted().clientId }, yes);
|
|
3442
|
+
const cfg = await ensureTenant(baseUrl, g, { yes, json, local: Boolean(opts.local), workspace: opts.workspace, persist: !check });
|
|
3443
|
+
const tz = await ensureTimezone(cfg, { yes, dryRun: dryRun || check });
|
|
3444
|
+
if (!json && tz.action !== "already-set") {
|
|
3445
|
+
const line = tz.action === "set" ? `${ok("\u2713")} timezone set to ${tz.timezone}
|
|
3446
|
+
` : tz.action === "dry-run" ? `(dry run \u2014 would set timezone to ${tz.timezone})
|
|
3447
|
+
` : `timezone not set \u2014 ${tz.note}
|
|
3448
|
+
`;
|
|
3449
|
+
process.stderr.write(line);
|
|
3450
|
+
}
|
|
3451
|
+
const wire = await chooseWire(opts, yes);
|
|
3452
|
+
if (wire === "cli-only") {
|
|
3453
|
+
if (json) {
|
|
3454
|
+
emit({ dryRun, baseUrl: cfg.baseUrl, tenant: cfg.tenant, workspaceId: cfg.workspaceId ?? null, timezone: tz, wire, clients: [] }, true);
|
|
3455
|
+
return;
|
|
3456
|
+
}
|
|
3457
|
+
if (!dryRun) {
|
|
3458
|
+
await ensureLanePin(cfg, { yes, dryRun, clients: detectInstalledClients(process.cwd()) });
|
|
3459
|
+
await maybeOfferHooks({ yes, dryRun, cwd: process.cwd() });
|
|
3460
|
+
}
|
|
3461
|
+
process.stdout.write(
|
|
3462
|
+
`
|
|
3463
|
+
${style.bold("Done.")} The CLI is configured for ${style.cyan(cfg.tenant)} \u2014 no AI-client files written.
|
|
3464
|
+
Try: ${style.cyan('sechroom memory search "..."')} or ${style.cyan("sechroom --help")}
|
|
3465
|
+
`
|
|
3466
|
+
);
|
|
3467
|
+
await printStarterPrompt("cli");
|
|
3468
|
+
return;
|
|
3469
|
+
}
|
|
3470
|
+
const keys = await chooseClients(opts.client, yes, process.cwd());
|
|
641
3471
|
const setup = await withSpinner("Fetching setup descriptors", () => fetchSetup(cfg));
|
|
642
3472
|
const targets = clientTargets(process.cwd());
|
|
643
|
-
const
|
|
644
|
-
|
|
3473
|
+
const personalWorkspaceId = await getPersonalWorkspaceId(cfg);
|
|
3474
|
+
if (!dryRun && !check) {
|
|
3475
|
+
await maybeOfferCopies(cfg, setup, targets, keys, personalWorkspaceId, copyChoice(opts));
|
|
3476
|
+
}
|
|
3477
|
+
const writeMcp = wire === "full";
|
|
645
3478
|
const result = [];
|
|
646
3479
|
for (const key of keys) {
|
|
647
3480
|
const target = targets[key];
|
|
648
3481
|
const actions = await applyClient(cfg, setup, target, {
|
|
649
|
-
dryRun
|
|
650
|
-
mcp:
|
|
651
|
-
agentFiles:
|
|
3482
|
+
dryRun,
|
|
3483
|
+
mcp: writeMcp,
|
|
3484
|
+
agentFiles: true,
|
|
3485
|
+
personalWorkspaceId,
|
|
3486
|
+
mode
|
|
652
3487
|
});
|
|
653
3488
|
result.push({ client: key, actions });
|
|
654
|
-
if (!json) printActions(target, actions);
|
|
3489
|
+
if (!json && !check) printActions(target, actions);
|
|
3490
|
+
}
|
|
3491
|
+
const evalCounts = { current: 0, stale: 0, drift: 0, absent: 0 };
|
|
3492
|
+
for (const { actions } of result) for (const a of actions) if (a.eval) evalCounts[a.eval]++;
|
|
3493
|
+
const wouldChange = evalCounts.stale + evalCounts.drift + evalCounts.absent;
|
|
3494
|
+
if (check) {
|
|
3495
|
+
if (json) {
|
|
3496
|
+
emit({ check: true, baseUrl: cfg.baseUrl, tenant: cfg.tenant, workspaceId: cfg.workspaceId ?? null, eval: evalCounts, wouldChange, clients: result }, true);
|
|
3497
|
+
} else if (wouldChange === 0) {
|
|
3498
|
+
process.stdout.write(`${ok("\u2713")} all instruction blocks are up to date.
|
|
3499
|
+
`);
|
|
3500
|
+
} else {
|
|
3501
|
+
const bits = [];
|
|
3502
|
+
if (evalCounts.stale) bits.push(`${evalCounts.stale} out of date`);
|
|
3503
|
+
if (evalCounts.drift) bits.push(`${evalCounts.drift} with local edits`);
|
|
3504
|
+
if (evalCounts.absent) bits.push(`${evalCounts.absent} not yet written`);
|
|
3505
|
+
process.stderr.write(
|
|
3506
|
+
`${warn("\u26A0")} ${wouldChange} instruction block(s) would change: ${bits.join(", ")}. Run ${style.cyan("sechroom onboard --refresh")}.
|
|
3507
|
+
`
|
|
3508
|
+
);
|
|
3509
|
+
}
|
|
3510
|
+
process.exit(wouldChange === 0 ? 0 : 1);
|
|
3511
|
+
}
|
|
3512
|
+
if (!json && !dryRun) {
|
|
3513
|
+
await ensureLanePin(cfg, { yes, dryRun, clients: keys });
|
|
3514
|
+
}
|
|
3515
|
+
if (!json && !dryRun) {
|
|
3516
|
+
await maybeOfferSkills(cfg, personalWorkspaceId, { yes, dryRun, surface: "claude-code" });
|
|
3517
|
+
}
|
|
3518
|
+
if (!json && !dryRun) {
|
|
3519
|
+
await maybeOfferHooks({ yes, dryRun, cwd: process.cwd() });
|
|
655
3520
|
}
|
|
656
3521
|
if (json) {
|
|
657
|
-
emit({ dryRun:
|
|
3522
|
+
emit({ dryRun, baseUrl: cfg.baseUrl, tenant: cfg.tenant, workspaceId: cfg.workspaceId ?? null, timezone: tz, wire, eval: evalCounts, clients: result }, true);
|
|
658
3523
|
return;
|
|
659
3524
|
}
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
const verify = findSection(surface, SectionType.Verify);
|
|
663
|
-
if (verify?.description) {
|
|
664
|
-
process.stdout.write(`
|
|
665
|
-
Next \u2014 verify: ${verify.description}
|
|
3525
|
+
if (!dryRun && evalCounts.stale) {
|
|
3526
|
+
process.stderr.write(`${style.cyan("\u21BB")} refreshed ${evalCounts.stale} section(s) the server had moved
|
|
666
3527
|
`);
|
|
667
3528
|
}
|
|
3529
|
+
if (!dryRun && evalCounts.drift) {
|
|
3530
|
+
process.stderr.write(
|
|
3531
|
+
mode === "force" ? `${warn("\u26A0")} overwrote ${evalCounts.drift} section(s) that had local edits (--force)
|
|
3532
|
+
` : `${warn("\u26A0")} ${evalCounts.drift} section(s) have local edits \u2014 wrote a .proposed file alongside (original untouched). Review + merge, or re-run with ${style.cyan("--force")}.
|
|
3533
|
+
`
|
|
3534
|
+
);
|
|
3535
|
+
}
|
|
3536
|
+
const wroteSomething = result.some(({ actions }) => actions.some((a) => a.status === "created" || a.status === "merged"));
|
|
668
3537
|
process.stdout.write(
|
|
669
|
-
|
|
3538
|
+
dryRun ? "\n(dry run \u2014 nothing written)\n" : !wroteSomething ? `
|
|
3539
|
+
${style.bold("Done.")} Everything's already up to date.
|
|
3540
|
+
` : writeMcp ? `
|
|
3541
|
+
${style.bold("Done.")} Restart your AI client (or reload MCP) to pick up the new config.
|
|
3542
|
+
` : `
|
|
3543
|
+
${style.bold("Done.")} Agent instructions written (no MCP config).
|
|
3544
|
+
`
|
|
670
3545
|
);
|
|
3546
|
+
if (!dryRun) await printStarterPrompt("agent", cfg);
|
|
671
3547
|
});
|
|
672
3548
|
}
|
|
673
|
-
function
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
3549
|
+
async function chooseWire(opts, yes) {
|
|
3550
|
+
if (opts.cliOnly) return "cli-only";
|
|
3551
|
+
if (canPrompt() && !yes) {
|
|
3552
|
+
return promptSelect(
|
|
3553
|
+
"How should I set up Sechroom in this project?",
|
|
3554
|
+
[
|
|
3555
|
+
{ label: "Wire my AI client", value: "full", hint: "MCP server (.mcp.json) + agent instructions" },
|
|
3556
|
+
{ label: "Agent instructions only", value: "agent-only", hint: "skip MCP config" },
|
|
3557
|
+
{ label: "CLI only", value: "cli-only", hint: "don't write any AI-client files" }
|
|
3558
|
+
],
|
|
3559
|
+
"full"
|
|
3560
|
+
);
|
|
3561
|
+
}
|
|
3562
|
+
return opts.mcp === false ? "agent-only" : "full";
|
|
3563
|
+
}
|
|
3564
|
+
var FALLBACK_AGENT_PROMPT = "Resume my sechroom continuity, summarise what I was last working on, then suggest the next step.";
|
|
3565
|
+
async function printStarterPrompt(mode, cfg) {
|
|
3566
|
+
if (mode === "cli") {
|
|
3567
|
+
process.stdout.write(
|
|
3568
|
+
`
|
|
3569
|
+
${style.bold("Next:")} pick up where you left off \u2014
|
|
3570
|
+
${style.cyan("sechroom continuity resume-me")}
|
|
3571
|
+
`
|
|
3572
|
+
);
|
|
3573
|
+
return;
|
|
3574
|
+
}
|
|
3575
|
+
let primary = FALLBACK_AGENT_PROMPT;
|
|
3576
|
+
if (cfg) {
|
|
3577
|
+
try {
|
|
3578
|
+
const client = await makeClient(cfg);
|
|
3579
|
+
const { data } = await client.GET("/me/onboarding/starter-prompt", {});
|
|
3580
|
+
if (data?.primary) primary = data.primary;
|
|
3581
|
+
} catch {
|
|
3582
|
+
}
|
|
3583
|
+
}
|
|
3584
|
+
process.stdout.write(
|
|
3585
|
+
`
|
|
3586
|
+
${style.bold("Next:")} paste this into your AI agent to get going \u2014
|
|
3587
|
+
${style.cyan(`"${primary}"`)}
|
|
3588
|
+
`
|
|
3589
|
+
);
|
|
3590
|
+
}
|
|
3591
|
+
|
|
3592
|
+
// src/commands/sweep.ts
|
|
3593
|
+
import { existsSync as existsSync8 } from "fs";
|
|
3594
|
+
import { dirname as dirname6, join as join8, resolve as resolve2 } from "path";
|
|
3595
|
+
var DEFAULT_MANIFEST = join8(".sechroom", "repos.json");
|
|
3596
|
+
function planEntry(entry, root) {
|
|
3597
|
+
const dir = resolveChildDir(entry.path, root);
|
|
3598
|
+
if (!existsSync8(dir)) {
|
|
3599
|
+
return { label: entry.path, dir, disposition: "skip-missing", argv: [], reason: "directory does not exist" };
|
|
3600
|
+
}
|
|
3601
|
+
if (committedBindingPath(dir)) {
|
|
3602
|
+
return {
|
|
3603
|
+
label: entry.path,
|
|
3604
|
+
dir,
|
|
3605
|
+
disposition: "refresh",
|
|
3606
|
+
argv: ["onboard", "--refresh", "--yes"],
|
|
3607
|
+
reason: "bound (committed .sechroom.json) \u2014 refresh in place"
|
|
3608
|
+
};
|
|
3609
|
+
}
|
|
3610
|
+
if (entry.workspaceId) {
|
|
3611
|
+
return {
|
|
3612
|
+
label: entry.path,
|
|
3613
|
+
dir,
|
|
3614
|
+
disposition: "bind",
|
|
3615
|
+
argv: ["onboard", "--yes", "--local", "--workspace", entry.workspaceId],
|
|
3616
|
+
reason: `unbound \u2014 bind to ${entry.workspaceId} + commit .sechroom.json`
|
|
3617
|
+
};
|
|
3618
|
+
}
|
|
3619
|
+
return {
|
|
3620
|
+
label: entry.path,
|
|
3621
|
+
dir,
|
|
3622
|
+
disposition: "skip-unbound",
|
|
3623
|
+
argv: [],
|
|
3624
|
+
reason: "unbound + no workspaceId in the manifest \u2014 add one or run `sechroom onboard` there"
|
|
3625
|
+
};
|
|
3626
|
+
}
|
|
3627
|
+
function registerSweep(program2) {
|
|
3628
|
+
program2.command("sweep").description("Non-interactive fan-out from ./.sechroom/repos.json (headless sibling of `onboard --recurse`)").option("--manifest <path>", "path to the repos manifest", DEFAULT_MANIFEST).option("--dry-run", "print the plan (per-repo disposition + the onboard command) without running anything", false).addHelpText(
|
|
3629
|
+
"after",
|
|
3630
|
+
`
|
|
3631
|
+
For an interactive, no-manifest run use ${"`sechroom onboard --recurse`"} instead \u2014 it
|
|
3632
|
+
auto-discovers the child repos and prompts for a workspace per new one. ${"`sweep`"} is
|
|
3633
|
+
the deterministic manifest-driven form for scripts / CI.
|
|
3634
|
+
|
|
3635
|
+
Manifest \u2014 ./.sechroom/repos.json (per-operator, gitignored, alongside lane.json):
|
|
3636
|
+
{
|
|
3637
|
+
"repos": [
|
|
3638
|
+
{ "path": "sechroom", "workspaceId": "wsp_XXXX" },
|
|
3639
|
+
{ "path": "../other-repo", "workspaceId": "wsp_YYYY" },
|
|
3640
|
+
{ "path": "already-bound" }
|
|
3641
|
+
]
|
|
3642
|
+
}
|
|
3643
|
+
|
|
3644
|
+
Per repo (paths resolve relative to the manifest's root):
|
|
3645
|
+
${ICON.refresh} bound committed .sechroom.json present \u2192 onboard --refresh (manifest workspace ignored)
|
|
3646
|
+
${ICON.bind} unbound bind to the manifest workspaceId \u2192 onboard --local --workspace <id>
|
|
3647
|
+
${ICON["skip-missing"]} missing directory does not exist \u2192 skipped
|
|
3648
|
+
${ICON["skip-unbound"]} no workspace unbound + no workspaceId in manifest \u2192 skipped (add one, or onboard manually)
|
|
3649
|
+
|
|
3650
|
+
Examples:
|
|
3651
|
+
$ sechroom sweep --dry-run preview every repo's disposition, run nothing
|
|
3652
|
+
$ sechroom sweep onboard the whole tree from the root
|
|
3653
|
+
$ sechroom --tenant ocd sweep force a tenant for every child (else each resolves its own)`
|
|
3654
|
+
).action((opts, cmd) => {
|
|
3655
|
+
const g = cmd.optsWithGlobals();
|
|
3656
|
+
const json = Boolean(g.json);
|
|
3657
|
+
const dryRun = Boolean(opts.dryRun);
|
|
3658
|
+
const manifestPath = resolve2(opts.manifest);
|
|
3659
|
+
let repos;
|
|
3660
|
+
try {
|
|
3661
|
+
repos = readManifest(manifestPath);
|
|
3662
|
+
} catch (err2) {
|
|
3663
|
+
fail(err2 instanceof Error ? err2.message : String(err2));
|
|
3664
|
+
}
|
|
3665
|
+
if (repos === null) {
|
|
3666
|
+
fail(`no manifest at ${manifestPath} \u2014 create ./.sechroom/repos.json, or use \`sechroom onboard --recurse\` to auto-discover (see \`sechroom sweep --help\`).`);
|
|
3667
|
+
}
|
|
3668
|
+
if (repos.length === 0) {
|
|
3669
|
+
if (json) process.stdout.write(JSON.stringify({ manifest: manifestPath, repos: [] }) + "\n");
|
|
3670
|
+
else process.stderr.write(`${warn("\u26A0")} ${manifestPath} lists no repos \u2014 nothing to do.
|
|
3671
|
+
`);
|
|
3672
|
+
return;
|
|
3673
|
+
}
|
|
3674
|
+
const root = dirname6(dirname6(manifestPath));
|
|
3675
|
+
const plans = repos.map((entry) => planEntry(entry, root));
|
|
3676
|
+
if (!json) {
|
|
3677
|
+
process.stderr.write(
|
|
3678
|
+
`${style.bold("sweep")} ${style.dim(`(${plans.length} repo${plans.length === 1 ? "" : "s"} from ${manifestPath})`)}
|
|
3679
|
+
`
|
|
3680
|
+
);
|
|
3681
|
+
}
|
|
3682
|
+
const results = runChildren(plans, { globals: passthroughGlobals(g), dryRun, json });
|
|
3683
|
+
if (json) {
|
|
3684
|
+
process.stdout.write(JSON.stringify({ manifest: manifestPath, dryRun, repos: results }) + "\n");
|
|
3685
|
+
return;
|
|
3686
|
+
}
|
|
3687
|
+
summarizeFanout(results, { dryRun });
|
|
3688
|
+
});
|
|
3689
|
+
}
|
|
3690
|
+
|
|
3691
|
+
// src/commands/skills.ts
|
|
3692
|
+
import { homedir as homedir6 } from "os";
|
|
3693
|
+
import { join as join9 } from "path";
|
|
3694
|
+
import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync6, rmSync as rmSync2, existsSync as existsSync9, readFileSync as readFileSync6 } from "fs";
|
|
3695
|
+
var DEFAULT_SLUG = "operator-skills";
|
|
3696
|
+
var ROLE_TAGS = ["sechroom:role:skill-template", "role:skill-template"];
|
|
3697
|
+
var LOCK = ".sechroom-skills.json";
|
|
3698
|
+
function skillsDir(global) {
|
|
3699
|
+
return global ? join9(homedir6(), ".claude", "skills") : join9(process.cwd(), ".claude", "skills");
|
|
3700
|
+
}
|
|
3701
|
+
function tagValue2(tags, prefix) {
|
|
3702
|
+
return (tags ?? []).find((t) => t.startsWith(prefix))?.slice(prefix.length);
|
|
3703
|
+
}
|
|
3704
|
+
function hasAny(tags, candidates) {
|
|
3705
|
+
return (tags ?? []).some((t) => candidates.includes(t));
|
|
3706
|
+
}
|
|
3707
|
+
function registerSkills(program2) {
|
|
3708
|
+
const skills = program2.command("skills").description("Install + manage operator skills from a bundle");
|
|
3709
|
+
skills.addHelpText(
|
|
3710
|
+
"after",
|
|
3711
|
+
`
|
|
3712
|
+
Examples:
|
|
3713
|
+
$ sechroom skills install --code-lane claude-code-chris --design-lane claude-design-chris
|
|
3714
|
+
$ sechroom skills install operator-skills --surface claude-code --local
|
|
3715
|
+
$ sechroom skills list
|
|
3716
|
+
$ sechroom skills set-lane --code-lane claude-code-chris --design-lane claude-design-chris
|
|
3717
|
+
$ sechroom skills lane
|
|
3718
|
+
$ sechroom skills clean`
|
|
3719
|
+
);
|
|
3720
|
+
skills.command("install [slug]").description(`Install a skills bundle (default ${DEFAULT_SLUG}) into your personal workspace + write SKILL.md files`).option("--version <v>", "bundle version (default: latest published in the catalogue)").option("--instance <name>", "install as a named, separate instance (install the same bundle more than once)").option("--code-lane <id>", "identity.code-lane binding (e.g. claude-code-chris)").option("--design-lane <id>", "identity.design-lane binding (e.g. claude-design-chris)").option("--surface <s>", "skill target surface to materialise", "claude-code").option("--local", "write to ./.claude/skills instead of ~/.claude/skills").option("--json", "machine output").action(async (slugArg, opts, cmd) => {
|
|
3721
|
+
const slug = slugArg || DEFAULT_SLUG;
|
|
3722
|
+
const client = await makeClient(resolveConfig(cmd.optsWithGlobals()));
|
|
3723
|
+
const pw = await runApi("resolving personal workspace", () => client.GET("/me/personal-workspace", {}));
|
|
3724
|
+
const personalWsId = pw?.id || pw?.workspaceId || pw?.personalWorkspaceId || pw?.item?.id;
|
|
3725
|
+
if (!personalWsId) fail("Could not resolve your personal workspace.");
|
|
3726
|
+
let version = opts.version;
|
|
3727
|
+
if (!version) {
|
|
3728
|
+
const cat = await runApi("reading the bundle catalogue", () => client.GET("/me/bundles", {}));
|
|
3729
|
+
const item = (cat?.bundles ?? cat?.Bundles ?? []).find((b) => (b.slug ?? b.Slug) === slug);
|
|
3730
|
+
if (!item) fail(`Bundle '${slug}' is not in your self-serve catalogue (must be UserInstallable + Published).`);
|
|
3731
|
+
version = item.latestVersion ?? item.LatestVersion;
|
|
3732
|
+
if (!version) fail(`Bundle '${slug}' has no installable (Published) version.`);
|
|
3733
|
+
}
|
|
3734
|
+
const installOptions = {};
|
|
3735
|
+
if (opts.codeLane) installOptions["identity.code-lane"] = opts.codeLane;
|
|
3736
|
+
if (opts.designLane) installOptions["identity.design-lane"] = opts.designLane;
|
|
3737
|
+
const res = await runApi(
|
|
3738
|
+
`installing ${slug}@${version}${opts.instance ? ` (${opts.instance})` : ""}`,
|
|
3739
|
+
() => client.POST("/me/bundles/{slug}/versions/{version}/install", {
|
|
3740
|
+
params: { path: { slug, version } },
|
|
3741
|
+
// instance: null/absent = the default instance (reinstall updates in
|
|
3742
|
+
// place); a name installs a separate instance.
|
|
3743
|
+
body: { installOptions, instance: opts.instance ?? null }
|
|
3744
|
+
})
|
|
3745
|
+
);
|
|
3746
|
+
const status = String(res?.status ?? res?.Status ?? "");
|
|
3747
|
+
if (status && status.toLowerCase() !== "completed") {
|
|
3748
|
+
fail(`Install did not complete (status=${status}; ${res?.failureReason ?? res?.FailureReason ?? ""}).`);
|
|
3749
|
+
}
|
|
3750
|
+
const feed = await runApi(
|
|
3751
|
+
"materialising skill files",
|
|
3752
|
+
() => client.GET("/workspaces/{workspaceId}/memories/feed", {
|
|
3753
|
+
// cascadeWorkspaces: skills land in an "Operator Skills" SUB-workspace of
|
|
3754
|
+
// the personal workspace, so we recurse from the personal-ws root.
|
|
3755
|
+
// includeText: the feed omits bodies by default; we need them for SKILL.md.
|
|
3756
|
+
params: {
|
|
3757
|
+
path: { workspaceId: personalWsId },
|
|
3758
|
+
query: { limit: 200, cascadeWorkspaces: true, includeText: true }
|
|
3759
|
+
}
|
|
3760
|
+
})
|
|
3761
|
+
);
|
|
3762
|
+
const rows = feed?.results ?? feed?.Results ?? [];
|
|
3763
|
+
const dir = skillsDir(!opts.local);
|
|
3764
|
+
const wantInstance = opts.instance || "default";
|
|
3765
|
+
const written = [];
|
|
3766
|
+
const bundleTagPrefix = `sechroom:bundle:${slug}@`;
|
|
3767
|
+
for (const r of rows) {
|
|
3768
|
+
const m = r.item ?? r;
|
|
3769
|
+
const tags = m.tags ?? m.Tags ?? [];
|
|
3770
|
+
if (!hasAny(tags, ROLE_TAGS)) continue;
|
|
3771
|
+
if (tagValue2(tags, "target:") !== opts.surface) continue;
|
|
3772
|
+
if (!tags.some((t) => t.startsWith(bundleTagPrefix))) continue;
|
|
3773
|
+
if ((tagValue2(tags, "sechroom:skill-instance:") ?? "default") !== wantInstance) continue;
|
|
3774
|
+
const name = tagValue2(tags, "skill:");
|
|
3775
|
+
if (!name) continue;
|
|
3776
|
+
const body = m.text ?? m.Text ?? "";
|
|
3777
|
+
mkdirSync6(join9(dir, name), { recursive: true });
|
|
3778
|
+
writeFileSync6(join9(dir, name, "SKILL.md"), body.endsWith("\n") ? body : body + "\n");
|
|
3779
|
+
written.push(name);
|
|
3780
|
+
}
|
|
3781
|
+
mkdirSync6(dir, { recursive: true });
|
|
3782
|
+
const lockPath = join9(dir, LOCK);
|
|
3783
|
+
const lock = existsSync9(lockPath) ? JSON.parse(readFileSync6(lockPath, "utf8")) : {};
|
|
3784
|
+
lock[slug] = { surface: opts.surface, version, instance: wantInstance, skills: written.sort() };
|
|
3785
|
+
writeFileSync6(lockPath, JSON.stringify(lock, null, 2) + "\n");
|
|
3786
|
+
if (opts.json) return emit({ slug, version, instance: wantInstance, surface: opts.surface, dir, installed: written }, true);
|
|
3787
|
+
const instanceNote = opts.instance ? ` (${opts.instance})` : "";
|
|
3788
|
+
console.log(style.green(`Installed ${slug}@${version}${instanceNote} \u2014 ${written.length} skill(s) \u2192 ${dir}`));
|
|
3789
|
+
written.forEach((n) => console.log(" " + style.dim("\u2022") + " " + n));
|
|
3790
|
+
if (written.length === 0) console.log(style.dim(` (no '${opts.surface}' skill bodies found; check --surface)`));
|
|
3791
|
+
});
|
|
3792
|
+
skills.command("list").description("List your installed bundles (GET /me/bundle-installs)").option("--json", "machine output").action(async (opts, cmd) => {
|
|
3793
|
+
const client = await makeClient(resolveConfig(cmd.optsWithGlobals()));
|
|
3794
|
+
const data = await runApi("reading your installs", () => client.GET("/me/bundle-installs", {}));
|
|
3795
|
+
if (opts.json) return emit(data, true);
|
|
3796
|
+
const installs = data?.installs ?? data?.Installs ?? [];
|
|
3797
|
+
if (installs.length === 0) return console.log(style.dim("No bundles installed."));
|
|
3798
|
+
installs.forEach((i) => {
|
|
3799
|
+
const inst = i.instance ?? i.Instance ?? "";
|
|
3800
|
+
const tag = inst ? style.dim(` [${inst}]`) : "";
|
|
3801
|
+
console.log(` ${i.bundleSlug ?? i.BundleSlug}@${i.bundleVersion ?? i.BundleVersion ?? "?"}${tag}`);
|
|
3802
|
+
});
|
|
3803
|
+
});
|
|
3804
|
+
skills.command("clean [slug]").description(`Remove materialised skill files written by install (default ${DEFAULT_SLUG})`).option("--local", "clean ./.claude/skills instead of ~/.claude/skills").option("--json", "machine output").action(async (slugArg, opts) => {
|
|
3805
|
+
const slug = slugArg || DEFAULT_SLUG;
|
|
3806
|
+
const dir = skillsDir(!opts.local);
|
|
3807
|
+
const lockPath = join9(dir, LOCK);
|
|
3808
|
+
if (!existsSync9(lockPath)) fail(`No skills lockfile at ${lockPath}; nothing to clean.`);
|
|
3809
|
+
const lock = JSON.parse(readFileSync6(lockPath, "utf8"));
|
|
3810
|
+
const entry = lock[slug];
|
|
3811
|
+
if (!entry) fail(`No installed record for '${slug}' in ${lockPath}.`);
|
|
3812
|
+
const removed = [];
|
|
3813
|
+
for (const name of entry.skills) {
|
|
3814
|
+
const skillPath = join9(dir, name);
|
|
3815
|
+
if (existsSync9(skillPath)) {
|
|
3816
|
+
rmSync2(skillPath, { recursive: true, force: true });
|
|
3817
|
+
removed.push(name);
|
|
3818
|
+
}
|
|
3819
|
+
}
|
|
3820
|
+
delete lock[slug];
|
|
3821
|
+
writeFileSync6(lockPath, JSON.stringify(lock, null, 2) + "\n");
|
|
3822
|
+
if (opts.json) return emit({ slug, removed, dir }, true);
|
|
3823
|
+
console.log(style.green(`Removed ${removed.length} skill(s) for ${slug} from ${dir}`));
|
|
3824
|
+
});
|
|
3825
|
+
skills.command("set-lane").description("Write this checkout's lane pin to a local ./.sechroom/lane.json file (read at runtime by skills)").option("--code-lane <id>", "code-surface lane id (e.g. claude-code-chris)").option("--design-lane <id>", "design / substrate-authoring lane id (e.g. claude-design-chris)").option("--json", "machine output").action((opts, cmd) => {
|
|
3826
|
+
if (!opts.codeLane && !opts.designLane) fail("Provide --code-lane and/or --design-lane.");
|
|
3827
|
+
const target = localSemPath();
|
|
3828
|
+
const values = readLocalSemValues();
|
|
3829
|
+
if (opts.codeLane) values["code-lane"] = opts.codeLane;
|
|
3830
|
+
if (opts.designLane) values["design-lane"] = opts.designLane;
|
|
3831
|
+
writeSem(values, target);
|
|
3832
|
+
if (cmd.optsWithGlobals().json) return emit({ path: target, values }, true);
|
|
3833
|
+
console.log(style.green(`Wrote lane pin \u2192 ${target} ${style.dim("(git-ignored)")}`));
|
|
3834
|
+
Object.entries(values).forEach(([k, v]) => console.log(" " + style.dim(k) + " = " + v));
|
|
3835
|
+
});
|
|
3836
|
+
skills.command("lane").description("Show the lane pin resolved from ./.sechroom/lane.json (nearest in this checkout; legacy ./.sem honoured)").option("--json", "machine output").action((opts, cmd) => {
|
|
3837
|
+
const json = cmd.optsWithGlobals().json;
|
|
3838
|
+
const found = readSem();
|
|
3839
|
+
if (!found) {
|
|
3840
|
+
if (json) return emit({ path: null, values: {} }, true);
|
|
3841
|
+
return console.log(style.dim(`No ./.sechroom/lane.json pin in this checkout. Run 'sechroom skills set-lane'.`));
|
|
3842
|
+
}
|
|
3843
|
+
if (json) return emit(found, true);
|
|
3844
|
+
console.log(style.dim(`from ${found.path}`));
|
|
3845
|
+
Object.entries(found.values).forEach(([k, v]) => console.log(" " + style.bold(k) + " = " + v));
|
|
3846
|
+
});
|
|
3847
|
+
skills.command("set-workflow").description("Set your per-operator workflow defaults (server-side; follows you across tenants)").option("--default-code-lane <id>", "personal default code lane (e.g. claude-code-chris)").option("--default-design-lane <id>", "personal default design lane (e.g. claude-design-chris)").option("--handover-recipient <id>", "your daily-handover counterparty (e.g. andy)").option("--json", "machine output").action(async (opts, cmd) => {
|
|
3848
|
+
if (!opts.defaultCodeLane && !opts.defaultDesignLane && !opts.handoverRecipient)
|
|
3849
|
+
fail("Provide at least one of --default-code-lane / --default-design-lane / --handover-recipient.");
|
|
3850
|
+
const client = await makeClient(resolveConfig(cmd.optsWithGlobals()));
|
|
3851
|
+
const cur = await runApi(
|
|
3852
|
+
"reading workflow preferences",
|
|
3853
|
+
() => client.GET("/me/workflow-preferences", {})
|
|
3854
|
+
);
|
|
3855
|
+
const body = {
|
|
3856
|
+
defaultCodeLane: opts.defaultCodeLane ?? cur?.defaultCodeLane ?? null,
|
|
3857
|
+
defaultDesignLane: opts.defaultDesignLane ?? cur?.defaultDesignLane ?? null,
|
|
3858
|
+
handoverRecipient: opts.handoverRecipient ?? cur?.handoverRecipient ?? null
|
|
3859
|
+
};
|
|
3860
|
+
const res = await runApi(
|
|
3861
|
+
"saving workflow preferences",
|
|
3862
|
+
() => client.POST("/me/workflow-preferences", { body })
|
|
3863
|
+
);
|
|
3864
|
+
if (cmd.optsWithGlobals().json) return emit(res, true);
|
|
3865
|
+
console.log(style.green("Saved your workflow preferences"));
|
|
3866
|
+
console.log(" " + style.dim("default-code-lane") + " = " + (body.defaultCodeLane ?? "(unset)"));
|
|
3867
|
+
console.log(" " + style.dim("default-design-lane") + " = " + (body.defaultDesignLane ?? "(unset)"));
|
|
3868
|
+
console.log(" " + style.dim("handover-recipient") + " = " + (body.handoverRecipient ?? "(unset)"));
|
|
3869
|
+
});
|
|
3870
|
+
skills.command("workflow").description("Show your per-operator workflow defaults (GET /me/workflow-preferences)").option("--json", "machine output").action(async (opts, cmd) => {
|
|
3871
|
+
const client = await makeClient(resolveConfig(cmd.optsWithGlobals()));
|
|
3872
|
+
const data = await runApi(
|
|
3873
|
+
"reading workflow preferences",
|
|
3874
|
+
() => client.GET("/me/workflow-preferences", {})
|
|
3875
|
+
);
|
|
3876
|
+
if (cmd.optsWithGlobals().json) return emit(data, true);
|
|
3877
|
+
console.log(" " + style.bold("default-code-lane") + " = " + (data?.defaultCodeLane ?? style.dim("(unset)")));
|
|
3878
|
+
console.log(" " + style.bold("default-design-lane") + " = " + (data?.defaultDesignLane ?? style.dim("(unset)")));
|
|
3879
|
+
console.log(" " + style.bold("handover-recipient") + " = " + (data?.handoverRecipient ?? style.dim("(unset)")));
|
|
677
3880
|
});
|
|
678
|
-
|
|
679
|
-
|
|
3881
|
+
skills.command("resolve").description("Resolve the effective ${identity.*} slot values (per-location .sem + per-operator workflow prefs)").option("--json", "machine output (a flat slot->value map + per-slot source)").action(async (opts, cmd) => {
|
|
3882
|
+
const local = readSem()?.values ?? {};
|
|
3883
|
+
let operator = {};
|
|
3884
|
+
try {
|
|
3885
|
+
const client = await makeClient(resolveConfig(cmd.optsWithGlobals()));
|
|
3886
|
+
operator = await runApi(
|
|
3887
|
+
"reading workflow preferences",
|
|
3888
|
+
() => client.GET("/me/workflow-preferences", {})
|
|
3889
|
+
) ?? {};
|
|
3890
|
+
} catch {
|
|
3891
|
+
}
|
|
3892
|
+
const pick = (loc, op) => loc != null && loc !== "" ? { value: loc, source: "per-location" } : op != null && op !== "" ? { value: op, source: "per-operator" } : { value: null, source: "unset" };
|
|
3893
|
+
const slots = {
|
|
3894
|
+
"identity.code-lane": pick(local["code-lane"], operator?.defaultCodeLane),
|
|
3895
|
+
"identity.design-lane": pick(local["design-lane"], operator?.defaultDesignLane),
|
|
3896
|
+
"identity.handover-recipient": pick(void 0, operator?.handoverRecipient)
|
|
3897
|
+
};
|
|
3898
|
+
if (cmd.optsWithGlobals().json) {
|
|
3899
|
+
const values = Object.fromEntries(Object.entries(slots).map(([k, v]) => [k, v.value]));
|
|
3900
|
+
return emit({ values, sources: Object.fromEntries(Object.entries(slots).map(([k, v]) => [k, v.source])) }, true);
|
|
3901
|
+
}
|
|
3902
|
+
for (const [slot, { value, source }] of Object.entries(slots)) {
|
|
3903
|
+
const v = value == null ? style.dim("(unset)") : value;
|
|
3904
|
+
console.log(" " + style.bold(slot) + " = " + v + " " + style.dim(`[${source}]`));
|
|
3905
|
+
}
|
|
680
3906
|
});
|
|
681
3907
|
}
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
3908
|
+
|
|
3909
|
+
// src/commands/reset.ts
|
|
3910
|
+
import { homedir as homedir7 } from "os";
|
|
3911
|
+
import { join as join10 } from "path";
|
|
3912
|
+
import { existsSync as existsSync10, readFileSync as readFileSync7, rmSync as rmSync3 } from "fs";
|
|
3913
|
+
var SKILLS_LOCK = ".sechroom-skills.json";
|
|
3914
|
+
var localSkillsDir = () => join10(process.cwd(), ".claude", "skills");
|
|
3915
|
+
var globalSkillsDir = () => join10(homedir7(), ".claude", "skills");
|
|
3916
|
+
function removeMaterialisedSkills(dir) {
|
|
3917
|
+
const removed = [];
|
|
3918
|
+
const lockPath = join10(dir, SKILLS_LOCK);
|
|
3919
|
+
if (!existsSync10(lockPath)) return removed;
|
|
3920
|
+
try {
|
|
3921
|
+
const lock = JSON.parse(readFileSync7(lockPath, "utf8"));
|
|
3922
|
+
for (const entry of Object.values(lock)) {
|
|
3923
|
+
for (const name of entry.skills ?? []) {
|
|
3924
|
+
const p = join10(dir, name);
|
|
3925
|
+
if (existsSync10(p)) {
|
|
3926
|
+
rmSync3(p, { recursive: true, force: true });
|
|
3927
|
+
removed.push(p);
|
|
3928
|
+
}
|
|
3929
|
+
}
|
|
3930
|
+
}
|
|
3931
|
+
} catch {
|
|
695
3932
|
}
|
|
3933
|
+
rmSync3(lockPath, { force: true });
|
|
3934
|
+
removed.push(lockPath);
|
|
3935
|
+
return removed;
|
|
3936
|
+
}
|
|
3937
|
+
function registerReset(program2) {
|
|
3938
|
+
program2.command("logout").description("Sign out \u2014 remove the cached (global) auth token").action((_opts, cmd) => {
|
|
3939
|
+
const removed = clearToken();
|
|
3940
|
+
if (cmd.optsWithGlobals().json) return emit({ removed: removed ? [removed] : [] }, true);
|
|
3941
|
+
console.log(
|
|
3942
|
+
removed ? style.green("Signed out \u2014 auth token removed.") : style.dim("Already signed out (no token).")
|
|
3943
|
+
);
|
|
3944
|
+
});
|
|
3945
|
+
program2.command("reset").description("Reset LOCAL CLI state for this directory (./.sechroom/, legacy ./.sechroom.json + ./.sem, ./.claude/skills); --global also wipes the machine-wide token + config + ~/.claude/skills").option("--global", "also remove the global auth token, config, and ~/.claude/skills").option("-y, --yes", "don't prompt for confirmation").option("--json", "machine output").action(async (opts, cmd) => {
|
|
3946
|
+
const json = cmd.optsWithGlobals().json;
|
|
3947
|
+
const global = Boolean(opts.global);
|
|
3948
|
+
if (!opts.yes && canPrompt()) {
|
|
3949
|
+
const scope = global ? "this directory's local state AND your global auth token + config + ~/.claude/skills" : "this directory's local state (./.sechroom/, legacy ./.sechroom.json + ./.sem, ./.claude/skills)";
|
|
3950
|
+
if (!await promptYesNo(`Remove ${scope}?`)) {
|
|
3951
|
+
if (!json) console.log(style.dim("Cancelled."));
|
|
3952
|
+
return;
|
|
3953
|
+
}
|
|
3954
|
+
}
|
|
3955
|
+
const removed = [];
|
|
3956
|
+
const stateDir = join10(process.cwd(), ".sechroom");
|
|
3957
|
+
if (existsSync10(stateDir)) {
|
|
3958
|
+
rmSync3(stateDir, { recursive: true, force: true });
|
|
3959
|
+
removed.push(stateDir);
|
|
3960
|
+
}
|
|
3961
|
+
const legacyCfg = join10(process.cwd(), ".sechroom.json");
|
|
3962
|
+
if (existsSync10(legacyCfg)) {
|
|
3963
|
+
rmSync3(legacyCfg, { force: true });
|
|
3964
|
+
removed.push(legacyCfg);
|
|
3965
|
+
}
|
|
3966
|
+
const legacySem = join10(process.cwd(), ".sem");
|
|
3967
|
+
if (existsSync10(legacySem)) {
|
|
3968
|
+
rmSync3(legacySem, { force: true });
|
|
3969
|
+
removed.push(legacySem);
|
|
3970
|
+
}
|
|
3971
|
+
removed.push(...removeMaterialisedSkills(localSkillsDir()));
|
|
3972
|
+
if (global) {
|
|
3973
|
+
const tok = clearToken();
|
|
3974
|
+
if (tok) removed.push(tok);
|
|
3975
|
+
const cfg = clearPersisted();
|
|
3976
|
+
if (cfg) removed.push(cfg);
|
|
3977
|
+
removed.push(...removeMaterialisedSkills(globalSkillsDir()));
|
|
3978
|
+
}
|
|
3979
|
+
if (json) return emit({ global, removed }, true);
|
|
3980
|
+
if (removed.length === 0) {
|
|
3981
|
+
console.log(style.dim(global ? "Nothing to remove \u2014 already clean." : "No local CLI state in this directory."));
|
|
3982
|
+
return;
|
|
3983
|
+
}
|
|
3984
|
+
console.log(style.green(`Reset complete \u2014 removed ${removed.length} item(s):`));
|
|
3985
|
+
removed.forEach((p) => console.log(" " + style.dim("\u2022") + " " + p));
|
|
3986
|
+
if (global) console.log(style.dim("Run 'sechroom onboard' to set up again."));
|
|
3987
|
+
});
|
|
696
3988
|
}
|
|
697
3989
|
|
|
698
3990
|
// src/index.ts
|
|
699
3991
|
function resolveVersion() {
|
|
700
3992
|
try {
|
|
701
3993
|
const pkg = JSON.parse(
|
|
702
|
-
|
|
3994
|
+
readFileSync8(new URL("../package.json", import.meta.url), "utf8")
|
|
703
3995
|
);
|
|
704
3996
|
return pkg.version ?? "0.0.0";
|
|
705
3997
|
} catch {
|
|
@@ -708,19 +4000,69 @@ function resolveVersion() {
|
|
|
708
4000
|
}
|
|
709
4001
|
var program = new Command();
|
|
710
4002
|
program.name("sechroom").description("Sechroom CLI \u2014 thin generated client over the Sechroom HTTP API. An agent/human surface alongside MCP.").version(resolveVersion()).option("--base-url <url>", "API base URL (overrides config / SECHROOM_BASE_URL)").option("--tenant <tenant>", "Tenant id (required by the API; overrides config / SECHROOM_TENANT)").option("--json", "Emit compact JSON (for scripts and agents)", false);
|
|
4003
|
+
program.addHelpText(
|
|
4004
|
+
"after",
|
|
4005
|
+
`
|
|
4006
|
+
Examples:
|
|
4007
|
+
$ sechroom onboard guided first-run: configure, sign in, wire this project
|
|
4008
|
+
$ sechroom login sign in via browser (OAuth + PKCE)
|
|
4009
|
+
$ sechroom config set tenant ocd set your tenant (global)
|
|
4010
|
+
$ sechroom config set --local tenant cli-smoke pin tenant for this directory (committed .sechroom.json)
|
|
4011
|
+
$ sechroom config show resolved config + which source won
|
|
4012
|
+
|
|
4013
|
+
$ sechroom memory create --text "a note" --title "Note" --tag idea
|
|
4014
|
+
$ sechroom memory search "convention drift" --limit 5
|
|
4015
|
+
$ sechroom memory get mem_XXXX
|
|
4016
|
+
$ sechroom worklog append --text "shipped X; PR #123" --source claude-code-chris
|
|
4017
|
+
$ sechroom lookup mem_XXXX resolve any id -> kind / title / view URL
|
|
4018
|
+
|
|
4019
|
+
$ sechroom --json memory search "auth" compact JSON for scripts and agents
|
|
4020
|
+
$ SECHROOM_TOKEN=<bearer> sechroom --json memory get mem_XXXX headless
|
|
4021
|
+
|
|
4022
|
+
Config precedence (high -> low): --flag > env (SECHROOM_*) > directory-local (committed ./.sechroom.json, shadowed per-field by the gitignored ./.sechroom/config.json override) > global > default.
|
|
4023
|
+
Run 'sechroom <command> --help' for command-specific examples.`
|
|
4024
|
+
);
|
|
711
4025
|
program.hook("preAction", (_thisCmd, actionCmd) => {
|
|
712
4026
|
setQuiet(Boolean(actionCmd.optsWithGlobals().json));
|
|
713
4027
|
});
|
|
714
|
-
program.command("login").description("Sign in via browser (OAuth auth-code + PKCE, dynamic client registration)").
|
|
4028
|
+
program.command("login").description("Sign in via browser (OAuth auth-code + PKCE, dynamic client registration)").addHelpText(
|
|
4029
|
+
"after",
|
|
4030
|
+
`
|
|
4031
|
+
Examples:
|
|
4032
|
+
$ sechroom login sign in to the configured base URL + tenant
|
|
4033
|
+
$ sechroom login --base-url https://staging.app.sechroom.ai/api
|
|
4034
|
+
$ export SECHROOM_TOKEN=<bearer> headless: skip login entirely (CI / agents)`
|
|
4035
|
+
).action(async (_opts, cmd) => {
|
|
715
4036
|
const g = cmd.optsWithGlobals();
|
|
716
4037
|
const persisted = readPersisted();
|
|
717
4038
|
const baseUrl = g.baseUrl ?? process.env.SECHROOM_BASE_URL ?? persisted.baseUrl ?? "https://app.sechroom.ai/api";
|
|
718
4039
|
await login({ baseUrl: baseUrl.replace(/\/$/, ""), tenant: g.tenant ?? "" });
|
|
719
4040
|
});
|
|
720
4041
|
var config = program.command("config").description("Manage persisted CLI config");
|
|
721
|
-
config.
|
|
722
|
-
|
|
723
|
-
|
|
4042
|
+
config.addHelpText(
|
|
4043
|
+
"after",
|
|
4044
|
+
`
|
|
4045
|
+
Examples:
|
|
4046
|
+
$ sechroom config set baseUrl https://app.sechroom.ai/api prod (staging: https://staging.app.sechroom.ai/api)
|
|
4047
|
+
$ sechroom config set tenant ocd
|
|
4048
|
+
$ sechroom config set --local tenant cli-smoke this dir + subdirs (committed .sechroom.json)
|
|
4049
|
+
$ sechroom config set clientId dyn-XXXX global-only escape hatch (no DCR endpoint)
|
|
4050
|
+
$ sechroom config show --json`
|
|
4051
|
+
);
|
|
4052
|
+
config.command("set <key> <value>").description("Set baseUrl | tenant | workspaceId | defaultProjectId | clientId (clientId is global-only)").option("--local", "Write the committed directory-local .sechroom.json (nearest up the tree, else cwd) instead of the global config").action((key, value, opts) => {
|
|
4053
|
+
if (opts.local) {
|
|
4054
|
+
if (!["baseUrl", "tenant", "workspaceId", "defaultProjectId"].includes(key)) {
|
|
4055
|
+
process.stderr.write(`--local supports only: baseUrl | tenant | workspaceId | defaultProjectId (clientId is global)
|
|
4056
|
+
`);
|
|
4057
|
+
process.exit(1);
|
|
4058
|
+
}
|
|
4059
|
+
const path = writeLocalConfig({ [key]: value });
|
|
4060
|
+
process.stdout.write(`set ${key} (local: ${path})
|
|
4061
|
+
`);
|
|
4062
|
+
return;
|
|
4063
|
+
}
|
|
4064
|
+
if (!["baseUrl", "tenant", "clientId", "workspaceId", "defaultProjectId"].includes(key)) {
|
|
4065
|
+
process.stderr.write(`unknown key: ${key} (expected baseUrl | tenant | workspaceId | defaultProjectId | clientId)
|
|
724
4066
|
`);
|
|
725
4067
|
process.exit(1);
|
|
726
4068
|
}
|
|
@@ -728,16 +4070,49 @@ config.command("set <key> <value>").description("Set baseUrl | tenant | clientId
|
|
|
728
4070
|
process.stdout.write(`set ${key}
|
|
729
4071
|
`);
|
|
730
4072
|
});
|
|
731
|
-
config.command("show").description("Print
|
|
732
|
-
|
|
4073
|
+
config.command("show").description("Print resolved config + sources (flag > env > local > global > default)").action((_opts, cmd) => {
|
|
4074
|
+
const g = cmd.optsWithGlobals();
|
|
4075
|
+
const d = describeConfig({ baseUrl: g.baseUrl, tenant: g.tenant });
|
|
4076
|
+
if (g.json) {
|
|
4077
|
+
process.stdout.write(
|
|
4078
|
+
JSON.stringify({
|
|
4079
|
+
resolved: { baseUrl: d.baseUrl, tenant: d.tenant, workspaceId: d.workspaceId },
|
|
4080
|
+
global: readPersisted(),
|
|
4081
|
+
local: readLocalConfig()
|
|
4082
|
+
}) + "\n"
|
|
4083
|
+
);
|
|
4084
|
+
return;
|
|
4085
|
+
}
|
|
4086
|
+
process.stdout.write(
|
|
4087
|
+
`baseUrl: ${d.baseUrl.value} [${d.baseUrl.source}]
|
|
4088
|
+
tenant: ${d.tenant.value ?? "(unset)"} [${d.tenant.source}]
|
|
4089
|
+
workspaceId: ${d.workspaceId.value ?? "(unset)"} [${d.workspaceId.source}]
|
|
4090
|
+
|
|
4091
|
+
global: ${JSON.stringify(readPersisted())}
|
|
4092
|
+
local: ${d.localPath ?? "(none)"} ${JSON.stringify(readLocalConfig())}
|
|
4093
|
+
`
|
|
4094
|
+
);
|
|
733
4095
|
});
|
|
734
4096
|
registerMemory(program);
|
|
735
4097
|
registerWorklog(program);
|
|
736
4098
|
registerLookup(program);
|
|
4099
|
+
registerRelationships(program);
|
|
4100
|
+
registerWorkspace(program);
|
|
4101
|
+
registerProject(program);
|
|
4102
|
+
registerFiling(program);
|
|
4103
|
+
registerContinuity(program);
|
|
4104
|
+
registerHook(program);
|
|
4105
|
+
registerId(program);
|
|
4106
|
+
registerAccount(program);
|
|
4107
|
+
registerChat(program);
|
|
737
4108
|
registerInit(program);
|
|
738
4109
|
registerSetup(program);
|
|
739
|
-
program
|
|
740
|
-
|
|
4110
|
+
registerOnboard(program);
|
|
4111
|
+
registerSweep(program);
|
|
4112
|
+
registerSkills(program);
|
|
4113
|
+
registerReset(program);
|
|
4114
|
+
program.parseAsync().catch((err2) => {
|
|
4115
|
+
process.stderr.write(`error: ${err2 instanceof Error ? err2.message : String(err2)}
|
|
741
4116
|
`);
|
|
742
4117
|
process.exit(1);
|
|
743
4118
|
});
|