@sechroom/cli 2026.6.8 → 2026.6.9-rc.2a81188f

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 +29 -1
  2. package/dist/index.js +3087 -282
  3. 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 readFileSync3 } from "fs";
4
+ import { readFileSync as readFileSync8 } from "fs";
5
5
  import { Command } from "commander";
6
6
 
7
7
  // src/auth.ts
@@ -12,12 +12,16 @@ import open from "open";
12
12
  // src/config.ts
13
13
  import { homedir } from "os";
14
14
  import { join, dirname } from "path";
15
- import { mkdirSync, readFileSync, writeFileSync, existsSync } from "fs";
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 LOCAL_CONFIG_NAME = ".sechroom.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"];
20
23
  var DEFAULT_BASE_URL = "https://app.sechroom.ai/api";
24
+ var LOCAL_CONFIG_SCHEMA_VERSION = 2;
21
25
  function ensureDir() {
22
26
  if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
23
27
  }
@@ -53,35 +57,65 @@ function writeToken(tok) {
53
57
  ensureDir();
54
58
  writeFileSync(TOKEN_FILE, JSON.stringify(tok, null, 2), { mode: 384 });
55
59
  }
56
- function findLocalConfigPath(start = process.cwd()) {
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()) {
57
78
  let dir = start;
58
79
  for (; ; ) {
59
- const candidate = join(dir, LOCAL_CONFIG_NAME);
60
- if (existsSync(candidate)) return candidate;
80
+ if (existsSync(join(dir, BASELINE_CONFIG_NAME)) || existsSync(join(dir, OVERRIDE_CONFIG_NAME))) return dir;
61
81
  const parent = dirname(dir);
62
82
  if (parent === dir) return void 0;
63
83
  dir = parent;
64
84
  }
65
85
  }
66
86
  function readLocalConfig() {
67
- const path = findLocalConfigPath();
68
- if (!path) return {};
69
- try {
70
- const c = JSON.parse(readFileSync(path, "utf8"));
71
- return { baseUrl: c.baseUrl, tenant: c.tenant, path };
72
- } catch {
73
- return {};
74
- }
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
+ };
75
100
  }
76
101
  function writeLocalConfig(patch) {
77
- const path = findLocalConfigPath() ?? join(process.cwd(), LOCAL_CONFIG_NAME);
78
- let current = {};
79
- try {
80
- current = JSON.parse(readFileSync(path, "utf8"));
81
- } catch {
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 });
82
113
  }
83
- writeFileSync(path, JSON.stringify({ ...current, ...patch }, null, 2), { mode: 384 });
84
- return path;
114
+ return baselinePath;
115
+ }
116
+ function committedBindingPath(dir) {
117
+ const p = join(dir, BASELINE_CONFIG_NAME);
118
+ return existsSync(p) ? p : void 0;
85
119
  }
86
120
  function resolveConfig(flags) {
87
121
  const local = readLocalConfig();
@@ -93,7 +127,9 @@ function resolveConfig(flags) {
93
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."
94
128
  );
95
129
  }
96
- return { baseUrl: baseUrl.replace(/\/$/, ""), tenant, clientId: persisted.clientId };
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 };
97
133
  }
98
134
  function describeConfig(flags) {
99
135
  const local = readLocalConfig();
@@ -111,6 +147,7 @@ function describeConfig(flags) {
111
147
  return {
112
148
  baseUrl: { value: baseUrl.value, source: baseUrl.source },
113
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),
114
151
  localPath: local.path
115
152
  };
116
153
  }
@@ -292,6 +329,7 @@ var style = {
292
329
  cyan: wrap(36, 39)
293
330
  };
294
331
  var ok = (s) => style.green(s);
332
+ var warn = (s) => style.yellow(s);
295
333
  var err = (s) => style.red(s);
