@sechroom/cli 2026.6.5 → 2026.6.6-rc.ff3f8b7d
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 +3406 -272
- 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,12 +356,12 @@ 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
|
|
@@ -263,8 +375,8 @@ async function promptYesNo(question) {
|
|
|
263
375
|
const { createInterface } = await import("readline");
|
|
264
376
|
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
265
377
|
try {
|
|
266
|
-
const answer = await new Promise((
|
|
267
|
-
rl.question(`${question} [y/N] `,
|
|
378
|
+
const answer = await new Promise((resolve3) => {
|
|
379
|
+
rl.question(`${question} [y/N] `, resolve3);
|
|
268
380
|
});
|
|
269
381
|
return /^y(es)?$/i.test(answer.trim());
|
|
270
382
|
} finally {
|
|
@@ -277,8 +389,8 @@ async function promptText(question, def) {
|
|
|
277
389
|
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
278
390
|
try {
|
|
279
391
|
const suffix = def ? ` [${def}]` : "";
|
|
280
|
-
const answer = await new Promise((
|
|
281
|
-
rl.question(`${question}${suffix} `,
|
|
392
|
+
const answer = await new Promise((resolve3) => {
|
|
393
|
+
rl.question(`${question}${suffix} `, resolve3);
|
|
282
394
|
});
|
|
283
395
|
const trimmed = answer.trim();
|
|
284
396
|
return trimmed.length > 0 ? trimmed : def ?? "";
|
|
@@ -286,15 +398,88 @@ async function promptText(question, def) {
|
|
|
286
398
|
rl.close();
|
|
287
399
|
}
|
|
288
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
|
+
}
|
|
289
474
|
async function withSpinner(text, fn) {
|
|
290
475
|
const s = spinner(text);
|
|
291
476
|
try {
|
|
292
477
|
const result = await fn();
|
|
293
478
|
s.succeed();
|
|
294
479
|
return result;
|
|
295
|
-
} catch (
|
|
480
|
+
} catch (err2) {
|
|
296
481
|
s.fail();
|
|
297
|
-
throw
|
|
482
|
+
throw err2;
|
|
298
483
|
}
|
|
299
484
|
}
|
|
300
485
|
|
|
@@ -306,6 +491,7 @@ async function makeClient(cfg) {
|
|
|
306
491
|
onRequest({ request }) {
|
|
307
492
|
request.headers.set("authorization", `Bearer ${token}`);
|
|
308
493
|
request.headers.set("tenant", cfg.tenant);
|
|
494
|
+
request.headers.set("x-sechroom-surface", "cli");
|
|
309
495
|
return request;
|
|
310
496
|
}
|
|
311
497
|
});
|
|
@@ -318,18 +504,36 @@ function emit(data, json) {
|
|
|
318
504
|
process.stdout.write(JSON.stringify(data, null, 2) + "\n");
|
|
319
505
|
}
|
|
320
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
|
+
}
|
|
321
524
|
async function runApi(label, fn) {
|
|
322
525
|
const s = spinner(label);
|
|
323
526
|
let res;
|
|
324
527
|
try {
|
|
325
528
|
res = await fn();
|
|
326
|
-
} catch (
|
|
529
|
+
} catch (err2) {
|
|
327
530
|
s.fail();
|
|
328
|
-
fail(
|
|
531
|
+
fail(err2);
|
|
329
532
|
}
|
|
330
|
-
|
|
533
|
+
const httpFailed = res.response !== void 0 && !res.response.ok;
|
|
534
|
+
if (res.error !== void 0 && res.error !== null || httpFailed) {
|
|
331
535
|
s.fail();
|
|
332
|
-
fail(res.error);
|
|
536
|
+
fail(res.error ?? (res.response ? `HTTP ${res.response.status} ${res.response.statusText}`.trim() : "request failed"));
|
|
333
537
|
}
|
|
334
538
|
s.succeed();
|
|
335
539
|
return res.data;
|
|
@@ -344,6 +548,22 @@ function fail(error) {
|
|
|
344
548
|
// src/commands/memory.ts
|
|
345
549
|
function registerMemory(program2) {
|
|
346
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
|
+
);
|
|
347
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) => {
|
|
348
568
|
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
349
569
|
const unfiled = String(opts.ownerType).toLowerCase() === "unfiled";
|
|
@@ -363,7 +583,10 @@ function registerMemory(program2) {
|
|
|
363
583
|
}
|
|
364
584
|
});
|
|
365
585
|
});
|
|
366
|
-
|
|
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);
|
|
367
590
|
});
|
|
368
591
|
memory.command("get <memoryId>").description("Fetch a memory by id (GET /memories/{memoryId})").action(async (memoryId, _opts, cmd) => {
|
|
369
592
|
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
@@ -393,11 +616,213 @@ function registerMemory(program2) {
|
|
|
393
616
|
});
|
|
394
617
|
emit(data, cmd.optsWithGlobals().json);
|
|
395
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
|
+
});
|
|
396
814
|
}
|
|
397
815
|
|
|
398
816
|
// src/commands/worklog.ts
|
|
399
817
|
function registerWorklog(program2) {
|
|
400
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
|
+
);
|
|
401
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) => {
|
|
402
827
|
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
403
828
|
const data = await runApi("Appending work-log entry", async () => {
|
|
@@ -411,7 +836,7 @@ function registerWorklog(program2) {
|
|
|
411
836
|
}
|
|
412
837
|
});
|
|
413
838
|
});
|
|
414
|
-
|
|
839
|
+
emitAction(`appended work-log entry ${style.bold(data.memoryId)}`, data, cmd.optsWithGlobals().json);
|
|
415
840
|
});
|
|
416
841
|
}
|
|
417
842
|
|
|
@@ -419,6 +844,14 @@ function registerWorklog(program2) {
|
|
|
419
844
|
function registerLookup(program2) {
|
|
420
845
|
program2.command("lookup <id>").description(
|
|
421
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`
|
|
422
855
|
).action(async (id, _opts, cmd) => {
|
|
423
856
|
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
424
857
|
const data = await runApi(`Resolving ${id}`, async () => {
|
|
@@ -434,126 +867,1622 @@ function registerLookup(program2) {
|
|
|
434
867
|
});
|
|
435
868
|
}
|
|
436
869
|
|
|
437
|
-
// src/
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
if (!section) return null;
|
|
463
|
-
for (const step of section.steps) {
|
|
464
|
-
if (step.copyValue) return step.copyValue;
|
|
465
|
-
if (step.codeSnippet) return step.codeSnippet;
|
|
466
|
-
}
|
|
467
|
-
return null;
|
|
468
|
-
}
|
|
469
|
-
function parseTagArtifactId(id) {
|
|
470
|
-
if (!id.startsWith("tag:")) return null;
|
|
471
|
-
const tags = id.slice("tag:".length).split(",").map((t) => t.trim()).filter((t) => t.length > 0);
|
|
472
|
-
return tags.length > 0 ? tags : null;
|
|
473
|
-
}
|
|
474
|
-
async function getPersonalWorkspaceId(cfg) {
|
|
475
|
-
const client = await makeClient(cfg);
|
|
476
|
-
const { data } = await client.GET("/me/personal-workspace", {});
|
|
477
|
-
return data?.workspaceId ?? null;
|
|
478
|
-
}
|
|
479
|
-
async function fetchMemoryFields(cfg, id) {
|
|
480
|
-
const client = await makeClient(cfg);
|
|
481
|
-
const { data } = await client.GET("/memories/{memoryId}", { params: { path: { memoryId: id } } });
|
|
482
|
-
const env = data;
|
|
483
|
-
return env?.item ?? env ?? null;
|
|
484
|
-
}
|
|
485
|
-
async function resolveInstruction(cfg, section, personalWorkspaceId) {
|
|
486
|
-
const client = await makeClient(cfg);
|
|
487
|
-
for (const artifact of section.artifacts) {
|
|
488
|
-
const tags = parseTagArtifactId(artifact.id);
|
|
489
|
-
if (!tags) continue;
|
|
490
|
-
const { data } = await client.POST("/memories/search", {
|
|
491
|
-
body: { query: null, textQuery: null, semanticQuery: artifact.title ?? "role instruction template", hybrid: true, limit: 1, includeArchived: false, includeSystem: false, tags }
|
|
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
|
+
});
|
|
492
895
|
});
|
|
493
|
-
const
|
|
494
|
-
|
|
495
|
-
const
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
+
}
|
|
502
919
|
});
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
920
|
+
});
|
|
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
|
|
508
942
|
}
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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) {
|
|
516
1800
|
const client = await makeClient(cfg);
|
|
517
1801
|
const overrideTags = template.templateTags.filter(
|
|
518
1802
|
(t) => t !== "sechroom:role:template" && !t.startsWith("sechroom:bundle:") && !t.startsWith("sechroom:template-ref:")
|
|
519
1803
|
);
|
|
520
|
-
overrideTags.push("sechroom:role:override", `sechroom:template-ref:${template.templateId}`);
|
|
521
|
-
const { error } = await client.POST("/memories", {
|
|
522
|
-
body: {
|
|
523
|
-
text: template.body,
|
|
524
|
-
type: "reference",
|
|
525
|
-
content: "{}",
|
|
526
|
-
confidence: 1,
|
|
527
|
-
source: "cli-agent-instructions-customize",
|
|
528
|
-
archetype: "Document",
|
|
529
|
-
title: template.title ?? null,
|
|
530
|
-
tags: overrideTags,
|
|
531
|
-
owner: { type: "Workspace", id: personalWorkspaceId }
|
|
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);
|
|
532
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);
|
|
533
2411
|
});
|
|
534
|
-
if (error) throw new Error(`creating personal copy failed: ${JSON.stringify(error)}`);
|
|
535
2412
|
}
|
|
536
2413
|
|
|
537
2414
|
// src/setup/apply.ts
|
|
538
|
-
|
|
539
|
-
|
|
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
|
+
}
|
|
540
2469
|
function ensureDir2(path) {
|
|
541
|
-
|
|
2470
|
+
mkdirSync4(dirname5(path), { recursive: true });
|
|
542
2471
|
}
|
|
543
2472
|
function readOr(path, fallback) {
|
|
544
2473
|
try {
|
|
545
|
-
return
|
|
2474
|
+
return readFileSync4(path, "utf8");
|
|
546
2475
|
} catch {
|
|
547
2476
|
return fallback;
|
|
548
2477
|
}
|
|
549
2478
|
}
|
|
550
2479
|
function mergeMcpJson(path, snippet, dryRun) {
|
|
551
2480
|
const incoming = JSON.parse(snippet);
|
|
552
|
-
const existed =
|
|
2481
|
+
const existed = existsSync5(path);
|
|
553
2482
|
let current = {};
|
|
554
2483
|
if (existed) {
|
|
555
2484
|
try {
|
|
556
|
-
current = JSON.parse(
|
|
2485
|
+
current = JSON.parse(readFileSync4(path, "utf8"));
|
|
557
2486
|
} catch {
|
|
558
2487
|
return { kind: "mcp", path, status: "skipped", note: "existing file isn't valid JSON \u2014 left untouched" };
|
|
559
2488
|
}
|
|
@@ -561,46 +2490,88 @@ function mergeMcpJson(path, snippet, dryRun) {
|
|
|
561
2490
|
current.mcpServers = { ...current.mcpServers ?? {}, ...incoming.mcpServers ?? {} };
|
|
562
2491
|
if (dryRun) return { kind: "mcp", path, status: "dry-run" };
|
|
563
2492
|
ensureDir2(path);
|
|
564
|
-
|
|
2493
|
+
writeFileSync4(path, JSON.stringify(current, null, 2) + "\n", { mode: 384 });
|
|
565
2494
|
return { kind: "mcp", path, status: existed ? "merged" : "created" };
|
|
566
2495
|
}
|
|
567
2496
|
function mergeCodexToml(path, snippet, dryRun) {
|
|
568
|
-
const existed =
|
|
2497
|
+
const existed = existsSync5(path);
|
|
569
2498
|
let body = readOr(path, "");
|
|
570
2499
|
body = body.replace(/(^|\n)\[mcp_servers\.sechroom\][^[]*/, "\n").replace(/\n{3,}/g, "\n\n");
|
|
571
2500
|
const trimmed = body.trim();
|
|
572
2501
|
const next = (trimmed.length > 0 ? trimmed + "\n\n" : "") + snippet.trim() + "\n";
|
|
573
2502
|
if (dryRun) return { kind: "mcp", path, status: "dry-run" };
|
|
574
2503
|
ensureDir2(path);
|
|
575
|
-
|
|
2504
|
+
writeFileSync4(path, next, { mode: 384 });
|
|
576
2505
|
return { kind: "mcp", path, status: existed ? "merged" : "created" };
|
|
577
2506
|
}
|
|
578
|
-
function writeInstructionBlock(path,
|
|
579
|
-
const
|
|
580
|
-
|
|
581
|
-
${BLOCK_END}
|
|
582
|
-
`;
|
|
583
|
-
const existed = existsSync2(path);
|
|
584
|
-
const current = readOr(path, "");
|
|
585
|
-
let next;
|
|
586
|
-
const re = new RegExp(`${escapeRe(BLOCK_BEGIN)}[\\s\\S]*?${escapeRe(BLOCK_END)}\\n?`);
|
|
587
|
-
if (re.test(current)) {
|
|
588
|
-
next = current.replace(re, block);
|
|
589
|
-
} else {
|
|
590
|
-
next = current.trim().length > 0 ? `${current.trimEnd()}
|
|
591
|
-
|
|
592
|
-
${block}` : block;
|
|
593
|
-
}
|
|
2507
|
+
function writeInstructionBlock(path, write, dryRun) {
|
|
2508
|
+
const existed = existsSync5(path);
|
|
2509
|
+
const next = computeBlockFile(readOr(path, ""), write);
|
|
594
2510
|
if (dryRun) return { kind: "instruction", path, status: "dry-run" };
|
|
595
2511
|
ensureDir2(path);
|
|
596
|
-
|
|
2512
|
+
writeFileSync4(path, next);
|
|
597
2513
|
return { kind: "instruction", path, status: existed ? "merged" : "created" };
|
|
598
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
|
+
}
|
|
599
2568
|
function escapeRe(s) {
|
|
600
2569
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
601
2570
|
}
|
|
602
2571
|
async function applyClient(cfg, setup, target, opts) {
|
|
603
2572
|
const actions = [];
|
|
2573
|
+
const mode = opts.mode ?? "apply";
|
|
2574
|
+
const dryRun = opts.dryRun || mode === "check";
|
|
604
2575
|
if (opts.mcp && target.mcp) {
|
|
605
2576
|
const surface = findSurface(setup, target.mcp.surfaceKey);
|
|
606
2577
|
const section = findSection(surface, target.mcp.sectionType);
|
|
@@ -609,7 +2580,7 @@ async function applyClient(cfg, setup, target, opts) {
|
|
|
609
2580
|
actions.push({ kind: "mcp", path: target.mcp.path, status: "skipped", note: `no ${target.mcp.sectionType} section on surface '${target.mcp.surfaceKey}'` });
|
|
610
2581
|
} else {
|
|
611
2582
|
actions.push(
|
|
612
|
-
target.mcp.format === "toml" ? mergeCodexToml(target.mcp.path, snippet,
|
|
2583
|
+
target.mcp.format === "toml" ? mergeCodexToml(target.mcp.path, snippet, dryRun) : mergeMcpJson(target.mcp.path, snippet, dryRun)
|
|
613
2584
|
);
|
|
614
2585
|
}
|
|
615
2586
|
}
|
|
@@ -623,67 +2594,206 @@ async function applyClient(cfg, setup, target, opts) {
|
|
|
623
2594
|
if (!resolved) {
|
|
624
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`" });
|
|
625
2596
|
} else {
|
|
626
|
-
const action =
|
|
627
|
-
|
|
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})` });
|
|
628
2617
|
}
|
|
629
2618
|
}
|
|
630
2619
|
}
|
|
631
2620
|
return actions;
|
|
632
2621
|
}
|
|
633
2622
|
|
|
634
|
-
// src/setup/
|
|
635
|
-
import {
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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
|
+
`);
|
|
646
2658
|
}
|
|
647
2659
|
}
|
|
648
|
-
|
|
649
|
-
|
|
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"]);
|
|
650
2697
|
return {
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
label: "Claude Code",
|
|
654
|
-
mcp: { surfaceKey: "claude-code", sectionType: SectionType.McpConfig, path: join2(cwd, ".mcp.json"), format: "json" },
|
|
655
|
-
instruction: { surfaceKey: "claude-code", path: join2(cwd, "CLAUDE.md") }
|
|
656
|
-
},
|
|
657
|
-
"claude-desktop": {
|
|
658
|
-
key: "claude-desktop",
|
|
659
|
-
label: "Claude Desktop",
|
|
660
|
-
mcp: { surfaceKey: "claude-desktop", sectionType: SectionType.McpConfig, path: claudeDesktopConfigPath(home), format: "json" },
|
|
661
|
-
instruction: { surfaceKey: "claude-desktop", path: join2(home, ".claude", "CLAUDE.md") }
|
|
662
|
-
},
|
|
663
|
-
codex: {
|
|
664
|
-
key: "codex",
|
|
665
|
-
label: "Codex CLI",
|
|
666
|
-
mcp: { surfaceKey: "chatgpt", sectionType: SectionType.McpConfigToml, path: join2(home, ".codex", "config.toml"), format: "toml" },
|
|
667
|
-
instruction: { surfaceKey: "chatgpt", path: join2(cwd, "AGENTS.md") }
|
|
668
|
-
},
|
|
669
|
-
cursor: {
|
|
670
|
-
key: "cursor",
|
|
671
|
-
label: "Cursor",
|
|
672
|
-
mcp: { surfaceKey: "claude-code", sectionType: SectionType.McpConfig, path: join2(cwd, ".cursor", "mcp.json"), format: "json" },
|
|
673
|
-
instruction: { surfaceKey: "chatgpt", path: join2(cwd, "AGENTS.md") }
|
|
674
|
-
}
|
|
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)
|
|
675
2700
|
};
|
|
676
2701
|
}
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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] });
|
|
687
2797
|
}
|
|
688
2798
|
|
|
689
2799
|
// src/commands/setup.ts
|
|
@@ -741,7 +2851,16 @@ ${client.label} (${client.key}):
|
|
|
741
2851
|
}
|
|
742
2852
|
}
|
|
743
2853
|
function registerInit(program2) {
|
|
744
|
-
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)").
|
|
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) => {
|
|
745
2864
|
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
746
2865
|
const setup = await withSpinner("Fetching setup descriptors", () => fetchSetup(cfg));
|
|
747
2866
|
const targets = clientTargets(process.cwd());
|
|
@@ -763,6 +2882,12 @@ function registerInit(program2) {
|
|
|
763
2882
|
result.push({ client: key, actions });
|
|
764
2883
|
if (!json) printActions(target, actions);
|
|
765
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
|
+
}
|
|
766
2891
|
if (json) {
|
|
767
2892
|
emit({ dryRun: Boolean(opts.dryRun), clients: result }, true);
|
|
768
2893
|
return;
|
|
@@ -782,36 +2907,147 @@ Next \u2014 verify: ${verify.description}
|
|
|
782
2907
|
}
|
|
783
2908
|
function registerSetup(program2) {
|
|
784
2909
|
const setup = program2.command("setup").description("Granular onboarding steps (init runs these together)");
|
|
785
|
-
setup.command("mcp <
|
|
786
|
-
await
|
|
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 });
|
|
787
2912
|
});
|
|
788
|
-
setup.command("agent-files <
|
|
789
|
-
await
|
|
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 });
|
|
790
2915
|
});
|
|
791
2916
|
}
|
|
792
|
-
async function
|
|
2917
|
+
async function runClients(clients, cmd, opts) {
|
|
793
2918
|
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
794
2919
|
const targets = clientTargets(process.cwd());
|
|
795
|
-
const
|
|
796
|
-
if (!target) fail(`unknown client '${client}'. Known: ${ALL_CLIENT_KEYS.join(", ")}.`);
|
|
2920
|
+
const keys = resolveClientKeys(clients.join(","));
|
|
797
2921
|
const setupData = await withSpinner("Fetching setup descriptors", () => fetchSetup(cfg));
|
|
798
2922
|
const personalWorkspaceId = await getPersonalWorkspaceId(cfg);
|
|
799
2923
|
if (opts.agentFiles && !opts.dryRun) {
|
|
800
|
-
await maybeOfferCopies(cfg, setupData, targets,
|
|
2924
|
+
await maybeOfferCopies(cfg, setupData, targets, keys, personalWorkspaceId, copyChoice(opts));
|
|
801
2925
|
}
|
|
802
|
-
const actions = await applyClient(cfg, setupData, target, {
|
|
803
|
-
dryRun: opts.dryRun,
|
|
804
|
-
mcp: opts.mcp,
|
|
805
|
-
agentFiles: opts.agentFiles,
|
|
806
|
-
personalWorkspaceId
|
|
807
|
-
});
|
|
808
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
|
+
}
|
|
809
2939
|
if (json) {
|
|
810
|
-
emit({ dryRun: opts.dryRun,
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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
|
+
});
|
|
814
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);
|
|
815
3051
|
}
|
|
816
3052
|
|
|
817
3053
|
// src/commands/onboard.ts
|
|
@@ -823,22 +3059,197 @@ function systemTimezone() {
|
|
|
823
3059
|
return "UTC";
|
|
824
3060
|
}
|
|
825
3061
|
}
|
|
826
|
-
|
|
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) {
|
|
827
3095
|
const persisted = readPersisted();
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
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
|
+
);
|
|
833
3123
|
}
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
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
|
+
`
|
|
838
3158
|
);
|
|
839
3159
|
}
|
|
840
|
-
|
|
841
|
-
|
|
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
|
+
`
|
|
3169
|
+
);
|
|
3170
|
+
}
|
|
3171
|
+
return opts.workspace;
|
|
3172
|
+
}
|
|
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
|
+
);
|
|
3206
|
+
} else {
|
|
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
|
+
);
|
|
3213
|
+
}
|
|
3214
|
+
}
|
|
3215
|
+
}
|
|
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 };
|
|
842
3253
|
}
|
|
843
3254
|
async function ensureAuth(cfg, yes) {
|
|
844
3255
|
if (process.env.SECHROOM_TOKEN) return;
|
|
@@ -879,59 +3290,700 @@ async function ensureTimezone(cfg, opts) {
|
|
|
879
3290
|
async function chooseClients(clientFlag, yes, cwd) {
|
|
880
3291
|
if (clientFlag) return resolveClientKeys(clientFlag);
|
|
881
3292
|
const detected = detectInstalledClients(cwd);
|
|
882
|
-
const
|
|
883
|
-
if (!canPrompt() || yes) return
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
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
|
|
889
3303
|
);
|
|
890
|
-
|
|
891
|
-
|
|
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
|
+
}
|
|
3342
|
+
return {
|
|
3343
|
+
label: entry.path,
|
|
3344
|
+
dir,
|
|
3345
|
+
disposition: "bind",
|
|
3346
|
+
argv: ["onboard", "--yes", "--local", "--workspace", ws],
|
|
3347
|
+
reason: `unbound \u2014 bind to ${ws}`
|
|
3348
|
+
};
|
|
3349
|
+
}
|
|
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;
|
|
3370
|
+
}
|
|
3371
|
+
}
|
|
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 };
|
|
3377
|
+
}
|
|
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.
|
|
3388
|
+
`);
|
|
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})`)}
|
|
3393
|
+
`);
|
|
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 });
|
|
892
3407
|
}
|
|
893
3408
|
function registerOnboard(program2) {
|
|
894
|
-
program2.command("onboard").description("Guided first-run setup: configure, sign in, set timezone, detect clients, and wire this project").option("--client <list>", `comma-separated clients (${ALL_CLIENT_KEYS.join(", ")}) or 'all' (default: auto-detected)`).option("--copy", "make a personal copy of the agent instructions you can edit (default: prompt on a TTY, else skip)").option("--dry-run", "walk through without writing files or changing the profile", false).option("-y, --yes", "non-interactive: accept defaults (system timezone, detected clients)", false).
|
|
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) => {
|
|
895
3425
|
const g = cmd.optsWithGlobals();
|
|
896
3426
|
const json = Boolean(g.json);
|
|
897
|
-
const yes = Boolean(opts.yes);
|
|
898
3427
|
const dryRun = Boolean(opts.dryRun);
|
|
899
|
-
const
|
|
900
|
-
|
|
901
|
-
const
|
|
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 });
|
|
902
3444
|
if (!json && tz.action !== "already-set") {
|
|
903
|
-
const line = tz.action === "set" ?
|
|
3445
|
+
const line = tz.action === "set" ? `${ok("\u2713")} timezone set to ${tz.timezone}
|
|
904
3446
|
` : tz.action === "dry-run" ? `(dry run \u2014 would set timezone to ${tz.timezone})
|
|
905
3447
|
` : `timezone not set \u2014 ${tz.note}
|
|
906
3448
|
`;
|
|
907
3449
|
process.stderr.write(line);
|
|
908
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
|
+
}
|
|
909
3470
|
const keys = await chooseClients(opts.client, yes, process.cwd());
|
|
910
3471
|
const setup = await withSpinner("Fetching setup descriptors", () => fetchSetup(cfg));
|
|
911
3472
|
const targets = clientTargets(process.cwd());
|
|
912
3473
|
const personalWorkspaceId = await getPersonalWorkspaceId(cfg);
|
|
913
|
-
if (!dryRun) {
|
|
3474
|
+
if (!dryRun && !check) {
|
|
914
3475
|
await maybeOfferCopies(cfg, setup, targets, keys, personalWorkspaceId, copyChoice(opts));
|
|
915
3476
|
}
|
|
3477
|
+
const writeMcp = wire === "full";
|
|
916
3478
|
const result = [];
|
|
917
3479
|
for (const key of keys) {
|
|
918
3480
|
const target = targets[key];
|
|
919
3481
|
const actions = await applyClient(cfg, setup, target, {
|
|
920
3482
|
dryRun,
|
|
921
|
-
mcp:
|
|
3483
|
+
mcp: writeMcp,
|
|
922
3484
|
agentFiles: true,
|
|
923
|
-
personalWorkspaceId
|
|
3485
|
+
personalWorkspaceId,
|
|
3486
|
+
mode
|
|
924
3487
|
});
|
|
925
3488
|
result.push({ client: key, actions });
|
|
926
|
-
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() });
|
|
927
3520
|
}
|
|
928
3521
|
if (json) {
|
|
929
|
-
emit({ dryRun, baseUrl: cfg.baseUrl, tenant: cfg.tenant, timezone: tz, clients: result }, true);
|
|
3522
|
+
emit({ dryRun, baseUrl: cfg.baseUrl, tenant: cfg.tenant, workspaceId: cfg.workspaceId ?? null, timezone: tz, wire, eval: evalCounts, clients: result }, true);
|
|
930
3523
|
return;
|
|
931
3524
|
}
|
|
3525
|
+
if (!dryRun && evalCounts.stale) {
|
|
3526
|
+
process.stderr.write(`${style.cyan("\u21BB")} refreshed ${evalCounts.stale} section(s) the server had moved
|
|
3527
|
+
`);
|
|
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"));
|
|
3537
|
+
process.stdout.write(
|
|
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
|
+
`
|
|
3545
|
+
);
|
|
3546
|
+
if (!dryRun) await printStarterPrompt("agent", cfg);
|
|
3547
|
+
});
|
|
3548
|
+
}
|
|
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") {
|
|
932
3567
|
process.stdout.write(
|
|
933
|
-
|
|
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
|
+
})
|
|
934
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)")));
|
|
3880
|
+
});
|
|
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
|
+
}
|
|
3906
|
+
});
|
|
3907
|
+
}
|
|
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 {
|
|
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."));
|
|
935
3987
|
});
|
|
936
3988
|
}
|
|
937
3989
|
|
|
@@ -939,7 +3991,7 @@ function registerOnboard(program2) {
|
|
|
939
3991
|
function resolveVersion() {
|
|
940
3992
|
try {
|
|
941
3993
|
const pkg = JSON.parse(
|
|
942
|
-
|
|
3994
|
+
readFileSync8(new URL("../package.json", import.meta.url), "utf8")
|
|
943
3995
|
);
|
|
944
3996
|
return pkg.version ?? "0.0.0";
|
|
945
3997
|
} catch {
|
|
@@ -948,19 +4000,69 @@ function resolveVersion() {
|
|
|
948
4000
|
}
|
|
949
4001
|
var program = new Command();
|
|
950
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
|
+
);
|
|
951
4025
|
program.hook("preAction", (_thisCmd, actionCmd) => {
|
|
952
4026
|
setQuiet(Boolean(actionCmd.optsWithGlobals().json));
|
|
953
4027
|
});
|
|
954
|
-
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) => {
|
|
955
4036
|
const g = cmd.optsWithGlobals();
|
|
956
4037
|
const persisted = readPersisted();
|
|
957
4038
|
const baseUrl = g.baseUrl ?? process.env.SECHROOM_BASE_URL ?? persisted.baseUrl ?? "https://app.sechroom.ai/api";
|
|
958
4039
|
await login({ baseUrl: baseUrl.replace(/\/$/, ""), tenant: g.tenant ?? "" });
|
|
959
4040
|
});
|
|
960
4041
|
var config = program.command("config").description("Manage persisted CLI config");
|
|
961
|
-
config.
|
|
962
|
-
|
|
963
|
-
|
|
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)
|
|
964
4066
|
`);
|
|
965
4067
|
process.exit(1);
|
|
966
4068
|
}
|
|
@@ -968,17 +4070,49 @@ config.command("set <key> <value>").description("Set baseUrl | tenant | clientId
|
|
|
968
4070
|
process.stdout.write(`set ${key}
|
|
969
4071
|
`);
|
|
970
4072
|
});
|
|
971
|
-
config.command("show").description("Print
|
|
972
|
-
|
|
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
|
+
);
|
|
973
4095
|
});
|
|
974
4096
|
registerMemory(program);
|
|
975
4097
|
registerWorklog(program);
|
|
976
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);
|
|
977
4108
|
registerInit(program);
|
|
978
4109
|
registerSetup(program);
|
|
979
4110
|
registerOnboard(program);
|
|
980
|
-
program
|
|
981
|
-
|
|
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)}
|
|
982
4116
|
`);
|
|
983
4117
|
process.exit(1);
|
|
984
4118
|
});
|