@sechroom/cli 2026.6.18 → 2026.6.19-rc.ff5beb8c
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +985 -324
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { readFileSync as
|
|
4
|
+
import { readFileSync as readFileSync8 } from "fs";
|
|
5
5
|
import { Command } from "commander";
|
|
6
6
|
|
|
7
7
|
// src/auth.ts
|
|
@@ -16,7 +16,10 @@ 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
|
|
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";
|
|
21
24
|
var LOCAL_CONFIG_SCHEMA_VERSION = 2;
|
|
22
25
|
function ensureDir() {
|
|
@@ -64,43 +67,55 @@ function clearPersisted() {
|
|
|
64
67
|
rmSync(CONFIG_FILE);
|
|
65
68
|
return CONFIG_FILE;
|
|
66
69
|
}
|
|
67
|
-
function
|
|
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()) {
|
|
68
78
|
let dir = start;
|
|
69
79
|
for (; ; ) {
|
|
70
|
-
|
|
71
|
-
if (existsSync(candidate)) return candidate;
|
|
80
|
+
if (existsSync(join(dir, BASELINE_CONFIG_NAME)) || existsSync(join(dir, OVERRIDE_CONFIG_NAME))) return dir;
|
|
72
81
|
const parent = dirname(dir);
|
|
73
82
|
if (parent === dir) return void 0;
|
|
74
83
|
dir = parent;
|
|
75
84
|
}
|
|
76
85
|
}
|
|
77
86
|
function readLocalConfig() {
|
|
78
|
-
const
|
|
79
|
-
if (!
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
return {};
|
|
92
|
-
}
|
|
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
|
+
};
|
|
93
100
|
}
|
|
94
101
|
function writeLocalConfig(patch) {
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
} catch {
|
|
100
|
-
}
|
|
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) ?? {};
|
|
101
106
|
const next = { ...current, ...patch, schemaVersion: LOCAL_CONFIG_SCHEMA_VERSION };
|
|
102
|
-
writeFileSync(
|
|
103
|
-
|
|
107
|
+
writeFileSync(baselinePath, JSON.stringify(next, null, 2), { mode: 420 });
|
|
108
|
+
const override = readJsonConfig(overridePath);
|
|
109
|
+
if (override) {
|
|
110
|
+
for (const f of BINDING_FIELDS) delete override[f];
|
|
111
|
+
if (Object.keys(override).length === 0) rmSync(overridePath, { force: true });
|
|
112
|
+
else writeFileSync(overridePath, JSON.stringify(override, null, 2), { mode: 384 });
|
|
113
|
+
}
|
|
114
|
+
return baselinePath;
|
|
115
|
+
}
|
|
116
|
+
function committedBindingPath(dir) {
|
|
117
|
+
const p = join(dir, BASELINE_CONFIG_NAME);
|
|
118
|
+
return existsSync(p) ? p : void 0;
|
|
104
119
|
}
|
|
105
120
|
function resolveConfig(flags) {
|
|
106
121
|
const local = readLocalConfig();
|
|
@@ -360,8 +375,8 @@ async function promptYesNo(question) {
|
|
|
360
375
|
const { createInterface } = await import("readline");
|
|
361
376
|
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
362
377
|
try {
|
|
363
|
-
const answer = await new Promise((
|
|
364
|
-
rl.question(`${question} [y/N] `,
|
|
378
|
+
const answer = await new Promise((resolve3) => {
|
|
379
|
+
rl.question(`${question} [y/N] `, resolve3);
|
|
365
380
|
});
|
|
366
381
|
return /^y(es)?$/i.test(answer.trim());
|
|
367
382
|
} finally {
|
|
@@ -374,8 +389,8 @@ async function promptText(question, def) {
|
|
|
374
389
|
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
375
390
|
try {
|
|
376
391
|
const suffix = def ? ` [${def}]` : "";
|
|
377
|
-
const answer = await new Promise((
|
|
378
|
-
rl.question(`${question}${suffix} `,
|
|
392
|
+
const answer = await new Promise((resolve3) => {
|
|
393
|
+
rl.question(`${question}${suffix} `, resolve3);
|
|
379
394
|
});
|
|
380
395
|
const trimmed = answer.trim();
|
|
381
396
|
return trimmed.length > 0 ? trimmed : def ?? "";
|
|
@@ -401,8 +416,8 @@ async function promptSelect(question, choices, def) {
|
|
|
401
416
|
process.stderr.write(` ${marker} ${style.bold(String(i + 1))}. ${c.label}${hint}
|
|
402
417
|
`);
|
|
403
418
|
});
|
|
404
|
-
const answer = await new Promise((
|
|
405
|
-
rl.question(`Choose ${style.dim(`[${defIdx + 1}]`)} `,
|
|
419
|
+
const answer = await new Promise((resolve3) => {
|
|
420
|
+
rl.question(`Choose ${style.dim(`[${defIdx + 1}]`)} `, resolve3);
|
|
406
421
|
});
|
|
407
422
|
const trimmed = answer.trim();
|
|
408
423
|
if (!trimmed) return choices[defIdx].value;
|
|
@@ -434,8 +449,8 @@ async function promptMultiSelect(question, choices, preselected = []) {
|
|
|
434
449
|
process.stderr.write(` ${box} ${style.bold(String(i + 1))}. ${c.label}${hint}
|
|
435
450
|
`);
|
|
436
451
|
});
|
|
437
|
-
const answer = await new Promise((
|
|
438
|
-
rl.question(`Select ${style.dim("[Enter = \u25C9]")} `,
|
|
452
|
+
const answer = await new Promise((resolve3) => {
|
|
453
|
+
rl.question(`Select ${style.dim("[Enter = \u25C9]")} `, resolve3);
|
|
439
454
|
});
|
|
440
455
|
const trimmed = answer.trim().toLowerCase();
|
|
441
456
|
if (!trimmed) return preValues();
|
|
@@ -1558,10 +1573,17 @@ Examples:
|
|
|
1558
1573
|
});
|
|
1559
1574
|
}
|
|
1560
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
|
+
|
|
1561
1581
|
// src/sem.ts
|
|
1562
|
-
import { dirname as dirname2, join as join2 } from "path";
|
|
1582
|
+
import { basename as basename2, dirname as dirname2, join as join2 } from "path";
|
|
1563
1583
|
import { appendFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
1564
|
-
var SEM_FILE = ".
|
|
1584
|
+
var SEM_FILE = join2(".sechroom", "lane.json");
|
|
1585
|
+
var LEGACY_SEM_FILE = ".sem";
|
|
1586
|
+
var STATE_DIR_NAME2 = ".sechroom";
|
|
1565
1587
|
function localSemPath(cwd = process.cwd()) {
|
|
1566
1588
|
return join2(cwd, SEM_FILE);
|
|
1567
1589
|
}
|
|
@@ -1570,6 +1592,8 @@ function resolveSemPathForRead(start = process.cwd()) {
|
|
|
1570
1592
|
while (true) {
|
|
1571
1593
|
const candidate = join2(dir, SEM_FILE);
|
|
1572
1594
|
if (existsSync2(candidate)) return candidate;
|
|
1595
|
+
const legacy = join2(dir, LEGACY_SEM_FILE);
|
|
1596
|
+
if (existsSync2(legacy)) return legacy;
|
|
1573
1597
|
const parent = dirname2(dir);
|
|
1574
1598
|
if (parent === dir) return void 0;
|
|
1575
1599
|
dir = parent;
|
|
@@ -1589,15 +1613,35 @@ function parseSem(text) {
|
|
|
1589
1613
|
return out;
|
|
1590
1614
|
}
|
|
1591
1615
|
function serializeSem(values) {
|
|
1592
|
-
|
|
1593
|
-
const body = Object.entries(values).map(([k, v]) => `${k} = ${v}`).join("\n");
|
|
1594
|
-
return header + body + "\n";
|
|
1616
|
+
return JSON.stringify(values, null, 2) + "\n";
|
|
1595
1617
|
}
|
|
1596
1618
|
function readSem(path) {
|
|
1597
1619
|
const p = path ?? resolveSemPathForRead();
|
|
1598
1620
|
if (!p || !existsSync2(p)) return void 0;
|
|
1599
|
-
|
|
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
|
+
}
|
|
1600
1643
|
}
|
|
1644
|
+
var STATE_DIR_IGNORE = `${STATE_DIR_NAME2}/`;
|
|
1601
1645
|
function writeSem(values, path = localSemPath()) {
|
|
1602
1646
|
mkdirSync2(dirname2(path), { recursive: true });
|
|
1603
1647
|
writeFileSync2(path, serializeSem(values));
|
|
@@ -1607,9 +1651,18 @@ function writeSem(values, path = localSemPath()) {
|
|
|
1607
1651
|
function ignoresSem(content) {
|
|
1608
1652
|
return content.split("\n").some((line) => {
|
|
1609
1653
|
const t = line.trim();
|
|
1610
|
-
return t ===
|
|
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}`;
|
|
1611
1655
|
});
|
|
1612
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
|
+
}
|
|
1613
1666
|
function resolveGitignoreTarget(startDir) {
|
|
1614
1667
|
let dir = startDir;
|
|
1615
1668
|
for (; ; ) {
|
|
@@ -1624,20 +1677,199 @@ function resolveGitignoreTarget(startDir) {
|
|
|
1624
1677
|
}
|
|
1625
1678
|
function ensureSemIgnored(semPath) {
|
|
1626
1679
|
try {
|
|
1627
|
-
const
|
|
1680
|
+
const checkoutDir = dirname2(dirname2(semPath));
|
|
1681
|
+
if (!inGitRepo(checkoutDir)) return;
|
|
1682
|
+
const target = resolveGitignoreTarget(checkoutDir);
|
|
1628
1683
|
if (target.exists) {
|
|
1629
1684
|
const content = readFileSync2(target.path, "utf8");
|
|
1630
1685
|
if (ignoresSem(content)) return;
|
|
1631
1686
|
const sep = content.length === 0 || content.endsWith("\n") ? "" : "\n";
|
|
1632
|
-
appendFileSync(target.path, `${sep}
|
|
1687
|
+
appendFileSync(target.path, `${sep}${STATE_DIR_IGNORE}
|
|
1633
1688
|
`);
|
|
1634
1689
|
} else {
|
|
1635
|
-
writeFileSync2(target.path,
|
|
1690
|
+
writeFileSync2(target.path, `${STATE_DIR_IGNORE}
|
|
1691
|
+
`);
|
|
1636
1692
|
}
|
|
1637
1693
|
} catch {
|
|
1638
1694
|
}
|
|
1639
1695
|
}
|
|
1640
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
|
+
|
|
1641
1873
|
// src/commands/hook.ts
|
|
1642
1874
|
async function readStdin() {
|
|
1643
1875
|
if (process.stdin.isTTY) return "";
|
|
@@ -1661,6 +1893,31 @@ function resolveLane(flagLane, cwd) {
|
|
|
1661
1893
|
const sem = readSem(resolveSemPathForRead(start));
|
|
1662
1894
|
return sem?.values["code-lane"];
|
|
1663
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
|
+
}
|
|
1664
1921
|
function formatContext(bundle, lane) {
|
|
1665
1922
|
const s = bundle?.latestSnapshot;
|
|
1666
1923
|
if (!s) return null;
|
|
@@ -1697,18 +1954,149 @@ function emitSessionStart(additionalContext) {
|
|
|
1697
1954
|
}) + "\n"
|
|
1698
1955
|
);
|
|
1699
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
|
+
}
|
|
1700
2083
|
function registerHook(program2) {
|
|
1701
2084
|
const hook = program2.command("hook").description("Agent-lifecycle hook adapter (Claude Code / Codex) \u2014 bridges hooks to continuity");
|
|
1702
2085
|
hook.addHelpText(
|
|
1703
2086
|
"after",
|
|
1704
2087
|
`
|
|
1705
2088
|
Examples:
|
|
1706
|
-
#
|
|
2089
|
+
# SessionStart (load): inject the lane's latest snapshot as context.
|
|
1707
2090
|
$ echo '{"hook_event_name":"SessionStart","cwd":"'"$PWD"'"}' | sechroom hook session-start
|
|
1708
|
-
|
|
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
|
|
1709
2097
|
|
|
1710
2098
|
Lane source (high -> low): --lane > SECHROOM_LANE > ./.sem code-lane (D-binding-5).
|
|
1711
|
-
Fail-soft: no lane / no auth / API error -> exit 0,
|
|
2099
|
+
Fail-soft: no lane / no auth / no-or-partial intent file / API error -> exit 0, never blocks.`
|
|
1712
2100
|
);
|
|
1713
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) => {
|
|
1714
2102
|
try {
|
|
@@ -1734,24 +2122,96 @@ Fail-soft: no lane / no auth / API error -> exit 0, no context injected, never b
|
|
|
1734
2122
|
return process.exit(0);
|
|
1735
2123
|
}
|
|
1736
2124
|
});
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
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);
|
|
1755
2215
|
return client.POST("/id-registry/allocate", {
|
|
1756
2216
|
body: { namespaceKind, scope, clientNonce: null }
|
|
1757
2217
|
});
|
|
@@ -1953,129 +2413,8 @@ Examples:
|
|
|
1953
2413
|
|
|
1954
2414
|
// src/setup/apply.ts
|
|
1955
2415
|
import { createHash as createHash2 } from "crypto";
|
|
1956
|
-
import { mkdirSync as
|
|
1957
|
-
import { dirname as
|
|
1958
|
-
|
|
1959
|
-
// src/setup/operator-surface.ts
|
|
1960
|
-
var SectionType = {
|
|
1961
|
-
McpConfig: "mcp-config",
|
|
1962
|
-
McpConfigToml: "mcp-config-toml",
|
|
1963
|
-
InstructionFile: "instruction-file",
|
|
1964
|
-
ProjectConfig: "project-config",
|
|
1965
|
-
Verify: "verify",
|
|
1966
|
-
/** SBC-999 — workspace-pinned conventions, emitted only when the request
|
|
1967
|
-
* carried a workspaceId and that workspace has agent-setup-bundle memories. */
|
|
1968
|
-
WorkspaceConventions: "workspace-conventions"
|
|
1969
|
-
};
|
|
1970
|
-
async function fetchSetup(cfg) {
|
|
1971
|
-
const client = await makeClient(cfg);
|
|
1972
|
-
const { data, error } = await client.GET(
|
|
1973
|
-
"/operator-surface/setup",
|
|
1974
|
-
cfg.workspaceId ? { params: { query: { workspaceId: cfg.workspaceId } } } : {}
|
|
1975
|
-
);
|
|
1976
|
-
if (error) throw new Error(`GET /operator-surface/setup failed: ${JSON.stringify(error)}`);
|
|
1977
|
-
return data;
|
|
1978
|
-
}
|
|
1979
|
-
function findSurface(setup, surfaceKey) {
|
|
1980
|
-
return setup.surfaces.find((s) => s.surfaceKey === surfaceKey);
|
|
1981
|
-
}
|
|
1982
|
-
function findSection(surface, sectionType) {
|
|
1983
|
-
return surface?.sections.find((s) => s.sectionType === sectionType);
|
|
1984
|
-
}
|
|
1985
|
-
function sectionSnippet(section) {
|
|
1986
|
-
if (!section) return null;
|
|
1987
|
-
for (const step of section.steps) {
|
|
1988
|
-
if (step.copyValue) return step.copyValue;
|
|
1989
|
-
if (step.codeSnippet) return step.codeSnippet;
|
|
1990
|
-
}
|
|
1991
|
-
return null;
|
|
1992
|
-
}
|
|
1993
|
-
function parseTagArtifactId(id) {
|
|
1994
|
-
if (!id.startsWith("tag:")) return null;
|
|
1995
|
-
const tags = id.slice("tag:".length).split(",").map((t) => t.trim()).filter((t) => t.length > 0);
|
|
1996
|
-
return tags.length > 0 ? tags : null;
|
|
1997
|
-
}
|
|
1998
|
-
async function getPersonalWorkspaceId(cfg) {
|
|
1999
|
-
const client = await makeClient(cfg);
|
|
2000
|
-
const { data } = await client.GET("/me/personal-workspace", {});
|
|
2001
|
-
return data?.workspaceId ?? null;
|
|
2002
|
-
}
|
|
2003
|
-
async function fetchMemoryFields(cfg, id) {
|
|
2004
|
-
const client = await makeClient(cfg);
|
|
2005
|
-
const { data } = await client.GET("/memories/{memoryId}", { params: { path: { memoryId: id } } });
|
|
2006
|
-
const env = data;
|
|
2007
|
-
const m = env?.item ?? env;
|
|
2008
|
-
if (!m) return null;
|
|
2009
|
-
const version = typeof m.currentVersion === "string" ? Number(m.currentVersion) : m.currentVersion;
|
|
2010
|
-
return { text: m.text, title: m.title, tags: m.tags, version: Number.isFinite(version) ? version : void 0 };
|
|
2011
|
-
}
|
|
2012
|
-
async function resolveInstruction(cfg, section, personalWorkspaceId) {
|
|
2013
|
-
const client = await makeClient(cfg);
|
|
2014
|
-
for (const artifact of section.artifacts) {
|
|
2015
|
-
const tags = parseTagArtifactId(artifact.id);
|
|
2016
|
-
if (!tags) continue;
|
|
2017
|
-
const { data } = await client.POST("/memories/search", {
|
|
2018
|
-
body: { query: null, textQuery: null, semanticQuery: null, hybrid: false, limit: 1, includeArchived: false, includeSystem: false, tags }
|
|
2019
|
-
});
|
|
2020
|
-
const hits = data ?? [];
|
|
2021
|
-
if (hits.length === 0) continue;
|
|
2022
|
-
const templateId = hits[0].id;
|
|
2023
|
-
const template = await fetchMemoryFields(cfg, templateId);
|
|
2024
|
-
if (typeof template?.text !== "string" || template.text.length === 0) continue;
|
|
2025
|
-
const templateTags = template.tags ?? tags;
|
|
2026
|
-
if (personalWorkspaceId) {
|
|
2027
|
-
const { data: ovr } = await client.POST("/memories/search", {
|
|
2028
|
-
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 } }
|
|
2029
|
-
});
|
|
2030
|
-
const ovrHits = ovr ?? [];
|
|
2031
|
-
if (ovrHits.length > 0) {
|
|
2032
|
-
const override = await fetchMemoryFields(cfg, ovrHits[0].id);
|
|
2033
|
-
if (typeof override?.text === "string" && override.text.length > 0) {
|
|
2034
|
-
return { title: override.title ?? template.title ?? artifact.title, body: override.text, source: "override", templateId, templateTags, sourceRef: `${ovrHits[0].id}@v${override.version ?? 1}` };
|
|
2035
|
-
}
|
|
2036
|
-
}
|
|
2037
|
-
}
|
|
2038
|
-
return { title: template.title ?? artifact.title, body: template.text, source: "template", templateId, templateTags, sourceRef: `${templateId}@v${template.version ?? 1}` };
|
|
2039
|
-
}
|
|
2040
|
-
return null;
|
|
2041
|
-
}
|
|
2042
|
-
async function resolveWorkspaceConventions(cfg, section) {
|
|
2043
|
-
const parts = [];
|
|
2044
|
-
const refs = [];
|
|
2045
|
-
for (const artifact of section.artifacts) {
|
|
2046
|
-
if (parseTagArtifactId(artifact.id)) continue;
|
|
2047
|
-
const mem = await fetchMemoryFields(cfg, artifact.id);
|
|
2048
|
-
if (typeof mem?.text === "string" && mem.text.trim().length > 0) {
|
|
2049
|
-
parts.push(mem.text.trim());
|
|
2050
|
-
refs.push(`${artifact.id}@v${mem.version ?? 1}`);
|
|
2051
|
-
}
|
|
2052
|
-
}
|
|
2053
|
-
if (parts.length === 0) return null;
|
|
2054
|
-
return { body: parts.join("\n\n---\n\n"), refs };
|
|
2055
|
-
}
|
|
2056
|
-
async function createOverride(cfg, template, personalWorkspaceId) {
|
|
2057
|
-
const client = await makeClient(cfg);
|
|
2058
|
-
const overrideTags = template.templateTags.filter(
|
|
2059
|
-
(t) => t !== "sechroom:role:template" && !t.startsWith("sechroom:bundle:") && !t.startsWith("sechroom:template-ref:")
|
|
2060
|
-
);
|
|
2061
|
-
overrideTags.push("sechroom:role:override", `sechroom:template-ref:${template.templateId}`);
|
|
2062
|
-
const { error } = await client.POST("/memories", {
|
|
2063
|
-
body: {
|
|
2064
|
-
text: template.body,
|
|
2065
|
-
type: "reference",
|
|
2066
|
-
content: "{}",
|
|
2067
|
-
confidence: 1,
|
|
2068
|
-
source: "cli-agent-instructions-customize",
|
|
2069
|
-
archetype: "Document",
|
|
2070
|
-
title: template.title ?? null,
|
|
2071
|
-
tags: overrideTags,
|
|
2072
|
-
owner: { type: "Workspace", id: personalWorkspaceId }
|
|
2073
|
-
}
|
|
2074
|
-
});
|
|
2075
|
-
if (error) throw new Error(`creating personal copy failed: ${JSON.stringify(error)}`);
|
|
2076
|
-
}
|
|
2077
|
-
|
|
2078
|
-
// src/setup/apply.ts
|
|
2416
|
+
import { mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync5 } from "fs";
|
|
2417
|
+
import { dirname as dirname5 } from "path";
|
|
2079
2418
|
var MARKER_BEGIN = "<!-- @sechroom/cli:begin";
|
|
2080
2419
|
var MARKER_END = "<!-- @sechroom/cli:end";
|
|
2081
2420
|
function normalizeBody(s) {
|
|
@@ -2128,22 +2467,22 @@ function parseManagedBlock(content, block) {
|
|
|
2128
2467
|
return null;
|
|
2129
2468
|
}
|
|
2130
2469
|
function ensureDir2(path) {
|
|
2131
|
-
|
|
2470
|
+
mkdirSync4(dirname5(path), { recursive: true });
|
|
2132
2471
|
}
|
|
2133
2472
|
function readOr(path, fallback) {
|
|
2134
2473
|
try {
|
|
2135
|
-
return
|
|
2474
|
+
return readFileSync4(path, "utf8");
|
|
2136
2475
|
} catch {
|
|
2137
2476
|
return fallback;
|
|
2138
2477
|
}
|
|
2139
2478
|
}
|
|
2140
2479
|
function mergeMcpJson(path, snippet, dryRun) {
|
|
2141
2480
|
const incoming = JSON.parse(snippet);
|
|
2142
|
-
const existed =
|
|
2481
|
+
const existed = existsSync5(path);
|
|
2143
2482
|
let current = {};
|
|
2144
2483
|
if (existed) {
|
|
2145
2484
|
try {
|
|
2146
|
-
current = JSON.parse(
|
|
2485
|
+
current = JSON.parse(readFileSync4(path, "utf8"));
|
|
2147
2486
|
} catch {
|
|
2148
2487
|
return { kind: "mcp", path, status: "skipped", note: "existing file isn't valid JSON \u2014 left untouched" };
|
|
2149
2488
|
}
|
|
@@ -2151,26 +2490,26 @@ function mergeMcpJson(path, snippet, dryRun) {
|
|
|
2151
2490
|
current.mcpServers = { ...current.mcpServers ?? {}, ...incoming.mcpServers ?? {} };
|
|
2152
2491
|
if (dryRun) return { kind: "mcp", path, status: "dry-run" };
|
|
2153
2492
|
ensureDir2(path);
|
|
2154
|
-
|
|
2493
|
+
writeFileSync4(path, JSON.stringify(current, null, 2) + "\n", { mode: 384 });
|
|
2155
2494
|
return { kind: "mcp", path, status: existed ? "merged" : "created" };
|
|
2156
2495
|
}
|
|
2157
2496
|
function mergeCodexToml(path, snippet, dryRun) {
|
|
2158
|
-
const existed =
|
|
2497
|
+
const existed = existsSync5(path);
|
|
2159
2498
|
let body = readOr(path, "");
|
|
2160
2499
|
body = body.replace(/(^|\n)\[mcp_servers\.sechroom\][^[]*/, "\n").replace(/\n{3,}/g, "\n\n");
|
|
2161
2500
|
const trimmed = body.trim();
|
|
2162
2501
|
const next = (trimmed.length > 0 ? trimmed + "\n\n" : "") + snippet.trim() + "\n";
|
|
2163
2502
|
if (dryRun) return { kind: "mcp", path, status: "dry-run" };
|
|
2164
2503
|
ensureDir2(path);
|
|
2165
|
-
|
|
2504
|
+
writeFileSync4(path, next, { mode: 384 });
|
|
2166
2505
|
return { kind: "mcp", path, status: existed ? "merged" : "created" };
|
|
2167
2506
|
}
|
|
2168
2507
|
function writeInstructionBlock(path, write, dryRun) {
|
|
2169
|
-
const existed =
|
|
2508
|
+
const existed = existsSync5(path);
|
|
2170
2509
|
const next = computeBlockFile(readOr(path, ""), write);
|
|
2171
2510
|
if (dryRun) return { kind: "instruction", path, status: "dry-run" };
|
|
2172
2511
|
ensureDir2(path);
|
|
2173
|
-
|
|
2512
|
+
writeFileSync4(path, next);
|
|
2174
2513
|
return { kind: "instruction", path, status: existed ? "merged" : "created" };
|
|
2175
2514
|
}
|
|
2176
2515
|
function computeBlockFile(current, write) {
|
|
@@ -2211,7 +2550,7 @@ function applyBlock(path, write, mode, dryRun) {
|
|
|
2211
2550
|
const next = computeBlockFile(current, write);
|
|
2212
2551
|
if (!dryRun) {
|
|
2213
2552
|
ensureDir2(proposedPath);
|
|
2214
|
-
|
|
2553
|
+
writeFileSync4(proposedPath, next);
|
|
2215
2554
|
}
|
|
2216
2555
|
return {
|
|
2217
2556
|
kind: "instruction",
|
|
@@ -2281,65 +2620,48 @@ async function applyClient(cfg, setup, target, opts) {
|
|
|
2281
2620
|
return actions;
|
|
2282
2621
|
}
|
|
2283
2622
|
|
|
2284
|
-
// src/setup/
|
|
2285
|
-
import {
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
mcp: { surfaceKey: "claude-desktop", sectionType: SectionType.McpConfig, path: claudeDesktopConfigPath(home), format: "json" },
|
|
2311
|
-
instruction: { surfaceKey: "claude-desktop", path: join3(home, ".claude", "CLAUDE.md") }
|
|
2312
|
-
},
|
|
2313
|
-
codex: {
|
|
2314
|
-
key: "codex",
|
|
2315
|
-
label: "Codex CLI",
|
|
2316
|
-
mcp: { surfaceKey: "chatgpt", sectionType: SectionType.McpConfigToml, path: join3(home, ".codex", "config.toml"), format: "toml" },
|
|
2317
|
-
instruction: { surfaceKey: "chatgpt", path: join3(cwd, "AGENTS.md") }
|
|
2318
|
-
},
|
|
2319
|
-
cursor: {
|
|
2320
|
-
key: "cursor",
|
|
2321
|
-
label: "Cursor",
|
|
2322
|
-
mcp: { surfaceKey: "claude-code", sectionType: SectionType.McpConfig, path: join3(cwd, ".cursor", "mcp.json"), format: "json" },
|
|
2323
|
-
instruction: { surfaceKey: "chatgpt", path: join3(cwd, "AGENTS.md") }
|
|
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
|
+
}
|
|
2324
2649
|
}
|
|
2325
|
-
|
|
2326
|
-
}
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
if (existsSync4(join3(home, ".codex"))) detected.push("codex");
|
|
2335
|
-
if (existsSync4(join3(home, ".cursor")) || existsSync4(join3(cwd, ".cursor"))) detected.push("cursor");
|
|
2336
|
-
return detected;
|
|
2650
|
+
if (changed) {
|
|
2651
|
+
process.stderr.write(`${style.dim("Restart (or reload) your agent for the hooks to take effect.")}
|
|
2652
|
+
`);
|
|
2653
|
+
}
|
|
2654
|
+
warnIfSechroomNotOnPath();
|
|
2655
|
+
} catch (err2) {
|
|
2656
|
+
process.stderr.write(`${style.dim(`(skipped hook install: ${err2.message})`)}
|
|
2657
|
+
`);
|
|
2658
|
+
}
|
|
2337
2659
|
}
|
|
2338
2660
|
|
|
2339
2661
|
// src/setup/skills-offer.ts
|
|
2340
|
-
import { mkdirSync as
|
|
2341
|
-
import { homedir as
|
|
2342
|
-
import { join as
|
|
2662
|
+
import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "fs";
|
|
2663
|
+
import { homedir as homedir5 } from "os";
|
|
2664
|
+
import { join as join5 } from "path";
|
|
2343
2665
|
|
|
2344
2666
|
// src/setup/lane-pin.ts
|
|
2345
2667
|
var CODE_LANE_PREFIX_BY_CLIENT = {
|
|
@@ -2359,18 +2681,7 @@ function codeLanePrefix(clients) {
|
|
|
2359
2681
|
for (const c of CLIENT_PRIORITY) if (clients.includes(c)) return CODE_LANE_PREFIX_BY_CLIENT[c];
|
|
2360
2682
|
return "claude-code";
|
|
2361
2683
|
}
|
|
2362
|
-
function
|
|
2363
|
-
const values = {};
|
|
2364
|
-
if (code) values["code-lane"] = code;
|
|
2365
|
-
if (design) values["design-lane"] = design;
|
|
2366
|
-
if (Object.keys(values).length === 0) return;
|
|
2367
|
-
const target = writeSem(values);
|
|
2368
|
-
process.stderr.write(`${ok("\u2713")} lane pin written \u2192 ${target} ${style.dim("(./.sem, git-ignored)")}
|
|
2369
|
-
`);
|
|
2370
|
-
}
|
|
2371
|
-
async function ensureLanePin(cfg, opts) {
|
|
2372
|
-
if (opts.dryRun) return;
|
|
2373
|
-
if (readSem()) return;
|
|
2684
|
+
async function inferLanes(cfg, clients) {
|
|
2374
2685
|
let wf;
|
|
2375
2686
|
let profile;
|
|
2376
2687
|
try {
|
|
@@ -2382,9 +2693,25 @@ async function ensureLanePin(cfg, opts) {
|
|
|
2382
2693
|
} catch {
|
|
2383
2694
|
}
|
|
2384
2695
|
const handle = handleFromDisplayName(profile?.effectiveDisplayName);
|
|
2385
|
-
const prefix = codeLanePrefix(
|
|
2386
|
-
|
|
2387
|
-
|
|
2696
|
+
const prefix = codeLanePrefix(clients ?? ["claude-code"]);
|
|
2697
|
+
return {
|
|
2698
|
+
code: process.env.SECHROOM_CODE_LANE ?? wf?.defaultCodeLane ?? (handle ? `${prefix}-${handle}` : void 0),
|
|
2699
|
+
design: process.env.SECHROOM_DESIGN_LANE ?? wf?.defaultDesignLane ?? (handle ? `claude-design-${handle}` : void 0)
|
|
2700
|
+
};
|
|
2701
|
+
}
|
|
2702
|
+
function writePin(code, design) {
|
|
2703
|
+
const values = {};
|
|
2704
|
+
if (code) values["code-lane"] = code;
|
|
2705
|
+
if (design) values["design-lane"] = design;
|
|
2706
|
+
if (Object.keys(values).length === 0) return;
|
|
2707
|
+
const target = writeSem(values);
|
|
2708
|
+
process.stderr.write(`${ok("\u2713")} lane pin written \u2192 ${target} ${style.dim("(./.sechroom/lane.json, git-ignored)")}
|
|
2709
|
+
`);
|
|
2710
|
+
}
|
|
2711
|
+
async function ensureLanePin(cfg, opts) {
|
|
2712
|
+
if (opts.dryRun) return;
|
|
2713
|
+
if (readSem()) return;
|
|
2714
|
+
const { code: codeGuess, design: designGuess } = await inferLanes(cfg, opts.clients);
|
|
2388
2715
|
if (!canPrompt() || opts.yes) {
|
|
2389
2716
|
if (opts.yes && (codeGuess || designGuess)) writePin(codeGuess, designGuess);
|
|
2390
2717
|
return;
|
|
@@ -2454,14 +2781,14 @@ async function maybeOfferSkills(cfg, personalWorkspaceId, opts) {
|
|
|
2454
2781
|
Found ${style.bold(String(names.length))} operator skill(s) installed in your workspace: ${names.join(", ")}.
|
|
2455
2782
|
`
|
|
2456
2783
|
);
|
|
2457
|
-
const dir =
|
|
2784
|
+
const dir = join5(homedir5(), ".claude", "skills");
|
|
2458
2785
|
const materialise = opts.yes ? true : canPrompt() ? await promptYesNo(`Write them to ${dir}/ so ${surface} can use them?`) : false;
|
|
2459
2786
|
if (!materialise) return;
|
|
2460
2787
|
const written = [];
|
|
2461
2788
|
for (const [name, m] of byName) {
|
|
2462
2789
|
const body = m.text ?? m.Text ?? "";
|
|
2463
|
-
|
|
2464
|
-
|
|
2790
|
+
mkdirSync5(join5(dir, name), { recursive: true });
|
|
2791
|
+
writeFileSync5(join5(dir, name, "SKILL.md"), body.endsWith("\n") ? body : body + "\n");
|
|
2465
2792
|
written.push(name);
|
|
2466
2793
|
}
|
|
2467
2794
|
process.stderr.write(`${style.green("\u2713")} wrote ${written.length} skill(s) to ${dir}
|
|
@@ -2558,6 +2885,9 @@ Examples:
|
|
|
2558
2885
|
if (!json && !opts.dryRun && !opts.mcpOnly) {
|
|
2559
2886
|
await maybeOfferSkills(cfg, personalWorkspaceId, { yes: false, dryRun: Boolean(opts.dryRun), surface: "claude-code" });
|
|
2560
2887
|
}
|
|
2888
|
+
if (!json && !opts.dryRun && !opts.mcpOnly) {
|
|
2889
|
+
await maybeOfferHooks({ yes: false, dryRun: Boolean(opts.dryRun), cwd: process.cwd() });
|
|
2890
|
+
}
|
|
2561
2891
|
if (json) {
|
|
2562
2892
|
emit({ dryRun: Boolean(opts.dryRun), clients: result }, true);
|
|
2563
2893
|
return;
|
|
@@ -2613,6 +2943,113 @@ async function runClients(clients, cmd, opts) {
|
|
|
2613
2943
|
process.stdout.write(opts.dryRun ? "\n(dry run \u2014 nothing written)\n" : "\nDone.\n");
|
|
2614
2944
|
}
|
|
2615
2945
|
|
|
2946
|
+
// src/commands/onboard.ts
|
|
2947
|
+
import { existsSync as existsSync7 } from "fs";
|
|
2948
|
+
import { join as join7 } from "path";
|
|
2949
|
+
|
|
2950
|
+
// src/commands/fanout.ts
|
|
2951
|
+
import { spawnSync } from "child_process";
|
|
2952
|
+
import { existsSync as existsSync6, readFileSync as readFileSync5, readdirSync, statSync } from "fs";
|
|
2953
|
+
import { isAbsolute, join as join6, resolve } from "path";
|
|
2954
|
+
var ICON = {
|
|
2955
|
+
refresh: "\u21BB",
|
|
2956
|
+
bind: "+",
|
|
2957
|
+
"skip-missing": "\u2013",
|
|
2958
|
+
"skip-unbound": "\u26A0"
|
|
2959
|
+
};
|
|
2960
|
+
function resolveChildDir(path, root) {
|
|
2961
|
+
return isAbsolute(path) ? path : resolve(root, path);
|
|
2962
|
+
}
|
|
2963
|
+
function discoverChildren(root) {
|
|
2964
|
+
let names;
|
|
2965
|
+
try {
|
|
2966
|
+
names = readdirSync(root);
|
|
2967
|
+
} catch {
|
|
2968
|
+
return [];
|
|
2969
|
+
}
|
|
2970
|
+
const out = [];
|
|
2971
|
+
for (const name of names.sort()) {
|
|
2972
|
+
if (name.startsWith(".") || name === "node_modules") continue;
|
|
2973
|
+
const dir = join6(root, name);
|
|
2974
|
+
try {
|
|
2975
|
+
if (!statSync(dir).isDirectory()) continue;
|
|
2976
|
+
} catch {
|
|
2977
|
+
continue;
|
|
2978
|
+
}
|
|
2979
|
+
if (existsSync6(join6(dir, ".git")) || committedBindingPath(dir)) out.push(name);
|
|
2980
|
+
}
|
|
2981
|
+
return out;
|
|
2982
|
+
}
|
|
2983
|
+
function readManifest(path) {
|
|
2984
|
+
if (!existsSync6(path)) return null;
|
|
2985
|
+
let parsed;
|
|
2986
|
+
try {
|
|
2987
|
+
parsed = JSON.parse(readFileSync5(path, "utf8"));
|
|
2988
|
+
} catch (err2) {
|
|
2989
|
+
throw new Error(`couldn't parse ${path}: ${err2 instanceof Error ? err2.message : String(err2)}`);
|
|
2990
|
+
}
|
|
2991
|
+
return Array.isArray(parsed.repos) ? parsed.repos.filter((r) => r && typeof r.path === "string") : [];
|
|
2992
|
+
}
|
|
2993
|
+
function passthroughGlobals(g) {
|
|
2994
|
+
const out = [];
|
|
2995
|
+
if (g.baseUrl) out.push("--base-url", g.baseUrl);
|
|
2996
|
+
if (g.tenant) out.push("--tenant", g.tenant);
|
|
2997
|
+
return out;
|
|
2998
|
+
}
|
|
2999
|
+
function runChildren(plans, o) {
|
|
3000
|
+
const { globals, dryRun, json } = o;
|
|
3001
|
+
const results = [];
|
|
3002
|
+
for (const plan of plans) {
|
|
3003
|
+
const runs = plan.argv.length > 0;
|
|
3004
|
+
const argv = runs ? [...globals, ...plan.argv] : [];
|
|
3005
|
+
if (!json) {
|
|
3006
|
+
process.stderr.write(` ${ICON[plan.disposition]} ${style.cyan(plan.label)} ${style.dim(plan.reason)}
|
|
3007
|
+
`);
|
|
3008
|
+
if (runs && dryRun) process.stderr.write(` ${style.dim(`would run: sechroom ${argv.join(" ")}`)}
|
|
3009
|
+
`);
|
|
3010
|
+
}
|
|
3011
|
+
let exitCode = runs ? 0 : null;
|
|
3012
|
+
if (runs && !dryRun) {
|
|
3013
|
+
const res = spawnSync(process.execPath, [process.argv[1], ...argv], {
|
|
3014
|
+
cwd: plan.dir,
|
|
3015
|
+
stdio: json ? "ignore" : "inherit"
|
|
3016
|
+
});
|
|
3017
|
+
exitCode = res.status;
|
|
3018
|
+
if (!json) {
|
|
3019
|
+
process.stderr.write(
|
|
3020
|
+
exitCode === 0 ? ` ${ok("\u2713")} ${style.dim("onboard ok")}
|
|
3021
|
+
` : ` ${warn("\u2717")} ${style.dim(`onboard exited ${exitCode ?? "signal"}`)}
|
|
3022
|
+
`
|
|
3023
|
+
);
|
|
3024
|
+
}
|
|
3025
|
+
}
|
|
3026
|
+
results.push({
|
|
3027
|
+
path: plan.label,
|
|
3028
|
+
dir: plan.dir,
|
|
3029
|
+
disposition: plan.disposition,
|
|
3030
|
+
ran: runs && !dryRun,
|
|
3031
|
+
exitCode,
|
|
3032
|
+
reason: plan.reason
|
|
3033
|
+
});
|
|
3034
|
+
}
|
|
3035
|
+
return results;
|
|
3036
|
+
}
|
|
3037
|
+
function summarizeFanout(results, o) {
|
|
3038
|
+
const ran = results.filter((r) => r.ran);
|
|
3039
|
+
const failed = ran.filter((r) => r.exitCode !== 0);
|
|
3040
|
+
const skipped = results.filter((r) => r.disposition.startsWith("skip"));
|
|
3041
|
+
const wouldRun = results.filter((r) => !r.disposition.startsWith("skip"));
|
|
3042
|
+
const tally = (o.dryRun ? [wouldRun.length ? `${wouldRun.length} would onboard` : null, skipped.length ? `${skipped.length} would skip` : null] : [
|
|
3043
|
+
ran.length ? `${ran.length - failed.length}/${ran.length} onboarded` : null,
|
|
3044
|
+
skipped.length ? `${skipped.length} skipped` : null,
|
|
3045
|
+
failed.length ? `${failed.length} failed` : null
|
|
3046
|
+
]).filter(Boolean).join(", ");
|
|
3047
|
+
process.stderr.write(`
|
|
3048
|
+
${failed.length ? warn("\u26A0") : ok("\u2713")} ${tally || "nothing to do"}${o.dryRun ? style.dim(" (dry run)") : ""}
|
|
3049
|
+
`);
|
|
3050
|
+
if (failed.length) process.exit(1);
|
|
3051
|
+
}
|
|
3052
|
+
|
|
2616
3053
|
// src/commands/onboard.ts
|
|
2617
3054
|
var DEFAULT_BASE_URL2 = "https://app.sechroom.ai/api";
|
|
2618
3055
|
function systemTimezone() {
|
|
@@ -2685,7 +3122,7 @@ async function warnIfProjectStray(client, projectId, workspaceId, json) {
|
|
|
2685
3122
|
);
|
|
2686
3123
|
}
|
|
2687
3124
|
}
|
|
2688
|
-
async function pickWorkspace(client) {
|
|
3125
|
+
async function pickWorkspace(client, promptLabel = "Bind this directory to a workspace:") {
|
|
2689
3126
|
const all = await withSpinner("Listing your workspaces", () => fetchWorkspaces(client));
|
|
2690
3127
|
if (all.length === 0) {
|
|
2691
3128
|
process.stderr.write(`no workspaces found \u2014 skipping workspace binding (you can set it later with \`sechroom config set --local workspaceId <id>\`)
|
|
@@ -2708,7 +3145,7 @@ async function pickWorkspace(client) {
|
|
|
2708
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 })),
|
|
2709
3146
|
{ label: style.dim("skip \u2014 don't bind a workspace"), value: SKIP, hint: void 0 }
|
|
2710
3147
|
];
|
|
2711
|
-
const chosen = await promptSelect(
|
|
3148
|
+
const chosen = await promptSelect(promptLabel, choices, SKIP);
|
|
2712
3149
|
if (chosen === SKIP) return void 0;
|
|
2713
3150
|
const picked = byId.get(chosen);
|
|
2714
3151
|
const collisions = all.filter((w) => w.id !== picked.id && namesCollide(w.name, picked.name));
|
|
@@ -2791,7 +3228,7 @@ async function ensureTenant(baseUrl, g, opts) {
|
|
|
2791
3228
|
"Where should this tenant + base URL be saved?",
|
|
2792
3229
|
[
|
|
2793
3230
|
{ label: "Globally", value: "global", hint: "all projects on this machine" },
|
|
2794
|
-
{ label: "This directory", value: "local", hint: ".sechroom.json \u2014 project + subdirs" }
|
|
3231
|
+
{ label: "This directory", value: "local", hint: ".sechroom.json \u2014 committed, project + subdirs" }
|
|
2795
3232
|
],
|
|
2796
3233
|
local.path ? "local" : "global"
|
|
2797
3234
|
) === "local";
|
|
@@ -2866,16 +3303,120 @@ async function chooseClients(clientFlag, yes, cwd) {
|
|
|
2866
3303
|
);
|
|
2867
3304
|
return picks.length > 0 ? picks : preselected;
|
|
2868
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
|
+
}
|
|
2869
3408
|
function registerOnboard(program2) {
|
|
2870
|
-
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
|
|
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(
|
|
2871
3410
|
"after",
|
|
2872
3411
|
`
|
|
2873
3412
|
Examples:
|
|
2874
3413
|
$ sechroom onboard guided, interactive (asks where to save config + how to wire)
|
|
2875
3414
|
$ sechroom onboard --cli-only just the CLI \u2014 no .mcp.json, no agent files
|
|
2876
3415
|
$ sechroom onboard --no-mcp agent instructions only, skip MCP config
|
|
2877
|
-
$ sechroom onboard --local save tenant + base URL to ./.sechroom.json
|
|
3416
|
+
$ sechroom onboard --local save tenant + base URL to a committed ./.sechroom.json
|
|
2878
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
|
|
2879
3420
|
$ sechroom onboard --refresh refresh out-of-date instruction blocks in place
|
|
2880
3421
|
$ sechroom onboard --check CI/pre-commit: nonzero exit if instructions are out of date
|
|
2881
3422
|
$ sechroom onboard --yes non-interactive: defaults + global config + full wire
|
|
@@ -2887,6 +3428,15 @@ Examples:
|
|
|
2887
3428
|
const mode = opts.check ? "check" : opts.force ? "force" : "apply";
|
|
2888
3429
|
const check = mode === "check";
|
|
2889
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
|
+
}
|
|
2890
3440
|
const baseUrl = resolveBaseUrl(g);
|
|
2891
3441
|
await ensureAuth({ baseUrl, tenant: "", clientId: readPersisted().clientId }, yes);
|
|
2892
3442
|
const cfg = await ensureTenant(baseUrl, g, { yes, json, local: Boolean(opts.local), workspace: opts.workspace, persist: !check });
|
|
@@ -2904,7 +3454,10 @@ Examples:
|
|
|
2904
3454
|
emit({ dryRun, baseUrl: cfg.baseUrl, tenant: cfg.tenant, workspaceId: cfg.workspaceId ?? null, timezone: tz, wire, clients: [] }, true);
|
|
2905
3455
|
return;
|
|
2906
3456
|
}
|
|
2907
|
-
if (!dryRun)
|
|
3457
|
+
if (!dryRun) {
|
|
3458
|
+
await ensureLanePin(cfg, { yes, dryRun, clients: detectInstalledClients(process.cwd()) });
|
|
3459
|
+
await maybeOfferHooks({ yes, dryRun, cwd: process.cwd() });
|
|
3460
|
+
}
|
|
2908
3461
|
process.stdout.write(
|
|
2909
3462
|
`
|
|
2910
3463
|
${style.bold("Done.")} The CLI is configured for ${style.cyan(cfg.tenant)} \u2014 no AI-client files written.
|
|
@@ -2962,6 +3515,9 @@ Try: ${style.cyan('sechroom memory search "..."')} or ${style.cyan("sechroom -
|
|
|
2962
3515
|
if (!json && !dryRun) {
|
|
2963
3516
|
await maybeOfferSkills(cfg, personalWorkspaceId, { yes, dryRun, surface: "claude-code" });
|
|
2964
3517
|
}
|
|
3518
|
+
if (!json && !dryRun) {
|
|
3519
|
+
await maybeOfferHooks({ yes, dryRun, cwd: process.cwd() });
|
|
3520
|
+
}
|
|
2965
3521
|
if (json) {
|
|
2966
3522
|
emit({ dryRun, baseUrl: cfg.baseUrl, tenant: cfg.tenant, workspaceId: cfg.workspaceId ?? null, timezone: tz, wire, eval: evalCounts, clients: result }, true);
|
|
2967
3523
|
return;
|
|
@@ -3033,15 +3589,114 @@ ${style.bold("Next:")} paste this into your AI agent to get going \u2014
|
|
|
3033
3589
|
);
|
|
3034
3590
|
}
|
|
3035
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
|
+
|
|
3036
3691
|
// src/commands/skills.ts
|
|
3037
|
-
import { homedir as
|
|
3038
|
-
import { join as
|
|
3039
|
-
import { mkdirSync as
|
|
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";
|
|
3040
3695
|
var DEFAULT_SLUG = "operator-skills";
|
|
3041
3696
|
var ROLE_TAGS = ["sechroom:role:skill-template", "role:skill-template"];
|
|
3042
3697
|
var LOCK = ".sechroom-skills.json";
|
|
3043
3698
|
function skillsDir(global) {
|
|
3044
|
-
return global ?
|
|
3699
|
+
return global ? join9(homedir6(), ".claude", "skills") : join9(process.cwd(), ".claude", "skills");
|
|
3045
3700
|
}
|
|
3046
3701
|
function tagValue2(tags, prefix) {
|
|
3047
3702
|
return (tags ?? []).find((t) => t.startsWith(prefix))?.slice(prefix.length);
|
|
@@ -3119,15 +3774,15 @@ Examples:
|
|
|
3119
3774
|
const name = tagValue2(tags, "skill:");
|
|
3120
3775
|
if (!name) continue;
|
|
3121
3776
|
const body = m.text ?? m.Text ?? "";
|
|
3122
|
-
|
|
3123
|
-
|
|
3777
|
+
mkdirSync6(join9(dir, name), { recursive: true });
|
|
3778
|
+
writeFileSync6(join9(dir, name, "SKILL.md"), body.endsWith("\n") ? body : body + "\n");
|
|
3124
3779
|
written.push(name);
|
|
3125
3780
|
}
|
|
3126
|
-
|
|
3127
|
-
const lockPath =
|
|
3128
|
-
const lock =
|
|
3781
|
+
mkdirSync6(dir, { recursive: true });
|
|
3782
|
+
const lockPath = join9(dir, LOCK);
|
|
3783
|
+
const lock = existsSync9(lockPath) ? JSON.parse(readFileSync6(lockPath, "utf8")) : {};
|
|
3129
3784
|
lock[slug] = { surface: opts.surface, version, instance: wantInstance, skills: written.sort() };
|
|
3130
|
-
|
|
3785
|
+
writeFileSync6(lockPath, JSON.stringify(lock, null, 2) + "\n");
|
|
3131
3786
|
if (opts.json) return emit({ slug, version, instance: wantInstance, surface: opts.surface, dir, installed: written }, true);
|
|
3132
3787
|
const instanceNote = opts.instance ? ` (${opts.instance})` : "";
|
|
3133
3788
|
console.log(style.green(`Installed ${slug}@${version}${instanceNote} \u2014 ${written.length} skill(s) \u2192 ${dir}`));
|
|
@@ -3149,28 +3804,28 @@ Examples:
|
|
|
3149
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) => {
|
|
3150
3805
|
const slug = slugArg || DEFAULT_SLUG;
|
|
3151
3806
|
const dir = skillsDir(!opts.local);
|
|
3152
|
-
const lockPath =
|
|
3153
|
-
if (!
|
|
3154
|
-
const lock = JSON.parse(
|
|
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"));
|
|
3155
3810
|
const entry = lock[slug];
|
|
3156
3811
|
if (!entry) fail(`No installed record for '${slug}' in ${lockPath}.`);
|
|
3157
3812
|
const removed = [];
|
|
3158
3813
|
for (const name of entry.skills) {
|
|
3159
|
-
const skillPath =
|
|
3160
|
-
if (
|
|
3814
|
+
const skillPath = join9(dir, name);
|
|
3815
|
+
if (existsSync9(skillPath)) {
|
|
3161
3816
|
rmSync2(skillPath, { recursive: true, force: true });
|
|
3162
3817
|
removed.push(name);
|
|
3163
3818
|
}
|
|
3164
3819
|
}
|
|
3165
3820
|
delete lock[slug];
|
|
3166
|
-
|
|
3821
|
+
writeFileSync6(lockPath, JSON.stringify(lock, null, 2) + "\n");
|
|
3167
3822
|
if (opts.json) return emit({ slug, removed, dir }, true);
|
|
3168
3823
|
console.log(style.green(`Removed ${removed.length} skill(s) for ${slug} from ${dir}`));
|
|
3169
3824
|
});
|
|
3170
|
-
skills.command("set-lane").description("Write this checkout's lane pin to a local ./.
|
|
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) => {
|
|
3171
3826
|
if (!opts.codeLane && !opts.designLane) fail("Provide --code-lane and/or --design-lane.");
|
|
3172
3827
|
const target = localSemPath();
|
|
3173
|
-
const values =
|
|
3828
|
+
const values = readLocalSemValues();
|
|
3174
3829
|
if (opts.codeLane) values["code-lane"] = opts.codeLane;
|
|
3175
3830
|
if (opts.designLane) values["design-lane"] = opts.designLane;
|
|
3176
3831
|
writeSem(values, target);
|
|
@@ -3178,12 +3833,12 @@ Examples:
|
|
|
3178
3833
|
console.log(style.green(`Wrote lane pin \u2192 ${target} ${style.dim("(git-ignored)")}`));
|
|
3179
3834
|
Object.entries(values).forEach(([k, v]) => console.log(" " + style.dim(k) + " = " + v));
|
|
3180
3835
|
});
|
|
3181
|
-
skills.command("lane").description("Show the lane pin resolved from ./.
|
|
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) => {
|
|
3182
3837
|
const json = cmd.optsWithGlobals().json;
|
|
3183
3838
|
const found = readSem();
|
|
3184
3839
|
if (!found) {
|
|
3185
3840
|
if (json) return emit({ path: null, values: {} }, true);
|
|
3186
|
-
return console.log(style.dim(`No ./.
|
|
3841
|
+
return console.log(style.dim(`No ./.sechroom/lane.json pin in this checkout. Run 'sechroom skills set-lane'.`));
|
|
3187
3842
|
}
|
|
3188
3843
|
if (json) return emit(found, true);
|
|
3189
3844
|
console.log(style.dim(`from ${found.path}`));
|
|
@@ -3252,22 +3907,22 @@ Examples:
|
|
|
3252
3907
|
}
|
|
3253
3908
|
|
|
3254
3909
|
// src/commands/reset.ts
|
|
3255
|
-
import { homedir as
|
|
3256
|
-
import { join as
|
|
3257
|
-
import { existsSync as
|
|
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";
|
|
3258
3913
|
var SKILLS_LOCK = ".sechroom-skills.json";
|
|
3259
|
-
var localSkillsDir = () =>
|
|
3260
|
-
var globalSkillsDir = () =>
|
|
3914
|
+
var localSkillsDir = () => join10(process.cwd(), ".claude", "skills");
|
|
3915
|
+
var globalSkillsDir = () => join10(homedir7(), ".claude", "skills");
|
|
3261
3916
|
function removeMaterialisedSkills(dir) {
|
|
3262
3917
|
const removed = [];
|
|
3263
|
-
const lockPath =
|
|
3264
|
-
if (!
|
|
3918
|
+
const lockPath = join10(dir, SKILLS_LOCK);
|
|
3919
|
+
if (!existsSync10(lockPath)) return removed;
|
|
3265
3920
|
try {
|
|
3266
|
-
const lock = JSON.parse(
|
|
3921
|
+
const lock = JSON.parse(readFileSync7(lockPath, "utf8"));
|
|
3267
3922
|
for (const entry of Object.values(lock)) {
|
|
3268
3923
|
for (const name of entry.skills ?? []) {
|
|
3269
|
-
const p =
|
|
3270
|
-
if (
|
|
3924
|
+
const p = join10(dir, name);
|
|
3925
|
+
if (existsSync10(p)) {
|
|
3271
3926
|
rmSync3(p, { recursive: true, force: true });
|
|
3272
3927
|
removed.push(p);
|
|
3273
3928
|
}
|
|
@@ -3287,26 +3942,31 @@ function registerReset(program2) {
|
|
|
3287
3942
|
removed ? style.green("Signed out \u2014 auth token removed.") : style.dim("Already signed out (no token).")
|
|
3288
3943
|
);
|
|
3289
3944
|
});
|
|
3290
|
-
program2.command("reset").description("Reset LOCAL CLI state for this directory (./.sechroom.json
|
|
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) => {
|
|
3291
3946
|
const json = cmd.optsWithGlobals().json;
|
|
3292
3947
|
const global = Boolean(opts.global);
|
|
3293
3948
|
if (!opts.yes && canPrompt()) {
|
|
3294
|
-
const scope = global ? "this directory's local state AND your global auth token + config + ~/.claude/skills" : "this directory's local state (./.sechroom.json
|
|
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)";
|
|
3295
3950
|
if (!await promptYesNo(`Remove ${scope}?`)) {
|
|
3296
3951
|
if (!json) console.log(style.dim("Cancelled."));
|
|
3297
3952
|
return;
|
|
3298
3953
|
}
|
|
3299
3954
|
}
|
|
3300
3955
|
const removed = [];
|
|
3301
|
-
const
|
|
3302
|
-
if (
|
|
3303
|
-
rmSync3(
|
|
3304
|
-
removed.push(
|
|
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);
|
|
3305
3965
|
}
|
|
3306
|
-
const
|
|
3307
|
-
if (
|
|
3308
|
-
rmSync3(
|
|
3309
|
-
removed.push(
|
|
3966
|
+
const legacySem = join10(process.cwd(), ".sem");
|
|
3967
|
+
if (existsSync10(legacySem)) {
|
|
3968
|
+
rmSync3(legacySem, { force: true });
|
|
3969
|
+
removed.push(legacySem);
|
|
3310
3970
|
}
|
|
3311
3971
|
removed.push(...removeMaterialisedSkills(localSkillsDir()));
|
|
3312
3972
|
if (global) {
|
|
@@ -3331,7 +3991,7 @@ function registerReset(program2) {
|
|
|
3331
3991
|
function resolveVersion() {
|
|
3332
3992
|
try {
|
|
3333
3993
|
const pkg = JSON.parse(
|
|
3334
|
-
|
|
3994
|
+
readFileSync8(new URL("../package.json", import.meta.url), "utf8")
|
|
3335
3995
|
);
|
|
3336
3996
|
return pkg.version ?? "0.0.0";
|
|
3337
3997
|
} catch {
|
|
@@ -3347,7 +4007,7 @@ Examples:
|
|
|
3347
4007
|
$ sechroom onboard guided first-run: configure, sign in, wire this project
|
|
3348
4008
|
$ sechroom login sign in via browser (OAuth + PKCE)
|
|
3349
4009
|
$ sechroom config set tenant ocd set your tenant (global)
|
|
3350
|
-
$ 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)
|
|
3351
4011
|
$ sechroom config show resolved config + which source won
|
|
3352
4012
|
|
|
3353
4013
|
$ sechroom memory create --text "a note" --title "Note" --tag idea
|
|
@@ -3359,7 +4019,7 @@ Examples:
|
|
|
3359
4019
|
$ sechroom --json memory search "auth" compact JSON for scripts and agents
|
|
3360
4020
|
$ SECHROOM_TOKEN=<bearer> sechroom --json memory get mem_XXXX headless
|
|
3361
4021
|
|
|
3362
|
-
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.
|
|
3363
4023
|
Run 'sechroom <command> --help' for command-specific examples.`
|
|
3364
4024
|
);
|
|
3365
4025
|
program.hook("preAction", (_thisCmd, actionCmd) => {
|
|
@@ -3385,11 +4045,11 @@ config.addHelpText(
|
|
|
3385
4045
|
Examples:
|
|
3386
4046
|
$ sechroom config set baseUrl https://app.sechroom.ai/api prod (staging: https://staging.app.sechroom.ai/api)
|
|
3387
4047
|
$ sechroom config set tenant ocd
|
|
3388
|
-
$ 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)
|
|
3389
4049
|
$ sechroom config set clientId dyn-XXXX global-only escape hatch (no DCR endpoint)
|
|
3390
4050
|
$ sechroom config show --json`
|
|
3391
4051
|
);
|
|
3392
|
-
config.command("set <key> <value>").description("Set baseUrl | tenant | workspaceId | defaultProjectId | clientId (clientId is global-only)").option("--local", "Write
|
|
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) => {
|
|
3393
4053
|
if (opts.local) {
|
|
3394
4054
|
if (!["baseUrl", "tenant", "workspaceId", "defaultProjectId"].includes(key)) {
|
|
3395
4055
|
process.stderr.write(`--local supports only: baseUrl | tenant | workspaceId | defaultProjectId (clientId is global)
|
|
@@ -3448,6 +4108,7 @@ registerChat(program);
|
|
|
3448
4108
|
registerInit(program);
|
|
3449
4109
|
registerSetup(program);
|
|
3450
4110
|
registerOnboard(program);
|
|
4111
|
+
registerSweep(program);
|
|
3451
4112
|
registerSkills(program);
|
|
3452
4113
|
registerReset(program);
|
|
3453
4114
|
program.parseAsync().catch((err2) => {
|