296
334
  function active() {
297
335
  return !quiet && Boolean(process.stderr.isTTY);
@@ -337,8 +375,8 @@ async function promptYesNo(question) {
337
375
  const { createInterface } = await import("readline");
338
376
  const rl = createInterface({ input: process.stdin, output: process.stderr });
339
377
  try {
340
- const answer = await new Promise((resolve) => {
341
- rl.question(`${question} [y/N] `, resolve);
378
+ const answer = await new Promise((resolve3) => {
379
+ rl.question(`${question} [y/N] `, resolve3);
342
380
  });
343
381
  return /^y(es)?$/i.test(answer.trim());
344
382
  } finally {
@@ -351,8 +389,8 @@ async function promptText(question, def) {
351
389
  const rl = createInterface({ input: process.stdin, output: process.stderr });
352
390
  try {
353
391
  const suffix = def ? ` [${def}]` : "";
354
- const answer = await new Promise((resolve) => {
355
- rl.question(`${question}${suffix} `, resolve);
392
+ const answer = await new Promise((resolve3) => {
393
+ rl.question(`${question}${suffix} `, resolve3);
356
394
  });
357
395
  const trimmed = answer.trim();
358
396
  return trimmed.length > 0 ? trimmed : def ?? "";
@@ -378,8 +416,8 @@ async function promptSelect(question, choices, def) {
378
416
  process.stderr.write(` ${marker} ${style.bold(String(i + 1))}. ${c.label}${hint}
379
417
  `);
380
418
  });
381
- const answer = await new Promise((resolve) => {
382
- rl.question(`Choose ${style.dim(`[${defIdx + 1}]`)} `, resolve);
419
+ const answer = await new Promise((resolve3) => {
420
+ rl.question(`Choose ${style.dim(`[${defIdx + 1}]`)} `, resolve3);
383
421
  });
384
422
  const trimmed = answer.trim();
385
423
  if (!trimmed) return choices[defIdx].value;
@@ -411,8 +449,8 @@ async function promptMultiSelect(question, choices, preselected = []) {
411
449
  process.stderr.write(` ${box} ${style.bold(String(i + 1))}. ${c.label}${hint}
412
450
  `);
413
451
  });
414
- const answer = await new Promise((resolve) => {
415
- rl.question(`Select ${style.dim("[Enter = \u25C9]")} `, resolve);
452
+ const answer = await new Promise((resolve3) => {
453
+ rl.question(`Select ${style.dim("[Enter = \u25C9]")} `, resolve3);
416
454
  });
417
455
  const trimmed = answer.trim().toLowerCase();
418
456
  if (!trimmed) return preValues();
@@ -453,6 +491,7 @@ async function makeClient(cfg) {
453
491
  onRequest({ request }) {
454
492
  request.headers.set("authorization", `Bearer ${token}`);
455
493
  request.headers.set("tenant", cfg.tenant);
494
+ request.headers.set("x-sechroom-surface", "cli");
456
495
  return request;
457
496
  }
458
497
  });
@@ -468,6 +507,12 @@ function emit(data, json) {
468
507
  function publicUrl(url) {
469
508
  return url.replace(/^https?:\/\/localhost:5012/, "https://sechroom.yi.ocd.codes");
470
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
+ }
471
516
  function emitAction(summary, data, json) {
472
517
  if (json) {
473
518
  process.stdout.write(JSON.stringify(data) + "\n");
@@ -485,9 +530,10 @@ async function runApi(label, fn) {
485
530
  s.fail();
486
531
  fail(err2);
487
532
  }
488
- if (res.error !== void 0 && res.error !== null) {
533
+ const httpFailed = res.response !== void 0 && !res.response.ok;
534
+ if (res.error !== void 0 && res.error !== null || httpFailed) {
489
535
  s.fail();
490
- fail(res.error);
536
+ fail(res.error ?? (res.response ? `HTTP ${res.response.status} ${res.response.statusText}`.trim() : "request failed"));
491
537
  }
492
538
  s.succeed();
493
539
  return res.data;
@@ -510,7 +556,13 @@ Examples:
510
556
  $ sechroom memory create --text "filed note" --owner-type Workspace --owner-id wsp_XXXX
511
557
  $ sechroom memory search "rate limiting" --limit 5 --tag kind:decision
512
558
  $ sechroom memory search "auth flow" --workspace wsp_XXXX --json
513
- $ sechroom memory get mem_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`
514
566
  );
515
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) => {
516
568
  const cfg = resolveConfig(cmd.optsWithGlobals());
@@ -532,7 +584,8 @@ Examples:
532
584
  });
533
585
  });
534
586
  const titlePart = opts.title ? ` ${style.dim(`"${opts.title}"`)}` : "";
535
- const urlPart = data.url ? ` ${style.dim("\u2192")} ${publicUrl(data.url)}` : "";
587
+ const view = resolveViewUrl(cfg.baseUrl, data.url);
588
+ const urlPart = view ? ` ${style.dim("\u2192")} ${view}` : "";
536
589
  emitAction(`created memory ${style.bold(data.id)}${titlePart}${urlPart}`, data, cmd.optsWithGlobals().json);
537
590
  });
538
591
  memory.command("get <memoryId>").description("Fetch a memory by id (GET /memories/{memoryId})").action(async (memoryId, _opts, cmd) => {
@@ -563,6 +616,201 @@ Examples:
563
616
  });
564
617
  emit(data, cmd.optsWithGlobals().json);
565
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
+ });
566
814
  }
567
815
 
568
816
  // src/commands/worklog.ts
@@ -619,126 +867,1622 @@ Examples:
619
867
  });
620
868
  }
621
869
 
622
- // src/setup/apply.ts
623
- import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync2 } from "fs";
624
- import { dirname as dirname2 } from "path";
625
-
626
- // src/setup/operator-surface.ts
627
- var SectionType = {
628
- McpConfig: "mcp-config",
629
- McpConfigToml: "mcp-config-toml",
630
- InstructionFile: "instruction-file",
631
- ProjectConfig: "project-config",
632
- Verify: "verify"
633
- };
634
- async function fetchSetup(cfg) {
635
- const client = await makeClient(cfg);
636
- const { data, error } = await client.GET("/operator-surface/setup", {});
637
- if (error) throw new Error(`GET /operator-surface/setup failed: ${JSON.stringify(error)}`);
638
- return data;
639
- }
640
- function findSurface(setup, surfaceKey) {
641
- return setup.surfaces.find((s) => s.surfaceKey === surfaceKey);
642
- }
643
- function findSection(surface, sectionType) {
644
- return surface?.sections.find((s) => s.sectionType === sectionType);
645
- }
646
- function sectionSnippet(section) {
647
- if (!section) return null;
648
- for (const step of section.steps) {
649
- if (step.copyValue) return step.copyValue;
650
- if (step.codeSnippet) return step.codeSnippet;
651
- }
652
- return null;
653
- }
654
- function parseTagArtifactId(id) {
655
- if (!id.startsWith("tag:")) return null;
656
- const tags = id.slice("tag:".length).split(",").map((t) => t.trim()).filter((t) => t.length > 0);
657
- return tags.length > 0 ? tags : null;
658
- }
659
- async function getPersonalWorkspaceId(cfg) {
660
- const client = await makeClient(cfg);
661
- const { data } = await client.GET("/me/personal-workspace", {});
662
- return data?.workspaceId ?? null;
663
- }
664
- async function fetchMemoryFields(cfg, id) {
665
- const client = await makeClient(cfg);
666
- const { data } = await client.GET("/memories/{memoryId}", { params: { path: { memoryId: id } } });
667
- const env = data;
668
- return env?.item ?? env ?? null;
669
- }
670
- async function resolveInstruction(cfg, section, personalWorkspaceId) {
671
- const client = await makeClient(cfg);
672
- for (const artifact of section.artifacts) {
673
- const tags = parseTagArtifactId(artifact.id);
674
- if (!tags) continue;
675
- const { data } = await client.POST("/memories/search", {
676
- 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
+ });
677
895
  });
678
- const hits = data ?? [];
679
- if (hits.length === 0) continue;
680
- const templateId = hits[0].id;
681
- const template = await fetchMemoryFields(cfg, templateId);
682
- if (typeof template?.text !== "string" || template.text.length === 0) continue;
683
- const templateTags = template.tags ?? tags;
684
- if (personalWorkspaceId) {
685
- const { data: ovr } = await client.POST("/memories/search", {
686
- body: { query: null, textQuery: null, semanticQuery: "role override", hybrid: true, limit: 1, includeArchived: false, includeSystem: false, tags: ["sechroom:role:override", `sechroom:template-ref:${templateId}`], owner: { type: "Workspace", id: personalWorkspaceId } }
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
+ }
687
919
  });
688
- const ovrHits = ovr ?? [];
689
- if (ovrHits.length > 0) {
690
- const override = await fetchMemoryFields(cfg, ovrHits[0].id);
691
- if (typeof override?.text === "string" && override.text.length > 0) {
692
- return { title: override.title ?? template.title ?? artifact.title, body: override.text, source: "override", templateId, templateTags };
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
693
942
  }
694
- }
695
- }
696
- return { title: template.title ?? artifact.title, body: template.text, source: "template", templateId, templateTags };
697
- }
698
- return null;
699
- }
700
- async function createOverride(cfg, template, personalWorkspaceId) {
701
- const client = await makeClient(cfg);
702
- const overrideTags = template.templateTags.filter(
703
- (t) => t !== "sechroom:role:template" && !t.startsWith("sechroom:bundle:") && !t.startsWith("sechroom:template-ref:")
704
- );
705
- overrideTags.push("sechroom:role:override", `sechroom:template-ref:${template.templateId}`);
706
- const { error } = await client.POST("/memories", {
707
- body: {
708
- text: template.body,
709
- type: "reference",
710
- content: "{}",
711
- confidence: 1,
712
- source: "cli-agent-instructions-customize",
713
- archetype: "Document",
714
- title: template.title ?? null,
715
- tags: overrideTags,
716
- owner: { type: "Workspace", id: personalWorkspaceId }
717
- }
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);
718
1019
  });
719
- if (error) throw new Error(`creating personal copy failed: ${JSON.stringify(error)}`);
720
1020
  }
721
1021
 
722
- // src/setup/apply.ts
723
- var BLOCK_BEGIN = "<!-- @sechroom/cli:begin (managed \u2014 re-run `sechroom setup agent-files` to refresh) -->";
724
- var BLOCK_END = "<!-- @sechroom/cli:end -->";
725
- function ensureDir2(path) {
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()) {
726
1646
  mkdirSync2(dirname2(path), { recursive: true });
1647
+ writeFileSync2(path, serializeSem(values));
1648
+ ensureSemIgnored(path);
1649
+ return path;
1650
+ }
1651
+ function ignoresSem(content) {
1652
+ return content.split("\n").some((line) => {
1653
+ const t = line.trim();
1654
+ return t === STATE_DIR_NAME2 || t === STATE_DIR_IGNORE || t === `/${STATE_DIR_NAME2}` || t === `/${STATE_DIR_IGNORE}` || t === `**/${STATE_DIR_NAME2}` || t === `**/${STATE_DIR_IGNORE}`;
1655
+ });
1656
+ }
1657
+ function inGitRepo(startDir) {
1658
+ let dir = startDir;
1659
+ for (; ; ) {
1660
+ if (existsSync2(join2(dir, ".git"))) return true;
1661
+ const parent = dirname2(dir);
1662
+ if (parent === dir) return false;
1663
+ dir = parent;
1664
+ }
1665
+ }
1666
+ function resolveGitignoreTarget(startDir) {
1667
+ let dir = startDir;
1668
+ for (; ; ) {
1669
+ const gi = join2(dir, ".gitignore");
1670
+ if (existsSync2(gi)) return { path: gi, exists: true };
1671
+ const parent = dirname2(dir);
1672
+ if (existsSync2(join2(dir, ".git")) || parent === dir) {
1673
+ return { path: join2(startDir, ".gitignore"), exists: false };
1674
+ }
1675
+ dir = parent;
1676
+ }
1677
+ }
1678
+ function ensureSemIgnored(semPath) {
1679
+ try {
1680
+ const checkoutDir = dirname2(dirname2(semPath));
1681
+ if (!inGitRepo(checkoutDir)) return;
1682
+ const target = resolveGitignoreTarget(checkoutDir);
1683
+ if (target.exists) {
1684
+ const content = readFileSync2(target.path, "utf8");
1685
+ if (ignoresSem(content)) return;
1686
+ const sep = content.length === 0 || content.endsWith("\n") ? "" : "\n";
1687
+ appendFileSync(target.path, `${sep}${STATE_DIR_IGNORE}
1688
+ `);
1689
+ } else {
1690
+ writeFileSync2(target.path, `${STATE_DIR_IGNORE}
1691
+ `);
1692
+ }
1693
+ } catch {
1694
+ }
1695
+ }
1696
+
1697
+ // src/setup/clients.ts
1698
+ import { existsSync as existsSync3 } from "fs";
1699
+ import { homedir as homedir2 } from "os";
1700
+ import { dirname as dirname3, join as join3 } from "path";
1701
+
1702
+ // src/setup/operator-surface.ts
1703
+ var SectionType = {
1704
+ McpConfig: "mcp-config",
1705
+ McpConfigToml: "mcp-config-toml",
1706
+ InstructionFile: "instruction-file",
1707
+ ProjectConfig: "project-config",
1708
+ Verify: "verify",
1709
+ /** SBC-999 — workspace-pinned conventions, emitted only when the request
1710
+ * carried a workspaceId and that workspace has agent-setup-bundle memories. */
1711
+ WorkspaceConventions: "workspace-conventions"
1712
+ };
1713
+ async function fetchSetup(cfg) {
1714
+ const client = await makeClient(cfg);
1715
+ const { data, error } = await client.GET(
1716
+ "/operator-surface/setup",
1717
+ cfg.workspaceId ? { params: { query: { workspaceId: cfg.workspaceId } } } : {}
1718
+ );
1719
+ if (error) throw new Error(`GET /operator-surface/setup failed: ${JSON.stringify(error)}`);
1720
+ return data;
1721
+ }
1722
+ function findSurface(setup, surfaceKey) {
1723
+ return setup.surfaces.find((s) => s.surfaceKey === surfaceKey);
1724
+ }
1725
+ function findSection(surface, sectionType) {
1726
+ return surface?.sections.find((s) => s.sectionType === sectionType);
1727
+ }
1728
+ function sectionSnippet(section) {
1729
+ if (!section) return null;
1730
+ for (const step of section.steps) {
1731
+ if (step.copyValue) return step.copyValue;
1732
+ if (step.codeSnippet) return step.codeSnippet;
1733
+ }
1734
+ return null;
1735
+ }
1736
+ function parseTagArtifactId(id) {
1737
+ if (!id.startsWith("tag:")) return null;
1738
+ const tags = id.slice("tag:".length).split(",").map((t) => t.trim()).filter((t) => t.length > 0);
1739
+ return tags.length > 0 ? tags : null;
1740
+ }
1741
+ async function getPersonalWorkspaceId(cfg) {
1742
+ const client = await makeClient(cfg);
1743
+ const { data } = await client.GET("/me/personal-workspace", {});
1744
+ return data?.workspaceId ?? null;
1745
+ }
1746
+ async function fetchMemoryFields(cfg, id) {
1747
+ const client = await makeClient(cfg);
1748
+ const { data } = await client.GET("/memories/{memoryId}", { params: { path: { memoryId: id } } });
1749
+ const env = data;
1750
+ const m = env?.item ?? env;
1751
+ if (!m) return null;
1752
+ const version = typeof m.currentVersion === "string" ? Number(m.currentVersion) : m.currentVersion;
1753
+ return { text: m.text, title: m.title, tags: m.tags, version: Number.isFinite(version) ? version : void 0 };
1754
+ }
1755
+ async function resolveInstruction(cfg, section, personalWorkspaceId) {
1756
+ const client = await makeClient(cfg);
1757
+ for (const artifact of section.artifacts) {
1758
+ const tags = parseTagArtifactId(artifact.id);
1759
+ if (!tags) continue;
1760
+ const { data } = await client.POST("/memories/search", {
1761
+ body: { query: null, textQuery: null, semanticQuery: null, hybrid: false, limit: 1, includeArchived: false, includeSystem: false, tags }
1762
+ });
1763
+ const hits = data ?? [];
1764
+ if (hits.length === 0) continue;
1765
+ const templateId = hits[0].id;
1766
+ const template = await fetchMemoryFields(cfg, templateId);
1767
+ if (typeof template?.text !== "string" || template.text.length === 0) continue;
1768
+ const templateTags = template.tags ?? tags;
1769
+ if (personalWorkspaceId) {
1770
+ const { data: ovr } = await client.POST("/memories/search", {
1771
+ body: { query: null, textQuery: null, semanticQuery: null, hybrid: false, limit: 1, includeArchived: false, includeSystem: false, tags: ["sechroom:role:override", `sechroom:template-ref:${templateId}`], owner: { type: "Workspace", id: personalWorkspaceId } }
1772
+ });
1773
+ const ovrHits = ovr ?? [];
1774
+ if (ovrHits.length > 0) {
1775
+ const override = await fetchMemoryFields(cfg, ovrHits[0].id);
1776
+ if (typeof override?.text === "string" && override.text.length > 0) {
1777
+ return { title: override.title ?? template.title ?? artifact.title, body: override.text, source: "override", templateId, templateTags, sourceRef: `${ovrHits[0].id}@v${override.version ?? 1}` };
1778
+ }
1779
+ }
1780
+ }
1781
+ return { title: template.title ?? artifact.title, body: template.text, source: "template", templateId, templateTags, sourceRef: `${templateId}@v${template.version ?? 1}` };
1782
+ }
1783
+ return null;
1784
+ }
1785
+ async function resolveWorkspaceConventions(cfg, section) {
1786
+ const parts = [];
1787
+ const refs = [];
1788
+ for (const artifact of section.artifacts) {
1789
+ if (parseTagArtifactId(artifact.id)) continue;
1790
+ const mem = await fetchMemoryFields(cfg, artifact.id);
1791
+ if (typeof mem?.text === "string" && mem.text.trim().length > 0) {
1792
+ parts.push(mem.text.trim());
1793
+ refs.push(`${artifact.id}@v${mem.version ?? 1}`);
1794
+ }
1795
+ }
1796
+ if (parts.length === 0) return null;
1797
+ return { body: parts.join("\n\n---\n\n"), refs };
1798
+ }
1799
+ async function createOverride(cfg, template, personalWorkspaceId) {
1800
+ const client = await makeClient(cfg);
1801
+ const overrideTags = template.templateTags.filter(
1802
+ (t) => t !== "sechroom:role:template" && !t.startsWith("sechroom:bundle:") && !t.startsWith("sechroom:template-ref:")
1803
+ );
1804
+ overrideTags.push("sechroom:role:override", `sechroom:template-ref:${template.templateId}`);
1805
+ const { error } = await client.POST("/memories", {
1806
+ body: {
1807
+ text: template.body,
1808
+ type: "reference",
1809
+ content: "{}",
1810
+ confidence: 1,
1811
+ source: "cli-agent-instructions-customize",
1812
+ archetype: "Document",
1813
+ title: template.title ?? null,
1814
+ tags: overrideTags,
1815
+ owner: { type: "Workspace", id: personalWorkspaceId }
1816
+ }
1817
+ });
1818
+ if (error) throw new Error(`creating personal copy failed: ${JSON.stringify(error)}`);
1819
+ }
1820
+
1821
+ // src/setup/clients.ts
1822
+ function claudeDesktopConfigPath(home) {
1823
+ switch (process.platform) {
1824
+ case "darwin":
1825
+ return join3(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
1826
+ case "win32":
1827
+ return join3(process.env.APPDATA ?? join3(home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
1828
+ default:
1829
+ return join3(home, ".config", "Claude", "claude_desktop_config.json");
1830
+ }
1831
+ }
1832
+ function clientTargets(cwd) {
1833
+ const home = homedir2();
1834
+ return {
1835
+ "claude-code": {
1836
+ key: "claude-code",
1837
+ label: "Claude Code",
1838
+ mcp: { surfaceKey: "claude-code", sectionType: SectionType.McpConfig, path: join3(cwd, ".mcp.json"), format: "json" },
1839
+ instruction: { surfaceKey: "claude-code", path: join3(cwd, "CLAUDE.md") }
1840
+ },
1841
+ "claude-desktop": {
1842
+ key: "claude-desktop",
1843
+ label: "Claude Desktop",
1844
+ mcp: { surfaceKey: "claude-desktop", sectionType: SectionType.McpConfig, path: claudeDesktopConfigPath(home), format: "json" },
1845
+ instruction: { surfaceKey: "claude-desktop", path: join3(home, ".claude", "CLAUDE.md") }
1846
+ },
1847
+ codex: {
1848
+ key: "codex",
1849
+ label: "Codex CLI",
1850
+ mcp: { surfaceKey: "chatgpt", sectionType: SectionType.McpConfigToml, path: join3(home, ".codex", "config.toml"), format: "toml" },
1851
+ instruction: { surfaceKey: "chatgpt", path: join3(cwd, "AGENTS.md") }
1852
+ },
1853
+ cursor: {
1854
+ key: "cursor",
1855
+ label: "Cursor",
1856
+ mcp: { surfaceKey: "claude-code", sectionType: SectionType.McpConfig, path: join3(cwd, ".cursor", "mcp.json"), format: "json" },
1857
+ instruction: { surfaceKey: "chatgpt", path: join3(cwd, "AGENTS.md") }
1858
+ }
1859
+ };
1860
+ }
1861
+ var ALL_CLIENT_KEYS = ["claude-code", "claude-desktop", "codex", "cursor"];
1862
+ var DEFAULT_CLIENT_KEY = "claude-code";
1863
+ function detectInstalledClients(cwd) {
1864
+ const home = homedir2();
1865
+ const detected = [];
1866
+ if (existsSync3(join3(home, ".claude"))) detected.push("claude-code");
1867
+ if (existsSync3(dirname3(claudeDesktopConfigPath(home)))) detected.push("claude-desktop");
1868
+ if (existsSync3(join3(home, ".codex"))) detected.push("codex");
1869
+ if (existsSync3(join3(home, ".cursor")) || existsSync3(join3(cwd, ".cursor"))) detected.push("cursor");
1870
+ return detected;
1871
+ }
1872
+
1873
+ // src/commands/hook.ts
1874
+ async function readStdin() {
1875
+ if (process.stdin.isTTY) return "";
1876
+ const chunks = [];
1877
+ for await (const chunk of process.stdin) chunks.push(chunk);
1878
+ return Buffer.concat(chunks).toString("utf8");
1879
+ }
1880
+ function parseHookInput(raw) {
1881
+ if (!raw.trim()) return {};
1882
+ try {
1883
+ return JSON.parse(raw);
1884
+ } catch {
1885
+ return {};
1886
+ }
1887
+ }
1888
+ function resolveLane(flagLane, cwd) {
1889
+ if (flagLane) return flagLane;
1890
+ const env = process.env.SECHROOM_LANE;
1891
+ if (env) return env;
1892
+ const start = cwd ?? process.cwd();
1893
+ const sem = readSem(resolveSemPathForRead(start));
1894
+ return sem?.values["code-lane"];
1895
+ }
1896
+ var INTENT_FILE = join4(".sechroom", "continuity.json");
1897
+ function resolveIntentPath(start) {
1898
+ let dir = start;
1899
+ for (; ; ) {
1900
+ const candidate = join4(dir, INTENT_FILE);
1901
+ if (existsSync4(candidate)) return candidate;
1902
+ const parent = dirname4(dir);
1903
+ if (parent === dir) return void 0;
1904
+ dir = parent;
1905
+ }
1906
+ }
1907
+ function readIntent(start) {
1908
+ const path = resolveIntentPath(start);
1909
+ if (!path) return void 0;
1910
+ try {
1911
+ return JSON.parse(readFileSync3(path, "utf8"));
1912
+ } catch {
1913
+ return void 0;
1914
+ }
1915
+ }
1916
+ function hasRequiredIntent(i) {
1917
+ return Boolean(
1918
+ i.objective?.trim() && i.state?.trim() && i.lastAction?.trim() && i.nextAction?.trim() && i.resumeInstruction?.trim()
1919
+ );
1920
+ }
1921
+ function formatContext(bundle, lane) {
1922
+ const s = bundle?.latestSnapshot;
1923
+ if (!s) return null;
1924
+ const lines = [];
1925
+ lines.push(`[sechroom continuity \u2014 resumed lane ${lane}]`);
1926
+ if (s.currentObjective) lines.push(`Objective: ${s.currentObjective}`);
1927
+ if (s.currentState) lines.push(`State: ${s.currentState}`);
1928
+ if (s.lastMeaningfulAction) lines.push(`Last action: ${s.lastMeaningfulAction}`);
1929
+ if (s.nextIntendedAction) lines.push(`Next: ${s.nextIntendedAction}`);
1930
+ if (s.resumeInstruction) lines.push(`Resume: ${s.resumeInstruction}`);
1931
+ const constraints = s.activeConstraints ?? [];
1932
+ if (constraints.length) {
1933
+ lines.push("Active constraints:");
1934
+ for (const c of constraints) lines.push(` - ${c}`);
1935
+ }
1936
+ const questions = s.openQuestions ?? [];
1937
+ if (questions.length) {
1938
+ lines.push("Open questions:");
1939
+ for (const q of questions) lines.push(` - ${q}`);
1940
+ }
1941
+ const artifacts = s.relevantArtifactIds ?? [];
1942
+ if (artifacts.length) lines.push(`Relevant artifacts: ${artifacts.join(", ")}`);
1943
+ const marker = [s.id, s.createdAt].filter(Boolean).join(" @ ");
1944
+ if (marker) lines.push(`(snapshot ${marker})`);
1945
+ return lines.join("\n");
1946
+ }
1947
+ function emitSessionStart(additionalContext) {
1948
+ process.stdout.write(
1949
+ JSON.stringify({
1950
+ hookSpecificOutput: {
1951
+ hookEventName: "SessionStart",
1952
+ additionalContext
1953
+ }
1954
+ }) + "\n"
1955
+ );
1956
+ }
1957
+ var HOOK_COMMANDS = {
1958
+ SessionStart: "sechroom hook session-start",
1959
+ PreCompact: "sechroom hook pre-compact"
1960
+ };
1961
+ var HOOK_EVENTS = ["SessionStart", "PreCompact"];
1962
+ function hasHookCommand(config2, event, command) {
1963
+ const groups = config2.hooks?.[event] ?? [];
1964
+ return groups.some((g) => (g.hooks ?? []).some((h) => h.type === "command" && h.command === command));
1965
+ }
1966
+ function mergeHooks(config2) {
1967
+ config2.hooks ??= {};
1968
+ let added = 0;
1969
+ for (const event of HOOK_EVENTS) {
1970
+ const command = HOOK_COMMANDS[event];
1971
+ if (hasHookCommand(config2, event, command)) continue;
1972
+ const groups = config2.hooks[event] ??= [];
1973
+ groups.push({ hooks: [{ type: "command", command }] });
1974
+ added += 1;
1975
+ }
1976
+ return added;
1977
+ }
1978
+ function readJsonConfig2(path) {
1979
+ if (!existsSync4(path)) return {};
1980
+ const raw = readFileSync3(path, "utf8");
1981
+ if (!raw.trim()) return {};
1982
+ return JSON.parse(raw);
1983
+ }
1984
+ function installHooksJson(path, dryRun) {
1985
+ const existed = existsSync4(path) && readFileSync3(path, "utf8").trim().length > 0;
1986
+ const config2 = readJsonConfig2(path);
1987
+ const added = mergeHooks(config2);
1988
+ if (added === 0 && existed) return { path, status: "current" };
1989
+ if (!dryRun) {
1990
+ mkdirSync3(dirname4(path), { recursive: true });
1991
+ writeFileSync3(path, JSON.stringify(config2, null, 2) + "\n");
1992
+ }
1993
+ return { path, status: existed ? "merged" : "created" };
1994
+ }
1995
+ function ensureCodexFeaturesHooks(content) {
1996
+ const lines = content.split("\n");
1997
+ const headerIdx = lines.findIndex((l) => l.trim() === "[features]");
1998
+ if (headerIdx === -1) {
1999
+ const base = content.length === 0 || content.endsWith("\n") ? content : content + "\n";
2000
+ return { next: base + "\n[features]\nhooks = true\n", changed: true };
2001
+ }
2002
+ for (let i = headerIdx + 1; i < lines.length; i += 1) {
2003
+ const trimmed = lines[i].trim();
2004
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) break;
2005
+ const m = lines[i].match(/^(\s*)hooks(\s*)=(\s*)(.*)$/);
2006
+ if (!m) continue;
2007
+ const value = m[4].replace(/\s*#.*$/, "").trim();
2008
+ if (value === "true") return { next: content, changed: false };
2009
+ lines[i] = `${m[1]}hooks${m[2]}=${m[3]}true`;
2010
+ return { next: lines.join("\n"), changed: true };
2011
+ }
2012
+ lines.splice(headerIdx + 1, 0, "hooks = true");
2013
+ return { next: lines.join("\n"), changed: true };
2014
+ }
2015
+ function installCodexFeatureFlag(path, dryRun) {
2016
+ const existed = existsSync4(path);
2017
+ const content = existed ? readFileSync3(path, "utf8") : "";
2018
+ const { next, changed } = ensureCodexFeaturesHooks(content);
2019
+ if (!changed) return { path, status: "current" };
2020
+ if (!dryRun) {
2021
+ mkdirSync3(dirname4(path), { recursive: true });
2022
+ writeFileSync3(path, next);
2023
+ }
2024
+ return { path, status: existed ? "merged" : "created" };
2025
+ }
2026
+ function resolveSurfaces(surface, cwd) {
2027
+ if (surface === "claude") return ["claude"];
2028
+ if (surface === "codex") return ["codex"];
2029
+ if (surface === "both") return ["claude", "codex"];
2030
+ if (surface) throw new Error(`--surface must be one of claude | codex | both (got '${surface}')`);
2031
+ const surfaces = detectHookSurfaces(cwd);
2032
+ return surfaces.length > 0 ? surfaces : ["claude", "codex"];
2033
+ }
2034
+ function describe(result, dryRun) {
2035
+ if (result.status === "current") return ` \u2713 ${result.path} (already configured)`;
2036
+ const verb = dryRun ? "would" : result.status === "created" ? "created" : "updated";
2037
+ return ` \u2713 ${result.path} (${dryRun ? `${verb} ${result.status === "created" ? "create" : "update"}` : verb})`;
2038
+ }
2039
+ var HOOK_SURFACE_LABEL = {
2040
+ claude: "Claude Code",
2041
+ codex: "Codex"
2042
+ };
2043
+ function installHookSurfaces(surfaces, opts) {
2044
+ const out = [];
2045
+ for (const surface of surfaces) {
2046
+ if (surface === "claude") {
2047
+ const path = opts.local ? join4(opts.cwd, ".claude", "settings.json") : join4(opts.home, ".claude", "settings.json");
2048
+ out.push({ surface, results: [installHooksJson(path, opts.dryRun)] });
2049
+ } else {
2050
+ const hooksJson = installHooksJson(join4(opts.home, ".codex", "hooks.json"), opts.dryRun);
2051
+ const featureFlag = installCodexFeatureFlag(join4(opts.home, ".codex", "config.toml"), opts.dryRun);
2052
+ out.push({ surface, results: [hooksJson, featureFlag] });
2053
+ }
2054
+ }
2055
+ return out;
2056
+ }
2057
+ function detectHookSurfaces(cwd) {
2058
+ const detected = detectInstalledClients(cwd);
2059
+ const surfaces = [];
2060
+ if (detected.includes("claude-code")) surfaces.push("claude");
2061
+ if (detected.includes("codex")) surfaces.push("codex");
2062
+ return surfaces;
2063
+ }
2064
+ function isSechroomOnPath() {
2065
+ const pathEnv = process.env.PATH ?? "";
2066
+ if (!pathEnv) return false;
2067
+ const names = process.platform === "win32" ? ["sechroom.cmd", "sechroom.exe", "sechroom.bat", "sechroom"] : ["sechroom"];
2068
+ for (const dir of pathEnv.split(delimiter)) {
2069
+ if (!dir) continue;
2070
+ for (const name of names) {
2071
+ if (existsSync4(join4(dir, name))) return true;
2072
+ }
2073
+ }
2074
+ return false;
2075
+ }
2076
+ function warnIfSechroomNotOnPath(write = (s) => void process.stderr.write(s)) {
2077
+ if (isSechroomOnPath()) return false;
2078
+ write(
2079
+ "\n\u26A0 `sechroom` isn't on your PATH. The hooks run a bare `sechroom hook \u2026` command\n when your agent fires them, so a non-global install (npx / local) will fail at\n that point. Install globally so the command resolves:\n npm i -g @sechroom/cli\n"
2080
+ );
2081
+ return true;
2082
+ }
2083
+ function registerHook(program2) {
2084
+ const hook = program2.command("hook").description("Agent-lifecycle hook adapter (Claude Code / Codex) \u2014 bridges hooks to continuity");
2085
+ hook.addHelpText(
2086
+ "after",
2087
+ `
2088
+ Examples:
2089
+ # SessionStart (load): inject the lane's latest snapshot as context.
2090
+ $ echo '{"hook_event_name":"SessionStart","cwd":"'"$PWD"'"}' | sechroom hook session-start
2091
+ # PreCompact (save): snapshot from ./${INTENT_FILE} before the agent compacts.
2092
+ $ echo '{"hook_event_name":"PreCompact","cwd":"'"$PWD"'"}' | sechroom hook pre-compact
2093
+ # Wire both hooks into the installed surface(s)' config (no hand-editing):
2094
+ $ sechroom hook install auto-detect Claude Code / Codex
2095
+ $ sechroom hook install --surface codex Codex only
2096
+ $ sechroom hook install --local --dry-run preview the project .claude/settings.json
2097
+
2098
+ Lane source (high -> low): --lane > SECHROOM_LANE > ./.sem code-lane (D-binding-5).
2099
+ Fail-soft: no lane / no auth / no-or-partial intent file / API error -> exit 0, never blocks.`
2100
+ );
2101
+ hook.command("session-start").description("Resume the checkout's lane and emit continuity context for a SessionStart hook").option("--lane <laneId>", "Override the resolved lane (else SECHROOM_LANE, else ./.sem code-lane)").option("--surface <surface>", "Target surface: claude | codex (output is identical for session-start)", "claude").option("--max-artifacts <n>", "Cap artifacts in the resume bundle").action(async (opts, cmd) => {
2102
+ try {
2103
+ const raw = await readStdin();
2104
+ const input = parseHookInput(raw);
2105
+ const lane = resolveLane(opts.lane, input.cwd);
2106
+ if (!lane) return process.exit(0);
2107
+ const cfg = resolveConfig(cmd.optsWithGlobals());
2108
+ const client = await makeClient(cfg);
2109
+ const { data } = await client.POST("/continuity/resume/lane", {
2110
+ body: {
2111
+ laneId: lane,
2112
+ workspaceId: null,
2113
+ maxArtifacts: opts.maxArtifacts != null ? Number(opts.maxArtifacts) : null,
2114
+ includeLookingAtMyself: null,
2115
+ changedSince: null
2116
+ }
2117
+ });
2118
+ const context = formatContext(data, lane);
2119
+ if (context) emitSessionStart(context);
2120
+ return process.exit(0);
2121
+ } catch {
2122
+ return process.exit(0);
2123
+ }
2124
+ });
2125
+ hook.command("pre-compact").description("Save a continuity snapshot from the agent-maintained intent file on a PreCompact hook").option("--lane <laneId>", "Override the resolved lane (else SECHROOM_LANE, else ./.sem code-lane)").option("--scope <scope>", "Snapshot scope (else the intent file's `scope`, else 'compaction')").option("--surface <surface>", "Target surface: claude | codex (lifecycle-only on both)", "claude").action(async (opts, cmd) => {
2126
+ try {
2127
+ const raw = await readStdin();
2128
+ const input = parseHookInput(raw);
2129
+ const cwd = input.cwd ?? process.cwd();
2130
+ const lane = resolveLane(opts.lane, input.cwd);
2131
+ if (!lane) return process.exit(0);
2132
+ const intent = readIntent(cwd);
2133
+ if (!intent || !hasRequiredIntent(intent)) return process.exit(0);
2134
+ const cfg = resolveConfig(cmd.optsWithGlobals());
2135
+ const client = await makeClient(cfg);
2136
+ await client.POST("/continuity/snapshots", {
2137
+ body: {
2138
+ laneId: lane,
2139
+ scope: opts.scope ?? intent.scope ?? "compaction",
2140
+ currentObjective: intent.objective,
2141
+ currentState: intent.state,
2142
+ lastMeaningfulAction: intent.lastAction,
2143
+ nextIntendedAction: intent.nextAction,
2144
+ resumeInstruction: intent.resumeInstruction,
2145
+ activeConstraints: intent.constraints ?? null,
2146
+ openQuestions: intent.questions ?? null,
2147
+ surfaceMarkers: intent.surfaceMarkers ?? null,
2148
+ relevantArtifactIds: intent.artifacts ?? null,
2149
+ confidence: intent.confidence ?? null,
2150
+ // Compaction is infrequent, so the FR-051 clobber guard doesn't bite;
2151
+ // Acknowledge lets a within-window checkpoint land on the lane.
2152
+ concurrentSessionPolicy: "Acknowledge"
2153
+ }
2154
+ });
2155
+ return process.exit(0);
2156
+ } catch {
2157
+ return process.exit(0);
2158
+ }
2159
+ });
2160
+ hook.command("install").description("Wire the session-start + pre-compact hooks into Claude Code and/or Codex config").option("--surface <surface>", "Target surface: claude | codex | both (default: auto-detect installed surfaces)").option("--local", "Claude Code only: write <cwd>/.claude/settings.json instead of ~/.claude/settings.json").option("--dry-run", "Print what would change; write nothing").action((opts) => {
2161
+ const dryRun = Boolean(opts.dryRun);
2162
+ const cwd = process.cwd();
2163
+ let surfaces;
2164
+ try {
2165
+ surfaces = resolveSurfaces(opts.surface, cwd);
2166
+ } catch (err2) {
2167
+ process.stderr.write(`${err2.message}
2168
+ `);
2169
+ return process.exit(2);
2170
+ }
2171
+ const results = [];
2172
+ try {
2173
+ const installed = installHookSurfaces(surfaces, { dryRun, local: opts.local, cwd, home: homedir3() });
2174
+ for (const { surface, results: surfaceResults } of installed) {
2175
+ process.stdout.write(`${HOOK_SURFACE_LABEL[surface]}:
2176
+ `);
2177
+ for (const r of surfaceResults) {
2178
+ results.push(r);
2179
+ process.stdout.write(describe(r, dryRun) + "\n");
2180
+ }
2181
+ }
2182
+ } catch (err2) {
2183
+ process.stderr.write(`hook install failed: ${err2.message}
2184
+ `);
2185
+ return process.exit(1);
2186
+ }
2187
+ if (dryRun) {
2188
+ process.stdout.write("\n(dry run \u2014 no files were written.)\n");
2189
+ } else if (results.every((r) => r.status === "current")) {
2190
+ process.stdout.write("\nAlready up to date \u2014 nothing to change.\n");
2191
+ } else {
2192
+ process.stdout.write("\nRestart (or reload) your agent for the hooks to take effect.\n");
2193
+ }
2194
+ warnIfSechroomNotOnPath();
2195
+ return process.exit(0);
2196
+ });
2197
+ }
2198
+
2199
+ // src/commands/account.ts
2200
+ function registerId(program2) {
2201
+ const id = program2.command("id").description("Allocate human-authored id sequences (FR-*, D-*)");
2202
+ id.addHelpText(
2203
+ "after",
2204
+ `
2205
+ Examples:
2206
+ $ sechroom id next FR sechroom allocate the next FR-sechroom-NNN id
2207
+ $ sechroom id next D Backend allocate the next D-Backend-NNN id
2208
+ $ sechroom id peek FR sechroom inspect the sequence without consuming
2209
+ $ sechroom id peek FR sechroom --json`
2210
+ );
2211
+ id.command("next <namespaceKind> <scope>").description("Allocate the next id in a sequence (POST /id-registry/allocate)").action(async (namespaceKind, scope, _opts, cmd) => {
2212
+ const cfg = resolveConfig(cmd.optsWithGlobals());
2213
+ const data = await runApi("Allocating id", async () => {
2214
+ const client = await makeClient(cfg);
2215
+ return client.POST("/id-registry/allocate", {
2216
+ body: { namespaceKind, scope, clientNonce: null }
2217
+ });
2218
+ });
2219
+ emitAction(`allocated ${style.bold(data.id)} ${style.dim(`(seq ${data.seq})`)}`, data, cmd.optsWithGlobals().json);
2220
+ });
2221
+ id.command("peek <namespaceKind> <scope>").description("Inspect a sequence without consuming (GET /id-registry/state)").action(async (namespaceKind, scope, _opts, cmd) => {
2222
+ const cfg = resolveConfig(cmd.optsWithGlobals());
2223
+ const data = await runApi("Peeking id sequence", async () => {
2224
+ const client = await makeClient(cfg);
2225
+ return client.GET("/id-registry/state", {
2226
+ params: { query: { namespaceKind, scope } }
2227
+ });
2228
+ });
2229
+ emit(data, cmd.optsWithGlobals().json);
2230
+ });
2231
+ }
2232
+ function registerAccount(program2) {
2233
+ const account = program2.command("account").description("Your profile, feeds, and review queue");
2234
+ account.addHelpText(
2235
+ "after",
2236
+ `
2237
+ Examples:
2238
+ $ sechroom account profile
2239
+ $ sechroom account set-profile --display-name "Chris" --timezone "Europe/London"
2240
+ $ sechroom account feed --limit 20
2241
+ $ sechroom account reviews --status Pending
2242
+ $ sechroom account lookup-batch mem_XXXX wsp_YYYY --json`
2243
+ );
2244
+ account.command("profile").description("Show your resolved profile (GET /me/profile)").action(async (_opts, cmd) => {
2245
+ const cfg = resolveConfig(cmd.optsWithGlobals());
2246
+ const data = await runApi("Fetching profile", async () => {
2247
+ const client = await makeClient(cfg);
2248
+ return client.GET("/me/profile");
2249
+ });
2250
+ emit(data, cmd.optsWithGlobals().json);
2251
+ });
2252
+ account.command("set-profile").description("Update your profile (PUT /me/profile)").option("--display-name <displayName>", "Display name").option("--timezone <timezone>", "IANA timezone (e.g. Europe/London)").option("--bio <bio>", "Short bio").option("--photo-url <photoUrl>", "Avatar / photo URL").action(async (opts, cmd) => {
2253
+ const cfg = resolveConfig(cmd.optsWithGlobals());
2254
+ const data = await runApi("Updating profile", async () => {
2255
+ const client = await makeClient(cfg);
2256
+ return client.PUT("/me/profile", {
2257
+ body: {
2258
+ displayName: opts.displayName ?? null,
2259
+ timezone: opts.timezone ?? null,
2260
+ bio: opts.bio ?? null,
2261
+ photoUrl: opts.photoUrl ?? null
2262
+ }
2263
+ });
2264
+ });
2265
+ emitAction("updated profile", data, cmd.optsWithGlobals().json);
2266
+ });
2267
+ account.command("feed").description("Your recent memory feed (GET /me/memories/feed)").option("--limit <n>", "Max results", "20").option("--cursor <cursor>", "Opaque paging cursor").option("--query <query>", "Free-text filter").option("--filter-tags <tags>", "Comma-separated tag filter").option("--include-archived", "Include archived memories", false).option("--include-text", "Include memory body text", false).action(async (opts, cmd) => {
2268
+ const cfg = resolveConfig(cmd.optsWithGlobals());
2269
+ const data = await runApi("Fetching feed", async () => {
2270
+ const client = await makeClient(cfg);
2271
+ return client.GET("/me/memories/feed", {
2272
+ params: {
2273
+ query: {
2274
+ limit: Number(opts.limit),
2275
+ includeArchived: Boolean(opts.includeArchived),
2276
+ includeText: Boolean(opts.includeText),
2277
+ ...opts.cursor ? { cursor: opts.cursor } : {},
2278
+ ...opts.query ? { query: opts.query } : {},
2279
+ ...opts.filterTags ? { filterTags: opts.filterTags } : {}
2280
+ }
2281
+ }
2282
+ });
2283
+ });
2284
+ emit(data, cmd.optsWithGlobals().json);
2285
+ });
2286
+ account.command("reviews").description("Your review queue (GET /reviews)").option("--status <status>", "Pending | Resolved | Empty").option("--scope-kind <scopeKind>", "Memory | Project | Workspace").option("--scope-target-id <id>", "Scope target id").option("--limit <n>", "Max results", "20").action(async (opts, cmd) => {
2287
+ const cfg = resolveConfig(cmd.optsWithGlobals());
2288
+ const data = await runApi("Fetching reviews", async () => {
2289
+ const client = await makeClient(cfg);
2290
+ return client.GET("/reviews", {
2291
+ params: {
2292
+ query: {
2293
+ limit: Number(opts.limit),
2294
+ ...opts.status ? { status: opts.status } : {},
2295
+ ...opts.scopeKind ? { scopeKind: opts.scopeKind } : {},
2296
+ ...opts.scopeTargetId ? { scopeTargetId: opts.scopeTargetId } : {}
2297
+ }
2298
+ }
2299
+ });
2300
+ });
2301
+ emit(data, cmd.optsWithGlobals().json);
2302
+ });
2303
+ account.command("review-get <reviewId>").description("Fetch one review bundle (GET /reviews/{reviewId})").action(async (reviewId, _opts, cmd) => {
2304
+ const cfg = resolveConfig(cmd.optsWithGlobals());
2305
+ const data = await runApi("Fetching review", async () => {
2306
+ const client = await makeClient(cfg);
2307
+ return client.GET("/reviews/{reviewId}", { params: { path: { reviewId } } });
2308
+ });
2309
+ emit(data, cmd.optsWithGlobals().json);
2310
+ });
2311
+ account.command("review-accept <reviewId>").description("Accept a review bundle (POST /reviews/{reviewId}/accept)").action(async (reviewId, _opts, cmd) => {
2312
+ const cfg = resolveConfig(cmd.optsWithGlobals());
2313
+ const data = await runApi("Accepting review", async () => {
2314
+ const client = await makeClient(cfg);
2315
+ return client.POST("/reviews/{reviewId}/accept", {
2316
+ params: { path: { reviewId } },
2317
+ body: { decisions: {} }
2318
+ });
2319
+ });
2320
+ emitAction(`accepted review ${style.bold(reviewId)}`, data, cmd.optsWithGlobals().json);
2321
+ });
2322
+ account.command("lookup-batch <ids...>").description("Resolve many ids at once (POST /lookup/batch)").action(async (ids, _opts, cmd) => {
2323
+ const cfg = resolveConfig(cmd.optsWithGlobals());
2324
+ const data = await runApi(`Resolving ${ids.length} ids`, async () => {
2325
+ const client = await makeClient(cfg);
2326
+ return client.POST("/lookup/batch", { body: { ids } });
2327
+ });
2328
+ emit(data, cmd.optsWithGlobals().json);
2329
+ });
2330
+ }
2331
+
2332
+ // src/commands/chat.ts
2333
+ function registerChat(program2) {
2334
+ const chat = program2.command("chat").description("Send and read Slack / Discord channel messages").option("--surface <surface>", "slack | discord", "slack");
2335
+ chat.addHelpText(
2336
+ "after",
2337
+ `
2338
+ Examples:
2339
+ $ sechroom chat send C0123456789 "deploy is green" --surface slack
2340
+ $ sechroom chat send 987654321098765432 "deploy is green" --surface discord --guild 123456789012345678
2341
+ $ sechroom chat send C0123456789 "lgtm" --surface slack --as user --parent 1718049600.123456
2342
+ $ sechroom chat messages --surface slack
2343
+ $ sechroom chat replies 1718049600.123456 --surface slack
2344
+ $ sechroom chat stop-tracking 1718049600.123456 --surface slack`
2345
+ );
2346
+ chat.command("send <channelId> <text>").description("Send a message to a channel (POST /chat/channel-messages/{surface})").option("--guild <guildId>", "Discord guild snowflake \u2014 required for --surface discord").option("--memory <memoryId>", "Attach a sechroom memory id").option("--no-track", "Don't capture replies to this message").option("--parent <parentMessage>", "Thread under a parent (Slack thread_ts / Discord message id)").option("--source <source>", "Source / lane stamp (renders an attribution footer)", "cli").option("--as <as>", "Slack only: 'bot' (default) or 'user' (your linked Slack identity)", "bot").action(async (channelId, text, opts, cmd) => {
2347
+ const { surface, ...globals } = cmd.optsWithGlobals();
2348
+ const json = Boolean(cmd.optsWithGlobals().json);
2349
+ const cfg = resolveConfig(globals);
2350
+ const data = await runApi("Sending message", async () => {
2351
+ const client = await makeClient(cfg);
2352
+ return client.POST("/chat/channel-messages/{surface}", {
2353
+ params: { path: { surface: String(surface) } },
2354
+ body: {
2355
+ channelId,
2356
+ text,
2357
+ guildId: opts.guild ?? null,
2358
+ attachedMemoryId: opts.memory ?? null,
2359
+ trackReplies: opts.track,
2360
+ parentMessage: opts.parent ?? null,
2361
+ source: opts.source,
2362
+ as: opts.as
2363
+ }
2364
+ });
2365
+ });
2366
+ if (!data.ok) {
2367
+ if (json) {
2368
+ emit(data, true);
2369
+ } else {
2370
+ process.stderr.write(
2371
+ `${err("\u2717")} send failed: ${data.upstreamError ?? "error"}${data.errorDescription ? ` \u2014 ${data.errorDescription}` : ""}
2372
+ `
2373
+ );
2374
+ }
2375
+ process.exit(1);
2376
+ }
2377
+ const idPart = data.persistedId ? ` ${style.dim(`(${data.persistedId})`)}` : "";
2378
+ emitAction(`sent to ${surface} ${style.bold(channelId)}${idPart}`, data, json);
2379
+ });
2380
+ chat.command("messages").description("List recent channel messages (GET /chat/channel-messages/{surface})").action(async (_opts, cmd) => {
2381
+ const { surface, ...globals } = cmd.optsWithGlobals();
2382
+ const cfg = resolveConfig(globals);
2383
+ const data = await runApi("Fetching messages", async () => {
2384
+ const client = await makeClient(cfg);
2385
+ return client.GET("/chat/channel-messages/{surface}", {
2386
+ params: { path: { surface: String(surface) } }
2387
+ });
2388
+ });
2389
+ emit(data, cmd.optsWithGlobals().json);
2390
+ });
2391
+ chat.command("replies <messageId>").description("List thread replies for a message (GET /chat/channel-messages/by-id/{id}/replies)").action(async (messageId, _opts, cmd) => {
2392
+ const cfg = resolveConfig(cmd.optsWithGlobals());
2393
+ const data = await runApi("Fetching replies", async () => {
2394
+ const client = await makeClient(cfg);
2395
+ return client.GET("/chat/channel-messages/by-id/{id}/replies", {
2396
+ params: { path: { id: messageId } }
2397
+ });
2398
+ });
2399
+ emit(data, cmd.optsWithGlobals().json);
2400
+ });
2401
+ chat.command("stop-tracking <messageId>").description("Stop watching a message for replies (POST .../by-id/{id}/stop-tracking-replies)").action(async (messageId, _opts, cmd) => {
2402
+ const cfg = resolveConfig(cmd.optsWithGlobals());
2403
+ const data = await runApi("Stopping reply tracking", async () => {
2404
+ const client = await makeClient(cfg);
2405
+ return client.POST("/chat/channel-messages/by-id/{id}/stop-tracking-replies", {
2406
+ params: { path: { id: messageId } },
2407
+ body: {}
2408
+ });
2409
+ });
2410
+ emitAction(`stopped tracking replies on ${style.bold(messageId)}`, data, cmd.optsWithGlobals().json);
2411
+ });
2412
+ }
2413
+
2414
+ // src/setup/apply.ts
2415
+ import { createHash as createHash2 } from "crypto";
2416
+ import { mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync5 } from "fs";
2417
+ import { dirname as dirname5 } from "path";
2418
+ var MARKER_BEGIN = "<!-- @sechroom/cli:begin";
2419
+ var MARKER_END = "<!-- @sechroom/cli:end";
2420
+ function normalizeBody(s) {
2421
+ return s.replace(/\r\n/g, "\n").trim();
2422
+ }
2423
+ function bodySha256(body) {
2424
+ return createHash2("sha256").update(normalizeBody(body), "utf8").digest("hex");
2425
+ }
2426
+ function renderBlock(write) {
2427
+ const body = normalizeBody(write.body);
2428
+ const attrs = [`block=${write.block}`];
2429
+ if (write.source) attrs.push(`source=${write.source}`);
2430
+ attrs.push(`sha256=${bodySha256(body)}`);
2431
+ return `${MARKER_BEGIN} ${attrs.join(" ")} -->
2432
+ ${body}
2433
+ ${MARKER_END} block=${write.block} -->
2434
+ `;
2435
+ }
2436
+ function keyedBlockRe(block) {
2437
+ const b = escapeRe(block);
2438
+ return new RegExp(
2439
+ `${escapeRe(MARKER_BEGIN)}[^\\n]*?\\bblock=${b}\\b[^\\n]*?-->\\n[\\s\\S]*?${escapeRe(MARKER_END)}[^\\n]*?\\bblock=${b}\\b[^\\n]*?-->\\n?`
2440
+ );
2441
+ }
2442
+ function legacyBlockRe() {
2443
+ return new RegExp(
2444
+ `${escapeRe(MARKER_BEGIN)}(?:(?!block=)[^\\n])*?-->\\n[\\s\\S]*?${escapeRe(MARKER_END)}(?:(?!block=)[^\\n])*?-->\\n?`
2445
+ );
2446
+ }
2447
+ function parseAttrs(beginLine) {
2448
+ const attrs = {};
2449
+ for (const m of beginLine.matchAll(/(\w+)=(\S+)/g)) attrs[m[1]] = m[2];
2450
+ return attrs;
2451
+ }
2452
+ function innerBody(segment) {
2453
+ const firstNl = segment.indexOf("\n");
2454
+ const endIdx = segment.lastIndexOf(MARKER_END);
2455
+ return segment.slice(firstNl + 1, endIdx).replace(/\n$/, "");
2456
+ }
2457
+ function parseManagedBlock(content, block) {
2458
+ const keyed = content.match(keyedBlockRe(block));
2459
+ if (keyed) {
2460
+ const attrs = parseAttrs(keyed[0].slice(0, keyed[0].indexOf("\n")));
2461
+ return { block, source: attrs.source ?? null, sha256: attrs.sha256 ?? null, body: innerBody(keyed[0]) };
2462
+ }
2463
+ if (block === "role-template") {
2464
+ const legacy = content.match(legacyBlockRe());
2465
+ if (legacy) return { block, source: null, sha256: null, body: innerBody(legacy[0]) };
2466
+ }
2467
+ return null;
2468
+ }
2469
+ function ensureDir2(path) {
2470
+ mkdirSync4(dirname5(path), { recursive: true });
727
2471
  }
728
2472
  function readOr(path, fallback) {
729
2473
  try {
730
- return readFileSync2(path, "utf8");
2474
+ return readFileSync4(path, "utf8");
731
2475
  } catch {
732
2476
  return fallback;
733
2477
  }
734
2478
  }
735
2479
  function mergeMcpJson(path, snippet, dryRun) {
736
2480
  const incoming = JSON.parse(snippet);
737
- const existed = existsSync2(path);
2481
+ const existed = existsSync5(path);
738
2482
  let current = {};
739
2483
  if (existed) {
740
2484
  try {
741
- current = JSON.parse(readFileSync2(path, "utf8"));
2485
+ current = JSON.parse(readFileSync4(path, "utf8"));
742
2486
  } catch {
743
2487
  return { kind: "mcp", path, status: "skipped", note: "existing file isn't valid JSON \u2014 left untouched" };
744
2488
  }
@@ -746,46 +2490,88 @@ function mergeMcpJson(path, snippet, dryRun) {
746
2490
  current.mcpServers = { ...current.mcpServers ?? {}, ...incoming.mcpServers ?? {} };
747
2491
  if (dryRun) return { kind: "mcp", path, status: "dry-run" };
748
2492
  ensureDir2(path);
749
- writeFileSync2(path, JSON.stringify(current, null, 2) + "\n", { mode: 384 });
2493
+ writeFileSync4(path, JSON.stringify(current, null, 2) + "\n", { mode: 384 });
750
2494
  return { kind: "mcp", path, status: existed ? "merged" : "created" };
751
2495
  }
752
2496
  function mergeCodexToml(path, snippet, dryRun) {
753
- const existed = existsSync2(path);
2497
+ const existed = existsSync5(path);
754
2498
  let body = readOr(path, "");
755
2499
  body = body.replace(/(^|\n)\[mcp_servers\.sechroom\][^[]*/, "\n").replace(/\n{3,}/g, "\n\n");
756
2500
  const trimmed = body.trim();
757
2501
  const next = (trimmed.length > 0 ? trimmed + "\n\n" : "") + snippet.trim() + "\n";
758
2502
  if (dryRun) return { kind: "mcp", path, status: "dry-run" };
759
2503
  ensureDir2(path);
760
- writeFileSync2(path, next, { mode: 384 });
2504
+ writeFileSync4(path, next, { mode: 384 });
761
2505
  return { kind: "mcp", path, status: existed ? "merged" : "created" };
762
2506
  }
763
- function writeInstructionBlock(path, body, dryRun) {
764
- const block = `${BLOCK_BEGIN}
765
- ${body.trim()}
766
- ${BLOCK_END}
767
- `;
768
- const existed = existsSync2(path);
769
- const current = readOr(path, "");
770
- let next;
771
- const re = new RegExp(`${escapeRe(BLOCK_BEGIN)}[\\s\\S]*?${escapeRe(BLOCK_END)}\\n?`);
772
- if (re.test(current)) {
773
- next = current.replace(re, block);
774
- } else {
775
- next = current.trim().length > 0 ? `${current.trimEnd()}
776
-
777
- ${block}` : block;
778
- }
2507
+ function writeInstructionBlock(path, write, dryRun) {
2508
+ const existed = existsSync5(path);
2509
+ const next = computeBlockFile(readOr(path, ""), write);
779
2510
  if (dryRun) return { kind: "instruction", path, status: "dry-run" };
780
2511
  ensureDir2(path);
781
- writeFileSync2(path, next);
2512
+ writeFileSync4(path, next);
782
2513
  return { kind: "instruction", path, status: existed ? "merged" : "created" };
783
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
+ }
784
2568
  function escapeRe(s) {
785
2569
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
786
2570
  }
787
2571
  async function applyClient(cfg, setup, target, opts) {
788
2572
  const actions = [];
2573
+ const mode = opts.mode ?? "apply";
2574
+ const dryRun = opts.dryRun || mode === "check";
789
2575
  if (opts.mcp && target.mcp) {
790
2576
  const surface = findSurface(setup, target.mcp.surfaceKey);
791
2577
  const section = findSection(surface, target.mcp.sectionType);
@@ -794,7 +2580,7 @@ async function applyClient(cfg, setup, target, opts) {
794
2580
  actions.push({ kind: "mcp", path: target.mcp.path, status: "skipped", note: `no ${target.mcp.sectionType} section on surface '${target.mcp.surfaceKey}'` });
795
2581
  } else {
796
2582
  actions.push(
797
- target.mcp.format === "toml" ? mergeCodexToml(target.mcp.path, snippet, opts.dryRun) : mergeMcpJson(target.mcp.path, snippet, opts.dryRun)
2583
+ target.mcp.format === "toml" ? mergeCodexToml(target.mcp.path, snippet, dryRun) : mergeMcpJson(target.mcp.path, snippet, dryRun)
798
2584
  );
799
2585
  }
800
2586
  }
@@ -808,67 +2594,206 @@ async function applyClient(cfg, setup, target, opts) {
808
2594
  if (!resolved) {
809
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`" });
810
2596
  } else {
811
- const action = writeInstructionBlock(target.instruction.path, resolved.body, opts.dryRun);
812
- actions.push(resolved.source === "override" ? { ...action, note: "your personal copy" } : action);
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})` });
813
2617
  }
814
2618
  }
815
2619
  }
816
2620
  return actions;
817
2621
  }
818
2622
 
819
- // src/setup/clients.ts
820
- import { existsSync as existsSync3 } from "fs";
821
- import { homedir as homedir2 } from "os";
822
- import { dirname as dirname3, join as join2 } from "path";
823
- function claudeDesktopConfigPath(home) {
824
- switch (process.platform) {
825
- case "darwin":
826
- return join2(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
827
- case "win32":
828
- return join2(process.env.APPDATA ?? join2(home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
829
- default:
830
- return join2(home, ".config", "Claude", "claude_desktop_config.json");
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
+ `);
831
2658
  }
832
2659
  }
833
- function clientTargets(cwd) {
834
- const home = homedir2();
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"]);
835
2697
  return {
836
- "claude-code": {
837
- key: "claude-code",
838
- label: "Claude Code",
839
- mcp: { surfaceKey: "claude-code", sectionType: SectionType.McpConfig, path: join2(cwd, ".mcp.json"), format: "json" },
840
- instruction: { surfaceKey: "claude-code", path: join2(cwd, "CLAUDE.md") }
841
- },
842
- "claude-desktop": {
843
- key: "claude-desktop",
844
- label: "Claude Desktop",
845
- mcp: { surfaceKey: "claude-desktop", sectionType: SectionType.McpConfig, path: claudeDesktopConfigPath(home), format: "json" },
846
- instruction: { surfaceKey: "claude-desktop", path: join2(home, ".claude", "CLAUDE.md") }
847
- },
848
- codex: {
849
- key: "codex",
850
- label: "Codex CLI",
851
- mcp: { surfaceKey: "chatgpt", sectionType: SectionType.McpConfigToml, path: join2(home, ".codex", "config.toml"), format: "toml" },
852
- instruction: { surfaceKey: "chatgpt", path: join2(cwd, "AGENTS.md") }
853
- },
854
- cursor: {
855
- key: "cursor",
856
- label: "Cursor",
857
- mcp: { surfaceKey: "claude-code", sectionType: SectionType.McpConfig, path: join2(cwd, ".cursor", "mcp.json"), format: "json" },
858
- instruction: { surfaceKey: "chatgpt", path: join2(cwd, "AGENTS.md") }
859
- }
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)
860
2700
  };
861
2701
  }
862
- var ALL_CLIENT_KEYS = ["claude-code", "claude-desktop", "codex", "cursor"];
863
- var DEFAULT_CLIENT_KEY = "claude-code";
864
- function detectInstalledClients(cwd) {
865
- const home = homedir2();
866
- const detected = [];
867
- if (existsSync3(join2(home, ".claude"))) detected.push("claude-code");
868
- if (existsSync3(dirname3(claudeDesktopConfigPath(home)))) detected.push("claude-desktop");
869
- if (existsSync3(join2(home, ".codex"))) detected.push("codex");
870
- if (existsSync3(join2(home, ".cursor")) || existsSync3(join2(cwd, ".cursor"))) detected.push("cursor");
871
- return detected;
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] });
872
2797
  }
873
2798
 
874
2799
  // src/commands/setup.ts
@@ -957,6 +2882,12 @@ Examples:
957
2882
  result.push({ client: key, actions });
958
2883
  if (!json) printActions(target, actions);
959
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
+ }
960
2891
  if (json) {
961
2892
  emit({ dryRun: Boolean(opts.dryRun), clients: result }, true);
962
2893
  return;
@@ -969,43 +2900,154 @@ Examples:
969
2900
  Next \u2014 verify: ${verify.description}
970
2901
  `);
971
2902
  }
972
- process.stdout.write(
973
- opts.dryRun ? "\n(dry run \u2014 nothing written)\n" : "\nDone. Restart your AI client (or reload MCP) to pick up the new config.\n"
974
- );
975
- });
976
- }
977
- function registerSetup(program2) {
978
- const setup = program2.command("setup").description("Granular onboarding steps (init runs these together)");
979
- 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) => {
980
- await runSingle(client, cmd, { dryRun: Boolean(opts.dryRun), mcp: true, agentFiles: false });
981
- });
982
- setup.command("agent-files <client>").description(`Write only the agent instruction file for a client (${ALL_CLIENT_KEYS.join(", ")})`).option("--dry-run", "print what would be written without writing", false).option("--copy", "make a personal copy you can edit (default: prompt on a TTY, else skip)").action(async (client, opts, cmd) => {
983
- await runSingle(client, cmd, { dryRun: Boolean(opts.dryRun), mcp: false, agentFiles: true, copy: opts.copy });
984
- });
2903
+ process.stdout.write(
2904
+ opts.dryRun ? "\n(dry run \u2014 nothing written)\n" : "\nDone. Restart your AI client (or reload MCP) to pick up the new config.\n"
2905
+ );
2906
+ });
2907
+ }
2908
+ function registerSetup(program2) {
2909
+ const setup = program2.command("setup").description("Granular onboarding steps (init runs these together)");
2910
+ setup.command("mcp <clients...>").description(`Write only the MCP config for one or more clients (${ALL_CLIENT_KEYS.join(", ")}, or 'all')`).option("--dry-run", "print what would be written without writing", false).addHelpText("after", "\nExamples:\n $ sechroom setup mcp codex\n $ sechroom setup mcp claude-code codex\n $ sechroom setup mcp all").action(async (clients, opts, cmd) => {
2911
+ await runClients(clients, cmd, { dryRun: Boolean(opts.dryRun), mcp: true, agentFiles: false });
2912
+ });
2913
+ setup.command("agent-files <clients...>").description(`Write only the agent instruction file(s) for one or more clients (${ALL_CLIENT_KEYS.join(", ")}, or 'all')`).option("--dry-run", "print what would be written without writing", false).option("--copy", "make a personal copy you can edit (default: prompt on a TTY, else skip)").addHelpText("after", "\nExamples:\n $ sechroom setup agent-files claude-code CLAUDE.md\n $ sechroom setup agent-files claude-code codex CLAUDE.md + AGENTS.md in one run\n $ sechroom setup agent-files all").action(async (clients, opts, cmd) => {
2914
+ await runClients(clients, cmd, { dryRun: Boolean(opts.dryRun), mcp: false, agentFiles: true, copy: opts.copy });
2915
+ });
2916
+ }
2917
+ async function runClients(clients, cmd, opts) {
2918
+ const cfg = resolveConfig(cmd.optsWithGlobals());
2919
+ const targets = clientTargets(process.cwd());
2920
+ const keys = resolveClientKeys(clients.join(","));
2921
+ const setupData = await withSpinner("Fetching setup descriptors", () => fetchSetup(cfg));
2922
+ const personalWorkspaceId = await getPersonalWorkspaceId(cfg);
2923
+ if (opts.agentFiles && !opts.dryRun) {
2924
+ await maybeOfferCopies(cfg, setupData, targets, keys, personalWorkspaceId, copyChoice(opts));
2925
+ }
2926
+ const json = cmd.optsWithGlobals().json;
2927
+ const result = [];
2928
+ for (const key of keys) {
2929
+ const target = targets[key];
2930
+ const actions = await applyClient(cfg, setupData, target, {
2931
+ dryRun: opts.dryRun,
2932
+ mcp: opts.mcp,
2933
+ agentFiles: opts.agentFiles,
2934
+ personalWorkspaceId
2935
+ });
2936
+ result.push({ client: key, actions });
2937
+ if (!json) printActions(target, actions);
2938
+ }
2939
+ if (json) {
2940
+ emit({ dryRun: opts.dryRun, clients: result }, true);
2941
+ return;
2942
+ }
2943
+ process.stdout.write(opts.dryRun ? "\n(dry run \u2014 nothing written)\n" : "\nDone.\n");
2944
+ }
2945
+
2946
+ // src/commands/onboard.ts
2947
+ import { existsSync as existsSync7 } from "fs";
2948
+ import { join as join7 } from "path";
2949
+
2950
+ // src/commands/fanout.ts
2951
+ import { spawnSync } from "child_process";
2952
+ import { existsSync as existsSync6, readFileSync as readFileSync5, readdirSync, statSync } from "fs";
2953
+ import { isAbsolute, join as join6, resolve } from "path";
2954
+ var ICON = {
2955
+ refresh: "\u21BB",
2956
+ bind: "+",
2957
+ "skip-missing": "\u2013",
2958
+ "skip-unbound": "\u26A0"
2959
+ };
2960
+ function resolveChildDir(path, root) {
2961
+ return isAbsolute(path) ? path : resolve(root, path);
2962
+ }
2963
+ function discoverChildren(root) {
2964
+ let names;
2965
+ try {
2966
+ names = readdirSync(root);
2967
+ } catch {
2968
+ return [];
2969
+ }
2970
+ const out = [];
2971
+ for (const name of names.sort()) {
2972
+ if (name.startsWith(".") || name === "node_modules") continue;
2973
+ const dir = join6(root, name);
2974
+ try {
2975
+ if (!statSync(dir).isDirectory()) continue;
2976
+ } catch {
2977
+ continue;
2978
+ }
2979
+ if (existsSync6(join6(dir, ".git")) || committedBindingPath(dir)) out.push(name);
2980
+ }
2981
+ return out;
985
2982
  }
986
- async function runSingle(client, cmd, opts) {
987
- const cfg = resolveConfig(cmd.optsWithGlobals());
988
- const targets = clientTargets(process.cwd());
989
- const target = targets[client];
990
- if (!target) fail(`unknown client '${client}'. Known: ${ALL_CLIENT_KEYS.join(", ")}.`);
991
- const setupData = await withSpinner("Fetching setup descriptors", () => fetchSetup(cfg));
992
- const personalWorkspaceId = await getPersonalWorkspaceId(cfg);
993
- if (opts.agentFiles && !opts.dryRun) {
994
- await maybeOfferCopies(cfg, setupData, targets, [client], personalWorkspaceId, copyChoice(opts));
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)}`);
995
2990
  }
996
- const actions = await applyClient(cfg, setupData, target, {
997
- dryRun: opts.dryRun,
998
- mcp: opts.mcp,
999
- agentFiles: opts.agentFiles,
1000
- personalWorkspaceId
1001
- });
1002
- const json = cmd.optsWithGlobals().json;
1003
- if (json) {
1004
- emit({ dryRun: opts.dryRun, client, actions }, true);
1005
- } else {
1006
- printActions(target, actions);
1007
- process.stdout.write(opts.dryRun ? "\n(dry run \u2014 nothing written)\n" : "\nDone.\n");
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
+ });
1008
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);
1009
3051
  }
