agent-trellis 0.1.0 → 0.3.0

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 (67) hide show
  1. package/README.md +69 -17
  2. package/dist/adapters/claude-code.d.ts +5 -3
  3. package/dist/adapters/claude-code.js +27 -14
  4. package/dist/adapters/codex.d.ts +8 -4
  5. package/dist/adapters/codex.js +47 -16
  6. package/dist/adapters/jsonMcp.d.ts +16 -5
  7. package/dist/adapters/jsonMcp.js +38 -29
  8. package/dist/adapters/kiro.d.ts +5 -3
  9. package/dist/adapters/kiro.js +29 -16
  10. package/dist/adapters/mcpPlan.d.ts +11 -6
  11. package/dist/adapters/mcpPlan.js +40 -7
  12. package/dist/adapters/pi.d.ts +2 -1
  13. package/dist/adapters/pi.js +4 -4
  14. package/dist/adapters/symlinkPlan.d.ts +7 -3
  15. package/dist/adapters/symlinkPlan.js +42 -16
  16. package/dist/cli.js +161 -18
  17. package/dist/commands/init.js +11 -0
  18. package/dist/commands/mcp.d.ts +114 -7
  19. package/dist/commands/mcp.js +258 -17
  20. package/dist/commands/memory.d.ts +39 -0
  21. package/dist/commands/memory.js +78 -0
  22. package/dist/commands/migrate.d.ts +30 -4
  23. package/dist/commands/migrate.js +83 -16
  24. package/dist/commands/onboard.d.ts +52 -7
  25. package/dist/commands/onboard.js +318 -35
  26. package/dist/commands/rollback.d.ts +44 -0
  27. package/dist/commands/rollback.js +201 -0
  28. package/dist/commands/secretsAudit.d.ts +7 -0
  29. package/dist/commands/secretsAudit.js +14 -7
  30. package/dist/commands/skill.d.ts +51 -0
  31. package/dist/commands/skill.js +104 -0
  32. package/dist/commands/sync.d.ts +13 -0
  33. package/dist/commands/sync.js +31 -5
  34. package/dist/core/adapter.d.ts +28 -11
  35. package/dist/core/adapter.js +2 -2
  36. package/dist/core/canonical.d.ts +26 -1
  37. package/dist/core/canonical.js +103 -3
  38. package/dist/core/types.d.ts +29 -1
  39. package/dist/core/types.js +11 -2
  40. package/dist/lib/backup.d.ts +56 -0
  41. package/dist/lib/backup.js +98 -0
  42. package/dist/lib/deepEqual.d.ts +8 -0
  43. package/dist/lib/deepEqual.js +26 -0
  44. package/dist/lib/dirEquals.d.ts +9 -0
  45. package/dist/lib/dirEquals.js +15 -1
  46. package/dist/lib/installAgent.d.ts +26 -0
  47. package/dist/lib/installAgent.js +46 -0
  48. package/dist/lib/mcpMigrateRead.d.ts +69 -0
  49. package/dist/lib/mcpMigrateRead.js +188 -0
  50. package/dist/lib/mcpOwnership.d.ts +25 -0
  51. package/dist/lib/mcpOwnership.js +50 -0
  52. package/dist/lib/memoryGraph.d.ts +60 -0
  53. package/dist/lib/memoryGraph.js +101 -0
  54. package/dist/lib/realHomeSnapshot.d.ts +26 -0
  55. package/dist/lib/realHomeSnapshot.js +77 -0
  56. package/dist/lib/terminalPicker.d.ts +45 -0
  57. package/dist/lib/terminalPicker.js +193 -0
  58. package/dist/lib/tomlSection.d.ts +20 -6
  59. package/dist/lib/tomlSection.js +78 -12
  60. package/dist/pi-bridge/bundle.js +100 -51
  61. package/dist/pi-bridge/index.js +14 -2
  62. package/dist/probes/codex.js +10 -2
  63. package/docs/architecture.md +7 -4
  64. package/docs/getting-started.md +267 -33
  65. package/docs/roadmap.md +444 -0
  66. package/package.json +1 -1
  67. package/schema/servers.example.yaml +39 -2
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Minimal, dependency-free arrow-key/checkbox terminal picker
3
+ * (trellis-onboard-interactive-picker design.md D1) — the interaction
4
+ * surface `trellis onboard`'s two prompts need (at most four rows, no
5
+ * search, no pagination) doesn't need a full prompt library; this
6
+ * hand-rolls just enough raw-mode key handling and ANSI rendering for
7
+ * that bounded case, matching the project's existing narrow-dependency
8
+ * precedent (src/lib/envVarNames.ts, src/lib/secretEnv.ts).
9
+ *
10
+ * `input`/`output` are injectable (never a CLI flag) purely so tests can
11
+ * drive the real key-parsing/render loop against a plain stream instead
12
+ * of a real TTY — same seam pattern (`homeDir`, etc.) used everywhere
13
+ * else in this project.
14
+ */
15
+ function defaultStreams() {
16
+ return { input: process.stdin, output: process.stdout };
17
+ }
18
+ /**
19
+ * True only when both streams are real, raw-mode-capable terminals — the
20
+ * exact gate deciding picker vs. the pre-existing numbered-typing prompt
21
+ * (design.md D2). A stream lacking `setRawMode` (piped input, some
22
+ * minimal TTYs) always falls back, never hangs or guesses.
23
+ */
24
+ export function canUseInteractivePicker(streams = defaultStreams()) {
25
+ const input = streams.input;
26
+ const output = streams.output;
27
+ return Boolean(input.isTTY && output.isTTY && typeof input.setRawMode === "function");
28
+ }
29
+ /** Wrapping index navigation — pure, exported for direct unit testing. */
30
+ export function nextIndex(current, delta, length) {
31
+ return ((current + delta) % length + length) % length;
32
+ }
33
+ /** Pure single-index toggle — exported for direct unit testing. */
34
+ export function toggled(checked, index) {
35
+ const next = [...checked];
36
+ next[index] = !next[index];
37
+ return next;
38
+ }
39
+ const ESC = "\u001b";
40
+ const CTRL_C = "\u0003";
41
+ const HIDE_CURSOR = `${ESC}[?25l`;
42
+ const SHOW_CURSOR = `${ESC}[?25h`;
43
+ /** Parses one raw input chunk into a single logical key — arrow escape
44
+ * sequences, j/k, space, enter, or Ctrl+C. Anything else is ignored. */
45
+ function parseKey(chunk) {
46
+ if (chunk === CTRL_C)
47
+ return "cancel";
48
+ if (chunk === "\r" || chunk === "\n")
49
+ return "confirm";
50
+ if (chunk === " ")
51
+ return "toggle";
52
+ if (chunk === `${ESC}[A` || chunk === "k")
53
+ return "up";
54
+ if (chunk === `${ESC}[B` || chunk === "j")
55
+ return "down";
56
+ return null;
57
+ }
58
+ function withRawMode(streams, body) {
59
+ const input = streams.input;
60
+ const output = streams.output;
61
+ const canSetRawMode = typeof input.setRawMode === "function";
62
+ if (canSetRawMode)
63
+ input.setRawMode(true);
64
+ input.resume();
65
+ input.setEncoding("utf-8");
66
+ output.write(HIDE_CURSOR);
67
+ const restore = () => {
68
+ output.write(SHOW_CURSOR);
69
+ if (canSetRawMode)
70
+ input.setRawMode(false);
71
+ input.pause();
72
+ };
73
+ // Second-layer safety net (design.md D6): a thrown error the caller's
74
+ // own try/finally doesn't get a chance to run for (process killed
75
+ // externally) must still not leave the terminal in raw mode/cursor
76
+ // hidden.
77
+ process.once("exit", restore);
78
+ return body().finally(() => {
79
+ restore();
80
+ process.removeListener("exit", restore);
81
+ });
82
+ }
83
+ function moveCursorUp(output, lines) {
84
+ if (lines > 0)
85
+ output.write(`${ESC}[${lines}A`);
86
+ }
87
+ function clearLines(output, lines) {
88
+ for (let i = 0; i < lines; i++) {
89
+ output.write(`${ESC}[2K`);
90
+ if (i < lines - 1)
91
+ output.write(`${ESC}[1B`);
92
+ }
93
+ moveCursorUp(output, lines - 1);
94
+ }
95
+ function renderRows(output, rows, previousRowCount) {
96
+ if (previousRowCount > 0) {
97
+ clearLines(output, previousRowCount);
98
+ }
99
+ for (const row of rows) {
100
+ output.write(`${row}\n`);
101
+ }
102
+ }
103
+ function highlightRow(text, isHighlighted) {
104
+ return isHighlighted ? `> ${ESC}[7m${text}${ESC}[0m` : ` ${text}`;
105
+ }
106
+ /**
107
+ * Single-select: Up/Down/j/k moves the highlight, Enter confirms. Resolves
108
+ * the confirmed index, or `null` on Ctrl+C cancel. Caller (onboard.ts)
109
+ * must have already confirmed `canUseInteractivePicker()` — this function
110
+ * does not re-check, and assumes `streams.input` genuinely supports raw
111
+ * mode.
112
+ */
113
+ export async function runSingleSelectPicker(items, streams = defaultStreams()) {
114
+ const { input, output } = streams;
115
+ return withRawMode(streams, () => {
116
+ return new Promise((resolve) => {
117
+ let highlighted = 0;
118
+ let rowCount = 0;
119
+ const draw = () => {
120
+ const rows = items.map((label, i) => highlightRow(label, i === highlighted));
121
+ renderRows(output, rows, rowCount);
122
+ rowCount = rows.length;
123
+ };
124
+ draw();
125
+ const onData = (chunk) => {
126
+ const key = parseKey(chunk.toString("utf-8"));
127
+ if (key === "up") {
128
+ highlighted = nextIndex(highlighted, -1, items.length);
129
+ draw();
130
+ }
131
+ else if (key === "down") {
132
+ highlighted = nextIndex(highlighted, 1, items.length);
133
+ draw();
134
+ }
135
+ else if (key === "confirm") {
136
+ input.removeListener("data", onData);
137
+ resolve(highlighted);
138
+ }
139
+ else if (key === "cancel") {
140
+ input.removeListener("data", onData);
141
+ resolve(null);
142
+ }
143
+ };
144
+ input.on("data", onData);
145
+ });
146
+ });
147
+ }
148
+ /**
149
+ * Multi-select (checkbox): Up/Down/j/k moves the highlight, Space toggles
150
+ * the current row, Enter confirms. Resolves the checked indices at
151
+ * confirm time, or `null` on Ctrl+C cancel. Same precondition as
152
+ * `runSingleSelectPicker`.
153
+ */
154
+ export async function runMultiSelectPicker(items, initiallyChecked, streams = defaultStreams()) {
155
+ const { input, output } = streams;
156
+ return withRawMode(streams, () => {
157
+ return new Promise((resolve) => {
158
+ let highlighted = 0;
159
+ let checked = [...initiallyChecked];
160
+ let rowCount = 0;
161
+ const draw = () => {
162
+ const rows = items.map((label, i) => highlightRow(`[${checked[i] ? "x" : " "}] ${label}`, i === highlighted));
163
+ renderRows(output, rows, rowCount);
164
+ rowCount = rows.length;
165
+ };
166
+ draw();
167
+ const onData = (chunk) => {
168
+ const key = parseKey(chunk.toString("utf-8"));
169
+ if (key === "up") {
170
+ highlighted = nextIndex(highlighted, -1, items.length);
171
+ draw();
172
+ }
173
+ else if (key === "down") {
174
+ highlighted = nextIndex(highlighted, 1, items.length);
175
+ draw();
176
+ }
177
+ else if (key === "toggle") {
178
+ checked = toggled(checked, highlighted);
179
+ draw();
180
+ }
181
+ else if (key === "confirm") {
182
+ input.removeListener("data", onData);
183
+ resolve(checked.flatMap((isChecked, i) => (isChecked ? [i] : [])));
184
+ }
185
+ else if (key === "cancel") {
186
+ input.removeListener("data", onData);
187
+ resolve(null);
188
+ }
189
+ };
190
+ input.on("data", onData);
191
+ });
192
+ });
193
+ }
@@ -23,10 +23,11 @@ export interface SectionRange {
23
23
  */
24
24
  export declare function findSection(content: string, header: string): SectionRange | null;
25
25
  /**
26
- * Returns the current stored text of `[mcp_servers.<name>]` (or `null` if
27
- * it doesn't exist), for comparing against `renderServerSection`'s output
28
- * to decide create/repair vs. no-op — exact text equality is enough here,
29
- * no parsing needed on either side.
26
+ * Returns the current stored text of a server's full owned range (see
27
+ * `findServerRange`) — or `null` if it doesn't exist for comparing
28
+ * against `renderServerSection`'s output to decide create/repair vs.
29
+ * no-op. Exact text equality is enough here, no parsing needed on either
30
+ * side.
30
31
  */
31
32
  export declare function currentServerSectionText(content: string, name: string): string | null;
32
33
  /**
@@ -40,9 +41,22 @@ export declare function currentServerSectionText(content: string, name: string):
40
41
  * than this function silently rendering nothing for it.
41
42
  */
42
43
  export declare function codexBearerTokenEnvVar(def: McpServerDef): string | undefined;
43
- /** Renders a `[mcp_servers.<name>]` block for a bounded, known shape —
44
- * this is templating, not general TOML serialization. */
44
+ /** Renders a `[mcp_servers.<name>]` block (plus, when `staticEnv` is set,
45
+ * an immediately-adjacent `[mcp_servers.<name>.env]` block see
46
+ * `findServerRange`) for a bounded, known shape — this is templating,
47
+ * not general TOML serialization. */
45
48
  export declare function renderServerSection(name: string, def: McpServerDef): string;
49
+ /**
50
+ * Reads a server's `[mcp_servers.<name>.env]` table's literal key-value
51
+ * pairs directly from real `config.toml` text — the exact inverse of
52
+ * `renderServerSection`'s own `static_env` block, the only piece of a
53
+ * Codex server's `static_env` that `codex mcp list --json` cannot ever
54
+ * report (it only exposes `env_vars`, i.e. names, from the *main*
55
+ * table — trellis-migrate-mcp-servers design.md D3). Returns `undefined`
56
+ * if the table doesn't exist; a malformed line inside it is skipped, not
57
+ * fatal, matching `parseMemoryGraph`'s own tolerant-parse precedent.
58
+ */
59
+ export declare function readServerEnvTable(content: string, name: string): Record<string, string> | undefined;
46
60
  /**
47
61
  * Replaces an existing section in place, or appends a new one at EOF
48
62
  * (with a leading blank-line separator) if none exists yet. Never touches
@@ -36,6 +36,9 @@ function tomlStringArray(values) {
36
36
  function serverHeader(name) {
37
37
  return `mcp_servers.${tomlKeySegment(name)}`;
38
38
  }
39
+ function envHeader(name) {
40
+ return `${serverHeader(name)}.env`;
41
+ }
39
42
  /**
40
43
  * Finds an existing `[header]` table's exact line range: `start` is the
41
44
  * header line itself, `end` is the line before the next table header (any
@@ -70,14 +73,42 @@ export function findSection(content, header) {
70
73
  return { start, end };
71
74
  }
72
75
  /**
73
- * Returns the current stored text of `[mcp_servers.<name>]` (or `null` if
74
- * it doesn't exist), for comparing against `renderServerSection`'s output
75
- * to decide create/repair vs. no-opexact text equality is enough here,
76
- * no parsing needed on either side.
76
+ * A server's full owned range: its main `[mcp_servers.<name>]` table,
77
+ * extended to also cover an immediately-following `[mcp_servers.<name>.env]`
78
+ * table (skipping only blank/comment padding in between the same skip
79
+ * rule `findSection`'s own trailing trim already applies) when one is
80
+ * present. The two are rendered and managed as one atomic unit whenever
81
+ * `staticEnv` is set (trellis-mcp-static-env-and-disabled-servers
82
+ * design.md D3) — create/repair/remove must never touch one without the
83
+ * other.
84
+ */
85
+ function findServerRange(content, name) {
86
+ const main = findSection(content, serverHeader(name));
87
+ if (!main) {
88
+ return null;
89
+ }
90
+ const lines = content.split("\n");
91
+ let i = main.end + 1;
92
+ while (i < lines.length && (lines[i].trim() === "" || lines[i].trim().startsWith("#"))) {
93
+ i += 1;
94
+ }
95
+ if (i < lines.length && lines[i].trim() === `[${envHeader(name)}]`) {
96
+ const envRange = findSection(content, envHeader(name));
97
+ if (envRange) {
98
+ return { start: main.start, end: envRange.end };
99
+ }
100
+ }
101
+ return main;
102
+ }
103
+ /**
104
+ * Returns the current stored text of a server's full owned range (see
105
+ * `findServerRange`) — or `null` if it doesn't exist — for comparing
106
+ * against `renderServerSection`'s output to decide create/repair vs.
107
+ * no-op. Exact text equality is enough here, no parsing needed on either
108
+ * side.
77
109
  */
78
110
  export function currentServerSectionText(content, name) {
79
- const header = serverHeader(name);
80
- const range = findSection(content, header);
111
+ const range = findServerRange(content, name);
81
112
  if (!range) {
82
113
  return null;
83
114
  }
@@ -103,8 +134,10 @@ export function codexBearerTokenEnvVar(def) {
103
134
  return undefined;
104
135
  return BEARER_TOKEN_VALUE_RE.exec(value)?.[1];
105
136
  }
106
- /** Renders a `[mcp_servers.<name>]` block for a bounded, known shape —
107
- * this is templating, not general TOML serialization. */
137
+ /** Renders a `[mcp_servers.<name>]` block (plus, when `staticEnv` is set,
138
+ * an immediately-adjacent `[mcp_servers.<name>.env]` block see
139
+ * `findServerRange`) for a bounded, known shape — this is templating,
140
+ * not general TOML serialization. */
108
141
  export function renderServerSection(name, def) {
109
142
  const lines = [`[${serverHeader(name)}]`];
110
143
  if (def.transport === "stdio") {
@@ -122,16 +155,50 @@ export function renderServerSection(name, def) {
122
155
  if (bearerEnvVar)
123
156
  lines.push(`bearer_token_env_var = ${tomlString(bearerEnvVar)}`);
124
157
  }
158
+ const staticEnvEntries = Object.entries(def.staticEnv ?? {});
159
+ if (staticEnvEntries.length > 0) {
160
+ lines.push(`[${envHeader(name)}]`);
161
+ for (const [key, value] of staticEnvEntries) {
162
+ lines.push(`${tomlKeySegment(key)} = ${tomlString(value)}`);
163
+ }
164
+ }
125
165
  return lines.join("\n");
126
166
  }
167
+ const ENV_TABLE_LINE_RE = /^("(?:[^"\\]|\\.)*"|[A-Za-z0-9_-]+)\s*=\s*("(?:[^"\\]|\\.)*")\s*$/;
168
+ /**
169
+ * Reads a server's `[mcp_servers.<name>.env]` table's literal key-value
170
+ * pairs directly from real `config.toml` text — the exact inverse of
171
+ * `renderServerSection`'s own `static_env` block, the only piece of a
172
+ * Codex server's `static_env` that `codex mcp list --json` cannot ever
173
+ * report (it only exposes `env_vars`, i.e. names, from the *main*
174
+ * table — trellis-migrate-mcp-servers design.md D3). Returns `undefined`
175
+ * if the table doesn't exist; a malformed line inside it is skipped, not
176
+ * fatal, matching `parseMemoryGraph`'s own tolerant-parse precedent.
177
+ */
178
+ export function readServerEnvTable(content, name) {
179
+ const range = findSection(content, envHeader(name));
180
+ if (!range) {
181
+ return undefined;
182
+ }
183
+ const lines = content.split("\n").slice(range.start + 1, range.end + 1);
184
+ const result = {};
185
+ for (const line of lines) {
186
+ const match = ENV_TABLE_LINE_RE.exec(line.trim());
187
+ if (!match)
188
+ continue;
189
+ const rawKey = match[1];
190
+ const key = rawKey.startsWith('"') ? JSON.parse(rawKey) : rawKey;
191
+ result[key] = JSON.parse(match[2]);
192
+ }
193
+ return result;
194
+ }
127
195
  /**
128
196
  * Replaces an existing section in place, or appends a new one at EOF
129
197
  * (with a leading blank-line separator) if none exists yet. Never touches
130
198
  * any line outside the section it locates or the single appended block.
131
199
  */
132
200
  export function upsertSection(content, name, def) {
133
- const header = serverHeader(name);
134
- const existing = findSection(content, header);
201
+ const existing = findServerRange(content, name);
135
202
  const newLines = renderServerSection(name, def).split("\n");
136
203
  if (existing) {
137
204
  const lines = content.split("\n");
@@ -147,8 +214,7 @@ export function upsertSection(content, name, def) {
147
214
  * No-op if the section doesn't exist — idempotent.
148
215
  */
149
216
  export function removeSection(content, name) {
150
- const header = serverHeader(name);
151
- const existing = findSection(content, header);
217
+ const existing = findServerRange(content, name);
152
218
  if (!existing) {
153
219
  return content;
154
220
  }