@synovitec/trellis 0.0.0-stage → 0.6.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +393 -0
  2. package/LICENSE +190 -0
  3. package/README.md +206 -2
  4. package/SECURITY.md +104 -0
  5. package/bin/trellis.mjs +5 -0
  6. package/docs/attestation/v0.1.md +96 -0
  7. package/package.json +78 -4
  8. package/schema/trellis.config.schema.json +349 -0
  9. package/src/agents/adapters.mjs +155 -0
  10. package/src/agents/git.mjs +241 -0
  11. package/src/agents/glob.mjs +41 -0
  12. package/src/agents/hooks.mjs +780 -0
  13. package/src/agents/install-adapters.mjs +172 -0
  14. package/src/agents/install.mjs +302 -0
  15. package/src/agents/manifests.mjs +127 -0
  16. package/src/agents/rules.mjs +330 -0
  17. package/src/agents/shell.mjs +253 -0
  18. package/src/agents/state.mjs +233 -0
  19. package/src/agents/transcript.mjs +120 -0
  20. package/src/cli/main.mjs +191 -0
  21. package/src/commands/attest.mjs +207 -0
  22. package/src/commands/checkpoint-state.mjs +62 -0
  23. package/src/commands/doctor.mjs +98 -0
  24. package/src/commands/guide.mjs +62 -0
  25. package/src/commands/init.mjs +129 -0
  26. package/src/commands/key.mjs +31 -0
  27. package/src/commands/scan.mjs +51 -0
  28. package/src/commands/uninstall.mjs +36 -0
  29. package/src/commands/verify.mjs +509 -0
  30. package/src/core/abatty.mjs +25 -0
  31. package/src/core/agents-block.mjs +65 -0
  32. package/src/core/attestation.mjs +213 -0
  33. package/src/core/config.mjs +135 -0
  34. package/src/core/context.mjs +280 -0
  35. package/src/core/diff.mjs +189 -0
  36. package/src/core/evidence.mjs +167 -0
  37. package/src/core/mcp.mjs +51 -0
  38. package/src/index.mjs +11 -0
  39. package/src/mcp/client.mjs +164 -0
  40. package/src/mcp/launch.mjs +57 -0
  41. package/src/mcp/relay.mjs +111 -0
  42. package/src/mcp/server.mjs +210 -0
  43. package/src/mcp/trust.mjs +142 -0
  44. package/src/tools/registry.mjs +370 -0
  45. package/src/tools/roles.mjs +17 -0
  46. package/src/verify/deps.mjs +382 -0
  47. package/src/verify/gitleaks-default.toml +3211 -0
  48. package/src/verify/rules.mjs +163 -0
  49. package/src/verify/secrets.mjs +169 -0
  50. package/src/verify/test-commands.mjs +116 -0