1010
3052
 
1011
3053
  // src/commands/onboard.ts
@@ -1017,42 +3059,197 @@ function systemTimezone() {
1017
3059
  return "UTC";
1018
3060
  }
1019
3061
  }
1020
- async function ensureConfig(g, opts) {
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) {
1021
3095
  const persisted = readPersisted();
1022
3096
  const local = readLocalConfig();
1023
- let baseUrl = g.baseUrl ?? process.env.SECHROOM_BASE_URL ?? local.baseUrl ?? persisted.baseUrl ?? DEFAULT_BASE_URL2;
1024
- let tenant = g.tenant ?? process.env.SECHROOM_TENANT ?? local.tenant ?? persisted.tenant ?? "";
1025
- if (canPrompt() && !opts.yes) {
1026
- baseUrl = await promptText("Sechroom API base URL?", baseUrl);
1027
- tenant = await promptText("Tenant id?", tenant || void 0);
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
+ );
1028
3123
  }
1029
- baseUrl = baseUrl.replace(/\/$/, "");
1030
- if (!tenant) {
1031
- fail(
1032
- "No tenant set. Pass --tenant <id>, set SECHROOM_TENANT, or run `sechroom config set tenant <id>` \u2014 the API rejects untenanted requests (HTTP 400)."
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
+ `
1033
3158
  );
1034
3159
  }
3160
+ return chosen;
3161
+ }
3162
+ async function resolveWorkspaceBinding(client, existing, opts) {
3163
+ if (opts.workspace) {
3164
+ const found = await lookupWorkspace(client, opts.workspace);
3165
+ if (!found && !opts.json) {
3166
+ process.stderr.write(
3167
+ `${warn("\u26A0")} workspace ${style.dim(opts.workspace)} not found (or you lack access) \u2014 binding it anyway.
3168
+ `
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);
1035
3225
  let storeLocal = Boolean(opts.local);
1036
3226
  if (!opts.local && canPrompt() && !opts.yes) {
1037
3227
  storeLocal = await promptSelect(
1038
3228
  "Where should this tenant + base URL be saved?",
1039
3229
  [
1040
3230
  { label: "Globally", value: "global", hint: "all projects on this machine" },
1041
- { label: "This directory", value: "local", hint: ".sechroom.json \u2014 project + subdirs" }
3231
+ { label: "This directory", value: "local", hint: ".sechroom.json \u2014 committed, project + subdirs" }
1042
3232
  ],
1043
3233
  local.path ? "local" : "global"
1044
3234
  ) === "local";
1045
3235
  }
1046
- if (storeLocal) {
1047
- const path = writeLocalConfig({ baseUrl, tenant });
1048
- if (!opts.json) process.stderr.write(`${ok("\u2713")} config saved to ${path} (directory-local)
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)
1049
3241
  `);
1050
- } else {
1051
- writePersisted({ baseUrl, tenant });
1052
- if (!opts.json) process.stderr.write(`${ok("\u2713")} config saved globally (~/.config/sechroom/config.json)
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)}
1053
3249
  `);
3250
+ }
1054
3251
  }
1055
- return { baseUrl, tenant, clientId: persisted.clientId };
3252
+ return { baseUrl, tenant, workspaceId, defaultProjectId, clientId: persisted.clientId };
1056
3253
  }
1057
3254
  async function ensureAuth(cfg, yes) {
1058
3255
  if (process.env.SECHROOM_TOKEN) return;
@@ -1106,25 +3303,144 @@ async function chooseClients(clientFlag, yes, cwd) {
1106
3303
  );
1107
3304
  return picks.length > 0 ? picks : preselected;
1108
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 });
3407
+ }
1109
3408
  function registerOnboard(program2) {
1110
- 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("--local", "save tenant + base URL to a directory-local .sechroom.json instead of the global config", false).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("-y, --yes", "non-interactive: accept defaults (system timezone, detected clients, global config, full wire)", false).addHelpText(
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(
1111
3410
  "after",
1112
3411
  `
