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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +406 -0
  2. package/LICENSE +190 -0
  3. package/README.md +206 -2
  4. package/SECURITY.md +105 -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 +208 -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 +131 -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 +510 -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 +177 -0
  49. package/src/verify/secrets.mjs +169 -0
  50. package/src/verify/test-commands.mjs +116 -0
@@ -0,0 +1,189 @@
1
+ // A change as content: the files that differ between two trees, with the blob each one has on
2
+ // either side. This is what every rule reads, whoever made the change and however it arrived
3
+ // (commit, rebase, fetch, side clone): two trees are compared, never a history walked.
4
+ //
5
+ // Every git call here either answers or throws. An empty list means "nothing changed", never
6
+ // "git could not tell": a check that cannot run is a finding, not a pass.
7
+ import { spawnSync } from "node:child_process";
8
+ import { copyFileSync, existsSync, mkdtempSync, rmSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join, resolve } from "node:path";
11
+
12
+ /** A git command that failed. The caller reports it; it never reads as "no change". */
13
+ export class GitError extends Error {
14
+ /** @param {string[]} args @param {string} detail */
15
+ constructor(args, detail) {
16
+ super(`git ${args.slice(0, 3).join(" ")} failed: ${detail}`);
17
+ this.name = "GitError";
18
+ }
19
+ }
20
+
21
+ /**
22
+ * @typedef {object} Change
23
+ * @property {string} path
24
+ * @property {"A" | "M" | "D" | "T"} status added, modified, deleted, type changed
25
+ * @property {string | null} oldBlob the blob before, null when added
26
+ * @property {string | null} newBlob the blob after, null when deleted
27
+ */
28
+
29
+ /**
30
+ * @param {string} root
31
+ * @param {string[]} args
32
+ * @param {string} [input] fed on stdin, so a long list never becomes a long command line
33
+ * @returns {string}
34
+ */
35
+ function git(root, args, input) {
36
+ const r = spawnSync("git", args, { cwd: root, encoding: "utf8", input, maxBuffer: 256 * 1024 * 1024, windowsHide: true });
37
+ if (r.error) throw new GitError(args, r.error.message);
38
+ if (r.status !== 0) throw new GitError(args, (r.stderr || `exit ${r.status}`).trim());
39
+ return r.stdout;
40
+ }
41
+
42
+ /** The empty tree, in this repository's object format (SHA-1 or SHA-256). @param {string} root */
43
+ const emptyTree = (root) => git(root, ["hash-object", "-t", "tree", "--stdin"], "").trim();
44
+
45
+ /** @param {string} sha */
46
+ const zero = (sha) => /^0+$/.test(sha);
47
+
48
+ /**
49
+ * Parses `--raw -z` output: `:<mode> <mode> <old> <new> <status>\0<path>\0`, per file.
50
+ * @param {string} out
51
+ * @returns {Change[]}
52
+ */
53
+ function parseRaw(out) {
54
+ const parts = out.split("\0");
55
+ /** @type {Change[]} */
56
+ const changes = [];
57
+ for (let i = 0; i + 1 < parts.length; i += 2) {
58
+ const head = parts[i].replace(/^\n/, "");
59
+ if (!head.startsWith(":")) continue;
60
+ const [, , oldSha, newSha, status] = head.slice(1).split(" ");
61
+ const s = /** @type {Change["status"]} */ (status[0]);
62
+ changes.push({
63
+ path: parts[i + 1],
64
+ status: s,
65
+ oldBlob: s === "A" || zero(oldSha) ? null : oldSha,
66
+ newBlob: s === "D" ? null : newSha,
67
+ });
68
+ }
69
+ return changes;
70
+ }
71
+
72
+ const RAW = ["--raw", "-z", "--no-renames", "--no-abbrev", "--no-ext-diff", "--no-textconv"];
73
+
74
+ /**
75
+ * A tree id for a revision or tree. Throws when it names nothing.
76
+ * @param {string} root
77
+ * @param {string} rev
78
+ */
79
+ export const treeOf = (root, rev) => git(root, ["rev-parse", "--verify", "--quiet", `${rev}^{tree}`]).trim();
80
+
81
+ /**
82
+ * What the next commit would contain: the index against HEAD (against nothing before the first
83
+ * commit).
84
+ * @param {string} root
85
+ * @returns {Change[]}
86
+ */
87
+ export function stagedDiff(root) {
88
+ let base;
89
+ try {
90
+ base = treeOf(root, "HEAD");
91
+ } catch (e) {
92
+ // No HEAD yet is the only failure that is not an error: a repository before its first commit.
93
+ if (git(root, ["rev-parse", "--is-inside-work-tree"]).trim() !== "true") throw e;
94
+ base = emptyTree(root);
95
+ }
96
+ const changes = parseRaw(git(root, ["diff-index", "--cached", ...RAW, base, "--"]));
97
+ // A merge being concluded: only what it adds beyond both sides, as commitDiff judges it.
98
+ const other = spawnSync("git", ["rev-parse", "-q", "--verify", "MERGE_HEAD^{commit}"], { cwd: root, encoding: "utf8", windowsHide: true });
99
+ if (other.status !== 0) return changes;
100
+ return ownChanges(changes, [parseRaw(git(root, ["diff-index", "--cached", ...RAW, other.stdout.trim(), "--"]))]);
101
+ }
102
+
103
+ /**
104
+ * The part of a merge that is the merge's own: the files that differ from every parent (a
105
+ * resolved conflict, an edit made in the merge). What each side brought is judged on its own
106
+ * commits, which CI checks one by one.
107
+ * @param {Change[]} first the change against the first parent
108
+ * @param {Change[][]} others the change against each other parent
109
+ */
110
+ const ownChanges = (first, others) => first.filter((c) => others.every((o) => o.some((x) => x.path === c.path)));
111
+
112
+ /**
113
+ * The change one commit makes: against its first parent, or against nothing for a root commit.
114
+ * A merge is judged by its own part only (`ownChanges`).
115
+ * @param {string} root
116
+ * @param {string} commit
117
+ * @returns {Change[]}
118
+ */
119
+ export function commitDiff(root, commit) {
120
+ const sha = git(root, ["rev-parse", "--verify", "--quiet", `${commit}^{commit}`]).trim();
121
+ const [, firstParent, ...others] = git(root, ["rev-list", "--parents", "-n", "1", sha]).trim().split(" ");
122
+ const against = (/** @type {string} */ base) => parseRaw(git(root, ["diff-tree", "-r", ...RAW, base, sha, "--"]));
123
+ const changes = against(firstParent ?? emptyTree(root));
124
+ return others.length ? ownChanges(changes, others.map(against)) : changes;
125
+ }
126
+
127
+ /**
128
+ * The change from one tree to another, whatever lies between them.
129
+ * @param {string} root @param {string} from @param {string} to
130
+ * @returns {Change[]}
131
+ */
132
+ export const treeDiff = (root, from, to) => parseRaw(git(root, ["diff-tree", "-r", ...RAW, from, to, "--"]));
133
+
134
+ /**
135
+ * The tree the work tree holds now, as `git add -A` and a commit would make it (ignored files
136
+ * left out), written through a copy of the index so the real one is untouched and files git has
137
+ * already hashed are not hashed again.
138
+ * @param {string} root
139
+ * @returns {string}
140
+ */
141
+ export function worktreeTree(root) {
142
+ const dir = mkdtempSync(join(tmpdir(), "trellis-tree-"));
143
+ try {
144
+ const index = join(dir, "index");
145
+ const real = resolve(root, git(root, ["rev-parse", "--git-path", "index"]).trim());
146
+ if (existsSync(real)) copyFileSync(real, index);
147
+ const env = { ...process.env, GIT_INDEX_FILE: index };
148
+ const run = (/** @type {string[]} */ args) => {
149
+ const r = spawnSync("git", args, { cwd: root, encoding: "utf8", env, maxBuffer: 64 * 1024 * 1024, windowsHide: true });
150
+ if (r.status !== 0) throw new GitError(args, (r.stderr || `exit ${r.status}`).trim());
151
+ return r.stdout.trim();
152
+ };
153
+ if (!existsSync(real)) run(["read-tree", "--empty"]);
154
+ run(["add", "-A", "--", "."]);
155
+ return run(["write-tree"]);
156
+ } finally {
157
+ rmSync(dir, { recursive: true, force: true });
158
+ }
159
+ }
160
+
161
+ /** A commit's parents. @param {string} root @param {string} commit */
162
+ export const parentsOf = (root, commit) => git(root, ["rev-list", "--parents", "-n", "1", commit]).trim().split(" ").slice(1);
163
+
164
+ /**
165
+ * The work tree against a recorded tree: tracked files as they are on disk, and untracked files
166
+ * git does not ignore. A file an ignore rule hides is not part of the change: it is not
167
+ * committed, so the commit checkpoint and CI never ship it either.
168
+ * @param {string} root
169
+ * @param {string} base a tree id (as `treeOf` gives it)
170
+ * @returns {Change[]}
171
+ */
172
+ export function worktreeDiff(root, base) {
173
+ if (git(root, ["cat-file", "-t", base]).trim() !== "tree") throw new GitError(["cat-file", "-t", base], "not a tree");
174
+ const tracked = parseRaw(git(root, ["diff-index", ...RAW, base, "--"]));
175
+ const known = new Set(tracked.map((c) => c.path));
176
+ const untracked = git(root, ["ls-files", "--others", "--exclude-standard", "-z"])
177
+ .split("\0")
178
+ .filter((p) => p && !known.has(p))
179
+ .map((path) => /** @type {Change} */ ({ path, status: "A", oldBlob: null, newBlob: null }));
180
+ const changes = [...tracked, ...untracked];
181
+ // git leaves a work-tree file it has not hashed as 000...; hash those as `git add` would.
182
+ const unhashed = changes.filter((c) => c.status !== "D" && (c.newBlob == null || zero(c.newBlob)));
183
+ if (unhashed.length) {
184
+ const ids = git(root, ["hash-object", "--stdin-paths"], `${unhashed.map((c) => c.path).join("\n")}\n`).trim().split(/\r?\n/);
185
+ if (ids.length !== unhashed.length) throw new GitError(["hash-object", "--stdin-paths"], "fewer ids than files");
186
+ unhashed.forEach((c, i) => (c.newBlob = ids[i]));
187
+ }
188
+ return changes;
189
+ }
@@ -0,0 +1,167 @@
1
+ // The evidence log: one append-only file per repository, written by whatever ran a check (the
2
+ // Trellis gateway, the `trellis` CLI, an agent hook) and read at the commit checkpoint. Each line
3
+ // is signed and chained to the one before it by its hash, so an edited, removed or reordered line
4
+ // reads as tampering, never as fewer events. A signed checkpoint marks the line the last commit
5
+ // stopped at; a verification reads the events after it.
6
+ //
7
+ // Same-user limit: the key is the user's, so an agent running as that user can forge a line with
8
+ // enough effort. Locally this is integrity, not proof; CI re-runs the deterministic checks.
9
+ import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
10
+ import { createHash } from "node:crypto";
11
+ import { join } from "node:path";
12
+ import { sign, stateDir, testOverride, valid } from "../agents/state.mjs";
13
+
14
+ /**
15
+ * @typedef {object} Event
16
+ * @property {string} kind graph, docs, schema, browser, tests, review, plan, approval, escalation, agent (an agent at work)
17
+ * @property {string} tool what ran it (graphify, context7, npm test...)
18
+ * @property {string} agent who asked for it: an agent's id, or "person"
19
+ * @property {string} [subject] what it was about (a query, package names, a URL), as a digest when private
20
+ * @property {Record<string, string>} [blobs] path -> blob id of each file it checked, as it was then
21
+ * @property {string} [tree] the whole tree a test run ran on, when the run left it unchanged
22
+ * @property {number} [result] an exit code, for checks that have one
23
+ * @property {number} [at] when it was recorded (ms), set by appendEvent
24
+ */
25
+
26
+ /** The evidence folder: the test override, else the git folder's `trellis`. @param {string} root @param {NodeJS.ProcessEnv} env */
27
+ const dir = (root, env) => testOverride(env, "TRELLIS_STATE_DIR") ?? join(stateDir(root, env), "..");
28
+
29
+ /** @param {string} root @param {NodeJS.ProcessEnv} [env] */
30
+ export const evidenceFile = (root, env = process.env) => join(dir(root, env), "evidence.jsonl");
31
+ /** @param {string} root @param {NodeJS.ProcessEnv} env */
32
+ const checkpointFile = (root, env) => join(dir(root, env), "checkpoint.json");
33
+
34
+ /** @param {string} line */
35
+ const hashOf = (line) => createHash("sha256").update(line).digest("hex");
36
+
37
+ /** @param {string} file */
38
+ const linesOf = (file) => (existsSync(file) ? readFileSync(file, "utf8").split("\n").filter(Boolean) : []);
39
+
40
+ /**
41
+ * Appends one event, signed and chained to the last line.
42
+ * @param {string} root
43
+ * @param {Event} event
44
+ * @param {NodeJS.ProcessEnv} [env]
45
+ */
46
+ export function appendEvent(root, event, env = process.env) {
47
+ const file = evidenceFile(root, env);
48
+ mkdirSync(join(file, ".."), { recursive: true });
49
+ // The gateway, the hooks and parallel tool calls write at once: reading the last line and
50
+ // appending after it must be one step, or two writers chain to the same line and the log reads
51
+ // as tampered for good.
52
+ withLock(`${file}.lock`, () => {
53
+ const lines = linesOf(file);
54
+ const prev = lines.length ? hashOf(lines[lines.length - 1]) : "";
55
+ const e = { ...event, at: Date.now() };
56
+ const body = JSON.stringify({ e, prev });
57
+ appendFileSync(file, `${JSON.stringify({ e, prev, sig: sign(body, env) })}\n`);
58
+ });
59
+ }
60
+
61
+ /** A lock older than this was left by a writer that died holding it. */
62
+ const STALE_LOCK_MS = 10_000;
63
+ const LOCK_WAIT_MS = 15_000;
64
+ const HELD = new Set(["EEXIST", "EPERM", "EBUSY", "EACCES"]);
65
+
66
+ /**
67
+ * Runs `fn` holding an exclusive lock file. Creating a file with `wx` is atomic on every platform.
68
+ * Waits for a writer holding it, takes over a lock left by a writer that died, and throws rather
69
+ * than write unlocked.
70
+ * @param {string} lock
71
+ * @param {() => void} fn
72
+ */
73
+ function withLock(lock, fn) {
74
+ const deadline = Date.now() + LOCK_WAIT_MS;
75
+ const sleeper = new Int32Array(new SharedArrayBuffer(4));
76
+ for (;;) {
77
+ try {
78
+ closeSync(openSync(lock, "wx"));
79
+ break;
80
+ } catch (e) {
81
+ // Windows answers EPERM or EBUSY, not EEXIST, while another writer's lock is being removed.
82
+ if (!HELD.has(String(/** @type {NodeJS.ErrnoException} */ (e).code))) throw e;
83
+ try {
84
+ if (Date.now() - statSync(lock).mtimeMs > STALE_LOCK_MS) rmSync(lock, { force: true });
85
+ } catch {
86
+ /* released meanwhile */
87
+ }
88
+ if (Date.now() > deadline) throw new Error(`the evidence log stayed locked (${lock}); remove the file if no Trellis is running`);
89
+ Atomics.wait(sleeper, 0, 0, 5 + Math.floor(Math.random() * 20));
90
+ }
91
+ }
92
+ try {
93
+ fn();
94
+ } finally {
95
+ rmSync(lock, { force: true });
96
+ }
97
+ }
98
+
99
+ /**
100
+ * The events since the last checkpoint, or "tampered" when any line, the chain or the checkpoint
101
+ * does not verify. A log that does not exist is no evidence, which every rule that needs some
102
+ * reads as missing.
103
+ * @param {string} root
104
+ * @param {NodeJS.ProcessEnv} [env]
105
+ * @returns {{ status: "ok" | "tampered", events: Event[] }}
106
+ */
107
+ export function readWindow(root, env = process.env) {
108
+ const lines = linesOf(evidenceFile(root, env));
109
+ /** @type {Event[]} */
110
+ const events = [];
111
+ let prev = "";
112
+ for (const line of lines) {
113
+ let rec;
114
+ try {
115
+ rec = JSON.parse(line);
116
+ } catch {
117
+ return { status: "tampered", events: [] };
118
+ }
119
+ if (rec?.prev !== prev || !valid(rec?.sig, JSON.stringify({ e: rec?.e, prev: rec?.prev }), env)) return { status: "tampered", events: [] };
120
+ events.push(rec.e);
121
+ prev = hashOf(line);
122
+ }
123
+ let from = 0;
124
+ const cp = checkpointFile(root, env);
125
+ if (existsSync(cp)) {
126
+ let c;
127
+ try {
128
+ c = JSON.parse(readFileSync(cp, "utf8"));
129
+ } catch {
130
+ return { status: "tampered", events: [] };
131
+ }
132
+ if (!Number.isInteger(c?.line) || c.line > lines.length || !valid(c?.sig, `checkpoint:${c?.line}`, env)) return { status: "tampered", events: [] };
133
+ from = c.line;
134
+ }
135
+ return { status: "ok", events: events.slice(from) };
136
+ }
137
+
138
+ /**
139
+ * Marks the end of the log as the start of the next window, after a commit.
140
+ * @param {string} root
141
+ * @param {NodeJS.ProcessEnv} [env]
142
+ */
143
+ export function checkpoint(root, env = process.env) {
144
+ const line = linesOf(evidenceFile(root, env)).length;
145
+ mkdirSync(dir(root, env), { recursive: true });
146
+ writeFileSync(checkpointFile(root, env), JSON.stringify({ line, sig: sign(`checkpoint:${line}`, env) }));
147
+ }
148
+
149
+ /**
150
+ * Sets the log and its checkpoint aside, kept under a dated name, and starts a new one: the way
151
+ * out of a log that no longer verifies. A person's step; what was set aside is never deleted.
152
+ * @param {string} root
153
+ * @param {NodeJS.ProcessEnv} [env]
154
+ * @returns {string | null} the name the old log was kept under, or null when there was none
155
+ */
156
+ export function resetEvidence(root, env = process.env) {
157
+ const file = evidenceFile(root, env);
158
+ let kept = null;
159
+ withLock(`${file}.lock`, () => {
160
+ if (existsSync(file)) {
161
+ kept = file.replace(/\.jsonl$/, `.${new Date().toISOString().replace(/[:.]/g, "-")}.set-aside.jsonl`);
162
+ renameSync(file, kept);
163
+ }
164
+ rmSync(checkpointFile(root, env), { force: true });
165
+ });
166
+ return kept;
167
+ }
@@ -0,0 +1,51 @@
1
+ // Merging MCP servers into .mcp.json. Additive only: an entry the repository already has for the
2
+ // same tool is left exactly as it is, because a person may have tuned it on purpose.
3
+ import { mcpServers } from "../tools/registry.mjs";
4
+ import { parseJson } from "./context.mjs";
5
+
6
+ /** @typedef {import("./context.mjs").Context} Context */
7
+ /** @typedef {import("../tools/registry.mjs").Tool} Tool */
8
+
9
+ /** How each tool's entry is recognised in an existing file, whatever its key is called. */
10
+ const MATCHERS = /** @type {Record<string, RegExp>} */ ({
11
+ context7: /context7-mcp/,
12
+ "postgres-mcp": /postgres-mcp/,
13
+ "playwright-mcp": /@playwright\/mcp/,
14
+ });
15
+
16
+ /** The key a new entry is written under. */
17
+ const KEYS = /** @type {Record<string, string>} */ ({ context7: "context7", "postgres-mcp": "postgres", "playwright-mcp": "playwright" });
18
+
19
+ /**
20
+ * The new .mcp.json text, and which entries it adds. `text` is null when nothing changes.
21
+ * @param {Context} ctx
22
+ * @param {Array<{ tool: Tool, opts: Record<string, any> }>} enabled
23
+ * @param {{ type: string, command: string, args: string[] } | null} [gateway] the Trellis server's entry, when Trellis is installed
24
+ * @returns {{ text: string | null, added: string[], kept: string[] }}
25
+ */
26
+ export function planMcp(ctx, enabled, gateway = null) {
27
+ const raw = ctx.read(".mcp.json");
28
+ const current = parseJson(raw);
29
+ if (raw != null && current == null) throw new Error(".mcp.json is not valid JSON; fix it before trellis writes to it");
30
+ const doc = current ?? {};
31
+ doc.mcpServers ??= {};
32
+ const added = [];
33
+ const kept = [];
34
+ for (const { tool, opts } of enabled) {
35
+ if (!tool.mcp) continue;
36
+ if (mcpServers(ctx, MATCHERS[tool.id]).length) {
37
+ kept.push(tool.id);
38
+ continue;
39
+ }
40
+ let key = KEYS[tool.id] ?? tool.id;
41
+ if (doc.mcpServers[key]) key = `${key}-trellis`;
42
+ doc.mcpServers[key] = tool.mcp(opts);
43
+ added.push(tool.id);
44
+ }
45
+ // Trellis's own server: the checks as tools for any agent. Kept as it is if already declared.
46
+ if (gateway && !Object.values(doc.mcpServers).some((s) => /trellis(\.mjs)?["']?,?\s*"?mcp\b/.test(JSON.stringify([s?.command, ...(s?.args ?? [])])))) {
47
+ doc.mcpServers[doc.mcpServers.trellis ? "trellis-gateway" : "trellis"] = gateway;
48
+ added.push("trellis");
49
+ }
50
+ return { text: added.length ? `${JSON.stringify(doc, null, 2)}\n` : null, added, kept };
51
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,11 @@
1
+ export { createContext } from "./core/context.mjs";
2
+ export { CONFIG_FILE, defaultConfig, enabledTools, loadConfig, validateConfig } from "./core/config.mjs";
3
+ export { planMcp } from "./core/mcp.mjs";
4
+ export { renderBlock, upsertBlock } from "./core/agents-block.mjs";
5
+ export { TOOLS, toolById, installHint, mcpServers } from "./tools/registry.mjs";
6
+ export { ROLES, roleById } from "./tools/roles.mjs";
7
+ export { diagnose, doctor } from "./commands/doctor.mjs";
8
+ export { planInit, init } from "./commands/init.mjs";
9
+ export { guide } from "./commands/guide.mjs";
10
+ export { scan } from "./commands/scan.mjs";
11
+ export { main } from "./cli/main.mjs";
@@ -0,0 +1,164 @@
1
+ // Trellis as an MCP client of the servers it relays, over stdio, either era (specification
2
+ // 2026-07-28, stdio "Backward Compatibility"): probe with server/discover; a discover result or a
3
+ // recognised modern error means a modern server, anything else (or no answer) a legacy one, which
4
+ // gets the initialize handshake.
5
+ import { spawn } from "node:child_process";
6
+ import { createInterface } from "node:readline";
7
+ import { VERSION } from "../core/config.mjs";
8
+ import { launchSpec } from "./launch.mjs";
9
+
10
+ const MODERN = "2026-07-28";
11
+ const LEGACY = "2025-11-25";
12
+ /** Errors only a modern server sends: UnsupportedProtocolVersion, HeaderMismatch, MissingRequiredClientCapability. */
13
+ const MODERN_ERRORS = new Set([-32022, -32020, -32021]);
14
+ const CLIENT = { name: "trellis", version: VERSION };
15
+
16
+ /** An error a server answered with. */
17
+ export class RpcError extends Error {
18
+ /** @param {{ code: number, message: string, data?: any }} e */
19
+ constructor(e) {
20
+ super(e.message);
21
+ this.code = e.code;
22
+ this.data = e.data;
23
+ }
24
+ }
25
+
26
+ /** `${VAR}` in a server entry, from the environment, as Claude Code expands it. @param {any} v @param {NodeJS.ProcessEnv} env */
27
+ const expand = (v, env) => (typeof v === "string" ? v.replace(/\$\{(\w+)\}/g, (_, name) => env[name] ?? "") : v);
28
+
29
+ /**
30
+ * One relayed server: launched on first use, its era detected once.
31
+ */
32
+ export class Upstream {
33
+ /**
34
+ * @param {string} name
35
+ * @param {{ command: string, args?: string[], env?: Record<string, string>, cwd?: string }} entry
36
+ * @param {{ cwd: string, env: NodeJS.ProcessEnv }} where
37
+ */
38
+ constructor(name, entry, where) {
39
+ this.name = name;
40
+ this.entry = entry;
41
+ this.where = where;
42
+ /** @type {"modern" | "legacy" | null} */
43
+ this.era = null;
44
+ this.version = MODERN;
45
+ /** @type {Map<number, { resolve: (v: any) => void, reject: (e: Error) => void, timer: NodeJS.Timeout }>} */
46
+ this.pending = new Map();
47
+ this.next = 1;
48
+ /** @type {Promise<void> | null} */
49
+ this.ready = null;
50
+ /** @type {import("node:child_process").ChildProcess | null} */
51
+ this.child = null;
52
+ /** @type {Error | null} why the server is gone, once it is: every later request fails at once */
53
+ this.gone = null;
54
+ }
55
+
56
+ /** Launches the server and settles its era; once. */
57
+ start() {
58
+ this.ready ??= this.#launch();
59
+ return this.ready;
60
+ }
61
+
62
+ async #launch() {
63
+ const env = { ...this.where.env, ...Object.fromEntries(Object.entries(this.entry.env ?? {}).map(([k, v]) => [k, expand(v, this.where.env)])) };
64
+ const command = expand(this.entry.command, this.where.env);
65
+ const args = (this.entry.args ?? []).map((a) => expand(a, this.where.env));
66
+ // `npx` and the like are .cmd shims on Windows: ./launch.mjs gets their arguments through cmd.exe intact.
67
+ const spec = launchSpec(command, args, env);
68
+ const child = spawn(spec.file, spec.args, { cwd: this.entry.cwd ?? this.where.cwd, env, stdio: ["pipe", "pipe", "pipe"], shell: spec.shell, windowsVerbatimArguments: spec.windowsVerbatimArguments, windowsHide: true });
69
+ this.child = child;
70
+ const end = (/** @type {Error} */ e) => {
71
+ this.gone ??= e;
72
+ this.#failAll(e);
73
+ };
74
+ child.on("error", end);
75
+ child.on("exit", (code) => end(new Error(`${this.name} exited (code ${code})`)));
76
+ child.stderr?.on("data", () => {});
77
+ createInterface({ input: /** @type {NodeJS.ReadableStream} */ (child.stdout) }).on("line", (line) => this.#receive(line));
78
+ try {
79
+ const discover = await this.#send("server/discover", { _meta: this.#meta() }, 5_000);
80
+ const supported = discover?.supportedVersions ?? [];
81
+ this.era = "modern";
82
+ if (supported.length && !supported.includes(MODERN)) throw new Error(`${this.name} supports ${supported.join(", ")}, not ${MODERN}`);
83
+ } catch (e) {
84
+ if (e instanceof RpcError && MODERN_ERRORS.has(/** @type {number} */ (e.code))) {
85
+ this.era = "modern";
86
+ const supported = e.data?.supported ?? [];
87
+ if (!supported.includes(MODERN)) throw new Error(`${this.name} supports ${supported.join(", ")}, not ${MODERN}`);
88
+ return;
89
+ }
90
+ if (this.era === "modern") throw e;
91
+ // Anything else, an unknown-method error or silence, is a legacy server.
92
+ this.era = "legacy";
93
+ const init = await this.#send("initialize", { protocolVersion: LEGACY, capabilities: {}, clientInfo: CLIENT }, 60_000);
94
+ this.version = String(init?.protocolVersion ?? LEGACY);
95
+ this.#notify("notifications/initialized");
96
+ }
97
+ }
98
+
99
+ #meta() {
100
+ return { "io.modelcontextprotocol/protocolVersion": MODERN, "io.modelcontextprotocol/clientInfo": CLIENT, "io.modelcontextprotocol/clientCapabilities": {} };
101
+ }
102
+
103
+ /** @param {string} line */
104
+ #receive(line) {
105
+ let msg;
106
+ try {
107
+ msg = JSON.parse(line);
108
+ } catch {
109
+ return;
110
+ }
111
+ const p = this.pending.get(msg?.id);
112
+ if (!p) return;
113
+ this.pending.delete(msg.id);
114
+ clearTimeout(p.timer);
115
+ if (msg.error) p.reject(new RpcError(msg.error));
116
+ else p.resolve(msg.result);
117
+ }
118
+
119
+ /** @param {Error} e */
120
+ #failAll(e) {
121
+ for (const p of this.pending.values()) {
122
+ clearTimeout(p.timer);
123
+ p.reject(e);
124
+ }
125
+ this.pending.clear();
126
+ }
127
+
128
+ /** @param {string} method @param {any} params @param {number} timeoutMs */
129
+ #send(method, params, timeoutMs) {
130
+ if (this.gone) return Promise.reject(this.gone);
131
+ const id = this.next++;
132
+ return new Promise((resolve, reject) => {
133
+ const timer = setTimeout(() => {
134
+ this.pending.delete(id);
135
+ reject(new Error(`${this.name}: no answer to ${method} within ${timeoutMs / 1000} s`));
136
+ }, timeoutMs);
137
+ this.pending.set(id, { resolve, reject, timer });
138
+ this.child?.stdin?.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`);
139
+ });
140
+ }
141
+
142
+ /** @param {string} method */
143
+ #notify(method) {
144
+ this.child?.stdin?.write(`${JSON.stringify({ jsonrpc: "2.0", method })}\n`);
145
+ }
146
+
147
+ /**
148
+ * A request in the server's era: modern ones carry their `_meta`.
149
+ * @param {string} method
150
+ * @param {Record<string, any>} [params]
151
+ * @param {number} [timeoutMs]
152
+ */
153
+ async request(method, params = {}, timeoutMs = 120_000) {
154
+ await this.start();
155
+ const p = this.era === "modern" ? { ...params, _meta: { ...(params._meta ?? {}), ...this.#meta() } } : params;
156
+ return this.#send(method, p, timeoutMs);
157
+ }
158
+
159
+ close() {
160
+ this.#failAll(new Error(`${this.name} closed`));
161
+ this.child?.stdin?.end();
162
+ this.child?.kill();
163
+ }
164
+ }
@@ -0,0 +1,57 @@
1
+ // How a relayed server's command line reaches the operating system unchanged. On Windows, a
2
+ // command named without a path (`npx`, `uvx`) is looked up on PATH as Windows does: an `.exe` is
3
+ // started directly, its arguments quoted by Node; a `.cmd` or `.bat` shim can only run through
4
+ // cmd.exe, which reads `"`, `%`, `&`, `^` and the like itself, so each argument is escaped for it,
5
+ // twice for a shim (cmd reads the line once, the shim passes it on once more), the way cross-spawn
6
+ // does. Elsewhere the command and its arguments go to the system as they are.
7
+ import { existsSync } from "node:fs";
8
+ import { delimiter, extname, join } from "node:path";
9
+
10
+ const META = /([()\][%!^"`<>&|;, *?])/g;
11
+
12
+ /**
13
+ * One argument for cmd.exe: backslashes before a quote doubled, quoted, cmd's characters escaped.
14
+ * @param {string} arg @param {boolean} shim
15
+ */
16
+ function cmdArg(arg, shim) {
17
+ let a = String(arg)
18
+ .replace(/(\\*)"/g, '$1$1\\"')
19
+ .replace(/(\\*)$/, "$1$1");
20
+ a = `"${a}"`.replace(META, "^$1");
21
+ return shim ? a.replace(META, "^$1") : a;
22
+ }
23
+
24
+ /**
25
+ * Where Windows finds `command`, by PATH and PATHEXT, or null.
26
+ * @param {string} command @param {NodeJS.ProcessEnv} env
27
+ */
28
+ function onPath(command, env) {
29
+ const exts = (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean);
30
+ const dirs = (env.PATH ?? env.Path ?? "").split(delimiter).filter(Boolean);
31
+ for (const dir of dirs) {
32
+ for (const ext of extname(command) ? [""] : exts) {
33
+ const file = join(dir, command + ext);
34
+ if (existsSync(file)) return file;
35
+ }
36
+ }
37
+ return null;
38
+ }
39
+
40
+ /**
41
+ * What to hand to spawn for `command` with `args`.
42
+ * @param {string} command
43
+ * @param {string[]} args
44
+ * @param {NodeJS.ProcessEnv} env
45
+ * @param {NodeJS.Platform} [platform]
46
+ * @returns {{ file: string, args: string[], shell: boolean, windowsVerbatimArguments: boolean }}
47
+ */
48
+ export function launchSpec(command, args, env, platform = process.platform) {
49
+ if (platform !== "win32") return { file: command, args, shell: false, windowsVerbatimArguments: false };
50
+ const found = /[\\/]/.test(command) ? command : (onPath(command, env) ?? command);
51
+ if (!/\.(cmd|bat)$/i.test(found)) return { file: found, args, shell: false, windowsVerbatimArguments: false };
52
+ // cmd.exe ends a command at a line break, whatever the quoting: such an argument would arrive cut.
53
+ if (args.some((a) => /[\r\n]/.test(a))) throw new Error(`an argument holds a line break, which cmd.exe cannot pass to ${command}; start it through node or an .exe instead`);
54
+ // cmd.exe /d /s /c "<line>": the line is passed as it is, each part escaped above.
55
+ const line = [found.replace(META, "^$1"), ...args.map((a) => cmdArg(a, true))].join(" ");
56
+ return { file: env.ComSpec ?? env.COMSPEC ?? "cmd.exe", args: ["/d", "/s", "/c", `"${line}"`], shell: false, windowsVerbatimArguments: true };
57
+ }