@sechroom/cli 2026.6.1 → 2026.6.3
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 +61 -8
- package/dist/index.js +443 -41
- package/package.json +9 -10
package/README.md
CHANGED
|
@@ -54,15 +54,27 @@ npm i -g @sechroom/cli@next
|
|
|
54
54
|
npx @sechroom/cli login
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
+
## Develop
|
|
58
|
+
|
|
59
|
+
This is a **pnpm workspace member** (`frontend/apps/cli`). Install from the repo root
|
|
60
|
+
(`pnpm install`), then run scripts via the filter (or `pnpm <script>` from this dir):
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pnpm --filter @sechroom/cli run gen # regenerate the typed client
|
|
64
|
+
pnpm --filter @sechroom/cli run check-types
|
|
65
|
+
pnpm --filter @sechroom/cli run build
|
|
66
|
+
pnpm --filter @sechroom/cli run dev -- --help # tsx, no build
|
|
67
|
+
```
|
|
68
|
+
|
|
57
69
|
## Generate the client (once, and after any API change)
|
|
58
70
|
|
|
59
71
|
```bash
|
|
60
72
|
# defaults to https://app.sechroom.ai/api/openapi/v1.json
|
|
61
|
-
|
|
62
|
-
SECHROOM_OPENAPI_URL=https://<host>/api/openapi/v1.json
|
|
73
|
+
pnpm --filter @sechroom/cli run gen
|
|
74
|
+
SECHROOM_OPENAPI_URL=https://<host>/api/openapi/v1.json pnpm --filter @sechroom/cli run gen # other envs
|
|
63
75
|
```
|
|
64
76
|
|
|
65
|
-
`
|
|
77
|
+
`gen` overwrites `src/generated/api.d.ts`. The **real generated types are
|
|
66
78
|
committed** (regenerated from the live prod spec) so builds are hermetic — no
|
|
67
79
|
live-API dependency at compile time; re-run `gen` after any API change. Generating
|
|
68
80
|
against the live schema is what caught the original placeholder's shape drift
|
|
@@ -82,17 +94,52 @@ sechroom memory get mem_XXXX
|
|
|
82
94
|
sechroom memory search "convention lifecycle drift" --limit 5
|
|
83
95
|
sechroom worklog append --text "shipped CLI skeleton; pointers: ..." --source claude-code-chris
|
|
84
96
|
|
|
97
|
+
sechroom lookup mem_XXXX # what is this id? -> kind / title / view URL
|
|
98
|
+
sechroom lookup sechroom:mem_XXXX --json # namespaced form also resolves
|
|
99
|
+
|
|
85
100
|
sechroom --json memory get mem_XXXX # agent-friendly
|
|
86
101
|
```
|
|
87
102
|
|
|
88
103
|
Headless:
|
|
89
104
|
|
|
90
105
|
```bash
|
|
91
|
-
export SECHROOM_TOKEN="<
|
|
106
|
+
export SECHROOM_TOKEN="<bearer: a PAT or dev-token JWT>"
|
|
92
107
|
export SECHROOM_TENANT=ocd
|
|
93
108
|
sechroom --json memory search "rate limiting"
|
|
94
109
|
```
|
|
95
110
|
|
|
111
|
+
## Onboarding (`init` / `setup`)
|
|
112
|
+
|
|
113
|
+
`sechroom init` wires a project for sechroom by rendering the server's
|
|
114
|
+
operator-surface setup descriptors (`GET /operator-surface/setup`, tenant-scoped
|
|
115
|
+
— the aggregator URL is baked in) into local AI-client config + agent instruction
|
|
116
|
+
files. **Idempotent — merges/appends, never clobbers** (JSON `mcpServers` merge;
|
|
117
|
+
Codex TOML table replace; instruction files use a managed marker block).
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
sechroom init # Claude Code (default): .mcp.json + CLAUDE.md
|
|
121
|
+
sechroom init --client all # claude-code, claude-desktop, codex, cursor
|
|
122
|
+
sechroom init --client codex,cursor # a subset
|
|
123
|
+
sechroom init --dry-run --json # preview the writes, no changes
|
|
124
|
+
|
|
125
|
+
# granular pieces init orchestrates:
|
|
126
|
+
sechroom setup mcp claude-desktop # just the MCP config for one client
|
|
127
|
+
sechroom setup agent-files codex # just the AGENTS.md instruction file
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Per client → where it writes:
|
|
131
|
+
|
|
132
|
+
| client | MCP config | instruction file |
|
|
133
|
+
|---|---|---|
|
|
134
|
+
| `claude-code` | `./.mcp.json` | `./CLAUDE.md` |
|
|
135
|
+
| `claude-desktop` | `claude_desktop_config.json` (OS path) | `~/.claude/CLAUDE.md` |
|
|
136
|
+
| `codex` | `~/.codex/config.toml` | `./AGENTS.md` |
|
|
137
|
+
| `cursor` | `./.cursor/mcp.json` | `./AGENTS.md` |
|
|
138
|
+
|
|
139
|
+
The instruction-file step pulls the role template the SEM Starter bundle ships
|
|
140
|
+
(via the descriptor's tag-query artifact). If that bundle isn't installed in the
|
|
141
|
+
tenant, the step **skips gracefully** with a note (MCP config still gets written).
|
|
142
|
+
|
|
96
143
|
## Layout
|
|
97
144
|
|
|
98
145
|
```
|
|
@@ -101,10 +148,16 @@ src/
|
|
|
101
148
|
auth.ts OAuth auth-code+PKCE loopback (fixed-port DCR) + refresh + cache
|
|
102
149
|
client.ts openapi-fetch client (auth + tenant middleware) + emit/fail
|
|
103
150
|
config.ts base-url / tenant / token resolution + persistence
|
|
104
|
-
generated/api.d.ts typed client — `
|
|
151
|
+
generated/api.d.ts typed client — `pnpm run gen`; real types committed (hermetic)
|
|
105
152
|
commands/
|
|
106
153
|
memory.ts create / get / search
|
|
107
154
|
worklog.ts append
|
|
155
|
+
lookup.ts resolve any id (mem_…/unprefixed/sechroom:<id>) -> kind/title/url
|
|
156
|
+
setup.ts init + setup mcp/agent-files
|
|
157
|
+
setup/
|
|
158
|
+
operator-surface.ts fetch GET /operator-surface/setup + resolve template artifacts
|
|
159
|
+
clients.ts client→(surface, local paths, format) registry
|
|
160
|
+
apply.ts writers: mcp json merge / codex toml / instruction marker block
|
|
108
161
|
```
|
|
109
162
|
|
|
110
163
|
## Remaining before ship
|
|
@@ -123,9 +176,9 @@ Public **npmjs** under the `@sechroom` org is the host; **TeamCity** is the
|
|
|
123
176
|
publisher (build config `Sechroom_PublishCli`). The full pipeline — steps,
|
|
124
177
|
parameters, npm auth, provisioning, and how to run a publish — is documented in
|
|
125
178
|
[`ci/PUBLISHING.md`](./ci/PUBLISHING.md). In short: a manual-trigger config runs
|
|
126
|
-
`
|
|
127
|
-
a
|
|
128
|
-
`
|
|
179
|
+
`pnpm install → gen → check-types → build → publish` (filtered to `@sechroom/cli`)
|
|
180
|
+
in a `node:20` container, authed by a masked `NPM_TOKEN` param. Every publish gets a
|
|
181
|
+
fresh calendar version `YYYY.M.<n>` (own counter); the dist-tag separates next/latest.
|
|
129
182
|
|
|
130
183
|
Why public npmjs: the CLI is customer-facing, and `npx @sechroom/cli` must work
|
|
131
184
|
with zero `.npmrc`/token on the customer side. GitHub Packages or a private
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
4
5
|
import { Command } from "commander";
|
|
5
6
|
|
|
6
7
|
// src/auth.ts
|
|
@@ -211,6 +212,62 @@ async function requireToken(cfg) {
|
|
|
211
212
|
|
|
212
213
|
// src/client.ts
|
|
213
214
|
import createClient from "openapi-fetch";
|
|
215
|
+
|
|
216
|
+
// src/ui.ts
|
|
217
|
+
var FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
|
|
218
|
+
var quiet = false;
|
|
219
|
+
function setQuiet(q) {
|
|
220
|
+
quiet = q;
|
|
221
|
+
}
|
|
222
|
+
function active() {
|
|
223
|
+
return !quiet && Boolean(process.stderr.isTTY);
|
|
224
|
+
}
|
|
225
|
+
function spinner(text) {
|
|
226
|
+
if (!active()) {
|
|
227
|
+
return { succeed() {
|
|
228
|
+
}, fail() {
|
|
229
|
+
}, stop() {
|
|
230
|
+
} };
|
|
231
|
+
}
|
|
232
|
+
let i = 0;
|
|
233
|
+
const render = () => {
|
|
234
|
+
i = (i + 1) % FRAMES.length;
|
|
235
|
+
process.stderr.write(`\r${FRAMES[i]} ${text}`);
|
|
236
|
+
};
|
|
237
|
+
process.stderr.write(`\r${FRAMES[0]} ${text}`);
|
|
238
|
+
const timer = setInterval(render, 80);
|
|
239
|
+
timer.unref?.();
|
|
240
|
+
const clear = () => {
|
|
241
|
+
clearInterval(timer);
|
|
242
|
+
process.stderr.write("\r\x1B[K");
|
|
243
|
+
};
|
|
244
|
+
return {
|
|
245
|
+
succeed(t) {
|
|
246
|
+
clear();
|
|
247
|
+
process.stderr.write(`\u2713 ${t ?? text}
|
|
248
|
+
`);
|
|
249
|
+
},
|
|
250
|
+
fail(t) {
|
|
251
|
+
clear();
|
|
252
|
+
process.stderr.write(`\u2717 ${t ?? text}
|
|
253
|
+
`);
|
|
254
|
+
},
|
|
255
|
+
stop: clear
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
async function withSpinner(text, fn) {
|
|
259
|
+
const s = spinner(text);
|
|
260
|
+
try {
|
|
261
|
+
const result = await fn();
|
|
262
|
+
s.succeed();
|
|
263
|
+
return result;
|
|
264
|
+
} catch (err) {
|
|
265
|
+
s.fail();
|
|
266
|
+
throw err;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// src/client.ts
|
|
214
271
|
async function makeClient(cfg) {
|
|
215
272
|
const token = await requireToken(cfg);
|
|
216
273
|
const client = createClient({ baseUrl: cfg.baseUrl });
|
|
@@ -230,6 +287,22 @@ function emit(data, json) {
|
|
|
230
287
|
process.stdout.write(JSON.stringify(data, null, 2) + "\n");
|
|
231
288
|
}
|
|
232
289
|
}
|
|
290
|
+
async function runApi(label, fn) {
|
|
291
|
+
const s = spinner(label);
|
|
292
|
+
let res;
|
|
293
|
+
try {
|
|
294
|
+
res = await fn();
|
|
295
|
+
} catch (err) {
|
|
296
|
+
s.fail();
|
|
297
|
+
fail(err);
|
|
298
|
+
}
|
|
299
|
+
if (res.error !== void 0 && res.error !== null) {
|
|
300
|
+
s.fail();
|
|
301
|
+
fail(res.error);
|
|
302
|
+
}
|
|
303
|
+
s.succeed();
|
|
304
|
+
return res.data;
|
|
305
|
+
}
|
|
233
306
|
function fail(error) {
|
|
234
307
|
const msg = typeof error === "object" && error !== null && "title" in error ? String(error.title) : typeof error === "object" ? JSON.stringify(error) : String(error);
|
|
235
308
|
process.stderr.write(`error: ${msg}
|
|
@@ -242,50 +315,51 @@ function registerMemory(program2) {
|
|
|
242
315
|
const memory = program2.command("memory").description("Create, read, and search memories");
|
|
243
316
|
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) => {
|
|
244
317
|
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
245
|
-
const client = await makeClient(cfg);
|
|
246
318
|
const unfiled = String(opts.ownerType).toLowerCase() === "unfiled";
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
319
|
+
const data = await runApi("Creating memory", async () => {
|
|
320
|
+
const client = await makeClient(cfg);
|
|
321
|
+
return client.POST("/memories", {
|
|
322
|
+
body: {
|
|
323
|
+
text: opts.text,
|
|
324
|
+
type: opts.type,
|
|
325
|
+
content: "{}",
|
|
326
|
+
confidence: Number(opts.confidence),
|
|
327
|
+
source: opts.source,
|
|
328
|
+
archetype: "Document",
|
|
329
|
+
title: opts.title ?? null,
|
|
330
|
+
tags: opts.tag ?? null,
|
|
331
|
+
owner: unfiled ? null : { type: opts.ownerType, id: String(opts.ownerId ?? "") }
|
|
332
|
+
}
|
|
333
|
+
});
|
|
259
334
|
});
|
|
260
|
-
if (error) fail(error);
|
|
261
335
|
emit(data, cmd.optsWithGlobals().json);
|
|
262
336
|
});
|
|
263
337
|
memory.command("get <memoryId>").description("Fetch a memory by id (GET /memories/{memoryId})").action(async (memoryId, _opts, cmd) => {
|
|
264
338
|
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
params: { path: { memoryId } }
|
|
339
|
+
const data = await runApi("Fetching memory", async () => {
|
|
340
|
+
const client = await makeClient(cfg);
|
|
341
|
+
return client.GET("/memories/{memoryId}", { params: { path: { memoryId } } });
|
|
268
342
|
});
|
|
269
|
-
if (error) fail(error);
|
|
270
343
|
emit(data, cmd.optsWithGlobals().json);
|
|
271
344
|
});
|
|
272
345
|
memory.command("search <query>").description("Hybrid search (POST /memories/search; SemanticQuery -> vector+FTS RRF)").option("--limit <n>", "Max results", "10").option("--tag <tag...>", "Require all listed tags").option("--workspace <workspaceId>", "Scope to a workspace (cascades to its projects)").option("--include-archived", "Include archived memories", false).action(async (query, opts, cmd) => {
|
|
273
346
|
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
347
|
+
const data = await runApi("Searching", async () => {
|
|
348
|
+
const client = await makeClient(cfg);
|
|
349
|
+
return client.POST("/memories/search", {
|
|
350
|
+
body: {
|
|
351
|
+
query: null,
|
|
352
|
+
textQuery: null,
|
|
353
|
+
semanticQuery: query,
|
|
354
|
+
hybrid: true,
|
|
355
|
+
limit: Number(opts.limit),
|
|
356
|
+
includeArchived: Boolean(opts.includeArchived),
|
|
357
|
+
includeSystem: false,
|
|
358
|
+
...opts.tag ? { tags: opts.tag } : {},
|
|
359
|
+
...opts.workspace ? { workspaceId: opts.workspace } : {}
|
|
360
|
+
}
|
|
361
|
+
});
|
|
287
362
|
});
|
|
288
|
-
if (error) fail(error);
|
|
289
363
|
emit(data, cmd.optsWithGlobals().json);
|
|
290
364
|
});
|
|
291
365
|
}
|
|
@@ -295,23 +369,348 @@ function registerWorklog(program2) {
|
|
|
295
369
|
const worklog = program2.command("worklog").description("Append to the daily work log");
|
|
296
370
|
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) => {
|
|
297
371
|
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
298
|
-
const
|
|
299
|
-
|
|
372
|
+
const data = await runApi("Appending work-log entry", async () => {
|
|
373
|
+
const client = await makeClient(cfg);
|
|
374
|
+
return client.POST("/operator-surface/work-log/append", {
|
|
375
|
+
body: {
|
|
376
|
+
bullet: opts.text,
|
|
377
|
+
laneId: opts.source ?? null,
|
|
378
|
+
workspaceId: opts.workspace ?? null,
|
|
379
|
+
title: opts.title ?? null
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
emit(data, cmd.optsWithGlobals().json);
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// src/commands/lookup.ts
|
|
388
|
+
function registerLookup(program2) {
|
|
389
|
+
program2.command("lookup <id>").description(
|
|
390
|
+
"Resolve a sechroom id to its kind, title, and view URL (mem_\u2026/wsp_\u2026/prj_\u2026, unprefixed, or sechroom:<id>)"
|
|
391
|
+
).action(async (id, _opts, cmd) => {
|
|
392
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
393
|
+
const data = await runApi(`Resolving ${id}`, async () => {
|
|
394
|
+
const client = await makeClient(cfg);
|
|
395
|
+
return client.GET("/lookup/{id}", { params: { path: { id } } });
|
|
396
|
+
});
|
|
397
|
+
if (data == null) {
|
|
398
|
+
process.stderr.write(`not found: ${id}
|
|
399
|
+
`);
|
|
400
|
+
process.exit(1);
|
|
401
|
+
}
|
|
402
|
+
emit(data, cmd.optsWithGlobals().json);
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// src/setup/apply.ts
|
|
407
|
+
import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync2 } from "fs";
|
|
408
|
+
import { dirname } from "path";
|
|
409
|
+
|
|
410
|
+
// src/setup/operator-surface.ts
|
|
411
|
+
var SectionType = {
|
|
412
|
+
McpConfig: "mcp-config",
|
|
413
|
+
McpConfigToml: "mcp-config-toml",
|
|
414
|
+
InstructionFile: "instruction-file",
|
|
415
|
+
ProjectConfig: "project-config",
|
|
416
|
+
Verify: "verify"
|
|
417
|
+
};
|
|
418
|
+
async function fetchSetup(cfg) {
|
|
419
|
+
const client = await makeClient(cfg);
|
|
420
|
+
const { data, error } = await client.GET("/operator-surface/setup", {});
|
|
421
|
+
if (error) throw new Error(`GET /operator-surface/setup failed: ${JSON.stringify(error)}`);
|
|
422
|
+
return data;
|
|
423
|
+
}
|
|
424
|
+
function findSurface(setup, surfaceKey) {
|
|
425
|
+
return setup.surfaces.find((s) => s.surfaceKey === surfaceKey);
|
|
426
|
+
}
|
|
427
|
+
function findSection(surface, sectionType) {
|
|
428
|
+
return surface?.sections.find((s) => s.sectionType === sectionType);
|
|
429
|
+
}
|
|
430
|
+
function sectionSnippet(section) {
|
|
431
|
+
if (!section) return null;
|
|
432
|
+
for (const step of section.steps) {
|
|
433
|
+
if (step.copyValue) return step.copyValue;
|
|
434
|
+
if (step.codeSnippet) return step.codeSnippet;
|
|
435
|
+
}
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
function parseTagArtifactId(id) {
|
|
439
|
+
if (!id.startsWith("tag:")) return null;
|
|
440
|
+
const tags = id.slice("tag:".length).split(",").map((t) => t.trim()).filter((t) => t.length > 0);
|
|
441
|
+
return tags.length > 0 ? tags : null;
|
|
442
|
+
}
|
|
443
|
+
async function resolveInstructionBody(cfg, section) {
|
|
444
|
+
const client = await makeClient(cfg);
|
|
445
|
+
for (const artifact of section.artifacts) {
|
|
446
|
+
const tags = parseTagArtifactId(artifact.id);
|
|
447
|
+
if (!tags) continue;
|
|
448
|
+
const { data } = await client.POST("/memories/search", {
|
|
300
449
|
body: {
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
450
|
+
query: null,
|
|
451
|
+
textQuery: null,
|
|
452
|
+
semanticQuery: artifact.title ?? "role instruction template",
|
|
453
|
+
hybrid: true,
|
|
454
|
+
limit: 1,
|
|
455
|
+
includeArchived: false,
|
|
456
|
+
includeSystem: false,
|
|
457
|
+
tags
|
|
305
458
|
}
|
|
306
459
|
});
|
|
307
|
-
|
|
308
|
-
|
|
460
|
+
const hits = data ?? [];
|
|
461
|
+
if (hits.length === 0) continue;
|
|
462
|
+
const memId = hits[0].id;
|
|
463
|
+
const { data: memEnvelope } = await client.GET("/memories/{memoryId}", {
|
|
464
|
+
params: { path: { memoryId: memId } }
|
|
465
|
+
});
|
|
466
|
+
const env = memEnvelope;
|
|
467
|
+
const body = env?.item?.text ?? env?.text;
|
|
468
|
+
if (typeof body === "string" && body.length > 0) {
|
|
469
|
+
return { title: env?.item?.title ?? env?.title ?? artifact.title, body };
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// src/setup/apply.ts
|
|
476
|
+
var BLOCK_BEGIN = "<!-- @sechroom/cli:begin (managed \u2014 re-run `sechroom setup agent-files` to refresh) -->";
|
|
477
|
+
var BLOCK_END = "<!-- @sechroom/cli:end -->";
|
|
478
|
+
function ensureDir2(path) {
|
|
479
|
+
mkdirSync2(dirname(path), { recursive: true });
|
|
480
|
+
}
|
|
481
|
+
function readOr(path, fallback) {
|
|
482
|
+
try {
|
|
483
|
+
return readFileSync2(path, "utf8");
|
|
484
|
+
} catch {
|
|
485
|
+
return fallback;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
function mergeMcpJson(path, snippet, dryRun) {
|
|
489
|
+
const incoming = JSON.parse(snippet);
|
|
490
|
+
const existed = existsSync2(path);
|
|
491
|
+
let current = {};
|
|
492
|
+
if (existed) {
|
|
493
|
+
try {
|
|
494
|
+
current = JSON.parse(readFileSync2(path, "utf8"));
|
|
495
|
+
} catch {
|
|
496
|
+
return { kind: "mcp", path, status: "skipped", note: "existing file isn't valid JSON \u2014 left untouched" };
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
current.mcpServers = { ...current.mcpServers ?? {}, ...incoming.mcpServers ?? {} };
|
|
500
|
+
if (dryRun) return { kind: "mcp", path, status: "dry-run" };
|
|
501
|
+
ensureDir2(path);
|
|
502
|
+
writeFileSync2(path, JSON.stringify(current, null, 2) + "\n", { mode: 384 });
|
|
503
|
+
return { kind: "mcp", path, status: existed ? "merged" : "created" };
|
|
504
|
+
}
|
|
505
|
+
function mergeCodexToml(path, snippet, dryRun) {
|
|
506
|
+
const existed = existsSync2(path);
|
|
507
|
+
let body = readOr(path, "");
|
|
508
|
+
body = body.replace(/(^|\n)\[mcp_servers\.sechroom\][^[]*/, "\n").replace(/\n{3,}/g, "\n\n");
|
|
509
|
+
const trimmed = body.trim();
|
|
510
|
+
const next = (trimmed.length > 0 ? trimmed + "\n\n" : "") + snippet.trim() + "\n";
|
|
511
|
+
if (dryRun) return { kind: "mcp", path, status: "dry-run" };
|
|
512
|
+
ensureDir2(path);
|
|
513
|
+
writeFileSync2(path, next, { mode: 384 });
|
|
514
|
+
return { kind: "mcp", path, status: existed ? "merged" : "created" };
|
|
515
|
+
}
|
|
516
|
+
function writeInstructionBlock(path, body, dryRun) {
|
|
517
|
+
const block = `${BLOCK_BEGIN}
|
|
518
|
+
${body.trim()}
|
|
519
|
+
${BLOCK_END}
|
|
520
|
+
`;
|
|
521
|
+
const existed = existsSync2(path);
|
|
522
|
+
const current = readOr(path, "");
|
|
523
|
+
let next;
|
|
524
|
+
const re = new RegExp(`${escapeRe(BLOCK_BEGIN)}[\\s\\S]*?${escapeRe(BLOCK_END)}\\n?`);
|
|
525
|
+
if (re.test(current)) {
|
|
526
|
+
next = current.replace(re, block);
|
|
527
|
+
} else {
|
|
528
|
+
next = current.trim().length > 0 ? `${current.trimEnd()}
|
|
529
|
+
|
|
530
|
+
${block}` : block;
|
|
531
|
+
}
|
|
532
|
+
if (dryRun) return { kind: "instruction", path, status: "dry-run" };
|
|
533
|
+
ensureDir2(path);
|
|
534
|
+
writeFileSync2(path, next);
|
|
535
|
+
return { kind: "instruction", path, status: existed ? "merged" : "created" };
|
|
536
|
+
}
|
|
537
|
+
function escapeRe(s) {
|
|
538
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
539
|
+
}
|
|
540
|
+
async function applyClient(cfg, setup, target, opts) {
|
|
541
|
+
const actions = [];
|
|
542
|
+
if (opts.mcp && target.mcp) {
|
|
543
|
+
const surface = findSurface(setup, target.mcp.surfaceKey);
|
|
544
|
+
const section = findSection(surface, target.mcp.sectionType);
|
|
545
|
+
const snippet = sectionSnippet(section);
|
|
546
|
+
if (!snippet) {
|
|
547
|
+
actions.push({ kind: "mcp", path: target.mcp.path, status: "skipped", note: `no ${target.mcp.sectionType} section on surface '${target.mcp.surfaceKey}'` });
|
|
548
|
+
} else {
|
|
549
|
+
actions.push(
|
|
550
|
+
target.mcp.format === "toml" ? mergeCodexToml(target.mcp.path, snippet, opts.dryRun) : mergeMcpJson(target.mcp.path, snippet, opts.dryRun)
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
if (opts.agentFiles && target.instruction) {
|
|
555
|
+
const surface = findSurface(setup, target.instruction.surfaceKey);
|
|
556
|
+
const section = findSection(surface, SectionType.InstructionFile);
|
|
557
|
+
if (!section) {
|
|
558
|
+
actions.push({ kind: "instruction", path: target.instruction.path, status: "skipped", note: `no instruction-file section on surface '${target.instruction.surfaceKey}'` });
|
|
559
|
+
} else {
|
|
560
|
+
const resolved = await resolveInstructionBody(cfg, section);
|
|
561
|
+
if (!resolved) {
|
|
562
|
+
actions.push({ kind: "instruction", path: target.instruction.path, status: "skipped", note: "no role template found in this tenant \u2014 install the SEM Starter bundle, then re-run `sechroom setup agent-files`" });
|
|
563
|
+
} else {
|
|
564
|
+
actions.push(writeInstructionBlock(target.instruction.path, resolved.body, opts.dryRun));
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
return actions;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// src/setup/clients.ts
|
|
572
|
+
import { homedir as homedir2 } from "os";
|
|
573
|
+
import { join as join2 } from "path";
|
|
574
|
+
function claudeDesktopConfigPath(home) {
|
|
575
|
+
switch (process.platform) {
|
|
576
|
+
case "darwin":
|
|
577
|
+
return join2(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
578
|
+
case "win32":
|
|
579
|
+
return join2(process.env.APPDATA ?? join2(home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
|
|
580
|
+
default:
|
|
581
|
+
return join2(home, ".config", "Claude", "claude_desktop_config.json");
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
function clientTargets(cwd) {
|
|
585
|
+
const home = homedir2();
|
|
586
|
+
return {
|
|
587
|
+
"claude-code": {
|
|
588
|
+
key: "claude-code",
|
|
589
|
+
label: "Claude Code",
|
|
590
|
+
mcp: { surfaceKey: "claude-code", sectionType: SectionType.McpConfig, path: join2(cwd, ".mcp.json"), format: "json" },
|
|
591
|
+
instruction: { surfaceKey: "claude-code", path: join2(cwd, "CLAUDE.md") }
|
|
592
|
+
},
|
|
593
|
+
"claude-desktop": {
|
|
594
|
+
key: "claude-desktop",
|
|
595
|
+
label: "Claude Desktop",
|
|
596
|
+
mcp: { surfaceKey: "claude-desktop", sectionType: SectionType.McpConfig, path: claudeDesktopConfigPath(home), format: "json" },
|
|
597
|
+
instruction: { surfaceKey: "claude-desktop", path: join2(home, ".claude", "CLAUDE.md") }
|
|
598
|
+
},
|
|
599
|
+
codex: {
|
|
600
|
+
key: "codex",
|
|
601
|
+
label: "Codex CLI",
|
|
602
|
+
mcp: { surfaceKey: "chatgpt", sectionType: SectionType.McpConfigToml, path: join2(home, ".codex", "config.toml"), format: "toml" },
|
|
603
|
+
instruction: { surfaceKey: "chatgpt", path: join2(cwd, "AGENTS.md") }
|
|
604
|
+
},
|
|
605
|
+
cursor: {
|
|
606
|
+
key: "cursor",
|
|
607
|
+
label: "Cursor",
|
|
608
|
+
mcp: { surfaceKey: "claude-code", sectionType: SectionType.McpConfig, path: join2(cwd, ".cursor", "mcp.json"), format: "json" },
|
|
609
|
+
instruction: { surfaceKey: "chatgpt", path: join2(cwd, "AGENTS.md") }
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
var ALL_CLIENT_KEYS = ["claude-code", "claude-desktop", "codex", "cursor"];
|
|
614
|
+
var DEFAULT_CLIENT_KEY = "claude-code";
|
|
615
|
+
|
|
616
|
+
// src/commands/setup.ts
|
|
617
|
+
function resolveClientKeys(raw) {
|
|
618
|
+
const targets = clientTargets(process.cwd());
|
|
619
|
+
if (raw === "all") return [...ALL_CLIENT_KEYS];
|
|
620
|
+
const keys = raw.split(",").map((k) => k.trim()).filter(Boolean);
|
|
621
|
+
for (const k of keys) {
|
|
622
|
+
if (!targets[k]) {
|
|
623
|
+
fail(`unknown client '${k}'. Known: ${ALL_CLIENT_KEYS.join(", ")}, or 'all'.`);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return keys;
|
|
627
|
+
}
|
|
628
|
+
function printActions(client, actions) {
|
|
629
|
+
process.stdout.write(`
|
|
630
|
+
${client.label} (${client.key}):
|
|
631
|
+
`);
|
|
632
|
+
for (const a of actions) {
|
|
633
|
+
const tag = a.status === "skipped" ? "skip" : a.status;
|
|
634
|
+
process.stdout.write(` [${tag}] ${a.kind}: ${a.path}${a.note ? ` \u2014 ${a.note}` : ""}
|
|
635
|
+
`);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
function registerInit(program2) {
|
|
639
|
+
program2.command("init").description("Wire this project for sechroom: write MCP config + agent instruction files from the server's setup descriptors").option("--client <list>", `comma-separated clients (${ALL_CLIENT_KEYS.join(", ")}) or 'all'`, DEFAULT_CLIENT_KEY).option("--dry-run", "print what would be written without writing", false).option("--mcp-only", "only write MCP config (skip agent files)", false).option("--agent-files-only", "only write agent instruction files (skip MCP config)", false).action(async (opts, cmd) => {
|
|
640
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
641
|
+
const setup = await withSpinner("Fetching setup descriptors", () => fetchSetup(cfg));
|
|
642
|
+
const targets = clientTargets(process.cwd());
|
|
643
|
+
const keys = resolveClientKeys(opts.client);
|
|
644
|
+
const json = cmd.optsWithGlobals().json;
|
|
645
|
+
const result = [];
|
|
646
|
+
for (const key of keys) {
|
|
647
|
+
const target = targets[key];
|
|
648
|
+
const actions = await applyClient(cfg, setup, target, {
|
|
649
|
+
dryRun: Boolean(opts.dryRun),
|
|
650
|
+
mcp: !opts.agentFilesOnly,
|
|
651
|
+
agentFiles: !opts.mcpOnly
|
|
652
|
+
});
|
|
653
|
+
result.push({ client: key, actions });
|
|
654
|
+
if (!json) printActions(target, actions);
|
|
655
|
+
}
|
|
656
|
+
if (json) {
|
|
657
|
+
emit({ dryRun: Boolean(opts.dryRun), clients: result }, true);
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
const first = targets[keys[0]];
|
|
661
|
+
const surface = findSurface(setup, first.mcp?.surfaceKey ?? first.instruction?.surfaceKey ?? "");
|
|
662
|
+
const verify = findSection(surface, SectionType.Verify);
|
|
663
|
+
if (verify?.description) {
|
|
664
|
+
process.stdout.write(`
|
|
665
|
+
Next \u2014 verify: ${verify.description}
|
|
666
|
+
`);
|
|
667
|
+
}
|
|
668
|
+
process.stdout.write(
|
|
669
|
+
opts.dryRun ? "\n(dry run \u2014 nothing written)\n" : "\nDone. Restart your AI client (or reload MCP) to pick up the new config.\n"
|
|
670
|
+
);
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
function registerSetup(program2) {
|
|
674
|
+
const setup = program2.command("setup").description("Granular onboarding steps (init runs these together)");
|
|
675
|
+
setup.command("mcp <client>").description(`Write only the MCP config for a client (${ALL_CLIENT_KEYS.join(", ")})`).option("--dry-run", "print what would be written without writing", false).action(async (client, opts, cmd) => {
|
|
676
|
+
await runSingle(client, cmd, { dryRun: Boolean(opts.dryRun), mcp: true, agentFiles: false });
|
|
677
|
+
});
|
|
678
|
+
setup.command("agent-files <client>").description(`Write only the agent instruction file for a client (${ALL_CLIENT_KEYS.join(", ")})`).option("--dry-run", "print what would be written without writing", false).action(async (client, opts, cmd) => {
|
|
679
|
+
await runSingle(client, cmd, { dryRun: Boolean(opts.dryRun), mcp: false, agentFiles: true });
|
|
309
680
|
});
|
|
310
681
|
}
|
|
682
|
+
async function runSingle(client, cmd, opts) {
|
|
683
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
684
|
+
const targets = clientTargets(process.cwd());
|
|
685
|
+
const target = targets[client];
|
|
686
|
+
if (!target) fail(`unknown client '${client}'. Known: ${ALL_CLIENT_KEYS.join(", ")}.`);
|
|
687
|
+
const setupData = await withSpinner("Fetching setup descriptors", () => fetchSetup(cfg));
|
|
688
|
+
const actions = await applyClient(cfg, setupData, target, opts);
|
|
689
|
+
const json = cmd.optsWithGlobals().json;
|
|
690
|
+
if (json) {
|
|
691
|
+
emit({ dryRun: opts.dryRun, client, actions }, true);
|
|
692
|
+
} else {
|
|
693
|
+
printActions(target, actions);
|
|
694
|
+
process.stdout.write(opts.dryRun ? "\n(dry run \u2014 nothing written)\n" : "\nDone.\n");
|
|
695
|
+
}
|
|
696
|
+
}
|
|
311
697
|
|
|
312
698
|
// src/index.ts
|
|
699
|
+
function resolveVersion() {
|
|
700
|
+
try {
|
|
701
|
+
const pkg = JSON.parse(
|
|
702
|
+
readFileSync3(new URL("../package.json", import.meta.url), "utf8")
|
|
703
|
+
);
|
|
704
|
+
return pkg.version ?? "0.0.0";
|
|
705
|
+
} catch {
|
|
706
|
+
return "0.0.0";
|
|
707
|
+
}
|
|
708
|
+
}
|
|
313
709
|
var program = new Command();
|
|
314
|
-
program.name("sechroom").description("Sechroom CLI \u2014 thin generated client over the Sechroom HTTP API. An agent/human surface alongside MCP.").version(
|
|
710
|
+
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);
|
|
711
|
+
program.hook("preAction", (_thisCmd, actionCmd) => {
|
|
712
|
+
setQuiet(Boolean(actionCmd.optsWithGlobals().json));
|
|
713
|
+
});
|
|
315
714
|
program.command("login").description("Sign in via browser (OAuth auth-code + PKCE, dynamic client registration)").action(async (_opts, cmd) => {
|
|
316
715
|
const g = cmd.optsWithGlobals();
|
|
317
716
|
const persisted = readPersisted();
|
|
@@ -334,6 +733,9 @@ config.command("show").description("Print persisted config").action(() => {
|
|
|
334
733
|
});
|
|
335
734
|
registerMemory(program);
|
|
336
735
|
registerWorklog(program);
|
|
736
|
+
registerLookup(program);
|
|
737
|
+
registerInit(program);
|
|
738
|
+
registerSetup(program);
|
|
337
739
|
program.parseAsync().catch((err) => {
|
|
338
740
|
process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}
|
|
339
741
|
`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sechroom/cli",
|
|
3
|
-
"version": "2026.6.
|
|
3
|
+
"version": "2026.6.3",
|
|
4
4
|
"description": "Sechroom CLI — a thin, generated client over the Sechroom HTTP API. An agent/human surface alongside MCP.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"repository": {
|
|
9
9
|
"type": "git",
|
|
10
10
|
"url": "git+https://github.com/OcdLimited/sechroom.git",
|
|
11
|
-
"directory": "
|
|
11
|
+
"directory": "frontend/apps/cli"
|
|
12
12
|
},
|
|
13
13
|
"bin": {
|
|
14
14
|
"sechroom": "./dist/index.js"
|
|
@@ -23,13 +23,6 @@
|
|
|
23
23
|
"access": "public",
|
|
24
24
|
"registry": "https://registry.npmjs.org/"
|
|
25
25
|
},
|
|
26
|
-
"scripts": {
|
|
27
|
-
"gen": "openapi-typescript \"${SECHROOM_OPENAPI_URL:-https://app.sechroom.ai/api/openapi/v1.json}\" -o src/generated/api.d.ts",
|
|
28
|
-
"build": "tsup src/index.ts --format esm --target node20 --clean",
|
|
29
|
-
"dev": "tsx src/index.ts",
|
|
30
|
-
"typecheck": "tsc --noEmit",
|
|
31
|
-
"prepublishOnly": "npm run build"
|
|
32
|
-
},
|
|
33
26
|
"dependencies": {
|
|
34
27
|
"commander": "^12.1.0",
|
|
35
28
|
"open": "^10.1.0",
|
|
@@ -41,5 +34,11 @@
|
|
|
41
34
|
"tsup": "^8.3.0",
|
|
42
35
|
"tsx": "^4.19.0",
|
|
43
36
|
"typescript": "^5.6.0"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"gen": "openapi-typescript \"${SECHROOM_OPENAPI_URL:-https://app.sechroom.ai/api/openapi/v1.json}\" -o src/generated/api.d.ts",
|
|
40
|
+
"build": "tsup src/index.ts --format esm --target node20 --clean",
|
|
41
|
+
"dev": "tsx src/index.ts",
|
|
42
|
+
"check-types": "tsc --noEmit"
|
|
44
43
|
}
|
|
45
|
-
}
|
|
44
|
+
}
|