1113
3412
  Examples:
1114
3413
  $ sechroom onboard guided, interactive (asks where to save config + how to wire)
1115
3414
  $ sechroom onboard --cli-only just the CLI \u2014 no .mcp.json, no agent files
1116
3415
  $ sechroom onboard --no-mcp agent instructions only, skip MCP config
1117
- $ sechroom onboard --local save tenant + base URL to ./.sechroom.json
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
1118
3422
  $ sechroom onboard --yes non-interactive: defaults + global config + full wire
1119
3423
  $ sechroom onboard --client all --dry-run preview wiring every client, write nothing`
1120
3424
  ).action(async (opts, cmd) => {
1121
3425
  const g = cmd.optsWithGlobals();
1122
3426
  const json = Boolean(g.json);
1123
- const yes = Boolean(opts.yes);
1124
3427
  const dryRun = Boolean(opts.dryRun);
1125
- const cfg = await ensureConfig(g, { yes, json, local: Boolean(opts.local) });
1126
- await ensureAuth(cfg, yes);
1127
- const tz = await ensureTimezone(cfg, { yes, dryRun });
3428
+ const mode = opts.check ? "check" : opts.force ? "force" : "apply";
3429
+ const check = mode === "check";
3430
+ const yes = Boolean(opts.yes) || check;
3431
+ if (opts.lane) process.env.SECHROOM_CODE_LANE = opts.lane;
3432
+ if (opts.designLane) process.env.SECHROOM_DESIGN_LANE = opts.designLane;
3433
+ if (opts.recurse) {
3434
+ const baseUrl2 = resolveBaseUrl(g);
3435
+ await ensureAuth({ baseUrl: baseUrl2, tenant: "", clientId: readPersisted().clientId }, yes);
3436
+ const cfg2 = await ensureTenant(baseUrl2, g, { yes: true, json, persist: false });
3437
+ await runRecurse(cfg2, g, { yes, dryRun, json, lane: opts.lane, designLane: opts.designLane });
3438
+ return;
3439
+ }
3440
+ const baseUrl = resolveBaseUrl(g);
3441
+ await ensureAuth({ baseUrl, tenant: "", clientId: readPersisted().clientId }, yes);
3442
+ const cfg = await ensureTenant(baseUrl, g, { yes, json, local: Boolean(opts.local), workspace: opts.workspace, persist: !check });
3443
+ const tz = await ensureTimezone(cfg, { yes, dryRun: dryRun || check });
1128
3444
  if (!json && tz.action !== "already-set") {
1129
3445
  const line = tz.action === "set" ? `${ok("\u2713")} timezone set to ${tz.timezone}
