@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.
Files changed (3) hide show
  1. package/README.md +61 -8
  2. package/dist/index.js +443 -41
  3. 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
- npm run gen
62
- SECHROOM_OPENAPI_URL=https://<host>/api/openapi/v1.json npm run gen # other envs
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
- `npm run gen` overwrites `src/generated/api.d.ts`. The **real generated types are
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="<jwt from /auth/dev/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 — `npm run gen`; real types committed (hermetic)
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
- `npm ci → gen → typecheck → build → publish` in a `node:20` container, authed by
127
- a masked `NPM_TOKEN` param; `next` stamps a per-build prerelease, `latest` ships
128
- `BASE_CLI_VERSION`.
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 { data, error } = await client.POST("/memories", {
248
- body: {
249
- text: opts.text,
250
- type: opts.type,
251
- content: "{}",
252
- confidence: Number(opts.confidence),
253
- source: opts.source,
254
- archetype: "Document",
255
- title: opts.title ?? null,
256
- tags: opts.tag ?? null,
257
- owner: unfiled ? null : { type: opts.ownerType, id: String(opts.ownerId ?? "") }
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 client = await makeClient(cfg);
266
- const { data, error } = await client.GET("/memories/{memoryId}", {
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 client = await makeClient(cfg);
275
- const { data, error } = await client.POST("/memories/search", {
276
- body: {
277
- query: null,
278
- textQuery: null,
279
- semanticQuery: query,
280
- hybrid: true,
281
- limit: Number(opts.limit),
282
- includeArchived: Boolean(opts.includeArchived),
283
- includeSystem: false,
284
- ...opts.tag ? { tags: opts.tag } : {},
285
- ...opts.workspace ? { workspaceId: opts.workspace } : {}
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 client = await makeClient(cfg);
299
- const { data, error } = await client.POST("/operator-surface/work-log/append", {
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
- bullet: opts.text,
302
- laneId: opts.source ?? null,
303
- workspaceId: opts.workspace ?? null,
304
- title: opts.title ?? null
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
- if (error) fail(error);
308
- emit(data, cmd.optsWithGlobals().json);
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("0.0.0").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);
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.1",
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": "tools/sechroom-cli"
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
+ }