@@ -0,0 +1,233 @@
1
+ // What a session looked like when it started, and the evidence collected since, inside the
2
+ // repository's git folder (never committed), signed with a key kept outside the repository.
3
+ //
4
+ // Two files per session. The snapshot (`<id>.json`) is written once at session start; only the
5
+ // Stop hook rewrites it, to count its blocks. The evidence (`<id>.events.jsonl`) is append-only,
6
+ // one signed line per event, so hooks running in parallel add to it without losing each other's
7
+ // lines. The agent runs as the same user, so nothing here is out of its reach: the signature
8
+ // makes an edit detectable, the deny rules and the shell guard make it a deliberate act, and a
9
+ // missing or altered file is itself a finding. Evidence is only ever positive (a check that
10
+ // happened), so removing a line can only hurt the agent that removes it.
11
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
12
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
13
+ import { homedir, tmpdir } from "node:os";
14
+ import { join, resolve } from "node:path";
15
+ import { git } from "./git.mjs";
16
+
17
+ /**
18
+ * @typedef {object} Ledger evidence, folded from the event log
19
+ * @property {Record<string, string>} browser file → its content hash at the last browser check
20
+ * @property {Record<string, string>} setup file → its content hash at the last clean AgentShield run
21
+ * @property {boolean} graph a code graph query returned an answer
22
+ * @property {boolean} schema a database schema lookup succeeded
23
+ * @property {string[]} lookups Context7 calls, as lowercased input
24
+ * @property {number} searches file searches
25
+ * @property {boolean} onPage the browser's last navigation was to the origin
26
+ * @property {boolean} nudged the graph reminder was given
27
+ */
28
+
29
+ /**
30
+ * @typedef {object} SessionState
31
+ * @property {number} v
32
+ * @property {number} startedAt
33
+ * @property {string | null} head
34
+ * @property {string[]} [tips] where local branches, HEAD and the stash pointed at the start
35
+ * @property {string[]} [remotes] where the remote-tracking refs pointed at the start
36
+ * @property {number} [reflog] HEAD's reflog length at the start
37
+ * @property {Record<string, string>} dirty
38
+ * @property {string[]} flagged files git was told to ignore (assume-unchanged, skip-worktree)
39
+ * @property {string | null} exclude .git/info/exclude as it was
40
+ * @property {string[]} [ignored] what git ignored and did not track at the start
41
+ * @property {string | null} [ignoreRules] a fingerprint of the ignore rules at the start
42
+ * @property {number} stashes
43
+ * @property {string | null} configText
44
+ * @property {string | null} [self] a hash of Trellis's own code, as installed at the start
45
+ * @property {number} blocks
46
+ * @property {Ledger} ledger
47
+ */
48
+
49
+ /** Session files older than this are removed when a new session starts. */
50
+ const KEEP_MS = 30 * 24 * 3600 * 1000;
51
+
52
+ /** @returns {Ledger} */
53
+ export const emptyLedger = () => ({ browser: {}, setup: {}, graph: false, schema: false, lookups: [], searches: 0, onPage: false, nudged: false });
54
+
55
+ /**
56
+ * A test override (`TRELLIS_STATE_DIR`, `TRELLIS_KEY_FILE`, `TRELLIS_AGENTSHIELD`), honoured only
57
+ * under Node's test runner and outside Claude Code's project (`CLAUDE_PROJECT_DIR`), where a
58
+ * settings file's `env` could set it. An agent's own command could set it too: the shell guard
59
+ * refuses a command that does (hooks.mjs).
60
+ * @param {NodeJS.ProcessEnv} env
61
+ * @param {"TRELLIS_STATE_DIR" | "TRELLIS_KEY_FILE" | "TRELLIS_AGENTSHIELD" | "TRELLIS_GRAPHIFY"} name
62
+ * @returns {string | undefined}
63
+ */
64
+ export const testOverride = (env, name) => (env.CLAUDE_PROJECT_DIR || !underTest(env) ? undefined : env[name] || undefined);
65
+
66
+ /** Whether Node's test runner started this process (or its parent, whose environment it inherits). @param {NodeJS.ProcessEnv} env */
67
+ const underTest = (env) => Boolean(process.env.NODE_TEST_CONTEXT || env.NODE_TEST_CONTEXT);
68
+
69
+ /**
70
+ * Where a repository's session files live: its git folder, else the temp folder.
71
+ * @param {string} root
72
+ * @param {NodeJS.ProcessEnv} env
73
+ */
74
+ export function stateDir(root, env = process.env) {
75
+ const override = testOverride(env, "TRELLIS_STATE_DIR");
76
+ if (override) return override;
77
+ const common = git(root, ["rev-parse", "--git-common-dir"])?.trim();
78
+ return common ? join(resolve(root, common), "trellis", "sessions") : join(tmpdir(), "trellis-sessions");
79
+ }
80
+
81
+ /** @param {NodeJS.ProcessEnv} env */
82
+ export const keyFile = (env = process.env) => {
83
+ const override = testOverride(env, "TRELLIS_KEY_FILE");
84
+ if (override) return override;
85
+ // A test that forgot its key would sign with the developer's own: fail it instead.
86
+ if (underTest(env) && !env.CLAUDE_PROJECT_DIR) throw new Error("a test reached the real signing key (~/.trellis): set TRELLIS_KEY_FILE");
87
+ return join(homedir(), ".trellis", "key");
88
+ };
89
+
90
+ /** The signing key, created once per user, readable by the user only. @param {NodeJS.ProcessEnv} env */
91
+ function key(env) {
92
+ const file = keyFile(env);
93
+ if (!existsSync(file)) {
94
+ mkdirSync(join(file, ".."), { recursive: true });
95
+ writeFileSync(file, randomBytes(32).toString("hex"), { mode: 0o600 });
96
+ try {
97
+ chmodSync(file, 0o600);
98
+ } catch {
99
+ /* Windows keeps its own ACLs */
100
+ }
101
+ }
102
+ return readFileSync(file, "utf8").trim();
103
+ }
104
+
105
+ /** @param {string} body @param {NodeJS.ProcessEnv} env */
106
+ export const sign = (body, env) => createHmac("sha256", key(env)).update(body).digest("hex");
107
+
108
+ /** @param {unknown} sig @param {string} body @param {NodeJS.ProcessEnv} env */
109
+ export function valid(sig, body, env) {
110
+ const expected = Buffer.from(sign(body, env), "hex");
111
+ const got = Buffer.from(String(sig ?? ""), "hex");
112
+ return got.length === expected.length && timingSafeEqual(got, expected);
113
+ }
114
+
115
+ /** @param {string} sessionId */
116
+ const baseName = (sessionId) => sessionId.replace(/[^A-Za-z0-9_.-]/g, "_");
117
+
118
+ /**
119
+ * The evidence, folded in the order it was recorded.
120
+ * @param {Array<Record<string, any>>} events
121
+ * @returns {Ledger}
122
+ */
123
+ export function fold(events) {
124
+ const l = emptyLedger();
125
+ for (const e of events) {
126
+ if (e.k === "graph") l.graph = true;
127
+ else if (e.k === "schema") l.schema = true;
128
+ else if (e.k === "search") l.searches++;
129
+ else if (e.k === "lookup") l.lookups.push(String(e.q ?? ""));
130
+ else if (e.k === "nav") l.onPage = !!e.on;
131
+ else if (e.k === "browser") Object.assign(l.browser, e.files ?? {});
132
+ else if (e.k === "setup") Object.assign(l.setup, e.files ?? {});
133
+ else if (e.k === "nudged") l.nudged = true;
134
+ }
135
+ return l;
136
+ }
137
+
138
+ /**
139
+ * @param {string} root
140
+ * @param {string | undefined} sessionId
141
+ * @param {NodeJS.ProcessEnv} [env]
142
+ * @returns {{ state: SessionState | null, status: "ok" | "missing" | "tampered" | "no-session" }}
143
+ */
144
+ export function loadState(root, sessionId, env = process.env) {
145
+ if (!sessionId) return { state: null, status: "no-session" };
146
+ const dir = stateDir(root, env);
147
+ let raw;
148
+ try {
149
+ raw = JSON.parse(readFileSync(join(dir, `${baseName(sessionId)}.json`), "utf8"));
150
+ } catch {
151
+ return { state: null, status: "missing" };
152
+ }
153
+ if (!valid(raw?.sig, JSON.stringify(raw?.state ?? null), env)) return { state: null, status: "tampered" };
154
+ /** @type {Array<Record<string, any>>} */
155
+ const events = [];
156
+ let text = "";
157
+ try {
158
+ text = readFileSync(join(dir, `${baseName(sessionId)}.events.jsonl`), "utf8");
159
+ } catch {
160
+ /* no evidence yet */
161
+ }
162
+ for (const line of text.split("\n")) {
163
+ if (!line.trim()) continue;
164
+ let rec;
165
+ try {
166
+ rec = JSON.parse(line);
167
+ } catch {
168
+ // A line a parallel hook is still appending can be read half-written; the next read has it whole.
169
+ continue;
170
+ }
171
+ if (!valid(rec?.sig, JSON.stringify(rec?.e ?? null), env)) return { state: null, status: "tampered" };
172
+ events.push(rec.e);
173
+ }
174
+ return { state: { ...raw.state, ledger: fold(events) }, status: "ok" };
175
+ }
176
+
177
+ /**
178
+ * Writes the snapshot (once at session start, and when Stop counts a block). The ledger is not
179
+ * part of it: evidence goes through `record`.
180
+ * @param {string} root
181
+ * @param {string | undefined} sessionId
182
+ * @param {SessionState} state
183
+ * @param {NodeJS.ProcessEnv} [env]
184
+ */
185
+ export function saveState(root, sessionId, state, env = process.env) {
186
+ if (!sessionId) return;
187
+ const dir = stateDir(root, env);
188
+ mkdirSync(dir, { recursive: true });
189
+ const { ledger, ...snapshot } = state;
190
+ void ledger;
191
+ const file = join(dir, `${baseName(sessionId)}.json`);
192
+ // Written beside, then renamed over: a reader never sees half a snapshot.
193
+ const tmp = `${file}.${process.pid}.tmp`;
194
+ writeFileSync(tmp, JSON.stringify({ state: snapshot, sig: sign(JSON.stringify(snapshot), env) }));
195
+ renameSync(tmp, file);
196
+ }
197
+
198
+ /**
199
+ * Appends one signed event to the session's evidence.
200
+ * @param {string} root
201
+ * @param {string | undefined} sessionId
202
+ * @param {Record<string, any>} event
203
+ * @param {NodeJS.ProcessEnv} [env]
204
+ */
205
+ export function record(root, sessionId, event, env = process.env) {
206
+ if (!sessionId) return;
207
+ const dir = stateDir(root, env);
208
+ mkdirSync(dir, { recursive: true });
209
+ appendFileSync(join(dir, `${baseName(sessionId)}.events.jsonl`), `${JSON.stringify({ e: event, sig: sign(JSON.stringify(event), env) })}\n`);
210
+ }
211
+
212
+ /**
213
+ * Removes session files older than 30 days.
214
+ * @param {string} root
215
+ * @param {NodeJS.ProcessEnv} [env]
216
+ * @param {number} [now]
217
+ */
218
+ export function prune(root, env = process.env, now = Date.now()) {
219
+ const dir = stateDir(root, env);
220
+ let names = [];
221
+ try {
222
+ names = readdirSync(dir);
223
+ } catch {
224
+ return;
225
+ }
226
+ for (const n of names) {
227
+ try {
228
+ if (now - statSync(join(dir, n)).mtimeMs > KEEP_MS) unlinkSync(join(dir, n));
229
+ } catch {
230
+ /* in use or gone */
231
+ }
232
+ }
233
+ }
@@ -0,0 +1,120 @@
1
+ // What a coding agent actually did in a session, read from its transcript: every tool call, with
2
+ // its time. Which files changed is read from git (git.mjs); the transcript's own edit calls are
3
+ // the fallback outside a git repository.
4
+ import { readFileSync, readdirSync } from "node:fs";
5
+ import { basename, dirname, join } from "node:path";
6
+
7
+ /**
8
+ * @typedef {{ i: number, t: number, id?: string, name: string, input: Record<string, any> }} ToolUse
9
+ * `t` is the call's time (ms since epoch), 0 when the transcript has none.
10
+ */
11
+
12
+ /** Tools whose call writes a file, and the input field that names it. */
13
+ const EDIT_TOOLS = /** @type {Record<string, string>} */ ({ Edit: "file_path", Write: "file_path", MultiEdit: "file_path", NotebookEdit: "notebook_path" });
14
+ /** Symbol-level editors over MCP that take a repository-relative path. */
15
+ const MCP_EDIT = /^mcp__[^_]+(?:_[^_]+)*__(replace|insert|create|rename|safe_delete|write|edit)/;
16
+
17
+ /**
18
+ * Every tool call of a session, its subagents' included, in time order. A subagent writes its
19
+ * own transcript (`<session>/subagents/agent-*.jsonl` beside the main one): the work it does is
20
+ * the session's work, so a browser check or a scan it ran counts, and so does an edit.
21
+ * Unreadable lines are skipped: a transcript is appended while the session runs.
22
+ * @param {string | null | undefined} path
23
+ * @returns {{ uses: ToolUse[], readable: boolean }}
24
+ */
25
+ export function readSession(path) {
26
+ if (!path) return { uses: [], readable: false };
27
+ let text;
28
+ try {
29
+ text = readFileSync(path, "utf8");
30
+ } catch {
31
+ return { uses: [], readable: false };
32
+ }
33
+ const all = parseToolUses(text);
34
+ const subDir = join(dirname(path), basename(path).replace(/\.jsonl$/, ""), "subagents");
35
+ let files = [];
36
+ try {
37
+ files = readdirSync(subDir).filter((f) => f.endsWith(".jsonl"));
38
+ } catch {
39
+ /* no subagent ran */
40
+ }
41
+ for (const f of files) {
42
+ try {
43
+ all.push(...parseToolUses(readFileSync(join(subDir, f), "utf8")));
44
+ } catch {
45
+ /* a subagent file being written */
46
+ }
47
+ }
48
+ // Stable: calls without a time keep their order relative to each other.
49
+ const sorted = all.map((u, k) => ({ u, k })).sort((a, b) => a.u.t - b.u.t || a.k - b.k);
50
+ return { uses: sorted.map(({ u }, i) => ({ ...u, i })), readable: true };
51
+ }
52
+
53
+ /** @param {string | null | undefined} path */
54
+ export function readToolUses(path) {
55
+ return readSession(path).uses;
56
+ }
57
+
58
+ /** @param {string} text @returns {ToolUse[]} */
59
+ export function parseToolUses(text) {
60
+ /** @type {ToolUse[]} */
61
+ const out = [];
62
+ for (const line of text.split(/\r?\n/)) {
63
+ if (!line.includes('"tool_use"')) continue;
64
+ let entry;
65
+ try {
66
+ entry = JSON.parse(line);
67
+ } catch {
68
+ continue;
69
+ }
70
+ const content = entry?.message?.role === "assistant" ? entry.message.content : null;
71
+ if (!Array.isArray(content)) continue;
72
+ const t = Date.parse(entry.timestamp ?? "") || 0;
73
+ for (const c of content) {
74
+ if (c?.type === "tool_use" && typeof c.name === "string") out.push({ i: out.length, t, id: typeof c.id === "string" ? c.id : undefined, name: c.name, input: c.input ?? {} });
75
+ }
76
+ }
77
+ return out;
78
+ }
79
+
80
+ /**
81
+ * The repository-relative path a tool call wrote, or null when it wrote nothing (or outside the
82
+ * repository). Edits made through a shell command are not seen: that is a stated limit.
83
+ * @param {ToolUse} use
84
+ * @param {string} root
85
+ * @param {NodeJS.Platform} platform
86
+ */
87
+ export function editedPath(use, root, platform) {
88
+ const field = EDIT_TOOLS[use.name];
89
+ if (field) return relativeTo(use.input[field], root, platform);
90
+ if (!MCP_EDIT.test(use.name)) return null;
91
+ const p = use.input.relative_path ?? use.input.path ?? use.input.file_path;
92
+ return typeof p === "string" ? relativeTo(p, root, platform) : null;
93
+ }
94
+
95
+ /**
96
+ * @param {unknown} file
97
+ * @param {string} root
98
+ * @param {NodeJS.Platform} platform
99
+ */
100
+ export function relativeTo(file, root, platform) {
101
+ if (typeof file !== "string" || !file) return null;
102
+ const norm = (/** @type {string} */ p) => {
103
+ let s = p.replace(/\\/g, "/").replace(/\/+$/, "");
104
+ // Git Bash spells C:\x as /c/x.
105
+ if (platform === "win32") s = s.replace(/^\/([a-zA-Z])\//, "$1:/");
106
+ return s;
107
+ };
108
+ const f = norm(file);
109
+ const r = norm(root);
110
+ // Windows and macOS file systems are case-insensitive by default.
111
+ const same = platform === "win32" || platform === "darwin" ?(/** @type {string} */ a, /** @type {string} */ b) => a.toLowerCase() === b.toLowerCase() : (/** @type {string} */ a, /** @type {string} */ b) => a === b;
112
+ if (!/^([a-zA-Z]:)?\//.test(f)) return f.replace(/^\.\//, "");
113
+ if (f.length > r.length && same(f.slice(0, r.length), r) && f[r.length] === "/") return f.slice(r.length + 1);
114
+ return null;
115
+ }
116
+
117
+ /** The shell command of a call, or "". @param {ToolUse} use */
118
+ export function commandOf(use) {
119
+ return use.name === "Bash" || use.name === "PowerShell" ? String(use.input.command ?? "") : "";
120
+ }
@@ -0,0 +1,191 @@
1
+ // Argument parsing and dispatch; no tool logic lives here.
2
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ import { createContext } from "../core/context.mjs";
5
+ import { PASSTHROUGH, runAbatty } from "../core/abatty.mjs";
6
+ import { doctor } from "../commands/doctor.mjs";
7
+ import { init } from "../commands/init.mjs";
8
+ import { guide } from "../commands/guide.mjs";
9
+ import { scan } from "../commands/scan.mjs";
10
+ import { TOOLS } from "../tools/registry.mjs";
11
+ import { ROLES } from "../tools/roles.mjs";
12
+ import { handleHook, hookFailure, hookRoot, EVENTS } from "../agents/hooks.mjs";
13
+ import { uninstall } from "../commands/uninstall.mjs";
14
+ import { parseJson } from "../core/context.mjs";
15
+ import { approve, checkTests, commitCheckpoint, escalate, evidenceReset, verify, verifyCi } from "../commands/verify.mjs";
16
+ import { keyCommand } from "../commands/key.mjs";
17
+ import { agentHook } from "../agents/adapters.mjs";
18
+ import { serveMcp } from "../mcp/server.mjs";
19
+ import { trustCommand } from "../mcp/trust.mjs";
20
+
21
+ /** A hook's JSON input. Nothing to read from a terminal: an interactive run has no event. */
22
+ function readStdin() {
23
+ if (process.stdin.isTTY) return "";
24
+ try {
25
+ return readFileSync(0, "utf8");
26
+ } catch {
27
+ return "";
28
+ }
29
+ }
30
+
31
+ const HELP = `trellis - the frame a repository grows on
32
+
33
+ trellis init [--dry-run] [--agents[=cursor,codex,copilot]]
34
+ write trellis.config.json, the MCP servers and the AGENTS.md block;
35
+ --agents also the Claude Code hooks and a pre-commit secret scan
36
+ trellis hook <${EVENTS.join("|")}>
37
+ what the agent hooks run (reads the hook's JSON on stdin)
38
+ trellis uninstall [--dry-run] take out the agent hooks, the pre-commit hook and the AGENTS.md block
39
+ trellis doctor [--json] [--strict] [--all]
40
+ every enabled tool: installed? actually wired? what to do next
41
+ trellis guide [tool|role] which tool answers which question, and how to use it here
42
+ trellis tools every tool Trellis knows, by role
43
+ trellis scan [--history] [--only a,b] [--allow-missing]
44
+ run the enabled scanners (Gitleaks, OSV-Scanner, AgentShield)
45
+ trellis verify the staged change against the rules (the pre-commit checkpoint)
46
+ trellis verify --ci [--base <rev>] [--head <rev>] [--summary <file>] [--checksums <file>]
47
+ every commit after base: its attestation checked, the checks re-run here
48
+ trellis evidence reset a person sets an unusable evidence log aside and starts a new one
49
+ trellis key [--add] this developer's public key; --add commits it under .trellis/keys/
50
+ trellis check tests -- <cmd> run the tests and record what they ran on
51
+ trellis escalate "<why>" an agent stops and hands the change to a person
52
+ trellis approve a person approves the change as it is now (in a terminal)
53
+ trellis checkpoint [--merge | --rewritten <how>]
54
+ after a commit, merge or rewrite: attest it (the git hooks run it)
55
+ trellis mcp the MCP server: the checks as tools for any agent, recorded as evidence
56
+ trellis mcp trust a person trusts the .mcp.json servers the gateway may start (in a terminal)
57
+ trellis ${PASSTHROUGH.join("|")} [...]
58
+ the guard engine: abatty's command, run as it is
59
+
60
+ Roles: ${ROLES.map((r) => r.id).join(", ")}. Exit codes: 0 ok, 1 something to fix, 2 bad usage.`;
61
+
62
+ /**
63
+ * @typedef {object} IO
64
+ * @property {string} [cwd]
65
+ * @property {(...lines: string[]) => void} [out]
66
+ * @property {string} [stdin] a hook's input; read from the process when absent
67
+ * @property {(text: string) => void} [stdout] raw output, for hooks
68
+ * @property {(text: string) => void} [stderr]
69
+ * @property {NodeJS.ProcessEnv} [env]
70
+ */
71
+
72
+ /**
73
+ * @param {string[]} argv
74
+ * @param {IO} [io]
75
+ * @returns {number | Promise<number>} exit code (a promise for the commands that use the network)
76
+ */
77
+ export function main(argv, io = {}) {
78
+ const root = io.cwd ?? process.cwd();
79
+ const out = io.out ?? ((/** @type {string[]} */ ...lines) => lines.forEach((l) => console.log(l)));
80
+ const [cmd, ...rest] = argv;
81
+ const flag = (/** @type {string} */ name) => rest.includes(`--${name}`);
82
+ const opt = (/** @type {string} */ name) => {
83
+ const i = rest.indexOf(`--${name}`);
84
+ return i === -1 ? undefined : rest[i + 1];
85
+ };
86
+ const ctx = createContext({ root });
87
+ const write = (/** @type {string} */ rel, /** @type {string} */ text, /** @type {boolean | undefined} */ executable) => {
88
+ // Absolute when git keeps its hooks outside the work tree (worktrees, a global hooksPath).
89
+ const full = resolve(root, rel);
90
+ mkdirSync(dirname(full), { recursive: true });
91
+ writeFileSync(full, text, executable ? { mode: 0o755 } : undefined);
92
+ };
93
+ try {
94
+ switch (cmd) {
95
+ case "init":
96
+ {
97
+ const list = rest.find((x) => x.startsWith("--agents="));
98
+ const agentList = list ? list.slice("--agents=".length).split(",").map((x) => x.trim()).filter(Boolean) : undefined;
99
+ return init(ctx, { dryRun: flag("dry-run"), agents: flag("agents") || Boolean(list), agentList }, out, write, io.env);
100
+ }
101
+ case "hook": {
102
+ const input = parseJson(io.stdin ?? readStdin()) ?? {};
103
+ const env = io.env ?? process.env;
104
+ const a = rest.indexOf("--agent");
105
+ const agent = a === -1 ? null : String(rest[a + 1] ?? "");
106
+ let r;
107
+ try {
108
+ const hctx = createContext({ root: hookRoot(input, env, root) });
109
+ r = agent ? agentHook(agent, rest[0] ?? "", input, hctx, env) : handleHook(rest[0] ?? "", input, hctx, { env });
110
+ } catch (err) {
111
+ // A crash in a hook must not read as a pass. Claude Code treats any exit but 2 as one; for
112
+ // the other agents a crashed pre-tool hook exits 2, a deny for Codex and Copilot, and
113
+ // Cursor's entry is written with failClosed.
114
+ r = agent
115
+ ? { code: rest[0] === "pre-tool" ? 2 : 0, stderr: `[trellis] hook ${rest[0]} failed: ${err instanceof Error ? err.message : String(err)}\n` }
116
+ : hookFailure(rest[0] ?? "", input, err);
117
+ }
118
+ if (r.stdout) (io.stdout ?? ((/** @type {string} */ t) => process.stdout.write(t)))(r.stdout);
119
+ if (r.stderr) (io.stderr ?? ((/** @type {string} */ t) => process.stderr.write(t)))(r.stderr);
120
+ return r.code;
121
+ }
122
+ case "uninstall":
123
+ return uninstall(ctx, { dryRun: flag("dry-run") }, out, write);
124
+ case "doctor":
125
+ return doctor(ctx, { json: flag("json"), strict: flag("strict"), all: flag("all"), env: io.env }, out);
126
+ case "guide":
127
+ return guide(ctx, rest.find((a) => !a.startsWith("--")), out);
128
+ case "tools":
129
+ for (const role of ROLES) {
130
+ out(role.title);
131
+ for (const t of TOOLS.filter((x) => x.role === role.id)) out(` ${t.id.padEnd(16)} ${t.name}${t.ai ? " (agent)" : ""}`);
132
+ }
133
+ return 0;
134
+ case "scan": {
135
+ const i = rest.indexOf("--only");
136
+ const only = i === -1 ? [] : String(rest[i + 1] ?? "").split(",").filter(Boolean);
137
+ return scan(ctx, { history: flag("history"), allowMissing: flag("allow-missing"), only }, out);
138
+ }
139
+ case "verify": {
140
+ if (!flag("ci")) return verify(ctx, out, { env: io.env });
141
+ return verifyCi(ctx, out, { env: io.env, base: opt("base"), head: opt("head"), summary: opt("summary"), checksums: opt("checksums") });
142
+ }
143
+ case "evidence":
144
+ if (rest[0] !== "reset") {
145
+ out("Usage: trellis evidence reset (a person sets an unusable evidence log aside)");
146
+ return 2;
147
+ }
148
+ return evidenceReset(ctx, out, { env: io.env });
149
+ case "key":
150
+ return keyCommand(ctx, { add: flag("add") }, out, io.env ?? process.env);
151
+ case "check": {
152
+ if (rest[0] !== "tests") {
153
+ out('Usage: trellis check tests -- <test command>');
154
+ return 2;
155
+ }
156
+ const dash = rest.indexOf("--");
157
+ return checkTests(ctx, dash === -1 ? [] : rest.slice(dash + 1), out, { env: io.env });
158
+ }
159
+ case "escalate":
160
+ return escalate(ctx, rest.join(" "), out, { env: io.env });
161
+ case "approve":
162
+ return approve(ctx, out, { env: io.env });
163
+ case "checkpoint":
164
+ return commitCheckpoint(ctx, out, { env: io.env, merge: flag("merge"), rewritten: opt("rewritten"), input: opt("rewritten") ? (io.stdin ?? readStdin()) : undefined });
165
+ case "mcp": {
166
+ // Launched by an agent from wherever it stands: the repository is the project it names,
167
+ // else the nearest folder above holding the config.
168
+ const env = io.env ?? process.env;
169
+ if (rest[0] === "trust") return trustCommand(ctx, out, { env });
170
+ return serveMcp(createContext({ root: hookRoot({ cwd: root }, env, root) }), env);
171
+ }
172
+ case "--version":
173
+ case "-v":
174
+ out(JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version);
175
+ return 0;
176
+ case undefined:
177
+ case "help":
178
+ case "--help":
179
+ case "-h":
180
+ out(HELP);
181
+ return 0;
182
+ default:
183
+ if (PASSTHROUGH.includes(cmd)) return runAbatty(root, [cmd, ...rest], out);
184
+ out(`Unknown command "${cmd}".`, "", HELP);
185
+ return 2;
186
+ }
187
+ } catch (err) {
188
+ out(`trellis ${cmd}: ${err instanceof Error ? err.message : String(err)}`);
189
+ return 1;
190
+ }
191
+ }