1130
3446
  ` : tz.action === "dry-run" ? `(dry run \u2014 would set timezone to ${tz.timezone})
@@ -1135,22 +3451,27 @@ Examples:
1135
3451
  const wire = await chooseWire(opts, yes);
1136
3452
  if (wire === "cli-only") {
1137
3453
  if (json) {
1138
- emit({ dryRun, baseUrl: cfg.baseUrl, tenant: cfg.tenant, timezone: tz, wire, clients: [] }, true);
3454
+ emit({ dryRun, baseUrl: cfg.baseUrl, tenant: cfg.tenant, workspaceId: cfg.workspaceId ?? null, timezone: tz, wire, clients: [] }, true);
1139
3455
  return;
1140
3456
  }
3457
+ if (!dryRun) {
3458
+ await ensureLanePin(cfg, { yes, dryRun, clients: detectInstalledClients(process.cwd()) });
3459
+ await maybeOfferHooks({ yes, dryRun, cwd: process.cwd() });
3460
+ }
1141
3461
  process.stdout.write(
1142
3462
  `
1143
3463
  ${style.bold("Done.")} The CLI is configured for ${style.cyan(cfg.tenant)} \u2014 no AI-client files written.
1144
3464
  Try: ${style.cyan('sechroom memory search "..."')} or ${style.cyan("sechroom --help")}
1145
3465
  `
1146
3466
  );
3467
+ await printStarterPrompt("cli");
1147
3468
  return;
1148
3469
  }
