@sechroom/cli 2026.6.33-rc.402b8a3a → 2026.6.33
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 +1036 -340
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1773,14 +1773,18 @@ Examples:
|
|
|
1773
1773
|
});
|
|
1774
1774
|
}
|
|
1775
1775
|
|
|
1776
|
+
// src/commands/checkpoint.ts
|
|
1777
|
+
import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync4 } from "fs";
|
|
1778
|
+
import { dirname as dirname5, join as join6 } from "path";
|
|
1779
|
+
|
|
1776
1780
|
// src/commands/hook.ts
|
|
1777
|
-
import {
|
|
1778
|
-
import {
|
|
1779
|
-
import { delimiter, dirname as dirname4, join as
|
|
1781
|
+
import { createHash as createHash2 } from "crypto";
|
|
1782
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync3, statSync as statSync2, writeFileSync as writeFileSync3 } from "fs";
|
|
1783
|
+
import { delimiter, dirname as dirname4, join as join5 } from "path";
|
|
1780
1784
|
|
|
1781
1785
|
// src/sem.ts
|
|
1782
1786
|
import { basename as basename2, dirname as dirname2, join as join2 } from "path";
|
|
1783
|
-
import { appendFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
1787
|
+
import { appendFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readdirSync, readFileSync as readFileSync2, statSync, writeFileSync as writeFileSync2 } from "fs";
|
|
1784
1788
|
var SEM_FILE = join2(".sechroom", "lane.json");
|
|
1785
1789
|
var LEGACY_SEM_FILE = ".sem";
|
|
1786
1790
|
var STATE_DIR_NAME2 = ".sechroom";
|
|
@@ -1799,6 +1803,43 @@ function resolveSemPathForRead(start = process.cwd()) {
|
|
|
1799
1803
|
dir = parent;
|
|
1800
1804
|
}
|
|
1801
1805
|
}
|
|
1806
|
+
function applyWorktreeLaneSuffix(lane, start = process.cwd()) {
|
|
1807
|
+
try {
|
|
1808
|
+
let dir = start;
|
|
1809
|
+
let gitPath;
|
|
1810
|
+
for (; ; ) {
|
|
1811
|
+
const candidate = join2(dir, ".git");
|
|
1812
|
+
if (existsSync2(candidate)) {
|
|
1813
|
+
gitPath = candidate;
|
|
1814
|
+
break;
|
|
1815
|
+
}
|
|
1816
|
+
const parent = dirname2(dir);
|
|
1817
|
+
if (parent === dir) break;
|
|
1818
|
+
dir = parent;
|
|
1819
|
+
}
|
|
1820
|
+
if (!gitPath || statSync(gitPath).isDirectory()) return lane;
|
|
1821
|
+
const gitFile = readFileSync2(gitPath, "utf8");
|
|
1822
|
+
const common = gitFile.trim().match(/^gitdir:\s*(.+)\/worktrees\/[^/\s]+\s*$/);
|
|
1823
|
+
if (!common) return lane;
|
|
1824
|
+
const worktreesDir = join2(common[1], "worktrees");
|
|
1825
|
+
const siblings = readdirSync(worktreesDir).filter((n) => {
|
|
1826
|
+
try {
|
|
1827
|
+
return statSync(join2(worktreesDir, n)).isDirectory();
|
|
1828
|
+
} catch {
|
|
1829
|
+
return false;
|
|
1830
|
+
}
|
|
1831
|
+
});
|
|
1832
|
+
return laneWithWorktreeSuffix(lane, gitFile, siblings);
|
|
1833
|
+
} catch {
|
|
1834
|
+
return lane;
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
function laneWithWorktreeSuffix(lane, gitFile, siblings) {
|
|
1838
|
+
const m = gitFile.trim().match(/\/worktrees\/([^/\s]+)\s*$/);
|
|
1839
|
+
if (!m) return lane;
|
|
1840
|
+
const idx = [...siblings].sort().indexOf(m[1]);
|
|
1841
|
+
return idx < 0 ? lane : `${lane}-${idx + 2}`;
|
|
1842
|
+
}
|
|
1802
1843
|
function parseSem(text) {
|
|
1803
1844
|
const out = {};
|
|
1804
1845
|
for (const raw of text.split("\n")) {
|
|
@@ -1896,8 +1937,55 @@ function ensureSemIgnored(semPath) {
|
|
|
1896
1937
|
|
|
1897
1938
|
// src/setup/clients.ts
|
|
1898
1939
|
import { existsSync as existsSync3 } from "fs";
|
|
1940
|
+
import { homedir as homedir3 } from "os";
|
|
1941
|
+
import { dirname as dirname3, join as join4 } from "path";
|
|
1942
|
+
|
|
1943
|
+
// src/setup/config-dirs.ts
|
|
1899
1944
|
import { homedir as homedir2 } from "os";
|
|
1900
|
-
import {
|
|
1945
|
+
import { join as join3 } from "path";
|
|
1946
|
+
function expandTilde(p) {
|
|
1947
|
+
if (p === "~") return homedir2();
|
|
1948
|
+
if (p.startsWith("~/")) return join3(homedir2(), p.slice(2));
|
|
1949
|
+
return p;
|
|
1950
|
+
}
|
|
1951
|
+
function splitDirs(raw) {
|
|
1952
|
+
if (!raw) return [];
|
|
1953
|
+
return raw.split(",").map((s) => expandTilde(s.trim())).filter(Boolean);
|
|
1954
|
+
}
|
|
1955
|
+
function resolveScope(flag) {
|
|
1956
|
+
if (flag == null) return "global";
|
|
1957
|
+
if (flag === "global" || flag === "project") return flag;
|
|
1958
|
+
throw new Error(`--scope must be 'global' or 'project' (got '${flag}')`);
|
|
1959
|
+
}
|
|
1960
|
+
function labelFor(dir) {
|
|
1961
|
+
const h = homedir2();
|
|
1962
|
+
if (dir === h) return "~";
|
|
1963
|
+
return dir.startsWith(h + "/") ? "~" + dir.slice(h.length) : dir;
|
|
1964
|
+
}
|
|
1965
|
+
function defaultClaudeDir() {
|
|
1966
|
+
return join3(homedir2(), ".claude");
|
|
1967
|
+
}
|
|
1968
|
+
function defaultCodexHome() {
|
|
1969
|
+
return join3(homedir2(), ".codex");
|
|
1970
|
+
}
|
|
1971
|
+
function resolveClaudeTargets(opts) {
|
|
1972
|
+
const scope = opts.scope ?? "global";
|
|
1973
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
1974
|
+
if (scope === "project") {
|
|
1975
|
+
return [{ dir: join3(cwd, ".claude"), scope, label: "<project>" }];
|
|
1976
|
+
}
|
|
1977
|
+
const fromFlag = splitDirs(opts.override);
|
|
1978
|
+
const fromEnv = splitDirs(process.env.CLAUDE_CONFIG_DIR);
|
|
1979
|
+
const dirs = fromFlag.length ? fromFlag : fromEnv.length ? fromEnv : [defaultClaudeDir()];
|
|
1980
|
+
return dirs.map((dir) => ({ dir, scope, label: labelFor(dir) }));
|
|
1981
|
+
}
|
|
1982
|
+
function resolveCodexHomes(opts) {
|
|
1983
|
+
const scope = opts.scope ?? "global";
|
|
1984
|
+
if (scope === "project") return [];
|
|
1985
|
+
const fromFlag = splitDirs(opts.override);
|
|
1986
|
+
const fromEnv = splitDirs(process.env.CODEX_HOME);
|
|
1987
|
+
return fromFlag.length ? fromFlag : fromEnv.length ? fromEnv : [defaultCodexHome()];
|
|
1988
|
+
}
|
|
1901
1989
|
|
|
1902
1990
|
// src/setup/operator-surface.ts
|
|
1903
1991
|
var SectionType = {
|
|
@@ -1910,15 +1998,25 @@ var SectionType = {
|
|
|
1910
1998
|
* carried a workspaceId and that workspace has agent-setup-bundle memories. */
|
|
1911
1999
|
WorkspaceConventions: "workspace-conventions"
|
|
1912
2000
|
};
|
|
1913
|
-
async function fetchSetup(cfg) {
|
|
2001
|
+
async function fetchSetup(cfg, namespaceSlug) {
|
|
1914
2002
|
const client = await makeClient(cfg);
|
|
2003
|
+
const query = {};
|
|
2004
|
+
if (cfg.workspaceId) query.workspaceId = cfg.workspaceId;
|
|
2005
|
+
if (namespaceSlug) query.namespaceSlug = namespaceSlug;
|
|
2006
|
+
const hasQuery = query.workspaceId !== void 0 || query.namespaceSlug !== void 0;
|
|
1915
2007
|
const { data, error } = await client.GET(
|
|
1916
2008
|
"/operator-surface/setup",
|
|
1917
|
-
|
|
2009
|
+
hasQuery ? { params: { query } } : {}
|
|
1918
2010
|
);
|
|
1919
2011
|
if (error) throw new Error(`GET /operator-surface/setup failed: ${JSON.stringify(error)}`);
|
|
1920
2012
|
return data;
|
|
1921
2013
|
}
|
|
2014
|
+
async function listNamespaces(cfg) {
|
|
2015
|
+
const client = await makeClient(cfg);
|
|
2016
|
+
const { data } = await client.GET("/mcp-aggregator/namespaces", {});
|
|
2017
|
+
const rows = data ?? [];
|
|
2018
|
+
return rows.filter((r) => typeof r.slug === "string").map((r) => ({ slug: r.slug, displayName: r.displayName ?? r.slug }));
|
|
2019
|
+
}
|
|
1922
2020
|
function findSurface(setup, surfaceKey) {
|
|
1923
2021
|
return setup.surfaces.find((s) => s.surfaceKey === surfaceKey);
|
|
1924
2022
|
}
|
|
@@ -1989,12 +2087,14 @@ async function resolveWorkspaceConventions(cfg, section) {
|
|
|
1989
2087
|
if (parseTagArtifactId(artifact.id)) continue;
|
|
1990
2088
|
const mem = await fetchMemoryFields(cfg, artifact.id);
|
|
1991
2089
|
if (typeof mem?.text === "string" && mem.text.trim().length > 0) {
|
|
1992
|
-
|
|
1993
|
-
|
|
2090
|
+
const ref = `${artifact.id}@v${mem.version ?? 1}`;
|
|
2091
|
+
parts.push(`<!-- @sechroom/cli:section source=${ref} -->
|
|
2092
|
+
${mem.text.trim()}`);
|
|
2093
|
+
refs.push(ref);
|
|
1994
2094
|
}
|
|
1995
2095
|
}
|
|
1996
2096
|
if (parts.length === 0) return null;
|
|
1997
|
-
return { body: parts.join("\n\n
|
|
2097
|
+
return { body: parts.join("\n\n"), refs };
|
|
1998
2098
|
}
|
|
1999
2099
|
async function createOverride(cfg, template, personalWorkspaceId) {
|
|
2000
2100
|
const client = await makeClient(cfg);
|
|
@@ -2022,51 +2122,53 @@ async function createOverride(cfg, template, personalWorkspaceId) {
|
|
|
2022
2122
|
function claudeDesktopConfigPath(home) {
|
|
2023
2123
|
switch (process.platform) {
|
|
2024
2124
|
case "darwin":
|
|
2025
|
-
return
|
|
2125
|
+
return join4(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
2026
2126
|
case "win32":
|
|
2027
|
-
return
|
|
2127
|
+
return join4(process.env.APPDATA ?? join4(home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
|
|
2028
2128
|
default:
|
|
2029
|
-
return
|
|
2129
|
+
return join4(home, ".config", "Claude", "claude_desktop_config.json");
|
|
2030
2130
|
}
|
|
2031
2131
|
}
|
|
2032
|
-
function clientTargets(cwd) {
|
|
2033
|
-
const home =
|
|
2132
|
+
function clientTargets(cwd, opts = {}) {
|
|
2133
|
+
const home = homedir3();
|
|
2134
|
+
const claudeDir = opts.claudeDir ?? join4(home, ".claude");
|
|
2135
|
+
const codexHome = opts.codexHome ?? join4(home, ".codex");
|
|
2034
2136
|
return {
|
|
2035
2137
|
"claude-code": {
|
|
2036
2138
|
key: "claude-code",
|
|
2037
2139
|
label: "Claude Code",
|
|
2038
|
-
mcp: { surfaceKey: "claude-code", sectionType: SectionType.McpConfig, path:
|
|
2039
|
-
instruction: { surfaceKey: "claude-code", path:
|
|
2140
|
+
mcp: { surfaceKey: "claude-code", sectionType: SectionType.McpConfig, path: join4(cwd, ".mcp.json"), format: "json" },
|
|
2141
|
+
instruction: { surfaceKey: "claude-code", path: join4(cwd, "CLAUDE.md") }
|
|
2040
2142
|
},
|
|
2041
2143
|
"claude-desktop": {
|
|
2042
2144
|
key: "claude-desktop",
|
|
2043
2145
|
label: "Claude Desktop",
|
|
2044
2146
|
mcp: { surfaceKey: "claude-desktop", sectionType: SectionType.McpConfig, path: claudeDesktopConfigPath(home), format: "json" },
|
|
2045
|
-
instruction: { surfaceKey: "claude-desktop", path:
|
|
2147
|
+
instruction: { surfaceKey: "claude-desktop", path: join4(claudeDir, "CLAUDE.md") }
|
|
2046
2148
|
},
|
|
2047
2149
|
codex: {
|
|
2048
2150
|
key: "codex",
|
|
2049
2151
|
label: "Codex CLI",
|
|
2050
|
-
mcp: { surfaceKey: "chatgpt", sectionType: SectionType.McpConfigToml, path:
|
|
2051
|
-
instruction: { surfaceKey: "chatgpt", path:
|
|
2152
|
+
mcp: { surfaceKey: "chatgpt", sectionType: SectionType.McpConfigToml, path: join4(codexHome, "config.toml"), format: "toml" },
|
|
2153
|
+
instruction: { surfaceKey: "chatgpt", path: join4(cwd, "AGENTS.md") }
|
|
2052
2154
|
},
|
|
2053
2155
|
cursor: {
|
|
2054
2156
|
key: "cursor",
|
|
2055
2157
|
label: "Cursor",
|
|
2056
|
-
mcp: { surfaceKey: "claude-code", sectionType: SectionType.McpConfig, path:
|
|
2057
|
-
instruction: { surfaceKey: "chatgpt", path:
|
|
2158
|
+
mcp: { surfaceKey: "claude-code", sectionType: SectionType.McpConfig, path: join4(cwd, ".cursor", "mcp.json"), format: "json" },
|
|
2159
|
+
instruction: { surfaceKey: "chatgpt", path: join4(cwd, "AGENTS.md") }
|
|
2058
2160
|
}
|
|
2059
2161
|
};
|
|
2060
2162
|
}
|
|
2061
2163
|
var ALL_CLIENT_KEYS = ["claude-code", "claude-desktop", "codex", "cursor"];
|
|
2062
2164
|
var DEFAULT_CLIENT_KEY = "claude-code";
|
|
2063
2165
|
function detectInstalledClients(cwd) {
|
|
2064
|
-
const home =
|
|
2166
|
+
const home = homedir3();
|
|
2065
2167
|
const detected = [];
|
|
2066
|
-
if (
|
|
2168
|
+
if (resolveClaudeTargets({}).some((t) => existsSync3(t.dir))) detected.push("claude-code");
|
|
2067
2169
|
if (existsSync3(dirname3(claudeDesktopConfigPath(home)))) detected.push("claude-desktop");
|
|
2068
|
-
if (
|
|
2069
|
-
if (existsSync3(
|
|
2170
|
+
if (resolveCodexHomes({}).some((d) => existsSync3(d))) detected.push("codex");
|
|
2171
|
+
if (existsSync3(join4(home, ".cursor")) || existsSync3(join4(cwd, ".cursor"))) detected.push("cursor");
|
|
2070
2172
|
return detected;
|
|
2071
2173
|
}
|
|
2072
2174
|
|
|
@@ -2090,14 +2192,15 @@ function resolveLane(flagLane, cwd) {
|
|
|
2090
2192
|
const env = process.env.SECHROOM_LANE;
|
|
2091
2193
|
if (env) return env;
|
|
2092
2194
|
const start = cwd ?? process.cwd();
|
|
2093
|
-
const
|
|
2094
|
-
return
|
|
2195
|
+
const base = readSem(resolveSemPathForRead(start))?.values["code-lane"];
|
|
2196
|
+
if (!base) return void 0;
|
|
2197
|
+
return applyWorktreeLaneSuffix(base, start);
|
|
2095
2198
|
}
|
|
2096
|
-
var INTENT_FILE =
|
|
2199
|
+
var INTENT_FILE = join5(".sechroom", "continuity.json");
|
|
2097
2200
|
function resolveIntentPath(start) {
|
|
2098
2201
|
let dir = start;
|
|
2099
2202
|
for (; ; ) {
|
|
2100
|
-
const candidate =
|
|
2203
|
+
const candidate = join5(dir, INTENT_FILE);
|
|
2101
2204
|
if (existsSync4(candidate)) return candidate;
|
|
2102
2205
|
const parent = dirname4(dir);
|
|
2103
2206
|
if (parent === dir) return void 0;
|
|
@@ -2118,6 +2221,102 @@ function hasRequiredIntent(i) {
|
|
|
2118
2221
|
i.objective?.trim() && i.state?.trim() && i.lastAction?.trim() && i.nextAction?.trim() && i.resumeInstruction?.trim()
|
|
2119
2222
|
);
|
|
2120
2223
|
}
|
|
2224
|
+
async function saveSnapshotFromIntent(cmd, cwd, laneFlag, scopeFlag, defaultScope, opts) {
|
|
2225
|
+
const lane = resolveLane(laneFlag, cwd);
|
|
2226
|
+
if (!lane) return false;
|
|
2227
|
+
const intent = readIntent(cwd);
|
|
2228
|
+
if (!intent || !hasRequiredIntent(intent)) return false;
|
|
2229
|
+
if (opts?.skipIfUnchanged && unchangedSinceLastPush(cwd, intent)) return false;
|
|
2230
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
2231
|
+
const client = await makeClient(cfg);
|
|
2232
|
+
await client.POST("/continuity/snapshots", {
|
|
2233
|
+
body: {
|
|
2234
|
+
laneId: lane,
|
|
2235
|
+
scope: scopeFlag ?? intent.scope ?? defaultScope,
|
|
2236
|
+
currentObjective: intent.objective,
|
|
2237
|
+
currentState: intent.state,
|
|
2238
|
+
lastMeaningfulAction: intent.lastAction,
|
|
2239
|
+
nextIntendedAction: intent.nextAction,
|
|
2240
|
+
resumeInstruction: intent.resumeInstruction,
|
|
2241
|
+
activeConstraints: intent.constraints ?? null,
|
|
2242
|
+
openQuestions: intent.questions ?? null,
|
|
2243
|
+
surfaceMarkers: intent.surfaceMarkers ?? null,
|
|
2244
|
+
relevantArtifactIds: intent.artifacts ?? null,
|
|
2245
|
+
confidence: intent.confidence ?? null,
|
|
2246
|
+
// Frequent triggers (compaction, session-end) land within the FR-051 4h
|
|
2247
|
+
// window; Acknowledge lets the checkpoint persist on the lane.
|
|
2248
|
+
concurrentSessionPolicy: "Acknowledge"
|
|
2249
|
+
}
|
|
2250
|
+
});
|
|
2251
|
+
recordPush(cwd, intent);
|
|
2252
|
+
return true;
|
|
2253
|
+
}
|
|
2254
|
+
function ledgerPath(start) {
|
|
2255
|
+
const intent = resolveIntentPath(start);
|
|
2256
|
+
const dir = intent ? dirname4(intent) : join5(start, ".sechroom");
|
|
2257
|
+
return join5(dir, ".checkpoint-state.json");
|
|
2258
|
+
}
|
|
2259
|
+
function readLedger(start) {
|
|
2260
|
+
try {
|
|
2261
|
+
const p = ledgerPath(start);
|
|
2262
|
+
if (!existsSync4(p)) return {};
|
|
2263
|
+
return JSON.parse(readFileSync3(p, "utf8"));
|
|
2264
|
+
} catch {
|
|
2265
|
+
return {};
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
function intentHash(i) {
|
|
2269
|
+
const canonical = JSON.stringify({
|
|
2270
|
+
objective: i.objective ?? "",
|
|
2271
|
+
state: i.state ?? "",
|
|
2272
|
+
lastAction: i.lastAction ?? "",
|
|
2273
|
+
nextAction: i.nextAction ?? "",
|
|
2274
|
+
resumeInstruction: i.resumeInstruction ?? "",
|
|
2275
|
+
scope: i.scope ?? "",
|
|
2276
|
+
constraints: i.constraints ?? [],
|
|
2277
|
+
questions: i.questions ?? [],
|
|
2278
|
+
surfaceMarkers: i.surfaceMarkers ?? [],
|
|
2279
|
+
artifacts: i.artifacts ?? [],
|
|
2280
|
+
confidence: i.confidence ?? null
|
|
2281
|
+
});
|
|
2282
|
+
return createHash2("sha256").update(canonical, "utf8").digest("hex");
|
|
2283
|
+
}
|
|
2284
|
+
function recentlyCheckpointed(start, minutes) {
|
|
2285
|
+
const { lastEpochMs } = readLedger(start);
|
|
2286
|
+
return typeof lastEpochMs === "number" && Date.now() - lastEpochMs < minutes * 6e4;
|
|
2287
|
+
}
|
|
2288
|
+
function unchangedSinceLastPush(start, intent) {
|
|
2289
|
+
const ledger = readLedger(start);
|
|
2290
|
+
if (ledger.lastHash == null) return false;
|
|
2291
|
+
const path = resolveIntentPath(start);
|
|
2292
|
+
if (path && ledger.lastMtimeMs != null) {
|
|
2293
|
+
try {
|
|
2294
|
+
if (statSync2(path).mtimeMs <= ledger.lastMtimeMs) return true;
|
|
2295
|
+
} catch {
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
return intentHash(intent) === ledger.lastHash;
|
|
2299
|
+
}
|
|
2300
|
+
function recordPush(start, intent) {
|
|
2301
|
+
try {
|
|
2302
|
+
const p = ledgerPath(start);
|
|
2303
|
+
const path = resolveIntentPath(start);
|
|
2304
|
+
let mtimeMs;
|
|
2305
|
+
try {
|
|
2306
|
+
if (path) mtimeMs = statSync2(path).mtimeMs;
|
|
2307
|
+
} catch {
|
|
2308
|
+
mtimeMs = void 0;
|
|
2309
|
+
}
|
|
2310
|
+
mkdirSync3(dirname4(p), { recursive: true });
|
|
2311
|
+
const ledger = {
|
|
2312
|
+
lastEpochMs: Date.now(),
|
|
2313
|
+
lastMtimeMs: mtimeMs,
|
|
2314
|
+
lastHash: intentHash(intent)
|
|
2315
|
+
};
|
|
2316
|
+
writeFileSync3(p, JSON.stringify(ledger) + "\n");
|
|
2317
|
+
} catch {
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2121
2320
|
function formatContext(bundle, lane) {
|
|
2122
2321
|
const s = bundle?.latestSnapshot;
|
|
2123
2322
|
if (!s) return null;
|
|
@@ -2154,20 +2353,23 @@ function emitSessionStart(additionalContext) {
|
|
|
2154
2353
|
}) + "\n"
|
|
2155
2354
|
);
|
|
2156
2355
|
}
|
|
2157
|
-
var
|
|
2356
|
+
var CLAUDE_HOOK_COMMANDS = {
|
|
2158
2357
|
SessionStart: "sechroom hook session-start",
|
|
2159
|
-
PreCompact: "sechroom hook pre-compact"
|
|
2358
|
+
PreCompact: "sechroom hook pre-compact",
|
|
2359
|
+
SessionEnd: "sechroom hook session-end"
|
|
2360
|
+
};
|
|
2361
|
+
var CODEX_HOOK_COMMANDS = {
|
|
2362
|
+
SessionStart: "sechroom hook session-start",
|
|
2363
|
+
Stop: "sechroom hook session-end --debounce-minutes 10"
|
|
2160
2364
|
};
|
|
2161
|
-
var HOOK_EVENTS = ["SessionStart", "PreCompact"];
|
|
2162
2365
|
function hasHookCommand(config2, event, command) {
|
|
2163
2366
|
const groups = config2.hooks?.[event] ?? [];
|
|
2164
2367
|
return groups.some((g) => (g.hooks ?? []).some((h) => h.type === "command" && h.command === command));
|
|
2165
2368
|
}
|
|
2166
|
-
function mergeHooks(config2) {
|
|
2369
|
+
function mergeHooks(config2, commands) {
|
|
2167
2370
|
config2.hooks ??= {};
|
|
2168
2371
|
let added = 0;
|
|
2169
|
-
for (const event of
|
|
2170
|
-
const command = HOOK_COMMANDS[event];
|
|
2372
|
+
for (const [event, command] of Object.entries(commands)) {
|
|
2171
2373
|
if (hasHookCommand(config2, event, command)) continue;
|
|
2172
2374
|
const groups = config2.hooks[event] ??= [];
|
|
2173
2375
|
groups.push({ hooks: [{ type: "command", command }] });
|
|
@@ -2181,10 +2383,10 @@ function readJsonConfig2(path) {
|
|
|
2181
2383
|
if (!raw.trim()) return {};
|
|
2182
2384
|
return JSON.parse(raw);
|
|
2183
2385
|
}
|
|
2184
|
-
function installHooksJson(path, dryRun) {
|
|
2386
|
+
function installHooksJson(path, commands, dryRun) {
|
|
2185
2387
|
const existed = existsSync4(path) && readFileSync3(path, "utf8").trim().length > 0;
|
|
2186
2388
|
const config2 = readJsonConfig2(path);
|
|
2187
|
-
const added = mergeHooks(config2);
|
|
2389
|
+
const added = mergeHooks(config2, commands);
|
|
2188
2390
|
if (added === 0 && existed) return { path, status: "current" };
|
|
2189
2391
|
if (!dryRun) {
|
|
2190
2392
|
mkdirSync3(dirname4(path), { recursive: true });
|
|
@@ -2244,11 +2446,11 @@ function installHookSurfaces(surfaces, opts) {
|
|
|
2244
2446
|
const out = [];
|
|
2245
2447
|
for (const surface of surfaces) {
|
|
2246
2448
|
if (surface === "claude") {
|
|
2247
|
-
const path =
|
|
2248
|
-
out.push({ surface, results: [installHooksJson(path, opts.dryRun)] });
|
|
2449
|
+
const path = join5(opts.claudeDir, "settings.json");
|
|
2450
|
+
out.push({ surface, results: [installHooksJson(path, CLAUDE_HOOK_COMMANDS, opts.dryRun)] });
|
|
2249
2451
|
} else {
|
|
2250
|
-
const hooksJson = installHooksJson(
|
|
2251
|
-
const featureFlag = installCodexFeatureFlag(
|
|
2452
|
+
const hooksJson = installHooksJson(join5(opts.codexHome, "hooks.json"), CODEX_HOOK_COMMANDS, opts.dryRun);
|
|
2453
|
+
const featureFlag = installCodexFeatureFlag(join5(opts.codexHome, "config.toml"), opts.dryRun);
|
|
2252
2454
|
out.push({ surface, results: [hooksJson, featureFlag] });
|
|
2253
2455
|
}
|
|
2254
2456
|
}
|
|
@@ -2268,7 +2470,7 @@ function isSechroomOnPath() {
|
|
|
2268
2470
|
for (const dir of pathEnv.split(delimiter)) {
|
|
2269
2471
|
if (!dir) continue;
|
|
2270
2472
|
for (const name of names) {
|
|
2271
|
-
if (existsSync4(
|
|
2473
|
+
if (existsSync4(join5(dir, name))) return true;
|
|
2272
2474
|
}
|
|
2273
2475
|
}
|
|
2274
2476
|
return false;
|
|
@@ -2327,52 +2529,62 @@ Fail-soft: no lane / no auth / no-or-partial intent file / API error -> exit 0,
|
|
|
2327
2529
|
const raw = await readStdin();
|
|
2328
2530
|
const input = parseHookInput(raw);
|
|
2329
2531
|
const cwd = input.cwd ?? process.cwd();
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
surfaceMarkers: intent.surfaceMarkers ?? null,
|
|
2348
|
-
relevantArtifactIds: intent.artifacts ?? null,
|
|
2349
|
-
confidence: intent.confidence ?? null,
|
|
2350
|
-
// Compaction is infrequent, so the FR-051 clobber guard doesn't bite;
|
|
2351
|
-
// Acknowledge lets a within-window checkpoint land on the lane.
|
|
2352
|
-
concurrentSessionPolicy: "Acknowledge"
|
|
2353
|
-
}
|
|
2354
|
-
});
|
|
2532
|
+
await saveSnapshotFromIntent(cmd, cwd, opts.lane, opts.scope, "compaction", { skipIfUnchanged: true });
|
|
2533
|
+
return process.exit(0);
|
|
2534
|
+
} catch {
|
|
2535
|
+
return process.exit(0);
|
|
2536
|
+
}
|
|
2537
|
+
});
|
|
2538
|
+
hook.command("session-end").description("Save a continuity snapshot from the intent file on a SessionEnd (Claude) / Stop (Codex) 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 'session-end')").option("--surface <surface>", "Target surface: claude | codex (lifecycle-only on both)", "claude").option(
|
|
2539
|
+
"--debounce-minutes <n>",
|
|
2540
|
+
"skip if a hook checkpoint ran within this many minutes \u2014 for high-frequency triggers like Codex Stop (Claude SessionEnd passes none)"
|
|
2541
|
+
).action(async (opts, cmd) => {
|
|
2542
|
+
try {
|
|
2543
|
+
const raw = await readStdin();
|
|
2544
|
+
const input = parseHookInput(raw);
|
|
2545
|
+
const cwd = input.cwd ?? process.cwd();
|
|
2546
|
+
const debounce = opts.debounceMinutes != null ? Number(opts.debounceMinutes) : 0;
|
|
2547
|
+
if (debounce > 0 && recentlyCheckpointed(cwd, debounce)) return process.exit(0);
|
|
2548
|
+
await saveSnapshotFromIntent(cmd, cwd, opts.lane, opts.scope, "session-end", { skipIfUnchanged: true });
|
|
2355
2549
|
return process.exit(0);
|
|
2356
2550
|
} catch {
|
|
2357
2551
|
return process.exit(0);
|
|
2358
2552
|
}
|
|
2359
2553
|
});
|
|
2360
|
-
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("--
|
|
2554
|
+
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("--scope <scope>", "global (config dir / CLAUDE_CONFIG_DIR) or project (<cwd>/.claude) \u2014 default global").option("--local", "alias for --scope project").option("--dry-run", "Print what would change; write nothing").action((opts, cmd) => {
|
|
2555
|
+
const g = cmd.optsWithGlobals();
|
|
2361
2556
|
const dryRun = Boolean(opts.dryRun);
|
|
2362
2557
|
const cwd = process.cwd();
|
|
2558
|
+
let scope;
|
|
2363
2559
|
let surfaces;
|
|
2364
2560
|
try {
|
|
2561
|
+
scope = opts.local ? "project" : resolveScope(opts.scope);
|
|
2365
2562
|
surfaces = resolveSurfaces(opts.surface, cwd);
|
|
2366
2563
|
} catch (err2) {
|
|
2367
2564
|
process.stderr.write(`${err2.message}
|
|
2368
2565
|
`);
|
|
2369
2566
|
return process.exit(2);
|
|
2370
2567
|
}
|
|
2568
|
+
const claudeTargets = surfaces.includes("claude") ? resolveClaudeTargets({ override: g.claudeConfigDir, scope, cwd }) : [];
|
|
2569
|
+
const codexHomes = surfaces.includes("codex") ? resolveCodexHomes({ override: g.codexHome, scope }) : [];
|
|
2371
2570
|
const results = [];
|
|
2372
2571
|
try {
|
|
2373
|
-
const
|
|
2374
|
-
for (const
|
|
2375
|
-
|
|
2572
|
+
const multiClaude = claudeTargets.length > 1;
|
|
2573
|
+
for (const t of claudeTargets) {
|
|
2574
|
+
const surfaceResults = installHookSurfaces(["claude"], { dryRun, claudeDir: t.dir, codexHome: "" })[0].results;
|
|
2575
|
+
process.stdout.write(`${HOOK_SURFACE_LABEL.claude}${multiClaude ? ` (${t.label})` : ""}:
|
|
2576
|
+
`);
|
|
2577
|
+
for (const r of surfaceResults) {
|
|
2578
|
+
results.push(r);
|
|
2579
|
+
process.stdout.write(describe(r, dryRun) + "\n");
|
|
2580
|
+
}
|
|
2581
|
+
}
|
|
2582
|
+
if (surfaces.includes("codex") && codexHomes.length === 0) {
|
|
2583
|
+
process.stdout.write("Codex has no project scope \u2014 skipped (use --scope global for Codex).\n");
|
|
2584
|
+
}
|
|
2585
|
+
for (const codexHome of codexHomes) {
|
|
2586
|
+
const surfaceResults = installHookSurfaces(["codex"], { dryRun, claudeDir: "", codexHome })[0].results;
|
|
2587
|
+
process.stdout.write(`${HOOK_SURFACE_LABEL.codex}:
|
|
2376
2588
|
`);
|
|
2377
2589
|
for (const r of surfaceResults) {
|
|
2378
2590
|
results.push(r);
|
|
@@ -2396,6 +2608,101 @@ Fail-soft: no lane / no auth / no-or-partial intent file / API error -> exit 0,
|
|
|
2396
2608
|
});
|
|
2397
2609
|
}
|
|
2398
2610
|
|
|
2611
|
+
// src/commands/checkpoint.ts
|
|
2612
|
+
function registerCheckpoint(program2) {
|
|
2613
|
+
program2.command("checkpoint").description(
|
|
2614
|
+
"Checkpoint working state: create a continuity snapshot (server-validated) AND sync ./.sechroom/continuity.json in one step"
|
|
2615
|
+
).option("--lane <laneId>", "Lane id (else SECHROOM_LANE, else ./.sem code-lane)").option("--scope <scope>", "Snapshot scope (else the file's scope, else 'session')").option("--objective <text>", "Current objective").option("--state <text>", "Current state").option("--last-action <text>", "Last meaningful action").option("--next-action <text>", "Next intended action").option("--resume-instruction <text>", "Resume instruction").option("--constraint <text...>", "Active constraints (repeatable)").option("--question <text...>", "Open questions (repeatable)").option("--surface-marker <text...>", "Surface markers (repeatable)").option("--artifact <id...>", "Relevant artifact ids (repeatable)").option("--confidence <n>", "Confidence 0..1").option("--dry-run", "validate + print the snapshot payload without creating it or writing the file", false).addHelpText(
|
|
2616
|
+
"after",
|
|
2617
|
+
`
|
|
2618
|
+
File-first: reads ./.sechroom/continuity.json (kept current as you work) as the base; any flag
|
|
2619
|
+
overrides that field. The snapshot is created FIRST (server-validated), then the local file is
|
|
2620
|
+
written/normalized with the returned snapshotId. Lane: --lane > SECHROOM_LANE > ./.sem code-lane.
|
|
2621
|
+
|
|
2622
|
+
Examples:
|
|
2623
|
+
$ sechroom checkpoint snapshot from ./.sechroom/continuity.json, then sync it
|
|
2624
|
+
$ sechroom checkpoint --next-action "..." override one field, keep the rest from the file
|
|
2625
|
+
$ sechroom checkpoint --lane claude-code-chris --objective "..." --state "..." \\
|
|
2626
|
+
--last-action "..." --next-action "..." --resume-instruction "..."`
|
|
2627
|
+
).action(async (opts, cmd) => {
|
|
2628
|
+
const cwd = process.cwd();
|
|
2629
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
2630
|
+
const json = Boolean(cmd.optsWithGlobals().json);
|
|
2631
|
+
const base = readIntent(cwd) ?? {};
|
|
2632
|
+
const merged = {
|
|
2633
|
+
objective: opts.objective ?? base.objective,
|
|
2634
|
+
state: opts.state ?? base.state,
|
|
2635
|
+
lastAction: opts.lastAction ?? base.lastAction,
|
|
2636
|
+
nextAction: opts.nextAction ?? base.nextAction,
|
|
2637
|
+
resumeInstruction: opts.resumeInstruction ?? base.resumeInstruction,
|
|
2638
|
+
scope: opts.scope ?? base.scope,
|
|
2639
|
+
constraints: opts.constraint ?? base.constraints,
|
|
2640
|
+
questions: opts.question ?? base.questions,
|
|
2641
|
+
surfaceMarkers: opts.surfaceMarker ?? base.surfaceMarkers,
|
|
2642
|
+
artifacts: opts.artifact ?? base.artifacts,
|
|
2643
|
+
confidence: opts.confidence != null ? Number(opts.confidence) : base.confidence
|
|
2644
|
+
};
|
|
2645
|
+
const lane = resolveLane(opts.lane, cwd);
|
|
2646
|
+
if (!lane) {
|
|
2647
|
+
fail(
|
|
2648
|
+
"no lane resolved \u2014 pass --lane, set SECHROOM_LANE, or pin one in ./.sem (code-lane). See `sechroom lane`."
|
|
2649
|
+
);
|
|
2650
|
+
}
|
|
2651
|
+
const required = [
|
|
2652
|
+
["objective", "--objective"],
|
|
2653
|
+
["state", "--state"],
|
|
2654
|
+
["lastAction", "--last-action"],
|
|
2655
|
+
["nextAction", "--next-action"],
|
|
2656
|
+
["resumeInstruction", "--resume-instruction"]
|
|
2657
|
+
];
|
|
2658
|
+
const missing = required.filter(([k]) => !String(merged[k] ?? "").trim()).map(([, flag]) => flag);
|
|
2659
|
+
if (missing.length > 0) {
|
|
2660
|
+
fail(
|
|
2661
|
+
`missing required field(s): ${missing.join(", ")} \u2014 supply via flag or in ./.sechroom/continuity.json`
|
|
2662
|
+
);
|
|
2663
|
+
}
|
|
2664
|
+
const scope = merged.scope ?? "session";
|
|
2665
|
+
const body = {
|
|
2666
|
+
laneId: lane,
|
|
2667
|
+
scope,
|
|
2668
|
+
currentObjective: merged.objective,
|
|
2669
|
+
currentState: merged.state,
|
|
2670
|
+
lastMeaningfulAction: merged.lastAction,
|
|
2671
|
+
nextIntendedAction: merged.nextAction,
|
|
2672
|
+
resumeInstruction: merged.resumeInstruction,
|
|
2673
|
+
activeConstraints: merged.constraints ?? null,
|
|
2674
|
+
openQuestions: merged.questions ?? null,
|
|
2675
|
+
surfaceMarkers: merged.surfaceMarkers ?? null,
|
|
2676
|
+
relevantArtifactIds: merged.artifacts ?? null,
|
|
2677
|
+
confidence: merged.confidence ?? null,
|
|
2678
|
+
// Explicit checkpoints are often within the FR-051 4h window; Acknowledge
|
|
2679
|
+
// lets one land on the lane (matches `hook pre-compact`).
|
|
2680
|
+
concurrentSessionPolicy: "Acknowledge"
|
|
2681
|
+
};
|
|
2682
|
+
if (opts.dryRun) {
|
|
2683
|
+
emit({ dryRun: true, lane, scope, wouldCreate: body }, json);
|
|
2684
|
+
return;
|
|
2685
|
+
}
|
|
2686
|
+
const data = await runApi("Creating snapshot", async () => {
|
|
2687
|
+
const client = await makeClient(cfg);
|
|
2688
|
+
return client.POST("/continuity/snapshots", { body });
|
|
2689
|
+
});
|
|
2690
|
+
const path = resolveIntentPath(cwd) ?? join6(cwd, INTENT_FILE);
|
|
2691
|
+
const fileBody = { ...merged, scope, lastSnapshotId: data.snapshotId };
|
|
2692
|
+
mkdirSync4(dirname5(path), { recursive: true });
|
|
2693
|
+
writeFileSync4(path, JSON.stringify(fileBody, null, 2) + "\n");
|
|
2694
|
+
recordPush(cwd, merged);
|
|
2695
|
+
if (json) {
|
|
2696
|
+
emit({ snapshotId: data.snapshotId, lane, scope, file: path }, true);
|
|
2697
|
+
return;
|
|
2698
|
+
}
|
|
2699
|
+
process.stdout.write(
|
|
2700
|
+
`${style.bold("\u2713")} checkpoint ${style.bold(data.snapshotId)} ${style.dim(`(lane ${lane}, scope ${scope})`)} \u2014 synced ${path}
|
|
2701
|
+
`
|
|
2702
|
+
);
|
|
2703
|
+
});
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2399
2706
|
// src/commands/account.ts
|
|
2400
2707
|
function registerId(program2) {
|
|
2401
2708
|
const id = program2.command("id").description("Allocate human-authored id sequences (FR-*, D-*)");
|
|
@@ -2612,16 +2919,16 @@ Examples:
|
|
|
2612
2919
|
}
|
|
2613
2920
|
|
|
2614
2921
|
// src/setup/apply.ts
|
|
2615
|
-
import { createHash as
|
|
2616
|
-
import { mkdirSync as
|
|
2617
|
-
import { dirname as
|
|
2922
|
+
import { createHash as createHash3 } from "crypto";
|
|
2923
|
+
import { mkdirSync as mkdirSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync5, existsSync as existsSync5 } from "fs";
|
|
2924
|
+
import { dirname as dirname6 } from "path";
|
|
2618
2925
|
var MARKER_BEGIN = "<!-- @sechroom/cli:begin";
|
|
2619
2926
|
var MARKER_END = "<!-- @sechroom/cli:end";
|
|
2620
2927
|
function normalizeBody(s) {
|
|
2621
2928
|
return s.replace(/\r\n/g, "\n").trim();
|
|
2622
2929
|
}
|
|
2623
2930
|
function bodySha256(body) {
|
|
2624
|
-
return
|
|
2931
|
+
return createHash3("sha256").update(normalizeBody(body), "utf8").digest("hex");
|
|
2625
2932
|
}
|
|
2626
2933
|
function renderBlock(write) {
|
|
2627
2934
|
const body = normalizeBody(write.body);
|
|
@@ -2667,7 +2974,7 @@ function parseManagedBlock(content, block) {
|
|
|
2667
2974
|
return null;
|
|
2668
2975
|
}
|
|
2669
2976
|
function ensureDir2(path) {
|
|
2670
|
-
|
|
2977
|
+
mkdirSync5(dirname6(path), { recursive: true });
|
|
2671
2978
|
}
|
|
2672
2979
|
function readOr(path, fallback) {
|
|
2673
2980
|
try {
|
|
@@ -2690,7 +2997,7 @@ function mergeMcpJson(path, snippet, dryRun) {
|
|
|
2690
2997
|
current.mcpServers = { ...current.mcpServers ?? {}, ...incoming.mcpServers ?? {} };
|
|
2691
2998
|
if (dryRun) return { kind: "mcp", path, status: "dry-run" };
|
|
2692
2999
|
ensureDir2(path);
|
|
2693
|
-
|
|
3000
|
+
writeFileSync5(path, JSON.stringify(current, null, 2) + "\n", { mode: 384 });
|
|
2694
3001
|
return { kind: "mcp", path, status: existed ? "merged" : "created" };
|
|
2695
3002
|
}
|
|
2696
3003
|
function mergeCodexToml(path, snippet, dryRun) {
|
|
@@ -2701,7 +3008,7 @@ function mergeCodexToml(path, snippet, dryRun) {
|
|
|
2701
3008
|
const next = (trimmed.length > 0 ? trimmed + "\n\n" : "") + snippet.trim() + "\n";
|
|
2702
3009
|
if (dryRun) return { kind: "mcp", path, status: "dry-run" };
|
|
2703
3010
|
ensureDir2(path);
|
|
2704
|
-
|
|
3011
|
+
writeFileSync5(path, next, { mode: 384 });
|
|
2705
3012
|
return { kind: "mcp", path, status: existed ? "merged" : "created" };
|
|
2706
3013
|
}
|
|
2707
3014
|
function writeInstructionBlock(path, write, dryRun) {
|
|
@@ -2709,7 +3016,7 @@ function writeInstructionBlock(path, write, dryRun) {
|
|
|
2709
3016
|
const next = computeBlockFile(readOr(path, ""), write);
|
|
2710
3017
|
if (dryRun) return { kind: "instruction", path, status: "dry-run" };
|
|
2711
3018
|
ensureDir2(path);
|
|
2712
|
-
|
|
3019
|
+
writeFileSync5(path, next);
|
|
2713
3020
|
return { kind: "instruction", path, status: existed ? "merged" : "created" };
|
|
2714
3021
|
}
|
|
2715
3022
|
function computeBlockFile(current, write) {
|
|
@@ -2750,7 +3057,7 @@ function applyBlock(path, write, mode, dryRun) {
|
|
|
2750
3057
|
const next = computeBlockFile(current, write);
|
|
2751
3058
|
if (!dryRun) {
|
|
2752
3059
|
ensureDir2(proposedPath);
|
|
2753
|
-
|
|
3060
|
+
writeFileSync5(proposedPath, next);
|
|
2754
3061
|
}
|
|
2755
3062
|
return {
|
|
2756
3063
|
kind: "instruction",
|
|
@@ -2821,12 +3128,14 @@ async function applyClient(cfg, setup, target, opts) {
|
|
|
2821
3128
|
}
|
|
2822
3129
|
|
|
2823
3130
|
// src/setup/hooks-offer.ts
|
|
2824
|
-
import { homedir as homedir4 } from "os";
|
|
2825
3131
|
async function maybeOfferHooks(opts) {
|
|
2826
3132
|
if (opts.dryRun) return;
|
|
2827
3133
|
const cwd = opts.cwd ?? process.cwd();
|
|
3134
|
+
const scope = opts.scope ?? "global";
|
|
2828
3135
|
const surfaces = detectHookSurfaces(cwd);
|
|
2829
3136
|
if (surfaces.length === 0) return;
|
|
3137
|
+
const claudeTargets = surfaces.includes("claude") ? resolveClaudeTargets({ override: opts.claudeConfigDir, scope, cwd }) : [];
|
|
3138
|
+
const codexHomes = surfaces.includes("codex") ? resolveCodexHomes({ override: opts.codexHome, scope }) : [];
|
|
2830
3139
|
const names = surfaces.map((s) => HOOK_SURFACE_LABEL[s]).join(" + ");
|
|
2831
3140
|
process.stderr.write(
|
|
2832
3141
|
`
|
|
@@ -2837,15 +3146,28 @@ auto-resumes where you left off and checkpoints working state before compacting.
|
|
|
2837
3146
|
const install = opts.yes ? true : canPrompt() ? await promptYesNo(`Install the continuity hooks for ${names}?`) : false;
|
|
2838
3147
|
if (!install) return;
|
|
2839
3148
|
try {
|
|
2840
|
-
const installed = installHookSurfaces(surfaces, { dryRun: false, cwd, home: homedir4() });
|
|
2841
3149
|
let changed = false;
|
|
2842
|
-
|
|
3150
|
+
const emit2 = (surface, results, label) => {
|
|
2843
3151
|
for (const r of results) {
|
|
2844
3152
|
if (r.status !== "current") changed = true;
|
|
2845
3153
|
const verb = r.status === "current" ? "already configured" : r.status === "created" ? "created" : "updated";
|
|
2846
|
-
|
|
3154
|
+
const tag = label ? ` ${style.dim(`(${label})`)}` : "";
|
|
3155
|
+
process.stderr.write(`${style.green("\u2713")} ${HOOK_SURFACE_LABEL[surface]}${tag}: ${r.path} (${verb})
|
|
2847
3156
|
`);
|
|
2848
3157
|
}
|
|
3158
|
+
};
|
|
3159
|
+
const multiClaude = claudeTargets.length > 1;
|
|
3160
|
+
for (const t of claudeTargets) {
|
|
3161
|
+
const results = installHookSurfaces(["claude"], { dryRun: false, claudeDir: t.dir, codexHome: "" })[0].results;
|
|
3162
|
+
emit2("claude", results, multiClaude ? t.label : void 0);
|
|
3163
|
+
}
|
|
3164
|
+
if (surfaces.includes("codex") && codexHomes.length === 0) {
|
|
3165
|
+
process.stderr.write(`${style.dim("Codex has no project scope \u2014 skipped (use --scope global for Codex).")}
|
|
3166
|
+
`);
|
|
3167
|
+
}
|
|
3168
|
+
for (const codexHome of codexHomes) {
|
|
3169
|
+
const results = installHookSurfaces(["codex"], { dryRun: false, claudeDir: "", codexHome })[0].results;
|
|
3170
|
+
emit2("codex", results);
|
|
2849
3171
|
}
|
|
2850
3172
|
if (changed) {
|
|
2851
3173
|
process.stderr.write(`${style.dim("Restart (or reload) your agent for the hooks to take effect.")}
|
|
@@ -2859,9 +3181,8 @@ auto-resumes where you left off and checkpoints working state before compacting.
|
|
|
2859
3181
|
}
|
|
2860
3182
|
|
|
2861
3183
|
// src/setup/skills-offer.ts
|
|
2862
|
-
import { mkdirSync as
|
|
2863
|
-
import {
|
|
2864
|
-
import { join as join5 } from "path";
|
|
3184
|
+
import { mkdirSync as mkdirSync7, writeFileSync as writeFileSync7 } from "fs";
|
|
3185
|
+
import { join as join8 } from "path";
|
|
2865
3186
|
|
|
2866
3187
|
// src/setup/lane-pin.ts
|
|
2867
3188
|
var CODE_LANE_PREFIX_BY_CLIENT = {
|
|
@@ -2943,57 +3264,157 @@ I can pin this checkout's lane so operator skills + the continuity hook resolve
|
|
|
2943
3264
|
writePin(code || void 0, design || void 0);
|
|
2944
3265
|
}
|
|
2945
3266
|
|
|
2946
|
-
// src/setup/
|
|
2947
|
-
var
|
|
3267
|
+
// src/setup/skill-resolution.ts
|
|
3268
|
+
var SYSTEM_WORKSPACE_ID = "wsp_system";
|
|
3269
|
+
var SKILL_ROLE_TAG = "sechroom:role:skill-template";
|
|
3270
|
+
var SKILL_NAME_PREFIX = "skill:";
|
|
3271
|
+
var AGENT_ROLE_TAG = "sechroom:role:agent-template";
|
|
3272
|
+
var AGENT_NAME_PREFIX = "agent:";
|
|
3273
|
+
function tagsOf(row) {
|
|
3274
|
+
const m = row?.item ?? row;
|
|
3275
|
+
return m?.tags ?? m?.Tags ?? [];
|
|
3276
|
+
}
|
|
2948
3277
|
function tagValue(tags, prefix) {
|
|
2949
3278
|
return tags.find((t) => t.startsWith(prefix))?.slice(prefix.length);
|
|
2950
3279
|
}
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
3280
|
+
function bodyOf(row) {
|
|
3281
|
+
const m = row?.item ?? row;
|
|
3282
|
+
return m?.text ?? m?.Text ?? "";
|
|
3283
|
+
}
|
|
3284
|
+
function entriesFromRows(rows, surface, source, roleTag, namePrefix) {
|
|
3285
|
+
const out = /* @__PURE__ */ new Map();
|
|
3286
|
+
for (const row of rows ?? []) {
|
|
3287
|
+
const tags = tagsOf(row);
|
|
3288
|
+
if (!tags.includes(roleTag)) continue;
|
|
3289
|
+
if (tagValue(tags, "target:") !== surface) continue;
|
|
3290
|
+
const name = tagValue(tags, namePrefix);
|
|
3291
|
+
if (!name) continue;
|
|
3292
|
+
out.set(name, { name, body: bodyOf(row), source });
|
|
3293
|
+
}
|
|
3294
|
+
return out;
|
|
3295
|
+
}
|
|
3296
|
+
function resolveByRole(systemRows, personalRows, surface, roleTag, namePrefix) {
|
|
3297
|
+
const merged = entriesFromRows(systemRows, surface, "system", roleTag, namePrefix);
|
|
3298
|
+
for (const [name, item] of entriesFromRows(personalRows, surface, "personal", roleTag, namePrefix)) {
|
|
3299
|
+
merged.set(name, item);
|
|
3300
|
+
}
|
|
3301
|
+
return [...merged.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
3302
|
+
}
|
|
3303
|
+
function resolveSkills(systemRows, personalRows, surface) {
|
|
3304
|
+
return resolveByRole(systemRows, personalRows, surface, SKILL_ROLE_TAG, SKILL_NAME_PREFIX);
|
|
3305
|
+
}
|
|
3306
|
+
function resolveAgents(systemRows, personalRows, surface) {
|
|
3307
|
+
return resolveByRole(systemRows, personalRows, surface, AGENT_ROLE_TAG, AGENT_NAME_PREFIX);
|
|
3308
|
+
}
|
|
3309
|
+
|
|
3310
|
+
// src/setup/skills-lock.ts
|
|
3311
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync6, readFileSync as readFileSync5, writeFileSync as writeFileSync6 } from "fs";
|
|
3312
|
+
import { join as join7 } from "path";
|
|
3313
|
+
var SKILLS_LOCK = ".sechroom-skills.json";
|
|
3314
|
+
var DEFAULT_SKILLS_SLUG = "operator-skills";
|
|
3315
|
+
function skillsDir(configDir) {
|
|
3316
|
+
return join7(configDir, "skills");
|
|
3317
|
+
}
|
|
3318
|
+
function agentsDir(configDir) {
|
|
3319
|
+
return join7(configDir, "agents");
|
|
3320
|
+
}
|
|
3321
|
+
function readSkillsLock(dir) {
|
|
3322
|
+
const lockPath = join7(dir, SKILLS_LOCK);
|
|
3323
|
+
if (!existsSync6(lockPath)) return {};
|
|
3324
|
+
try {
|
|
3325
|
+
return JSON.parse(readFileSync5(lockPath, "utf8"));
|
|
3326
|
+
} catch {
|
|
3327
|
+
return {};
|
|
3328
|
+
}
|
|
3329
|
+
}
|
|
3330
|
+
function writeSkillsLock(dir, lock) {
|
|
3331
|
+
mkdirSync6(dir, { recursive: true });
|
|
3332
|
+
writeFileSync6(join7(dir, SKILLS_LOCK), JSON.stringify(lock, null, 2) + "\n");
|
|
3333
|
+
}
|
|
3334
|
+
function recordMaterialisedSkills(dir, slug, skills, meta = {}) {
|
|
3335
|
+
const lock = readSkillsLock(dir);
|
|
3336
|
+
lock[slug] = { surface: meta.surface, skills: [...skills].sort() };
|
|
3337
|
+
writeSkillsLock(dir, lock);
|
|
3338
|
+
}
|
|
3339
|
+
|
|
3340
|
+
// src/setup/skills-offer.ts
|
|
3341
|
+
async function fetchFeedRows(cfg, workspaceId) {
|
|
2955
3342
|
try {
|
|
2956
3343
|
const client = await makeClient(cfg);
|
|
2957
3344
|
const feed = await client.GET("/workspaces/{workspaceId}/memories/feed", {
|
|
2958
3345
|
params: {
|
|
2959
|
-
path: { workspaceId
|
|
3346
|
+
path: { workspaceId },
|
|
3347
|
+
// cascadeWorkspaces: skills land in an "Operator Skills" SUB-workspace;
|
|
3348
|
+
// includeText: the feed omits bodies by default, we need them for SKILL.md.
|
|
2960
3349
|
query: { limit: 200, cascadeWorkspaces: true, includeText: true }
|
|
2961
3350
|
}
|
|
2962
3351
|
}).then((r) => r.data).catch(() => void 0);
|
|
2963
|
-
|
|
3352
|
+
return feed?.results ?? feed?.Results ?? [];
|
|
2964
3353
|
} catch {
|
|
3354
|
+
return [];
|
|
3355
|
+
}
|
|
3356
|
+
}
|
|
3357
|
+
async function maybeOfferSkills(cfg, personalWorkspaceId, opts) {
|
|
3358
|
+
const surface = opts.surface ?? "claude-code";
|
|
3359
|
+
const configDir = opts.configDir ?? resolveClaudeTargets({})[0].dir;
|
|
3360
|
+
const [systemRows, personalRows] = await Promise.all([
|
|
3361
|
+
fetchFeedRows(cfg, SYSTEM_WORKSPACE_ID),
|
|
3362
|
+
personalWorkspaceId ? fetchFeedRows(cfg, personalWorkspaceId) : Promise.resolve([])
|
|
3363
|
+
]);
|
|
3364
|
+
const skills = resolveSkills(systemRows, personalRows, surface);
|
|
3365
|
+
const agents = resolveAgents(systemRows, personalRows, surface);
|
|
3366
|
+
if (skills.length === 0 && agents.length === 0) return;
|
|
3367
|
+
const sDir = skillsDir(configDir);
|
|
3368
|
+
const aDir = agentsDir(configDir);
|
|
3369
|
+
if (opts.dryRun) {
|
|
3370
|
+
const lines = (label, items) => items.length === 0 ? "" : `
|
|
3371
|
+
Would materialise ${style.bold(String(items.length))} ${label} for ${surface}:
|
|
3372
|
+
` + items.map((s) => ` ${s.name} ${style.dim(`[${s.source}]`)}`).join("\n") + "\n";
|
|
3373
|
+
process.stderr.write(lines("operator skill(s)", skills) + lines("agent(s)", agents));
|
|
2965
3374
|
return;
|
|
2966
3375
|
}
|
|
2967
|
-
const
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
}
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
`
|
|
2981
|
-
Found ${style.bold(String(names.length))} operator skill(s) installed in your workspace: ${names.join(", ")}.
|
|
2982
|
-
`
|
|
2983
|
-
);
|
|
2984
|
-
const dir = join5(homedir5(), ".claude", "skills");
|
|
2985
|
-
const materialise = opts.yes ? true : canPrompt() ? await promptYesNo(`Write them to ${dir}/ so ${surface} can use them?`) : false;
|
|
3376
|
+
const summary = [
|
|
3377
|
+
skills.length > 0 ? `${style.bold(String(skills.length))} skill(s)` : "",
|
|
3378
|
+
agents.length > 0 ? `${style.bold(String(agents.length))} agent(s)` : ""
|
|
3379
|
+
].filter(Boolean).join(" + ");
|
|
3380
|
+
process.stderr.write(`
|
|
3381
|
+
Found ${summary} available to you for ${surface}.
|
|
3382
|
+
`);
|
|
3383
|
+
if (skills.length > 0) process.stderr.write(` skills: ${skills.map((s) => s.name).join(", ")}
|
|
3384
|
+
`);
|
|
3385
|
+
if (agents.length > 0) process.stderr.write(` agents: ${agents.map((a) => a.name).join(", ")}
|
|
3386
|
+
`);
|
|
3387
|
+
const dest = [skills.length > 0 ? `${sDir}/` : "", agents.length > 0 ? `${aDir}/` : ""].filter(Boolean).join(" + ");
|
|
3388
|
+
const materialise = opts.yes ? true : canPrompt() ? await promptYesNo(`Write them to ${dest} so ${surface} can use them?`) : false;
|
|
2986
3389
|
if (!materialise) return;
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
const
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
3390
|
+
if (skills.length > 0) {
|
|
3391
|
+
const written = [];
|
|
3392
|
+
for (const s of skills) {
|
|
3393
|
+
mkdirSync7(join8(sDir, s.name), { recursive: true });
|
|
3394
|
+
writeFileSync7(join8(sDir, s.name, "SKILL.md"), s.body.endsWith("\n") ? s.body : s.body + "\n");
|
|
3395
|
+
written.push(s.name);
|
|
3396
|
+
}
|
|
3397
|
+
recordMaterialisedSkills(sDir, DEFAULT_SKILLS_SLUG, written, { surface });
|
|
3398
|
+
process.stderr.write(`${style.green("\u2713")} wrote ${written.length} skill(s) to ${sDir}
|
|
2995
3399
|
`);
|
|
2996
|
-
|
|
3400
|
+
}
|
|
3401
|
+
if (agents.length > 0) {
|
|
3402
|
+
mkdirSync7(aDir, { recursive: true });
|
|
3403
|
+
const written = [];
|
|
3404
|
+
for (const a of agents) {
|
|
3405
|
+
const file = `${a.name}.md`;
|
|
3406
|
+
writeFileSync7(join8(aDir, file), a.body.endsWith("\n") ? a.body : a.body + "\n");
|
|
3407
|
+
written.push(file);
|
|
3408
|
+
}
|
|
3409
|
+
recordMaterialisedSkills(aDir, DEFAULT_SKILLS_SLUG, written, { surface });
|
|
3410
|
+
process.stderr.write(`${style.green("\u2713")} wrote ${written.length} agent(s) to ${aDir}
|
|
3411
|
+
`);
|
|
3412
|
+
}
|
|
3413
|
+
await ensureLanePin(cfg, {
|
|
3414
|
+
yes: opts.yes,
|
|
3415
|
+
dryRun: opts.dryRun,
|
|
3416
|
+
clients: [surface]
|
|
3417
|
+
});
|
|
2997
3418
|
}
|
|
2998
3419
|
|
|
2999
3420
|
// src/commands/setup.ts
|
|
@@ -3031,14 +3452,14 @@ version, the shared template stays clean, and you can discard back anytime.
|
|
|
3031
3452
|
}
|
|
3032
3453
|
function resolveClientKeys(raw) {
|
|
3033
3454
|
const targets = clientTargets(process.cwd());
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
for (const k of
|
|
3455
|
+
const tokens = (Array.isArray(raw) ? raw : [raw]).flatMap((t) => t.split(",")).map((k) => k.trim()).filter(Boolean);
|
|
3456
|
+
if (tokens.includes("all")) return [...ALL_CLIENT_KEYS];
|
|
3457
|
+
for (const k of tokens) {
|
|
3037
3458
|
if (!targets[k]) {
|
|
3038
3459
|
fail(`unknown client '${k}'. Known: ${ALL_CLIENT_KEYS.join(", ")}, or 'all'.`);
|
|
3039
3460
|
}
|
|
3040
3461
|
}
|
|
3041
|
-
return
|
|
3462
|
+
return [...new Set(tokens)];
|
|
3042
3463
|
}
|
|
3043
3464
|
function printActions(client, actions) {
|
|
3044
3465
|
process.stdout.write(`
|
|
@@ -3050,24 +3471,96 @@ ${client.label} (${client.key}):
|
|
|
3050
3471
|
`);
|
|
3051
3472
|
}
|
|
3052
3473
|
}
|
|
3474
|
+
function resolveEvalMode(opts) {
|
|
3475
|
+
return opts.check ? "check" : opts.force ? "force" : "apply";
|
|
3476
|
+
}
|
|
3477
|
+
function summarizeEval(result, mode, json, dryRun) {
|
|
3478
|
+
const counts = { current: 0, stale: 0, drift: 0, absent: 0 };
|
|
3479
|
+
for (const { actions } of result) for (const a of actions) if (a.eval) counts[a.eval]++;
|
|
3480
|
+
const wouldChange = counts.stale + counts.drift + counts.absent;
|
|
3481
|
+
if (mode === "check") {
|
|
3482
|
+
if (!json) {
|
|
3483
|
+
if (wouldChange === 0) {
|
|
3484
|
+
process.stdout.write("\u2713 all instruction blocks are up to date.\n");
|
|
3485
|
+
} else {
|
|
3486
|
+
const bits = [];
|
|
3487
|
+
if (counts.stale) bits.push(`${counts.stale} out of date`);
|
|
3488
|
+
if (counts.drift) bits.push(`${counts.drift} with local edits`);
|
|
3489
|
+
if (counts.absent) bits.push(`${counts.absent} not yet written`);
|
|
3490
|
+
process.stderr.write(
|
|
3491
|
+
`\u26A0 ${wouldChange} instruction block(s) would change: ${bits.join(", ")}. Re-run with ${style.cyan("--refresh")}.
|
|
3492
|
+
`
|
|
3493
|
+
);
|
|
3494
|
+
}
|
|
3495
|
+
}
|
|
3496
|
+
process.exit(wouldChange === 0 ? 0 : 1);
|
|
3497
|
+
}
|
|
3498
|
+
if (json) return;
|
|
3499
|
+
if (!dryRun && counts.stale) {
|
|
3500
|
+
process.stderr.write(`\u21BB refreshed ${counts.stale} section(s) the server had moved
|
|
3501
|
+
`);
|
|
3502
|
+
}
|
|
3503
|
+
if (!dryRun && counts.drift) {
|
|
3504
|
+
process.stderr.write(
|
|
3505
|
+
mode === "force" ? `\u26A0 overwrote ${counts.drift} section(s) that had local edits (--force)
|
|
3506
|
+
` : `\u26A0 ${counts.drift} section(s) have local edits \u2014 wrote a .proposed file alongside (original untouched). Review + merge, or re-run with ${style.cyan("--force")}.
|
|
3507
|
+
`
|
|
3508
|
+
);
|
|
3509
|
+
}
|
|
3510
|
+
}
|
|
3511
|
+
var GLOBAL_NAMESPACE = "__global__";
|
|
3512
|
+
async function resolveNamespaceChoice(cfg, flag) {
|
|
3513
|
+
if (flag) return flag;
|
|
3514
|
+
if (!canPrompt()) return null;
|
|
3515
|
+
const namespaces = await listNamespaces(cfg);
|
|
3516
|
+
if (namespaces.length === 0) return null;
|
|
3517
|
+
const picked = await promptSelect(
|
|
3518
|
+
"Which namespace should this connection use?",
|
|
3519
|
+
[
|
|
3520
|
+
{ label: "Global (whole tenant)", value: GLOBAL_NAMESPACE },
|
|
3521
|
+
...namespaces.map((n) => ({
|
|
3522
|
+
label: n.displayName,
|
|
3523
|
+
value: n.slug,
|
|
3524
|
+
hint: n.slug
|
|
3525
|
+
}))
|
|
3526
|
+
],
|
|
3527
|
+
GLOBAL_NAMESPACE
|
|
3528
|
+
);
|
|
3529
|
+
return picked === GLOBAL_NAMESPACE ? null : picked;
|
|
3530
|
+
}
|
|
3053
3531
|
function registerInit(program2) {
|
|
3054
|
-
program2.command("init").description("Wire this project for sechroom: write MCP config + agent instruction files from the server's setup descriptors").option("--client <list
|
|
3532
|
+
program2.command("init").description("Wire this project for sechroom: write MCP config + agent instruction files from the server's setup descriptors").option("--client <list...>", `clients to wire \u2014 space- or comma-separated (${ALL_CLIENT_KEYS.join(", ")}) or 'all'`, DEFAULT_CLIENT_KEY).option("--scope <scope>", "install skills/agents/hooks 'global' (config dir / CLAUDE_CONFIG_DIR) or 'project' (<cwd>/.claude) \u2014 default global", "global").option("--dry-run", "print what would be written without writing", false).option("--mcp-only", "only write MCP config (skip agent files)", false).option("--agent-files-only", "only write agent instruction files (skip MCP config)", false).option("--copy", "make a personal copy of the agent instructions you can edit (default: prompt on a TTY, else skip)").option("--namespace <slug>", "MCP namespace for the connection URL (interactive picker if omitted on a TTY; defaults to tenant-global)").option("--refresh", "refresh out-of-date agent-file blocks in place (local edits preserved to .proposed)", false).option("--force", "rewrite agent-file managed blocks, overwriting local edits inside the markers", false).option("--check", "report whether agent files would change and exit (0 = current, 1 = stale/drift/absent); writes nothing", false).addHelpText(
|
|
3055
3533
|
"after",
|
|
3056
3534
|
`
|
|
3057
3535
|
Examples:
|
|
3058
3536
|
$ sechroom init Claude Code (default): ./.mcp.json + ./CLAUDE.md
|
|
3059
3537
|
$ sechroom init --client all claude-code, claude-desktop, codex, cursor
|
|
3060
|
-
$ sechroom init --client codex
|
|
3538
|
+
$ sechroom init --client codex cursor space-separated (comma also works)
|
|
3061
3539
|
$ sechroom init --mcp-only just the MCP config (skip agent files)
|
|
3062
3540
|
$ sechroom init --dry-run --json preview the writes, change nothing`
|
|
3063
3541
|
).action(async (opts, cmd) => {
|
|
3064
3542
|
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
3065
|
-
const
|
|
3066
|
-
const
|
|
3543
|
+
const mode = resolveEvalMode(opts);
|
|
3544
|
+
const check = mode === "check";
|
|
3545
|
+
const namespaceSlug = await resolveNamespaceChoice(cfg, opts.namespace);
|
|
3546
|
+
const setup = await withSpinner(
|
|
3547
|
+
"Fetching setup descriptors",
|
|
3548
|
+
() => fetchSetup(cfg, namespaceSlug ?? void 0)
|
|
3549
|
+
);
|
|
3550
|
+
const g = cmd.optsWithGlobals();
|
|
3551
|
+
let scope;
|
|
3552
|
+
try {
|
|
3553
|
+
scope = resolveScope(opts.scope);
|
|
3554
|
+
} catch (err2) {
|
|
3555
|
+
return fail(err2.message);
|
|
3556
|
+
}
|
|
3557
|
+
const claudeTargets = resolveClaudeTargets({ override: g.claudeConfigDir, scope, cwd: process.cwd() });
|
|
3558
|
+
const codexHomes = resolveCodexHomes({ override: g.codexHome, scope });
|
|
3559
|
+
const targets = clientTargets(process.cwd(), { claudeDir: claudeTargets[0]?.dir, codexHome: codexHomes[0] });
|
|
3067
3560
|
const keys = resolveClientKeys(opts.client);
|
|
3068
|
-
const json =
|
|
3561
|
+
const json = g.json;
|
|
3069
3562
|
const personalWorkspaceId = await getPersonalWorkspaceId(cfg);
|
|
3070
|
-
if (!opts.dryRun && !opts.mcpOnly) {
|
|
3563
|
+
if (!opts.dryRun && !opts.mcpOnly && !check) {
|
|
3071
3564
|
await maybeOfferCopies(cfg, setup, targets, keys, personalWorkspaceId, copyChoice(opts));
|
|
3072
3565
|
}
|
|
3073
3566
|
const result = [];
|
|
@@ -3077,16 +3570,20 @@ Examples:
|
|
|
3077
3570
|
dryRun: Boolean(opts.dryRun),
|
|
3078
3571
|
mcp: !opts.agentFilesOnly,
|
|
3079
3572
|
agentFiles: !opts.mcpOnly,
|
|
3080
|
-
personalWorkspaceId
|
|
3573
|
+
personalWorkspaceId,
|
|
3574
|
+
mode
|
|
3081
3575
|
});
|
|
3082
3576
|
result.push({ client: key, actions });
|
|
3083
|
-
if (!json) printActions(target, actions);
|
|
3577
|
+
if (!json && !check) printActions(target, actions);
|
|
3084
3578
|
}
|
|
3579
|
+
summarizeEval(result, mode, Boolean(json), Boolean(opts.dryRun));
|
|
3085
3580
|
if (!json && !opts.dryRun && !opts.mcpOnly) {
|
|
3086
|
-
|
|
3581
|
+
for (const t of claudeTargets) {
|
|
3582
|
+
await maybeOfferSkills(cfg, personalWorkspaceId, { yes: false, dryRun: Boolean(opts.dryRun), surface: "claude-code", configDir: t.dir });
|
|
3583
|
+
}
|
|
3087
3584
|
}
|
|
3088
3585
|
if (!json && !opts.dryRun && !opts.mcpOnly) {
|
|
3089
|
-
await maybeOfferHooks({ yes: false, dryRun: Boolean(opts.dryRun), cwd: process.cwd() });
|
|
3586
|
+
await maybeOfferHooks({ yes: false, dryRun: Boolean(opts.dryRun), cwd: process.cwd(), scope, claudeConfigDir: g.claudeConfigDir, codexHome: g.codexHome });
|
|
3090
3587
|
}
|
|
3091
3588
|
if (json) {
|
|
3092
3589
|
emit({ dryRun: Boolean(opts.dryRun), clients: result }, true);
|
|
@@ -3107,23 +3604,99 @@ Next \u2014 verify: ${verify.description}
|
|
|
3107
3604
|
}
|
|
3108
3605
|
function registerSetup(program2) {
|
|
3109
3606
|
const setup = program2.command("setup").description("Granular onboarding steps (init runs these together)");
|
|
3110
|
-
setup.command("mcp <clients...>").description(`Write only the MCP config for one or more clients (${ALL_CLIENT_KEYS.join(", ")}, or 'all')`).option("--dry-run", "print what would be written without writing", false).addHelpText("after", "\nExamples:\n $ sechroom setup mcp codex\n $ sechroom setup mcp claude-code codex\n $ sechroom setup mcp all").action(async (clients, opts, cmd) => {
|
|
3111
|
-
await runClients(clients, cmd, { dryRun: Boolean(opts.dryRun), mcp: true, agentFiles: false });
|
|
3607
|
+
setup.command("mcp <clients...>").description(`Write only the MCP config for one or more clients (${ALL_CLIENT_KEYS.join(", ")}, or 'all')`).option("--dry-run", "print what would be written without writing", false).option("--namespace <slug>", "MCP namespace for the connection URL (interactive picker if omitted on a TTY; defaults to tenant-global)").addHelpText("after", "\nExamples:\n $ sechroom setup mcp codex\n $ sechroom setup mcp claude-code codex\n $ sechroom setup mcp all").action(async (clients, opts, cmd) => {
|
|
3608
|
+
await runClients(clients, cmd, { dryRun: Boolean(opts.dryRun), mcp: true, agentFiles: false, namespace: opts.namespace });
|
|
3112
3609
|
});
|
|
3113
|
-
setup.command("agent-files <clients...>").description(`Write only the agent instruction file(s) for one or more clients (${ALL_CLIENT_KEYS.join(", ")}, or 'all')`).option("--dry-run", "print what would be written without writing", false).option("--copy", "make a personal copy you can edit (default: prompt on a TTY, else skip)").addHelpText("after", "\nExamples:\n $ sechroom setup agent-files claude-code CLAUDE.md\n $ sechroom setup agent-files claude-code codex CLAUDE.md + AGENTS.md in one run\n $ sechroom setup agent-files all").action(async (clients, opts, cmd) => {
|
|
3114
|
-
await runClients(clients, cmd, { dryRun: Boolean(opts.dryRun), mcp: false, agentFiles: true, copy: opts.copy });
|
|
3610
|
+
setup.command("agent-files <clients...>").description(`Write only the agent instruction file(s) for one or more clients (${ALL_CLIENT_KEYS.join(", ")}, or 'all')`).option("--dry-run", "print what would be written without writing", false).option("--copy", "make a personal copy you can edit (default: prompt on a TTY, else skip)").option("--refresh", "refresh out-of-date blocks in place (local edits preserved to .proposed)", false).option("--force", "rewrite managed blocks, overwriting local edits inside the markers", false).option("--check", "report whether anything would change and exit (0 = current, 1 = stale/drift/absent); writes nothing", false).addHelpText("after", "\nExamples:\n $ sechroom setup agent-files claude-code CLAUDE.md\n $ sechroom setup agent-files claude-code codex CLAUDE.md + AGENTS.md in one run\n $ sechroom setup agent-files all --check CI gate: nonzero exit if out of date\n $ sechroom setup agent-files claude-code --force overwrite local edits in the managed block").action(async (clients, opts, cmd) => {
|
|
3611
|
+
await runClients(clients, cmd, { dryRun: Boolean(opts.dryRun), mcp: false, agentFiles: true, copy: opts.copy, mode: resolveEvalMode(opts) });
|
|
3612
|
+
});
|
|
3613
|
+
setup.command("new-convention <title...>").description("Scaffold a workspace-conventions section: author a correctly-tagged memo (header as first body line) + regen the agent files").option("--kind <kind>", "reference | standard (orders the section; reference first)", "reference").option("--workspace <id>", "workspace to author in (default: the bound workspace)").option("--body <markdown>", "section body (default: a TODO scaffold to edit later)").option("--no-regen", "skip the agent-files regen after authoring").option("--dry-run", "print what would be authored; write nothing", false).addHelpText(
|
|
3614
|
+
"after",
|
|
3615
|
+
`
|
|
3616
|
+
The memo carries the two conventions a workspace-conventions section needs (FR-sechroom-236):
|
|
3617
|
+
the \`agent-setup-bundle\` tag + the \`# Header\` as the FIRST body line. It's authored in the
|
|
3618
|
+
BOUND workspace (so the regen, which sources conventions from there, picks it up). Edit it later
|
|
3619
|
+
in the app or via \`sechroom memory edit-text\`.
|
|
3620
|
+
|
|
3621
|
+
Examples:
|
|
3622
|
+
$ sechroom setup new-convention "Deploy runbook"
|
|
3623
|
+
$ sechroom setup new-convention "Backend testing" --kind standard --body "- run dotnet test ..."
|
|
3624
|
+
$ sechroom setup new-convention "Draft section" --no-regen author only, regen later`
|
|
3625
|
+
).action(async (titleParts, opts, cmd) => {
|
|
3626
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
3627
|
+
const json = Boolean(cmd.optsWithGlobals().json);
|
|
3628
|
+
const title = titleParts.join(" ").trim();
|
|
3629
|
+
if (!title) fail('a section title is required, e.g. `sechroom setup new-convention "Deploy runbook"`.');
|
|
3630
|
+
const workspaceId = opts.workspace ?? cfg.workspaceId;
|
|
3631
|
+
if (!workspaceId)
|
|
3632
|
+
fail("no workspace \u2014 pass --workspace <id> or bind one (`sechroom config set --local workspaceId <id>`).");
|
|
3633
|
+
const kind = String(opts.kind).toLowerCase() === "standard" ? "standard" : "reference";
|
|
3634
|
+
const body = typeof opts.body === "string" && opts.body.trim().length > 0 ? opts.body.trim() : "_TODO: write this section, then edit the memo and re-run the regen._";
|
|
3635
|
+
const text = `# ${title}
|
|
3636
|
+
|
|
3637
|
+
${body}
|
|
3638
|
+
`;
|
|
3639
|
+
const tags = ["agent-setup-bundle", "scope:sechroom", `kind:${kind}`, "archetype:document"];
|
|
3640
|
+
if (opts.dryRun) {
|
|
3641
|
+
emit({ dryRun: true, workspaceId, title, kind, tags, text }, json);
|
|
3642
|
+
return;
|
|
3643
|
+
}
|
|
3644
|
+
const data = await runApi("Authoring convention memo", async () => {
|
|
3645
|
+
const client = await makeClient(cfg);
|
|
3646
|
+
return client.POST("/memories", {
|
|
3647
|
+
body: {
|
|
3648
|
+
text,
|
|
3649
|
+
type: kind,
|
|
3650
|
+
content: "{}",
|
|
3651
|
+
confidence: 1,
|
|
3652
|
+
source: "cli-new-convention",
|
|
3653
|
+
archetype: "Document",
|
|
3654
|
+
title,
|
|
3655
|
+
tags,
|
|
3656
|
+
owner: { type: "Workspace", id: workspaceId }
|
|
3657
|
+
}
|
|
3658
|
+
});
|
|
3659
|
+
});
|
|
3660
|
+
if (!json) {
|
|
3661
|
+
const view = resolveViewUrl(cfg.baseUrl, data.url);
|
|
3662
|
+
process.stdout.write(
|
|
3663
|
+
`\u2713 authored convention ${style.bold(data.id)} ${style.dim(`"${title}"`)}${view ? ` ${style.dim("\u2192")} ${view}` : ""}
|
|
3664
|
+
`
|
|
3665
|
+
);
|
|
3666
|
+
}
|
|
3667
|
+
if (opts.regen === false) {
|
|
3668
|
+
if (json) emit({ id: data.id, workspaceId, regen: false }, true);
|
|
3669
|
+
else process.stdout.write("Skipped regen (--no-regen). Run `sechroom setup agent-files all` to apply.\n");
|
|
3670
|
+
return;
|
|
3671
|
+
}
|
|
3672
|
+
await runClients(["claude-code", "codex"], cmd, {
|
|
3673
|
+
dryRun: false,
|
|
3674
|
+
mcp: false,
|
|
3675
|
+
agentFiles: true,
|
|
3676
|
+
copy: false,
|
|
3677
|
+
mode: "apply"
|
|
3678
|
+
});
|
|
3115
3679
|
});
|
|
3116
3680
|
}
|
|
3117
3681
|
async function runClients(clients, cmd, opts) {
|
|
3118
|
-
const
|
|
3119
|
-
const
|
|
3682
|
+
const g = cmd.optsWithGlobals();
|
|
3683
|
+
const cfg = resolveConfig(g);
|
|
3684
|
+
const mode = opts.mode ?? "apply";
|
|
3685
|
+
const check = mode === "check";
|
|
3686
|
+
const claudeDir = resolveClaudeTargets({ override: g.claudeConfigDir })[0]?.dir;
|
|
3687
|
+
const codexHome = resolveCodexHomes({ override: g.codexHome })[0];
|
|
3688
|
+
const targets = clientTargets(process.cwd(), { claudeDir, codexHome });
|
|
3120
3689
|
const keys = resolveClientKeys(clients.join(","));
|
|
3121
|
-
const
|
|
3690
|
+
const namespaceSlug = opts.mcp ? await resolveNamespaceChoice(cfg, opts.namespace) : null;
|
|
3691
|
+
const setupData = await withSpinner(
|
|
3692
|
+
"Fetching setup descriptors",
|
|
3693
|
+
() => fetchSetup(cfg, namespaceSlug ?? void 0)
|
|
3694
|
+
);
|
|
3122
3695
|
const personalWorkspaceId = await getPersonalWorkspaceId(cfg);
|
|
3123
|
-
if (opts.agentFiles && !opts.dryRun) {
|
|
3696
|
+
if (opts.agentFiles && !opts.dryRun && !check) {
|
|
3124
3697
|
await maybeOfferCopies(cfg, setupData, targets, keys, personalWorkspaceId, copyChoice(opts));
|
|
3125
3698
|
}
|
|
3126
|
-
const json =
|
|
3699
|
+
const json = g.json;
|
|
3127
3700
|
const result = [];
|
|
3128
3701
|
for (const key of keys) {
|
|
3129
3702
|
const target = targets[key];
|
|
@@ -3131,11 +3704,13 @@ async function runClients(clients, cmd, opts) {
|
|
|
3131
3704
|
dryRun: opts.dryRun,
|
|
3132
3705
|
mcp: opts.mcp,
|
|
3133
3706
|
agentFiles: opts.agentFiles,
|
|
3134
|
-
personalWorkspaceId
|
|
3707
|
+
personalWorkspaceId,
|
|
3708
|
+
mode
|
|
3135
3709
|
});
|
|
3136
3710
|
result.push({ client: key, actions });
|
|
3137
|
-
if (!json) printActions(target, actions);
|
|
3711
|
+
if (!json && !check) printActions(target, actions);
|
|
3138
3712
|
}
|
|
3713
|
+
summarizeEval(result, mode, Boolean(json), opts.dryRun);
|
|
3139
3714
|
if (json) {
|
|
3140
3715
|
emit({ dryRun: opts.dryRun, clients: result }, true);
|
|
3141
3716
|
return;
|
|
@@ -3143,14 +3718,81 @@ async function runClients(clients, cmd, opts) {
|
|
|
3143
3718
|
process.stdout.write(opts.dryRun ? "\n(dry run \u2014 nothing written)\n" : "\nDone.\n");
|
|
3144
3719
|
}
|
|
3145
3720
|
|
|
3721
|
+
// src/commands/namespace.ts
|
|
3722
|
+
function registerNamespace(program2) {
|
|
3723
|
+
const namespace = program2.command("namespace").description("Browse, inspect, and wire up MCP namespaces");
|
|
3724
|
+
namespace.addHelpText(
|
|
3725
|
+
"after",
|
|
3726
|
+
`
|
|
3727
|
+
Examples:
|
|
3728
|
+
$ sechroom namespace list
|
|
3729
|
+
$ sechroom namespace show eng
|
|
3730
|
+
$ sechroom namespace use eng wire Claude Code to the 'eng' namespace
|
|
3731
|
+
$ sechroom namespace use eng --client all`
|
|
3732
|
+
);
|
|
3733
|
+
namespace.command("list").description("List the namespaces you can reach (GET /mcp-aggregator/namespaces)").action(async (_opts, cmd) => {
|
|
3734
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
3735
|
+
const data = await runApi("Listing namespaces", async () => {
|
|
3736
|
+
const client = await makeClient(cfg);
|
|
3737
|
+
return client.GET("/mcp-aggregator/namespaces", {});
|
|
3738
|
+
});
|
|
3739
|
+
emit(data, cmd.optsWithGlobals().json);
|
|
3740
|
+
});
|
|
3741
|
+
namespace.command("show <slug>").description("Show a namespace's details (GET /mcp-aggregator/namespaces/{slug}). For the tool list it exposes, point an OpenAPI client at /t/{tenant}/namespaces/{slug}/api/openapi.json.").action(async (slug, _opts, cmd) => {
|
|
3742
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
3743
|
+
const data = await runApi("Fetching namespace", async () => {
|
|
3744
|
+
const client = await makeClient(cfg);
|
|
3745
|
+
return client.GET("/mcp-aggregator/namespaces/{slug}", {
|
|
3746
|
+
params: { path: { slug } }
|
|
3747
|
+
});
|
|
3748
|
+
});
|
|
3749
|
+
emit(data, cmd.optsWithGlobals().json);
|
|
3750
|
+
});
|
|
3751
|
+
namespace.command("use <slug>").description("Wire an AI client's MCP config to this namespace's URL").option(
|
|
3752
|
+
"--client <list>",
|
|
3753
|
+
`comma-separated clients (${ALL_CLIENT_KEYS.join(", ")}) or 'all'`,
|
|
3754
|
+
DEFAULT_CLIENT_KEY
|
|
3755
|
+
).option("--dry-run", "print what would be written without writing", false).action(async (slug, opts, cmd) => {
|
|
3756
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
3757
|
+
const setup = await withSpinner(
|
|
3758
|
+
"Fetching setup descriptors",
|
|
3759
|
+
() => fetchSetup(cfg, slug)
|
|
3760
|
+
);
|
|
3761
|
+
const targets = clientTargets(process.cwd());
|
|
3762
|
+
const keys = resolveClientKeys(opts.client);
|
|
3763
|
+
const json = cmd.optsWithGlobals().json;
|
|
3764
|
+
const result = [];
|
|
3765
|
+
for (const key of keys) {
|
|
3766
|
+
const target = targets[key];
|
|
3767
|
+
const actions = await applyClient(cfg, setup, target, {
|
|
3768
|
+
dryRun: Boolean(opts.dryRun),
|
|
3769
|
+
mcp: true,
|
|
3770
|
+
agentFiles: false,
|
|
3771
|
+
personalWorkspaceId: null
|
|
3772
|
+
});
|
|
3773
|
+
result.push({ client: key, actions });
|
|
3774
|
+
if (!json) printActions(target, actions);
|
|
3775
|
+
}
|
|
3776
|
+
if (json) {
|
|
3777
|
+
emit({ namespace: slug, dryRun: Boolean(opts.dryRun), clients: result }, true);
|
|
3778
|
+
return;
|
|
3779
|
+
}
|
|
3780
|
+
process.stdout.write(
|
|
3781
|
+
opts.dryRun ? "\n(dry run \u2014 nothing written)\n" : `
|
|
3782
|
+
Wired to namespace '${slug}'. Restart your AI client (or reload MCP) to pick it up.
|
|
3783
|
+
`
|
|
3784
|
+
);
|
|
3785
|
+
});
|
|
3786
|
+
}
|
|
3787
|
+
|
|
3146
3788
|
// src/commands/onboard.ts
|
|
3147
|
-
import { existsSync as
|
|
3148
|
-
import { join as
|
|
3789
|
+
import { existsSync as existsSync8 } from "fs";
|
|
3790
|
+
import { basename as basename3, join as join10 } from "path";
|
|
3149
3791
|
|
|
3150
3792
|
// src/commands/fanout.ts
|
|
3151
3793
|
import { spawnSync } from "child_process";
|
|
3152
|
-
import { existsSync as
|
|
3153
|
-
import { isAbsolute, join as
|
|
3794
|
+
import { existsSync as existsSync7, readFileSync as readFileSync6, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
|
|
3795
|
+
import { isAbsolute, join as join9, resolve } from "path";
|
|
3154
3796
|
var ICON = {
|
|
3155
3797
|
refresh: "\u21BB",
|
|
3156
3798
|
bind: "+",
|
|
@@ -3163,28 +3805,28 @@ function resolveChildDir(path, root) {
|
|
|
3163
3805
|
function discoverChildren(root) {
|
|
3164
3806
|
let names;
|
|
3165
3807
|
try {
|
|
3166
|
-
names =
|
|
3808
|
+
names = readdirSync2(root);
|
|
3167
3809
|
} catch {
|
|
3168
3810
|
return [];
|
|
3169
3811
|
}
|
|
3170
3812
|
const out = [];
|
|
3171
3813
|
for (const name of names.sort()) {
|
|
3172
3814
|
if (name.startsWith(".") || name === "node_modules") continue;
|
|
3173
|
-
const dir =
|
|
3815
|
+
const dir = join9(root, name);
|
|
3174
3816
|
try {
|
|
3175
|
-
if (!
|
|
3817
|
+
if (!statSync3(dir).isDirectory()) continue;
|
|
3176
3818
|
} catch {
|
|
3177
3819
|
continue;
|
|
3178
3820
|
}
|
|
3179
|
-
if (
|
|
3821
|
+
if (existsSync7(join9(dir, ".git")) || committedBindingPath(dir)) out.push(name);
|
|
3180
3822
|
}
|
|
3181
3823
|
return out;
|
|
3182
3824
|
}
|
|
3183
3825
|
function readManifest(path) {
|
|
3184
|
-
if (!
|
|
3826
|
+
if (!existsSync7(path)) return null;
|
|
3185
3827
|
let parsed;
|
|
3186
3828
|
try {
|
|
3187
|
-
parsed = JSON.parse(
|
|
3829
|
+
parsed = JSON.parse(readFileSync6(path, "utf8"));
|
|
3188
3830
|
} catch (err2) {
|
|
3189
3831
|
throw new Error(`couldn't parse ${path}: ${err2 instanceof Error ? err2.message : String(err2)}`);
|
|
3190
3832
|
}
|
|
@@ -3322,7 +3964,39 @@ async function warnIfProjectStray(client, projectId, workspaceId, json) {
|
|
|
3322
3964
|
);
|
|
3323
3965
|
}
|
|
3324
3966
|
}
|
|
3325
|
-
async function
|
|
3967
|
+
async function fetchPersonalWorkspaceId(client) {
|
|
3968
|
+
try {
|
|
3969
|
+
const { data } = await client.GET("/me/personal-workspace", {});
|
|
3970
|
+
return data?.workspaceId ?? null;
|
|
3971
|
+
} catch {
|
|
3972
|
+
return null;
|
|
3973
|
+
}
|
|
3974
|
+
}
|
|
3975
|
+
function nameTokens(s) {
|
|
3976
|
+
return s.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length >= 2);
|
|
3977
|
+
}
|
|
3978
|
+
function personalSubtreeIds(personalId, all) {
|
|
3979
|
+
const childrenOf = /* @__PURE__ */ new Map();
|
|
3980
|
+
for (const w of all) {
|
|
3981
|
+
if (!w.parentId) continue;
|
|
3982
|
+
(childrenOf.get(w.parentId) ?? childrenOf.set(w.parentId, []).get(w.parentId)).push(w);
|
|
3983
|
+
}
|
|
3984
|
+
const ids = /* @__PURE__ */ new Set([personalId]);
|
|
3985
|
+
const queue = [personalId];
|
|
3986
|
+
while (queue.length > 0) {
|
|
3987
|
+
const id = queue.shift();
|
|
3988
|
+
for (const child of childrenOf.get(id) ?? []) {
|
|
3989
|
+
if (!ids.has(child.id)) {
|
|
3990
|
+
ids.add(child.id);
|
|
3991
|
+
queue.push(child.id);
|
|
3992
|
+
}
|
|
3993
|
+
}
|
|
3994
|
+
}
|
|
3995
|
+
return ids;
|
|
3996
|
+
}
|
|
3997
|
+
async function pickWorkspace(client, opts = {}) {
|
|
3998
|
+
const promptLabel = opts.promptLabel ?? "Bind this directory to a workspace:";
|
|
3999
|
+
const dirName = opts.dirName ?? basename3(process.cwd());
|
|
3326
4000
|
const all = await withSpinner("Listing your workspaces", () => fetchWorkspaces(client));
|
|
3327
4001
|
if (all.length === 0) {
|
|
3328
4002
|
process.stderr.write(`no workspaces found \u2014 skipping workspace binding (you can set it later with \`sechroom config set --local workspaceId <id>\`)
|
|
@@ -3330,22 +4004,34 @@ async function pickWorkspace(client, promptLabel = "Bind this directory to a wor
|
|
|
3330
4004
|
return void 0;
|
|
3331
4005
|
}
|
|
3332
4006
|
const byId = new Map(all.map((w) => [w.id, w]));
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
4007
|
+
const personalId = await fetchPersonalWorkspaceId(client);
|
|
4008
|
+
const excluded = personalId ? personalSubtreeIds(personalId, all) : /* @__PURE__ */ new Set();
|
|
4009
|
+
let candidates = all.filter((w) => !excluded.has(w.id));
|
|
4010
|
+
if (candidates.length === 0) candidates = all;
|
|
4011
|
+
const dirToks = new Set(nameTokens(dirName));
|
|
4012
|
+
const isMatch = (w) => nameTokens(w.name).some((t) => dirToks.has(t));
|
|
4013
|
+
const suggestions = candidates.filter(isMatch);
|
|
4014
|
+
let pool = candidates;
|
|
4015
|
+
if (candidates.length > 12 && suggestions.length === 0) {
|
|
4016
|
+
const q = (await promptText(`Filter ${candidates.length} workspaces (substring, Enter to list all)?`, "")).trim().toLowerCase();
|
|
3336
4017
|
if (q) {
|
|
3337
|
-
const hits =
|
|
4018
|
+
const hits = candidates.filter((w) => `${w.name} ${workspacePath(w, byId)}`.toLowerCase().includes(q));
|
|
3338
4019
|
if (hits.length > 0) pool = hits;
|
|
3339
4020
|
else process.stderr.write(`no match for "${q}" \u2014 listing all
|
|
3340
4021
|
`);
|
|
3341
4022
|
}
|
|
3342
4023
|
}
|
|
3343
4024
|
const SKIP = "__skip__";
|
|
4025
|
+
const byPath = (a, b) => workspacePath(a, byId).localeCompare(workspacePath(b, byId));
|
|
4026
|
+
const matched = pool.filter(isMatch).sort(byPath);
|
|
4027
|
+
const rest = pool.filter((w) => !isMatch(w)).sort(byPath);
|
|
3344
4028
|
const choices = [
|
|
3345
|
-
...
|
|
4029
|
+
...matched.map((w) => ({ label: workspacePath(w, byId), value: w.id, hint: style.dim(`matches "${dirName}"`) })),
|
|
4030
|
+
...rest.map((w) => ({ label: workspacePath(w, byId), value: w.id, hint: w.id })),
|
|
3346
4031
|
{ label: style.dim("skip \u2014 don't bind a workspace"), value: SKIP, hint: void 0 }
|
|
3347
4032
|
];
|
|
3348
|
-
const
|
|
4033
|
+
const defaultValue = matched.length === 1 ? matched[0].id : SKIP;
|
|
4034
|
+
const chosen = await promptSelect(promptLabel, choices, defaultValue);
|
|
3349
4035
|
if (chosen === SKIP) return void 0;
|
|
3350
4036
|
const picked = byId.get(chosen);
|
|
3351
4037
|
const collisions = all.filter((w) => w.id !== picked.id && namesCollide(w.name, picked.name));
|
|
@@ -3372,7 +4058,7 @@ async function resolveWorkspaceBinding(client, existing, opts) {
|
|
|
3372
4058
|
}
|
|
3373
4059
|
if (existing) return existing;
|
|
3374
4060
|
if (!canPrompt() || opts.yes) return void 0;
|
|
3375
|
-
return pickWorkspace(client);
|
|
4061
|
+
return pickWorkspace(client, { dirName: basename3(process.cwd()) });
|
|
3376
4062
|
}
|
|
3377
4063
|
async function ensureTenant(baseUrl, g, opts) {
|
|
3378
4064
|
const persisted = readPersisted();
|
|
@@ -3488,7 +4174,7 @@ async function ensureTimezone(cfg, opts) {
|
|
|
3488
4174
|
return { timezone: tz, action: "set" };
|
|
3489
4175
|
}
|
|
3490
4176
|
async function chooseClients(clientFlag, yes, cwd) {
|
|
3491
|
-
if (clientFlag) return resolveClientKeys(clientFlag);
|
|
4177
|
+
if (clientFlag && clientFlag.length > 0) return resolveClientKeys(clientFlag);
|
|
3492
4178
|
const detected = detectInstalledClients(cwd);
|
|
3493
4179
|
const preselected = detected.length > 0 ? detected : [DEFAULT_CLIENT_KEY];
|
|
3494
4180
|
if (!canPrompt() || yes) return preselected;
|
|
@@ -3503,12 +4189,24 @@ async function chooseClients(clientFlag, yes, cwd) {
|
|
|
3503
4189
|
);
|
|
3504
4190
|
return picks.length > 0 ? picks : preselected;
|
|
3505
4191
|
}
|
|
4192
|
+
async function chooseScope(scopeFlag, yes) {
|
|
4193
|
+
if (scopeFlag != null) return resolveScope(scopeFlag);
|
|
4194
|
+
if (!canPrompt() || yes) return "global";
|
|
4195
|
+
return promptSelect(
|
|
4196
|
+
"Install skills, agents, and hooks globally or just for this project?",
|
|
4197
|
+
[
|
|
4198
|
+
{ label: "Globally", value: "global", hint: "~/.claude (or CLAUDE_CONFIG_DIR) \u2014 all projects" },
|
|
4199
|
+
{ label: "This project", value: "project", hint: "<repo>/.claude" }
|
|
4200
|
+
],
|
|
4201
|
+
"global"
|
|
4202
|
+
);
|
|
4203
|
+
}
|
|
3506
4204
|
async function planRecurseChild(entry, root, client, opts) {
|
|
3507
4205
|
const dir = resolveChildDir(entry.path, root);
|
|
3508
|
-
if (!
|
|
4206
|
+
if (!existsSync8(dir)) {
|
|
3509
4207
|
return { label: entry.path, dir, disposition: "skip-missing", argv: [], reason: "directory does not exist" };
|
|
3510
4208
|
}
|
|
3511
|
-
if (
|
|
4209
|
+
if (existsSync8(join10(dir, ".sechroom.json"))) {
|
|
3512
4210
|
return {
|
|
3513
4211
|
label: entry.path,
|
|
3514
4212
|
dir,
|
|
@@ -3535,7 +4233,10 @@ async function planRecurseChild(entry, root, client, opts) {
|
|
|
3535
4233
|
process.stderr.write(`
|
|
3536
4234
|
${style.bold(entry.path)} ${style.dim("is not bound yet.")}
|
|
3537
4235
|
`);
|
|
3538
|
-
const ws = await pickWorkspace(client,
|
|
4236
|
+
const ws = await pickWorkspace(client, {
|
|
4237
|
+
promptLabel: `Bind ${style.cyan(entry.path)} to a workspace:`,
|
|
4238
|
+
dirName: basename3(entry.path)
|
|
4239
|
+
});
|
|
3539
4240
|
if (!ws) {
|
|
3540
4241
|
return { label: entry.path, dir, disposition: "skip-unbound", argv: [], reason: "unbound \u2014 no workspace chosen (skipped)" };
|
|
3541
4242
|
}
|
|
@@ -3578,7 +4279,7 @@ This fan-out will pin the same lane in every repo:
|
|
|
3578
4279
|
async function runRecurse(cfg, g, opts) {
|
|
3579
4280
|
const { yes, dryRun, json } = opts;
|
|
3580
4281
|
const root = process.cwd();
|
|
3581
|
-
const manifestPath =
|
|
4282
|
+
const manifestPath = join10(root, ".sechroom", "repos.json");
|
|
3582
4283
|
const fromManifest = readManifest(manifestPath);
|
|
3583
4284
|
const entries = fromManifest ?? discoverChildren(root).map((path) => ({ path }));
|
|
3584
4285
|
const sourceLabel = fromManifest ? `manifest ${manifestPath}` : `auto-discovered under ${root}`;
|
|
@@ -3606,7 +4307,7 @@ async function runRecurse(cfg, g, opts) {
|
|
|
3606
4307
|
summarizeFanout(results, { dryRun });
|
|
3607
4308
|
}
|
|
3608
4309
|
function registerOnboard(program2) {
|
|
3609
|
-
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
|
|
4310
|
+
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...>", `clients to wire \u2014 space- or comma-separated (${ALL_CLIENT_KEYS.join(", ")}) or 'all' (default: auto-detected)`).option("--scope <scope>", "install skills/agents/hooks 'global' (config dir / CLAUDE_CONFIG_DIR) or 'project' (<cwd>/.claude) \u2014 default: prompt, else global").option("--local", "save the binding (tenant + base URL + workspace) to a committed .sechroom.json in this repo instead of the global config", false).option("--here", "with --local: write the binding at THIS directory even when a parent already carries one \u2014 binds a subtree (e.g. a monorepo's frontend/) to its own workspace", 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(
|
|
3610
4311
|
"after",
|
|
3611
4312
|
`
|
|
3612
4313
|
Examples:
|
|
@@ -3649,6 +4350,13 @@ Examples:
|
|
|
3649
4350
|
process.stderr.write(line);
|
|
3650
4351
|
}
|
|
3651
4352
|
const wire = await chooseWire(opts, yes);
|
|
4353
|
+
const scope = await chooseScope(opts.scope, yes);
|
|
4354
|
+
const claudeTargets = resolveClaudeTargets({ override: g.claudeConfigDir, scope, cwd: process.cwd() });
|
|
4355
|
+
const codexHomes = resolveCodexHomes({ override: g.codexHome, scope });
|
|
4356
|
+
if (scope === "project" && g.claudeConfigDir && !json) {
|
|
4357
|
+
process.stderr.write(`${style.dim("(--claude-config-dir is ignored for --scope project \u2014 project files are repo-relative)")}
|
|
4358
|
+
`);
|
|
4359
|
+
}
|
|
3652
4360
|
if (wire === "cli-only") {
|
|
3653
4361
|
if (json) {
|
|
3654
4362
|
emit({ dryRun, baseUrl: cfg.baseUrl, tenant: cfg.tenant, workspaceId: cfg.workspaceId ?? null, timezone: tz, wire, clients: [] }, true);
|
|
@@ -3656,7 +4364,7 @@ Examples:
|
|
|
3656
4364
|
}
|
|
3657
4365
|
if (!dryRun) {
|
|
3658
4366
|
await ensureLanePin(cfg, { yes, dryRun, clients: detectInstalledClients(process.cwd()) });
|
|
3659
|
-
await maybeOfferHooks({ yes, dryRun, cwd: process.cwd() });
|
|
4367
|
+
await maybeOfferHooks({ yes, dryRun, cwd: process.cwd(), scope, claudeConfigDir: g.claudeConfigDir, codexHome: g.codexHome });
|
|
3660
4368
|
}
|
|
3661
4369
|
process.stdout.write(
|
|
3662
4370
|
`
|
|
@@ -3669,7 +4377,7 @@ Try: ${style.cyan('sechroom memory search "..."')} or ${style.cyan("sechroom -
|
|
|
3669
4377
|
}
|
|
3670
4378
|
const keys = await chooseClients(opts.client, yes, process.cwd());
|
|
3671
4379
|
const setup = await withSpinner("Fetching setup descriptors", () => fetchSetup(cfg));
|
|
3672
|
-
const targets = clientTargets(process.cwd());
|
|
4380
|
+
const targets = clientTargets(process.cwd(), { claudeDir: claudeTargets[0]?.dir, codexHome: codexHomes[0] });
|
|
3673
4381
|
const personalWorkspaceId = await getPersonalWorkspaceId(cfg);
|
|
3674
4382
|
if (!dryRun && !check) {
|
|
3675
4383
|
await maybeOfferCopies(cfg, setup, targets, keys, personalWorkspaceId, copyChoice(opts));
|
|
@@ -3713,10 +4421,12 @@ Try: ${style.cyan('sechroom memory search "..."')} or ${style.cyan("sechroom -
|
|
|
3713
4421
|
await ensureLanePin(cfg, { yes, dryRun, clients: keys });
|
|
3714
4422
|
}
|
|
3715
4423
|
if (!json && !dryRun) {
|
|
3716
|
-
|
|
4424
|
+
for (const t of claudeTargets) {
|
|
4425
|
+
await maybeOfferSkills(cfg, personalWorkspaceId, { yes, dryRun, surface: "claude-code", configDir: t.dir });
|
|
4426
|
+
}
|
|
3717
4427
|
}
|
|
3718
4428
|
if (!json && !dryRun) {
|
|
3719
|
-
await maybeOfferHooks({ yes, dryRun, cwd: process.cwd() });
|
|
4429
|
+
await maybeOfferHooks({ yes, dryRun, cwd: process.cwd(), scope, claudeConfigDir: g.claudeConfigDir, codexHome: g.codexHome });
|
|
3720
4430
|
}
|
|
3721
4431
|
if (json) {
|
|
3722
4432
|
emit({ dryRun, baseUrl: cfg.baseUrl, tenant: cfg.tenant, workspaceId: cfg.workspaceId ?? null, timezone: tz, wire, eval: evalCounts, clients: result }, true);
|
|
@@ -3762,14 +4472,21 @@ async function chooseWire(opts, yes) {
|
|
|
3762
4472
|
return opts.mcp === false ? "agent-only" : "full";
|
|
3763
4473
|
}
|
|
3764
4474
|
var FALLBACK_AGENT_PROMPT = "Resume my sechroom continuity, summarise what I was last working on, then suggest the next step.";
|
|
4475
|
+
function printNextStepBlock(heading, lines) {
|
|
4476
|
+
const rule = style.dim("\u2500".repeat(52));
|
|
4477
|
+
process.stdout.write(
|
|
4478
|
+
`
|
|
4479
|
+
${rule}
|
|
4480
|
+
${style.bold(heading)}
|
|
4481
|
+
|
|
4482
|
+
` + lines.map((l) => ` ${l}`).join("\n") + `
|
|
4483
|
+
${rule}
|
|
4484
|
+
`
|
|
4485
|
+
);
|
|
4486
|
+
}
|
|
3765
4487
|
async function printStarterPrompt(mode, cfg) {
|
|
3766
4488
|
if (mode === "cli") {
|
|
3767
|
-
|
|
3768
|
-
`
|
|
3769
|
-
${style.bold("Next:")} pick up where you left off \u2014
|
|
3770
|
-
${style.cyan("sechroom continuity resume-me")}
|
|
3771
|
-
`
|
|
3772
|
-
);
|
|
4489
|
+
printNextStepBlock("Next \u2014 pick up where you left off:", [style.cyan("sechroom continuity resume-me")]);
|
|
3773
4490
|
return;
|
|
3774
4491
|
}
|
|
3775
4492
|
let primary = FALLBACK_AGENT_PROMPT;
|
|
@@ -3781,21 +4498,16 @@ ${style.bold("Next:")} pick up where you left off \u2014
|
|
|
3781
4498
|
} catch {
|
|
3782
4499
|
}
|
|
3783
4500
|
}
|
|
3784
|
-
|
|
3785
|
-
`
|
|
3786
|
-
${style.bold("Next:")} paste this into your AI agent to get going \u2014
|
|
3787
|
-
${style.cyan(`"${primary}"`)}
|
|
3788
|
-
`
|
|
3789
|
-
);
|
|
4501
|
+
printNextStepBlock("Next \u2014 paste this into your AI agent to get going:", [style.cyan(`"${primary}"`)]);
|
|
3790
4502
|
}
|
|
3791
4503
|
|
|
3792
4504
|
// src/commands/sweep.ts
|
|
3793
|
-
import { existsSync as
|
|
3794
|
-
import { dirname as
|
|
3795
|
-
var DEFAULT_MANIFEST =
|
|
4505
|
+
import { existsSync as existsSync9 } from "fs";
|
|
4506
|
+
import { dirname as dirname7, join as join11, resolve as resolve2 } from "path";
|
|
4507
|
+
var DEFAULT_MANIFEST = join11(".sechroom", "repos.json");
|
|
3796
4508
|
function planEntry(entry, root) {
|
|
3797
4509
|
const dir = resolveChildDir(entry.path, root);
|
|
3798
|
-
if (!
|
|
4510
|
+
if (!existsSync9(dir)) {
|
|
3799
4511
|
return { label: entry.path, dir, disposition: "skip-missing", argv: [], reason: "directory does not exist" };
|
|
3800
4512
|
}
|
|
3801
4513
|
if (committedBindingPath(dir)) {
|
|
@@ -3871,7 +4583,7 @@ Examples:
|
|
|
3871
4583
|
`);
|
|
3872
4584
|
return;
|
|
3873
4585
|
}
|
|
3874
|
-
const root =
|
|
4586
|
+
const root = dirname7(dirname7(manifestPath));
|
|
3875
4587
|
const plans = repos.map((entry) => planEntry(entry, root));
|
|
3876
4588
|
if (!json) {
|
|
3877
4589
|
process.stderr.write(
|
|
@@ -3889,106 +4601,78 @@ Examples:
|
|
|
3889
4601
|
}
|
|
3890
4602
|
|
|
3891
4603
|
// src/commands/skills.ts
|
|
3892
|
-
import {
|
|
3893
|
-
import {
|
|
3894
|
-
|
|
3895
|
-
|
|
3896
|
-
var
|
|
3897
|
-
|
|
3898
|
-
|
|
3899
|
-
|
|
3900
|
-
}
|
|
3901
|
-
|
|
3902
|
-
|
|
3903
|
-
|
|
3904
|
-
|
|
3905
|
-
|
|
4604
|
+
import { join as join12 } from "path";
|
|
4605
|
+
import { existsSync as existsSync10, rmSync as rmSync2 } from "fs";
|
|
4606
|
+
|
|
4607
|
+
// src/commands/lane.ts
|
|
4608
|
+
var LANE_KEYS = ["code-lane", "design-lane"];
|
|
4609
|
+
function showLane(json) {
|
|
4610
|
+
const found = readSem();
|
|
4611
|
+
if (!found) {
|
|
4612
|
+
if (json) return emit({ path: null, values: {} }, true);
|
|
4613
|
+
return console.log(
|
|
4614
|
+
style.dim(`No ./.sechroom/lane.json pin in this checkout. Run 'sechroom lane set'.`)
|
|
4615
|
+
);
|
|
4616
|
+
}
|
|
4617
|
+
const resolved = { ...found.values };
|
|
4618
|
+
let suffixed = false;
|
|
4619
|
+
for (const k of LANE_KEYS) {
|
|
4620
|
+
const v = found.values[k];
|
|
4621
|
+
if (!v) continue;
|
|
4622
|
+
resolved[k] = applyWorktreeLaneSuffix(v);
|
|
4623
|
+
if (resolved[k] !== v) suffixed = true;
|
|
4624
|
+
}
|
|
4625
|
+
if (json) return emit({ path: found.path, values: resolved, worktreeSuffixApplied: suffixed }, true);
|
|
4626
|
+
console.log(style.dim(`from ${found.path}`));
|
|
4627
|
+
Object.entries(resolved).forEach(([k, v]) => console.log(" " + style.bold(k) + " = " + v));
|
|
4628
|
+
if (suffixed) console.log(style.dim(" (worktree -N suffix applied \u2014 non-primary git worktree)"));
|
|
4629
|
+
}
|
|
4630
|
+
function setLane(opts) {
|
|
4631
|
+
if (!opts.codeLane && !opts.designLane) fail("Provide --code-lane and/or --design-lane.");
|
|
4632
|
+
const target = localSemPath();
|
|
4633
|
+
const values = readLocalSemValues();
|
|
4634
|
+
if (opts.codeLane) values["code-lane"] = opts.codeLane;
|
|
4635
|
+
if (opts.designLane) values["design-lane"] = opts.designLane;
|
|
4636
|
+
writeSem(values, target);
|
|
4637
|
+
if (opts.json) return emit({ path: target, values }, true);
|
|
4638
|
+
console.log(style.green(`Wrote lane pin \u2192 ${target} ${style.dim("(git-ignored)")}`));
|
|
4639
|
+
Object.entries(values).forEach(([k, v]) => console.log(" " + style.dim(k) + " = " + v));
|
|
4640
|
+
}
|
|
4641
|
+
function registerLane(program2) {
|
|
4642
|
+
const lane = program2.command("lane").description("Show this checkout's continuity lane pin (worktree-aware -N suffix applied)").option("--json", "machine output").action((opts, cmd) => showLane(Boolean(opts.json) || Boolean(cmd.optsWithGlobals().json)));
|
|
4643
|
+
lane.command("set").description("Write this checkout's lane pin to ./.sechroom/lane.json").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(
|
|
4644
|
+
(opts, cmd) => setLane({
|
|
4645
|
+
codeLane: opts.codeLane,
|
|
4646
|
+
designLane: opts.designLane,
|
|
4647
|
+
json: Boolean(opts.json) || Boolean(cmd.optsWithGlobals().json)
|
|
4648
|
+
})
|
|
4649
|
+
);
|
|
4650
|
+
lane.addHelpText(
|
|
4651
|
+
"after",
|
|
4652
|
+
`
|
|
4653
|
+
Examples:
|
|
4654
|
+
$ sechroom lane show the resolved lane(s)
|
|
4655
|
+
$ sechroom lane set --code-lane claude-code-chris --design-lane claude-design-chris
|
|
4656
|
+
|
|
4657
|
+
In a non-primary git worktree the ratified concurrent-session -N suffix is auto-applied (SBC-1094).
|
|
4658
|
+
(Aliases: 'sechroom skills lane' / 'skills set-lane' \u2014 kept for back-compat.)`
|
|
4659
|
+
);
|
|
3906
4660
|
}
|
|
4661
|
+
|
|
4662
|
+
// src/commands/skills.ts
|
|
3907
4663
|
function registerSkills(program2) {
|
|
3908
|
-
const skills = program2.command("skills").description("
|
|
4664
|
+
const skills = program2.command("skills").description("Manage operator skills (materialised by `onboard`)");
|
|
3909
4665
|
skills.addHelpText(
|
|
3910
4666
|
"after",
|
|
3911
4667
|
`
|
|
3912
4668
|
Examples:
|
|
3913
|
-
$ sechroom skills install --code-lane claude-code-chris --design-lane claude-design-chris
|
|
3914
|
-
$ sechroom skills install operator-skills --surface claude-code --local
|
|
3915
4669
|
$ sechroom skills list
|
|
3916
4670
|
$ sechroom skills set-lane --code-lane claude-code-chris --design-lane claude-design-chris
|
|
3917
4671
|
$ sechroom skills lane
|
|
3918
|
-
$ sechroom skills clean
|
|
4672
|
+
$ sechroom skills clean
|
|
4673
|
+
|
|
4674
|
+
To install/refresh skills, run 'sechroom onboard' (it offers to materialise them).`
|
|
3919
4675
|
);
|
|
3920
|
-
skills.command("install [slug]").description(`Install a skills bundle (default ${DEFAULT_SLUG}) into your personal workspace + write SKILL.md files`).option("--version <v>", "bundle version (default: latest published in the catalogue)").option("--instance <name>", "install as a named, separate instance (install the same bundle more than once)").option("--code-lane <id>", "identity.code-lane binding (e.g. claude-code-chris)").option("--design-lane <id>", "identity.design-lane binding (e.g. claude-design-chris)").option("--surface <s>", "skill target surface to materialise", "claude-code").option("--local", "write to ./.claude/skills instead of ~/.claude/skills").option("--json", "machine output").action(async (slugArg, opts, cmd) => {
|
|
3921
|
-
const slug = slugArg || DEFAULT_SLUG;
|
|
3922
|
-
const client = await makeClient(resolveConfig(cmd.optsWithGlobals()));
|
|
3923
|
-
const pw = await runApi("resolving personal workspace", () => client.GET("/me/personal-workspace", {}));
|
|
3924
|
-
const personalWsId = pw?.id || pw?.workspaceId || pw?.personalWorkspaceId || pw?.item?.id;
|
|
3925
|
-
if (!personalWsId) fail("Could not resolve your personal workspace.");
|
|
3926
|
-
let version = opts.version;
|
|
3927
|
-
if (!version) {
|
|
3928
|
-
const cat = await runApi("reading the bundle catalogue", () => client.GET("/me/bundles", {}));
|
|
3929
|
-
const item = (cat?.bundles ?? cat?.Bundles ?? []).find((b) => (b.slug ?? b.Slug) === slug);
|
|
3930
|
-
if (!item) fail(`Bundle '${slug}' is not in your self-serve catalogue (must be UserInstallable + Published).`);
|
|
3931
|
-
version = item.latestVersion ?? item.LatestVersion;
|
|
3932
|
-
if (!version) fail(`Bundle '${slug}' has no installable (Published) version.`);
|
|
3933
|
-
}
|
|
3934
|
-
const installOptions = {};
|
|
3935
|
-
if (opts.codeLane) installOptions["identity.code-lane"] = opts.codeLane;
|
|
3936
|
-
if (opts.designLane) installOptions["identity.design-lane"] = opts.designLane;
|
|
3937
|
-
const res = await runApi(
|
|
3938
|
-
`installing ${slug}@${version}${opts.instance ? ` (${opts.instance})` : ""}`,
|
|
3939
|
-
() => client.POST("/me/bundles/{slug}/versions/{version}/install", {
|
|
3940
|
-
params: { path: { slug, version } },
|
|
3941
|
-
// instance: null/absent = the default instance (reinstall updates in
|
|
3942
|
-
// place); a name installs a separate instance.
|
|
3943
|
-
body: { installOptions, instance: opts.instance ?? null }
|
|
3944
|
-
})
|
|
3945
|
-
);
|
|
3946
|
-
const status = String(res?.status ?? res?.Status ?? "");
|
|
3947
|
-
if (status && status.toLowerCase() !== "completed") {
|
|
3948
|
-
fail(`Install did not complete (status=${status}; ${res?.failureReason ?? res?.FailureReason ?? ""}).`);
|
|
3949
|
-
}
|
|
3950
|
-
const feed = await runApi(
|
|
3951
|
-
"materialising skill files",
|
|
3952
|
-
() => client.GET("/workspaces/{workspaceId}/memories/feed", {
|
|
3953
|
-
// cascadeWorkspaces: skills land in an "Operator Skills" SUB-workspace of
|
|
3954
|
-
// the personal workspace, so we recurse from the personal-ws root.
|
|
3955
|
-
// includeText: the feed omits bodies by default; we need them for SKILL.md.
|
|
3956
|
-
params: {
|
|
3957
|
-
path: { workspaceId: personalWsId },
|
|
3958
|
-
query: { limit: 200, cascadeWorkspaces: true, includeText: true }
|
|
3959
|
-
}
|
|
3960
|
-
})
|
|
3961
|
-
);
|
|
3962
|
-
const rows = feed?.results ?? feed?.Results ?? [];
|
|
3963
|
-
const dir = skillsDir(!opts.local);
|
|
3964
|
-
const wantInstance = opts.instance || "default";
|
|
3965
|
-
const written = [];
|
|
3966
|
-
const bundleTagPrefix = `sechroom:bundle:${slug}@`;
|
|
3967
|
-
for (const r of rows) {
|
|
3968
|
-
const m = r.item ?? r;
|
|
3969
|
-
const tags = m.tags ?? m.Tags ?? [];
|
|
3970
|
-
if (!hasAny(tags, ROLE_TAGS)) continue;
|
|
3971
|
-
if (tagValue2(tags, "target:") !== opts.surface) continue;
|
|
3972
|
-
if (!tags.some((t) => t.startsWith(bundleTagPrefix))) continue;
|
|
3973
|
-
if ((tagValue2(tags, "sechroom:skill-instance:") ?? "default") !== wantInstance) continue;
|
|
3974
|
-
const name = tagValue2(tags, "skill:");
|
|
3975
|
-
if (!name) continue;
|
|
3976
|
-
const body = m.text ?? m.Text ?? "";
|
|
3977
|
-
mkdirSync6(join9(dir, name), { recursive: true });
|
|
3978
|
-
writeFileSync6(join9(dir, name, "SKILL.md"), body.endsWith("\n") ? body : body + "\n");
|
|
3979
|
-
written.push(name);
|
|
3980
|
-
}
|
|
3981
|
-
mkdirSync6(dir, { recursive: true });
|
|
3982
|
-
const lockPath = join9(dir, LOCK);
|
|
3983
|
-
const lock = existsSync9(lockPath) ? JSON.parse(readFileSync6(lockPath, "utf8")) : {};
|
|
3984
|
-
lock[slug] = { surface: opts.surface, version, instance: wantInstance, skills: written.sort() };
|
|
3985
|
-
writeFileSync6(lockPath, JSON.stringify(lock, null, 2) + "\n");
|
|
3986
|
-
if (opts.json) return emit({ slug, version, instance: wantInstance, surface: opts.surface, dir, installed: written }, true);
|
|
3987
|
-
const instanceNote = opts.instance ? ` (${opts.instance})` : "";
|
|
3988
|
-
console.log(style.green(`Installed ${slug}@${version}${instanceNote} \u2014 ${written.length} skill(s) \u2192 ${dir}`));
|
|
3989
|
-
written.forEach((n) => console.log(" " + style.dim("\u2022") + " " + n));
|
|
3990
|
-
if (written.length === 0) console.log(style.dim(` (no '${opts.surface}' skill bodies found; check --surface)`));
|
|
3991
|
-
});
|
|
3992
4676
|
skills.command("list").description("List your installed bundles (GET /me/bundle-installs)").option("--json", "machine output").action(async (opts, cmd) => {
|
|
3993
4677
|
const client = await makeClient(resolveConfig(cmd.optsWithGlobals()));
|
|
3994
4678
|
const data = await runApi("reading your installs", () => client.GET("/me/bundle-installs", {}));
|
|
@@ -4001,49 +4685,54 @@ Examples:
|
|
|
4001
4685
|
console.log(` ${i.bundleSlug ?? i.BundleSlug}@${i.bundleVersion ?? i.BundleVersion ?? "?"}${tag}`);
|
|
4002
4686
|
});
|
|
4003
4687
|
});
|
|
4004
|
-
skills.command("clean [slug]").description(`Remove
|
|
4005
|
-
const
|
|
4006
|
-
const
|
|
4007
|
-
|
|
4008
|
-
|
|
4009
|
-
|
|
4010
|
-
|
|
4011
|
-
|
|
4012
|
-
|
|
4013
|
-
|
|
4014
|
-
|
|
4015
|
-
|
|
4016
|
-
|
|
4017
|
-
|
|
4688
|
+
skills.command("clean [slug]").description(`Remove skill files materialised by onboard (default ${DEFAULT_SKILLS_SLUG})`).option("--scope <scope>", "global (config dir / CLAUDE_CONFIG_DIR) or project (<cwd>/.claude) \u2014 default global").option("--local", "alias for --scope project").option("--json", "machine output").action(async (slugArg, opts, cmd) => {
|
|
4689
|
+
const g = cmd.optsWithGlobals();
|
|
4690
|
+
const slug = slugArg || DEFAULT_SKILLS_SLUG;
|
|
4691
|
+
let scope;
|
|
4692
|
+
try {
|
|
4693
|
+
scope = opts.local ? "project" : resolveScope(opts.scope);
|
|
4694
|
+
} catch (err2) {
|
|
4695
|
+
return fail(err2.message);
|
|
4696
|
+
}
|
|
4697
|
+
const targets = resolveClaudeTargets({ override: g.claudeConfigDir, scope, cwd: process.cwd() });
|
|
4698
|
+
const cleaned = [];
|
|
4699
|
+
const missing = [];
|
|
4700
|
+
for (const t of targets) {
|
|
4701
|
+
const dir = skillsDir(t.dir);
|
|
4702
|
+
const lock = readSkillsLock(dir);
|
|
4703
|
+
const entry = lock[slug];
|
|
4704
|
+
if (!entry) {
|
|
4705
|
+
missing.push(join12(dir, SKILLS_LOCK));
|
|
4706
|
+
continue;
|
|
4707
|
+
}
|
|
4708
|
+
const removed = [];
|
|
4709
|
+
for (const name of entry.skills) {
|
|
4710
|
+
const skillPath = join12(dir, name);
|
|
4711
|
+
if (existsSync10(skillPath)) {
|
|
4712
|
+
rmSync2(skillPath, { recursive: true, force: true });
|
|
4713
|
+
removed.push(name);
|
|
4714
|
+
}
|
|
4018
4715
|
}
|
|
4716
|
+
delete lock[slug];
|
|
4717
|
+
writeSkillsLock(dir, lock);
|
|
4718
|
+
cleaned.push({ dir, removed });
|
|
4019
4719
|
}
|
|
4020
|
-
|
|
4021
|
-
|
|
4022
|
-
|
|
4023
|
-
|
|
4024
|
-
|
|
4025
|
-
|
|
4026
|
-
if (!opts.codeLane && !opts.designLane) fail("Provide --code-lane and/or --design-lane.");
|
|
4027
|
-
const target = localSemPath();
|
|
4028
|
-
const values = readLocalSemValues();
|
|
4029
|
-
if (opts.codeLane) values["code-lane"] = opts.codeLane;
|
|
4030
|
-
if (opts.designLane) values["design-lane"] = opts.designLane;
|
|
4031
|
-
writeSem(values, target);
|
|
4032
|
-
if (cmd.optsWithGlobals().json) return emit({ path: target, values }, true);
|
|
4033
|
-
console.log(style.green(`Wrote lane pin \u2192 ${target} ${style.dim("(git-ignored)")}`));
|
|
4034
|
-
Object.entries(values).forEach(([k, v]) => console.log(" " + style.dim(k) + " = " + v));
|
|
4035
|
-
});
|
|
4036
|
-
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) => {
|
|
4037
|
-
const json = cmd.optsWithGlobals().json;
|
|
4038
|
-
const found = readSem();
|
|
4039
|
-
if (!found) {
|
|
4040
|
-
if (json) return emit({ path: null, values: {} }, true);
|
|
4041
|
-
return console.log(style.dim(`No ./.sechroom/lane.json pin in this checkout. Run 'sechroom skills set-lane'.`));
|
|
4720
|
+
if (cleaned.length === 0) {
|
|
4721
|
+
return fail(`No materialised skills recorded for '${slug}' in ${missing.join(", ")}.`);
|
|
4722
|
+
}
|
|
4723
|
+
if (opts.json) return emit({ slug, cleaned, missing }, true);
|
|
4724
|
+
for (const { dir, removed } of cleaned) {
|
|
4725
|
+
console.log(style.green(`Removed ${removed.length} skill(s) for ${slug} from ${dir}`));
|
|
4042
4726
|
}
|
|
4043
|
-
if (json) return emit(found, true);
|
|
4044
|
-
console.log(style.dim(`from ${found.path}`));
|
|
4045
|
-
Object.entries(found.values).forEach(([k, v]) => console.log(" " + style.bold(k) + " = " + v));
|
|
4046
4727
|
});
|
|
4728
|
+
skills.command("set-lane").description("Alias of `sechroom lane set` (kept for back-compat) \u2014 write this checkout's lane pin").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(
|
|
4729
|
+
(opts, cmd) => setLane({
|
|
4730
|
+
codeLane: opts.codeLane,
|
|
4731
|
+
designLane: opts.designLane,
|
|
4732
|
+
json: Boolean(opts.json) || Boolean(cmd.optsWithGlobals().json)
|
|
4733
|
+
})
|
|
4734
|
+
);
|
|
4735
|
+
skills.command("lane").description("Alias of `sechroom lane` (kept for back-compat) \u2014 show this checkout's lane pin").option("--json", "machine output").action((opts, cmd) => showLane(Boolean(opts.json) || Boolean(cmd.optsWithGlobals().json)));
|
|
4047
4736
|
skills.command("set-workflow").description("Set your per-operator workflow defaults (server-side; follows you across tenants)").option("--default-code-lane <id>", "personal default code lane (e.g. claude-code-chris)").option("--default-design-lane <id>", "personal default design lane (e.g. claude-design-chris)").option("--handover-recipient <id>", "your daily-handover counterparty (e.g. andy)").option("--json", "machine output").action(async (opts, cmd) => {
|
|
4048
4737
|
if (!opts.defaultCodeLane && !opts.defaultDesignLane && !opts.handoverRecipient)
|
|
4049
4738
|
fail("Provide at least one of --default-code-lane / --default-design-lane / --handover-recipient.");
|
|
@@ -4107,22 +4796,24 @@ Examples:
|
|
|
4107
4796
|
}
|
|
4108
4797
|
|
|
4109
4798
|
// src/commands/reset.ts
|
|
4110
|
-
import { homedir as
|
|
4111
|
-
import { join as
|
|
4112
|
-
import { existsSync as
|
|
4113
|
-
var
|
|
4114
|
-
var localSkillsDir = () =>
|
|
4115
|
-
var globalSkillsDir = () =>
|
|
4799
|
+
import { homedir as homedir4 } from "os";
|
|
4800
|
+
import { join as join13 } from "path";
|
|
4801
|
+
import { existsSync as existsSync11, readFileSync as readFileSync7, rmSync as rmSync3 } from "fs";
|
|
4802
|
+
var SKILLS_LOCK2 = ".sechroom-skills.json";
|
|
4803
|
+
var localSkillsDir = () => join13(process.cwd(), ".claude", "skills");
|
|
4804
|
+
var globalSkillsDir = () => join13(homedir4(), ".claude", "skills");
|
|
4805
|
+
var localAgentsDir = () => join13(process.cwd(), ".claude", "agents");
|
|
4806
|
+
var globalAgentsDir = () => join13(homedir4(), ".claude", "agents");
|
|
4116
4807
|
function removeMaterialisedSkills(dir) {
|
|
4117
4808
|
const removed = [];
|
|
4118
|
-
const lockPath =
|
|
4119
|
-
if (!
|
|
4809
|
+
const lockPath = join13(dir, SKILLS_LOCK2);
|
|
4810
|
+
if (!existsSync11(lockPath)) return removed;
|
|
4120
4811
|
try {
|
|
4121
4812
|
const lock = JSON.parse(readFileSync7(lockPath, "utf8"));
|
|
4122
4813
|
for (const entry of Object.values(lock)) {
|
|
4123
4814
|
for (const name of entry.skills ?? []) {
|
|
4124
|
-
const p =
|
|
4125
|
-
if (
|
|
4815
|
+
const p = join13(dir, name);
|
|
4816
|
+
if (existsSync11(p)) {
|
|
4126
4817
|
rmSync3(p, { recursive: true, force: true });
|
|
4127
4818
|
removed.push(p);
|
|
4128
4819
|
}
|
|
@@ -4167,28 +4858,30 @@ function registerReset(program2) {
|
|
|
4167
4858
|
}
|
|
4168
4859
|
}
|
|
4169
4860
|
const removed = [];
|
|
4170
|
-
const stateDir =
|
|
4171
|
-
if (
|
|
4861
|
+
const stateDir = join13(process.cwd(), ".sechroom");
|
|
4862
|
+
if (existsSync11(stateDir)) {
|
|
4172
4863
|
rmSync3(stateDir, { recursive: true, force: true });
|
|
4173
4864
|
removed.push(stateDir);
|
|
4174
4865
|
}
|
|
4175
|
-
const legacyCfg =
|
|
4176
|
-
if (
|
|
4866
|
+
const legacyCfg = join13(process.cwd(), ".sechroom.json");
|
|
4867
|
+
if (existsSync11(legacyCfg)) {
|
|
4177
4868
|
rmSync3(legacyCfg, { force: true });
|
|
4178
4869
|
removed.push(legacyCfg);
|
|
4179
4870
|
}
|
|
4180
|
-
const legacySem =
|
|
4181
|
-
if (
|
|
4871
|
+
const legacySem = join13(process.cwd(), ".sem");
|
|
4872
|
+
if (existsSync11(legacySem)) {
|
|
4182
4873
|
rmSync3(legacySem, { force: true });
|
|
4183
4874
|
removed.push(legacySem);
|
|
4184
4875
|
}
|
|
4185
4876
|
removed.push(...removeMaterialisedSkills(localSkillsDir()));
|
|
4877
|
+
removed.push(...removeMaterialisedSkills(localAgentsDir()));
|
|
4186
4878
|
if (global) {
|
|
4187
4879
|
const tok = clearToken();
|
|
4188
4880
|
if (tok) removed.push(tok);
|
|
4189
4881
|
const cfg = clearPersisted();
|
|
4190
4882
|
if (cfg) removed.push(cfg);
|
|
4191
4883
|
removed.push(...removeMaterialisedSkills(globalSkillsDir()));
|
|
4884
|
+
removed.push(...removeMaterialisedSkills(globalAgentsDir()));
|
|
4192
4885
|
}
|
|
4193
4886
|
if (json) return emit({ global, removed }, true);
|
|
4194
4887
|
if (removed.length === 0) {
|
|
@@ -4213,7 +4906,7 @@ function resolveVersion() {
|
|
|
4213
4906
|
}
|
|
4214
4907
|
}
|
|
4215
4908
|
var program = new Command();
|
|
4216
|
-
program.name("sechroom").description("Sechroom CLI \u2014 thin generated client over the Sechroom HTTP API. An agent/human surface alongside MCP.").version(resolveVersion()).option("--base-url <url>", "API base URL (overrides config / SECHROOM_BASE_URL)").option("--tenant <tenant>", "Tenant id (required by the API; overrides config / SECHROOM_TENANT)").option("--binding <name>", "Named workspace binding from .sechroom.json `workspaces` (overrides path auto-selection / SECHROOM_BINDING)").option("--json", "Emit compact JSON (for scripts and agents)", false);
|
|
4909
|
+
program.name("sechroom").description("Sechroom CLI \u2014 thin generated client over the Sechroom HTTP API. An agent/human surface alongside MCP.").version(resolveVersion()).option("--base-url <url>", "API base URL (overrides config / SECHROOM_BASE_URL)").option("--tenant <tenant>", "Tenant id (required by the API; overrides config / SECHROOM_TENANT)").option("--binding <name>", "Named workspace binding from .sechroom.json `workspaces` (overrides path auto-selection / SECHROOM_BINDING)").option("--claude-config-dir <dirs>", "Claude config dir(s), comma-separated (overrides CLAUDE_CONFIG_DIR / ~/.claude)").option("--codex-home <dir>", "Codex home (overrides CODEX_HOME / ~/.codex)").option("--json", "Emit compact JSON (for scripts and agents)", false);
|
|
4217
4910
|
program.addHelpText(
|
|
4218
4911
|
"after",
|
|
4219
4912
|
`
|
|
@@ -4317,15 +5010,18 @@ registerWorkspace(program);
|
|
|
4317
5010
|
registerProject(program);
|
|
4318
5011
|
registerFiling(program);
|
|
4319
5012
|
registerContinuity(program);
|
|
5013
|
+
registerCheckpoint(program);
|
|
4320
5014
|
registerHook(program);
|
|
4321
5015
|
registerId(program);
|
|
4322
5016
|
registerAccount(program);
|
|
4323
5017
|
registerChat(program);
|
|
4324
5018
|
registerInit(program);
|
|
4325
5019
|
registerSetup(program);
|
|
5020
|
+
registerNamespace(program);
|
|
4326
5021
|
registerOnboard(program);
|
|
4327
5022
|
registerSweep(program);
|
|
4328
5023
|
registerSkills(program);
|
|
5024
|
+
registerLane(program);
|
|
4329
5025
|
registerReset(program);
|
|
4330
5026
|
program.parseAsync().catch((err2) => {
|
|
4331
5027
|
process.stderr.write(`error: ${err2 instanceof Error ? err2.message : String(err2)}
|