1149
3470
  const keys = await chooseClients(opts.client, yes, process.cwd());
1150
3471
  const setup = await withSpinner("Fetching setup descriptors", () => fetchSetup(cfg));
1151
3472
  const targets = clientTargets(process.cwd());
1152
3473
  const personalWorkspaceId = await getPersonalWorkspaceId(cfg);
1153
- if (!dryRun) {
3474
+ if (!dryRun && !check) {
1154
3475
  await maybeOfferCopies(cfg, setup, targets, keys, personalWorkspaceId, copyChoice(opts));
1155
3476
  }
1156
3477
  const writeMcp = wire === "full";
@@ -1161,22 +3482,68 @@ Try: ${style.cyan('sechroom memory search "..."')} or ${style.cyan("sechroom -
1161
3482
  dryRun,
1162
3483
  mcp: writeMcp,
1163
3484
  agentFiles: true,
1164
- personalWorkspaceId
3485
+ personalWorkspaceId,
3486
+ mode
1165
3487
  });
1166
3488
  result.push({ client: key, actions });
1167
- 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() });
1168
3520
  }
1169
3521
  if (json) {
1170
- emit({ dryRun, baseUrl: cfg.baseUrl, tenant: cfg.tenant, timezone: tz, wire, clients: result }, true);
3522
+ emit({ dryRun, baseUrl: cfg.baseUrl, tenant: cfg.tenant, workspaceId: cfg.workspaceId ?? null, timezone: tz, wire, eval: evalCounts, clients: result }, true);
1171
3523
  return;
1172
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"));
1173
3537
  process.stdout.write(
1174
- dryRun ? "\n(dry run \u2014 nothing written)\n" : writeMcp ? `
3538
+ dryRun ? "\n(dry run \u2014 nothing written)\n" : !wroteSomething ? `
3539
+ ${style.bold("Done.")} Everything's already up to date.
3540
+ ` : writeMcp ? `
1175
3541
  ${style.bold("Done.")} Restart your AI client (or reload MCP) to pick up the new config.
1176
3542
  ` : `
1177
3543
  ${style.bold("Done.")} Agent instructions written (no MCP config).
1178
3544
  `
1179
3545
  );
3546
+ if (!dryRun) await printStarterPrompt("agent", cfg);
1180
3547
  });
1181
3548
  }
1182
3549
  async function chooseWire(opts, yes) {
@@ -1194,12 +3561,437 @@ async function chooseWire(opts, yes) {
1194
3561
  }
1195
3562
  return opts.mcp === false ? "agent-only" : "full";
1196
3563
  }
3564
+ var FALLBACK_AGENT_PROMPT = "Resume my sechroom continuity, summarise what I was last working on, then suggest the next step.";
3565
+ async function printStarterPrompt(mode, cfg) {
3566
+ if (mode === "cli") {
3567
+ process.stdout.write(
3568
+ `
3569
+ ${style.bold("Next:")} pick up where you left off \u2014
3570
+ ${style.cyan("sechroom continuity resume-me")}
3571
+ `
3572
+ );
3573
+ return;
3574
+ }
3575
+ let primary = FALLBACK_AGENT_PROMPT;
3576
+ if (cfg) {
3577
+ try {
3578
+ const client = await makeClient(cfg);
3579
+ const { data } = await client.GET("/me/onboarding/starter-prompt", {});
3580
+ if (data?.primary) primary = data.primary;
3581
+ } catch {
3582
+ }
3583
+ }
3584
+ process.stdout.write(
3585
+ `
3586
+ ${style.bold("Next:")} paste this into your AI agent to get going \u2014
3587
+ ${style.cyan(`"${primary}"`)}
3588
+ `
3589
+ );
3590
+ }
3591
+
3592
+ // src/commands/sweep.ts
3593
+ import { existsSync as existsSync8 } from "fs";
3594
+ import { dirname as dirname6, join as join8, resolve as resolve2 } from "path";
3595
+ var DEFAULT_MANIFEST = join8(".sechroom", "repos.json");
3596
+ function planEntry(entry, root) {
3597
+ const dir = resolveChildDir(entry.path, root);
3598
+ if (!existsSync8(dir)) {
3599
+ return { label: entry.path, dir, disposition: "skip-missing", argv: [], reason: "directory does not exist" };
3600
+ }
3601
+ if (committedBindingPath(dir)) {
3602
+ return {
3603
+ label: entry.path,
3604
+ dir,
3605
+ disposition: "refresh",
3606
+ argv: ["onboard", "--refresh", "--yes"],
3607
+ reason: "bound (committed .sechroom.json) \u2014 refresh in place"
3608
+ };
3609
+ }
3610
+ if (entry.workspaceId) {
3611
+ return {
3612
+ label: entry.path,
3613
+ dir,
3614
+ disposition: "bind",
3615
+ argv: ["onboard", "--yes", "--local", "--workspace", entry.workspaceId],
3616
+ reason: `unbound \u2014 bind to ${entry.workspaceId} + commit .sechroom.json`
3617
+ };
3618
+ }
3619
+ return {
3620
+ label: entry.path,
3621
+ dir,
3622
+ disposition: "skip-unbound",
3623
+ argv: [],
3624
+ reason: "unbound + no workspaceId in the manifest \u2014 add one or run `sechroom onboard` there"
3625
+ };
3626
+ }
3627
+ function registerSweep(program2) {
3628
+ program2.command("sweep").description("Non-interactive fan-out from ./.sechroom/repos.json (headless sibling of `onboard --recurse`)").option("--manifest <path>", "path to the repos manifest", DEFAULT_MANIFEST).option("--dry-run", "print the plan (per-repo disposition + the onboard command) without running anything", false).addHelpText(
3629
+ "after",
3630
+ `
3631
+ For an interactive, no-manifest run use ${"`sechroom onboard --recurse`"} instead \u2014 it
3632
+ auto-discovers the child repos and prompts for a workspace per new one. ${"`sweep`"} is
3633
+ the deterministic manifest-driven form for scripts / CI.
3634
+
3635
+ Manifest \u2014 ./.sechroom/repos.json (per-operator, gitignored, alongside lane.json):
3636
+ {
3637
+ "repos": [
3638
+ { "path": "sechroom", "workspaceId": "wsp_XXXX" },
3639
+ { "path": "../other-repo", "workspaceId": "wsp_YYYY" },
3640
+ { "path": "already-bound" }
3641
+ ]
3642
+ }
3643
+
3644
+ Per repo (paths resolve relative to the manifest's root):
3645
+ ${ICON.refresh} bound committed .sechroom.json present \u2192 onboard --refresh (manifest workspace ignored)
3646
+ ${ICON.bind} unbound bind to the manifest workspaceId \u2192 onboard --local --workspace <id>
3647
+ ${ICON["skip-missing"]} missing directory does not exist \u2192 skipped
3648
+ ${ICON["skip-unbound"]} no workspace unbound + no workspaceId in manifest \u2192 skipped (add one, or onboard manually)
3649
+
3650
+ Examples:
3651
+ $ sechroom sweep --dry-run preview every repo's disposition, run nothing
3652
+ $ sechroom sweep onboard the whole tree from the root
3653
+ $ sechroom --tenant ocd sweep force a tenant for every child (else each resolves its own)`
3654
+ ).action((opts, cmd) => {
3655
+ const g = cmd.optsWithGlobals();
3656
+ const json = Boolean(g.json);
3657
+ const dryRun = Boolean(opts.dryRun);
3658
+ const manifestPath = resolve2(opts.manifest);
3659
+ let repos;
3660
+ try {
3661
+ repos = readManifest(manifestPath);
3662
+ } catch (err2) {
3663
+ fail(err2 instanceof Error ? err2.message : String(err2));
3664
+ }
3665
+ if (repos === null) {
3666
+ fail(`no manifest at ${manifestPath} \u2014 create ./.sechroom/repos.json, or use \`sechroom onboard --recurse\` to auto-discover (see \`sechroom sweep --help\`).`);
3667
+ }
3668
+ if (repos.length === 0) {
3669
+ if (json) process.stdout.write(JSON.stringify({ manifest: manifestPath, repos: [] }) + "\n");
3670
+ else process.stderr.write(`${warn("\u26A0")} ${manifestPath} lists no repos \u2014 nothing to do.
3671
+ `);
3672
+ return;
3673
+ }
3674
+ const root = dirname6(dirname6(manifestPath));
3675
+ const plans = repos.map((entry) => planEntry(entry, root));
3676
+ if (!json) {
3677
+ process.stderr.write(
3678
+ `${style.bold("sweep")} ${style.dim(`(${plans.length} repo${plans.length === 1 ? "" : "s"} from ${manifestPath})`)}
3679
+ `
3680
+ );
3681
+ }
3682
+ const results = runChildren(plans, { globals: passthroughGlobals(g), dryRun, json });
3683
+ if (json) {
3684
+ process.stdout.write(JSON.stringify({ manifest: manifestPath, dryRun, repos: results }) + "\n");
3685
+ return;
3686
+ }
3687
+ summarizeFanout(results, { dryRun });
3688
+ });
3689
+ }
3690
+
3691
+ // src/commands/skills.ts
3692
+ import { homedir as homedir6 } from "os";
3693
+ import { join as join9 } from "path";
3694
+ import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync6, rmSync as rmSync2, existsSync as existsSync9, readFileSync as readFileSync6 } from "fs";
3695
+ var DEFAULT_SLUG = "operator-skills";
3696
+ var ROLE_TAGS = ["sechroom:role:skill-template", "role:skill-template"];
3697
+ var LOCK = ".sechroom-skills.json";
3698
+ function skillsDir(global) {
3699
+ return global ? join9(homedir6(), ".claude", "skills") : join9(process.cwd(), ".claude", "skills");
3700
+ }
3701
+ function tagValue2(tags, prefix) {
3702
+ return (tags ?? []).find((t) => t.startsWith(prefix))?.slice(prefix.length);
3703
+ }
3704
+ function hasAny(tags, candidates) {
3705
+ return (tags ?? []).some((t) => candidates.includes(t));
3706
+ }
3707
+ function registerSkills(program2) {
3708
+ const skills = program2.command("skills").description("Install + manage operator skills from a bundle");
3709
+ skills.addHelpText(
3710
+ "after",
3711
+ `
3712
+ Examples:
3713
+ $ sechroom skills install --code-lane claude-code-chris --design-lane claude-design-chris
3714
+ $ sechroom skills install operator-skills --surface claude-code --local
3715
+ $ sechroom skills list
3716
+ $ sechroom skills set-lane --code-lane claude-code-chris --design-lane claude-design-chris
3717
+ $ sechroom skills lane
3718
+ $ sechroom skills clean`
3719
+ );
3720
+ skills.command("install [slug]").description(`Install a skills bundle (default ${DEFAULT_SLUG}) into your personal workspace + write SKILL.md files`).option("--version <v>", "bundle version (default: latest published in the catalogue)").option("--instance <name>", "install as a named, separate instance (install the same bundle more than once)").option("--code-lane <id>", "identity.code-lane binding (e.g. claude-code-chris)").option("--design-lane <id>", "identity.design-lane binding (e.g. claude-design-chris)").option("--surface <s>", "skill target surface to materialise", "claude-code").option("--local", "write to ./.claude/skills instead of ~/.claude/skills").option("--json", "machine output").action(async (slugArg, opts, cmd) => {
3721
+ const slug = slugArg || DEFAULT_SLUG;
3722
+ const client = await makeClient(resolveConfig(cmd.optsWithGlobals()));
3723
+ const pw = await runApi("resolving personal workspace", () => client.GET("/me/personal-workspace", {}));
3724
+ const personalWsId = pw?.id || pw?.workspaceId || pw?.personalWorkspaceId || pw?.item?.id;
3725
+ if (!personalWsId) fail("Could not resolve your personal workspace.");
3726
+ let version = opts.version;
3727
+ if (!version) {
3728
+ const cat = await runApi("reading the bundle catalogue", () => client.GET("/me/bundles", {}));
3729
+ const item = (cat?.bundles ?? cat?.Bundles ?? []).find((b) => (b.slug ?? b.Slug) === slug);
3730
+ if (!item) fail(`Bundle '${slug}' is not in your self-serve catalogue (must be UserInstallable + Published).`);
3731
+ version = item.latestVersion ?? item.LatestVersion;
3732
+ if (!version) fail(`Bundle '${slug}' has no installable (Published) version.`);
3733
+ }
3734
+ const installOptions = {};
3735
+ if (opts.codeLane) installOptions["identity.code-lane"] = opts.codeLane;
3736
+ if (opts.designLane) installOptions["identity.design-lane"] = opts.designLane;
3737
+ const res = await runApi(
3738
+ `installing ${slug}@${version}${opts.instance ? ` (${opts.instance})` : ""}`,
3739
+ () => client.POST("/me/bundles/{slug}/versions/{version}/install", {
3740
+ params: { path: { slug, version } },
3741
+ // instance: null/absent = the default instance (reinstall updates in
3742
+ // place); a name installs a separate instance.
3743
+ body: { installOptions, instance: opts.instance ?? null }
3744
+ })
3745
+ );
3746
+ const status = String(res?.status ?? res?.Status ?? "");
3747
+ if (status && status.toLowerCase() !== "completed") {
3748
+ fail(`Install did not complete (status=${status}; ${res?.failureReason ?? res?.FailureReason ?? ""}).`);
3749
+ }
3750
+ const feed = await runApi(
3751
+ "materialising skill files",
3752
+ () => client.GET("/workspaces/{workspaceId}/memories/feed", {
3753
+ // cascadeWorkspaces: skills land in an "Operator Skills" SUB-workspace of
3754
+ // the personal workspace, so we recurse from the personal-ws root.
3755
+ // includeText: the feed omits bodies by default; we need them for SKILL.md.
3756
+ params: {
3757
+ path: { workspaceId: personalWsId },
3758
+ query: { limit: 200, cascadeWorkspaces: true, includeText: true }
3759
+ }
3760
+ })
3761
+ );
3762
+ const rows = feed?.results ?? feed?.Results ?? [];
3763
+ const dir = skillsDir(!opts.local);
3764
+ const wantInstance = opts.instance || "default";
3765
+ const written = [];
3766
+ const bundleTagPrefix = `sechroom:bundle:${slug}@`;
3767
+ for (const r of rows) {
3768
+ const m = r.item ?? r;
3769
+ const tags = m.tags ?? m.Tags ?? [];
3770
+ if (!hasAny(tags, ROLE_TAGS)) continue;
3771
+ if (tagValue2(tags, "target:") !== opts.surface) continue;
3772
+ if (!tags.some((t) => t.startsWith(bundleTagPrefix))) continue;
3773
+ if ((tagValue2(tags, "sechroom:skill-instance:") ?? "default") !== wantInstance) continue;
3774
+ const name = tagValue2(tags, "skill:");
3775
+ if (!name) continue;
3776
+ const body = m.text ?? m.Text ?? "";
3777
+ mkdirSync6(join9(dir, name), { recursive: true });
3778
+ writeFileSync6(join9(dir, name, "SKILL.md"), body.endsWith("\n") ? body : body + "\n");
3779
+ written.push(name);
3780
+ }
3781
+ mkdirSync6(dir, { recursive: true });
3782
+ const lockPath = join9(dir, LOCK);
3783
+ const lock = existsSync9(lockPath) ? JSON.parse(readFileSync6(lockPath, "utf8")) : {};
3784
+ lock[slug] = { surface: opts.surface, version, instance: wantInstance, skills: written.sort() };
3785
+ writeFileSync6(lockPath, JSON.stringify(lock, null, 2) + "\n");
3786
+ if (opts.json) return emit({ slug, version, instance: wantInstance, surface: opts.surface, dir, installed: written }, true);
3787
+ const instanceNote = opts.instance ? ` (${opts.instance})` : "";
3788
+ console.log(style.green(`Installed ${slug}@${version}${instanceNote} \u2014 ${written.length} skill(s) \u2192 ${dir}`));
3789
+ written.forEach((n) => console.log(" " + style.dim("\u2022") + " " + n));
3790
+ if (written.length === 0) console.log(style.dim(` (no '${opts.surface}' skill bodies found; check --surface)`));
3791
+ });
3792
+ skills.command("list").description("List your installed bundles (GET /me/bundle-installs)").option("--json", "machine output").action(async (opts, cmd) => {
3793
+ const client = await makeClient(resolveConfig(cmd.optsWithGlobals()));
3794
+ const data = await runApi("reading your installs", () => client.GET("/me/bundle-installs", {}));
3795
+ if (opts.json) return emit(data, true);
3796
+ const installs = data?.installs ?? data?.Installs ?? [];
3797
+ if (installs.length === 0) return console.log(style.dim("No bundles installed."));
3798
+ installs.forEach((i) => {
3799
+ const inst = i.instance ?? i.Instance ?? "";
3800
+ const tag = inst ? style.dim(` [${inst}]`) : "";
3801
+ console.log(` ${i.bundleSlug ?? i.BundleSlug}@${i.bundleVersion ?? i.BundleVersion ?? "?"}${tag}`);
3802
+ });
3803
+ });
3804
+ skills.command("clean [slug]").description(`Remove materialised skill files written by install (default ${DEFAULT_SLUG})`).option("--local", "clean ./.claude/skills instead of ~/.claude/skills").option("--json", "machine output").action(async (slugArg, opts) => {
3805
+ const slug = slugArg || DEFAULT_SLUG;
3806
+ const dir = skillsDir(!opts.local);
3807
+ const lockPath = join9(dir, LOCK);
3808
+ if (!existsSync9(lockPath)) fail(`No skills lockfile at ${lockPath}; nothing to clean.`);
3809
+ const lock = JSON.parse(readFileSync6(lockPath, "utf8"));
3810
+ const entry = lock[slug];
3811
+ if (!entry) fail(`No installed record for '${slug}' in ${lockPath}.`);
3812
+ const removed = [];
3813
+ for (const name of entry.skills) {
3814
+ const skillPath = join9(dir, name);
3815
+ if (existsSync9(skillPath)) {
3816
+ rmSync2(skillPath, { recursive: true, force: true });
3817
+ removed.push(name);
3818
+ }
3819
+ }
3820
+ delete lock[slug];
3821
+ writeFileSync6(lockPath, JSON.stringify(lock, null, 2) + "\n");
3822
+ if (opts.json) return emit({ slug, removed, dir }, true);
3823
+ console.log(style.green(`Removed ${removed.length} skill(s) for ${slug} from ${dir}`));
3824
+ });
3825
+ skills.command("set-lane").description("Write this checkout's lane pin to a local ./.sechroom/lane.json file (read at runtime by skills)").option("--code-lane <id>", "code-surface lane id (e.g. claude-code-chris)").option("--design-lane <id>", "design / substrate-authoring lane id (e.g. claude-design-chris)").option("--json", "machine output").action((opts, cmd) => {
3826
+ if (!opts.codeLane && !opts.designLane) fail("Provide --code-lane and/or --design-lane.");
3827
+ const target = localSemPath();
3828
+ const values = readLocalSemValues();
3829
+ if (opts.codeLane) values["code-lane"] = opts.codeLane;
3830
+ if (opts.designLane) values["design-lane"] = opts.designLane;
3831
+ writeSem(values, target);
3832
+ if (cmd.optsWithGlobals().json) return emit({ path: target, values }, true);
3833
+ console.log(style.green(`Wrote lane pin \u2192 ${target} ${style.dim("(git-ignored)")}`));
3834
+ Object.entries(values).forEach(([k, v]) => console.log(" " + style.dim(k) + " = " + v));
3835
+ });
3836
+ skills.command("lane").description("Show the lane pin resolved from ./.sechroom/lane.json (nearest in this checkout; legacy ./.sem honoured)").option("--json", "machine output").action((opts, cmd) => {
3837
+ const json = cmd.optsWithGlobals().json;
3838
+ const found = readSem();
3839
+ if (!found) {
3840
+ if (json) return emit({ path: null, values: {} }, true);
3841
+ return console.log(style.dim(`No ./.sechroom/lane.json pin in this checkout. Run 'sechroom skills set-lane'.`));
3842
+ }
3843
+ if (json) return emit(found, true);
3844
+ console.log(style.dim(`from ${found.path}`));
3845
+ Object.entries(found.values).forEach(([k, v]) => console.log(" " + style.bold(k) + " = " + v));
3846
+ });
3847
+ skills.command("set-workflow").description("Set your per-operator workflow defaults (server-side; follows you across tenants)").option("--default-code-lane <id>", "personal default code lane (e.g. claude-code-chris)").option("--default-design-lane <id>", "personal default design lane (e.g. claude-design-chris)").option("--handover-recipient <id>", "your daily-handover counterparty (e.g. andy)").option("--json", "machine output").action(async (opts, cmd) => {
3848
+ if (!opts.defaultCodeLane && !opts.defaultDesignLane && !opts.handoverRecipient)
3849
+ fail("Provide at least one of --default-code-lane / --default-design-lane / --handover-recipient.");
3850
+ const client = await makeClient(resolveConfig(cmd.optsWithGlobals()));
3851
+ const cur = await runApi(
3852
+ "reading workflow preferences",
3853
+ () => client.GET("/me/workflow-preferences", {})
3854
+ );
3855
+ const body = {
3856
+ defaultCodeLane: opts.defaultCodeLane ?? cur?.defaultCodeLane ?? null,
3857
+ defaultDesignLane: opts.defaultDesignLane ?? cur?.defaultDesignLane ?? null,
3858
+ handoverRecipient: opts.handoverRecipient ?? cur?.handoverRecipient ?? null
3859
+ };
3860
+ const res = await runApi(
3861
+ "saving workflow preferences",
3862
+ () => client.POST("/me/workflow-preferences", { body })
3863
+ );
3864
+ if (cmd.optsWithGlobals().json) return emit(res, true);
3865
+ console.log(style.green("Saved your workflow preferences"));
3866
+ console.log(" " + style.dim("default-code-lane") + " = " + (body.defaultCodeLane ?? "(unset)"));
3867
+ console.log(" " + style.dim("default-design-lane") + " = " + (body.defaultDesignLane ?? "(unset)"));
3868
+ console.log(" " + style.dim("handover-recipient") + " = " + (body.handoverRecipient ?? "(unset)"));
3869
+ });
3870
+ skills.command("workflow").description("Show your per-operator workflow defaults (GET /me/workflow-preferences)").option("--json", "machine output").action(async (opts, cmd) => {
3871
+ const client = await makeClient(resolveConfig(cmd.optsWithGlobals()));
3872
+ const data = await runApi(
3873
+ "reading workflow preferences",
3874
+ () => client.GET("/me/workflow-preferences", {})
3875
+ );
3876
+ if (cmd.optsWithGlobals().json) return emit(data, true);
3877
+ console.log(" " + style.bold("default-code-lane") + " = " + (data?.defaultCodeLane ?? style.dim("(unset)")));
3878
+ console.log(" " + style.bold("default-design-lane") + " = " + (data?.defaultDesignLane ?? style.dim("(unset)")));
3879
+ console.log(" " + style.bold("handover-recipient") + " = " + (data?.handoverRecipient ?? style.dim("(unset)")));
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."));
3987
+ });
3988
+ }
1197
3989
 
1198
3990
  // src/index.ts
1199
3991
  function resolveVersion() {
1200
3992
  try {
1201
3993
  const pkg = JSON.parse(
1202
- readFileSync3(new URL("../package.json", import.meta.url), "utf8")
3994
+ readFileSync8(new URL("../package.json", import.meta.url), "utf8")
1203
3995
  );
1204
3996
  return pkg.version ?? "0.0.0";
1205
3997
  } catch {
@@ -1215,7 +4007,7 @@ Examples:
1215
4007
  $ sechroom onboard guided first-run: configure, sign in, wire this project
1216
4008
  $ sechroom login sign in via browser (OAuth + PKCE)
1217
4009
  $ sechroom config set tenant ocd set your tenant (global)
1218
- $ sechroom config set --local tenant cli-smoke pin tenant for this directory (.sechroom.json)
4010
+ $ sechroom config set --local tenant cli-smoke pin tenant for this directory (committed .sechroom.json)
1219
4011
  $ sechroom config show resolved config + which source won
1220
4012
 
1221
4013
  $ sechroom memory create --text "a note" --title "Note" --tag idea
@@ -1227,7 +4019,7 @@ Examples:
1227
4019
  $ sechroom --json memory search "auth" compact JSON for scripts and agents
1228
4020
  $ SECHROOM_TOKEN=<bearer> sechroom --json memory get mem_XXXX headless
1229
4021
 
1230
- Config precedence (high -> low): --flag > env (SECHROOM_*) > ./.sechroom.json > global > default.
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.
1231
4023
  Run 'sechroom <command> --help' for command-specific examples.`
1232
4024
  );
1233
4025
  program.hook("preAction", (_thisCmd, actionCmd) => {
@@ -1253,14 +4045,14 @@ config.addHelpText(
1253
4045
  Examples:
1254
4046
  $ sechroom config set baseUrl https://app.sechroom.ai/api prod (staging: https://staging.app.sechroom.ai/api)
1255
4047
  $ sechroom config set tenant ocd
1256
- $ sechroom config set --local tenant cli-smoke this dir + subdirs (.sechroom.json)
4048
+ $ sechroom config set --local tenant cli-smoke this dir + subdirs (committed .sechroom.json)
1257
4049
  $ sechroom config set clientId dyn-XXXX global-only escape hatch (no DCR endpoint)
1258
4050
  $ sechroom config show --json`
1259
4051
  );
1260
- config.command("set <key> <value>").description("Set baseUrl | tenant | clientId (clientId is global-only)").option("--local", "Write to the directory-local .sechroom.json (nearest up the tree, else cwd) instead of the global config").action((key, value, opts) => {
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) => {
1261
4053
  if (opts.local) {
1262
- if (!["baseUrl", "tenant"].includes(key)) {
1263
- process.stderr.write(`--local supports only: baseUrl | tenant (clientId is global)
4054
+ if (!["baseUrl", "tenant", "workspaceId", "defaultProjectId"].includes(key)) {
4055
+ process.stderr.write(`--local supports only: baseUrl | tenant | workspaceId | defaultProjectId (clientId is global)
1264
4056
  `);
1265
4057
  process.exit(1);
1266
4058
  }
@@ -1269,8 +4061,8 @@ config.command("set <key> <value>").description("Set baseUrl | tenant | clientId
1269
4061
  `);
1270
4062
  return;
1271
4063
  }
1272
- if (!["baseUrl", "tenant", "clientId"].includes(key)) {
1273
- process.stderr.write(`unknown key: ${key} (expected baseUrl | tenant | clientId)
4064
+ if (!["baseUrl", "tenant", "clientId", "workspaceId", "defaultProjectId"].includes(key)) {
4065
+ process.stderr.write(`unknown key: ${key} (expected baseUrl | tenant | workspaceId | defaultProjectId | clientId)
1274
4066
  `);
1275
4067
  process.exit(1);
1276
4068
  }
@@ -1284,7 +4076,7 @@ config.command("show").description("Print resolved config + sources (flag > env
1284
4076
  if (g.json) {
1285
4077
  process.stdout.write(
1286
4078
  JSON.stringify({
1287
- resolved: { baseUrl: d.baseUrl, tenant: d.tenant },
4079
+ resolved: { baseUrl: d.baseUrl, tenant: d.tenant, workspaceId: d.workspaceId },
1288
4080
  global: readPersisted(),
1289
4081
  local: readLocalConfig()
1290
4082
  }) + "\n"
@@ -1292,8 +4084,9 @@ config.command("show").description("Print resolved config + sources (flag > env
1292
4084
  return;
1293
4085
  }
1294
4086
  process.stdout.write(
1295
- `baseUrl: ${d.baseUrl.value} [${d.baseUrl.source}]
1296
- tenant: ${d.tenant.value ?? "(unset)"} [${d.tenant.source}]
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}]
1297
4090
 
1298
4091
  global: ${JSON.stringify(readPersisted())}
1299
4092
  local: ${d.localPath ?? "(none)"} ${JSON.stringify(readLocalConfig())}
@@ -1303,9 +4096,21 @@ local: ${d.localPath ?? "(none)"} ${JSON.stringify(readLocalConfig())}
1303
4096
  registerMemory(program);
1304
4097
  registerWorklog(program);
1305
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);
1306
4108
  registerInit(program);
1307
4109
  registerSetup(program);
1308
4110
  registerOnboard(program);
4111
+ registerSweep(program);
4112
+ registerSkills(program);
4113
+ registerReset(program);
1309
4114
  program.parseAsync().catch((err2) => {
1310
4115
  process.stderr.write(`error: ${err2 instanceof Error ? err2.message : String(err2)}
1311
4116
  `);