agent-trellis 0.2.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.
- package/README.md +12 -0
- package/dist/adapters/claude-code.d.ts +3 -2
- package/dist/adapters/claude-code.js +21 -8
- package/dist/adapters/codex.d.ts +6 -3
- package/dist/adapters/codex.js +41 -10
- package/dist/adapters/jsonMcp.d.ts +15 -5
- package/dist/adapters/jsonMcp.js +38 -29
- package/dist/adapters/kiro.d.ts +3 -2
- package/dist/adapters/kiro.js +22 -9
- package/dist/adapters/mcpPlan.d.ts +11 -6
- package/dist/adapters/mcpPlan.js +39 -6
- package/dist/cli.js +123 -10
- package/dist/commands/mcp.d.ts +101 -7
- package/dist/commands/mcp.js +227 -10
- package/dist/commands/memory.d.ts +39 -0
- package/dist/commands/memory.js +78 -0
- package/dist/commands/migrate.d.ts +30 -4
- package/dist/commands/migrate.js +83 -16
- package/dist/commands/onboard.d.ts +18 -8
- package/dist/commands/onboard.js +124 -14
- package/dist/commands/skill.d.ts +51 -0
- package/dist/commands/skill.js +104 -0
- package/dist/core/adapter.d.ts +18 -8
- package/dist/core/canonical.d.ts +26 -1
- package/dist/core/canonical.js +81 -3
- package/dist/core/types.d.ts +17 -0
- package/dist/lib/deepEqual.d.ts +8 -0
- package/dist/lib/deepEqual.js +26 -0
- package/dist/lib/dirEquals.d.ts +9 -0
- package/dist/lib/dirEquals.js +15 -1
- package/dist/lib/mcpMigrateRead.d.ts +69 -0
- package/dist/lib/mcpMigrateRead.js +188 -0
- package/dist/lib/mcpOwnership.d.ts +25 -0
- package/dist/lib/mcpOwnership.js +50 -0
- package/dist/lib/memoryGraph.d.ts +60 -0
- package/dist/lib/memoryGraph.js +101 -0
- package/dist/lib/realHomeSnapshot.d.ts +26 -0
- package/dist/lib/realHomeSnapshot.js +77 -0
- package/dist/lib/terminalPicker.d.ts +45 -0
- package/dist/lib/terminalPicker.js +193 -0
- package/dist/lib/tomlSection.d.ts +20 -6
- package/dist/lib/tomlSection.js +78 -12
- package/dist/pi-bridge/bundle.js +76 -46
- package/dist/pi-bridge/index.js +7 -2
- package/dist/probes/codex.js +10 -2
- package/docs/architecture.md +7 -4
- package/docs/getting-started.md +166 -10
- package/docs/roadmap.md +311 -0
- package/package.json +1 -1
- package/schema/servers.example.yaml +39 -2
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Converts canonical `memories/*.md` into `@modelcontextprotocol/
|
|
3
|
+
* server-memory`'s own on-disk JSON-lines knowledge-graph format, and
|
|
4
|
+
* upserts them into an existing graph file without disturbing anything
|
|
5
|
+
* else in it (trellis-memory-sync) — closing P6's explicitly-left-open
|
|
6
|
+
* "auto-ingesting memories/*.md content into the running memory
|
|
7
|
+
* server's store" gap.
|
|
8
|
+
*
|
|
9
|
+
* Each line in that file is one JSON object, either
|
|
10
|
+
* `{type:"entity", name, entityType, observations}` or
|
|
11
|
+
* `{type:"relation", from, to, relationType}` — the real, documented
|
|
12
|
+
* shape the official server persists and reads back on its own next
|
|
13
|
+
* startup (this project writes the file directly; it never spawns or
|
|
14
|
+
* talks to a running server process).
|
|
15
|
+
*
|
|
16
|
+
* `entityType: "trellis-memory"` is this project's own in-band ownership
|
|
17
|
+
* marker — simpler than a separate ledger file (unlike MCP server sync,
|
|
18
|
+
* every agent connected to this one server shares the exact same graph,
|
|
19
|
+
* so there's no per-agent rendering to track). An existing entity with
|
|
20
|
+
* the same name but a different `entityType` was created by something
|
|
21
|
+
* else (an agent's own runtime tool calls, most likely) and is left
|
|
22
|
+
* completely untouched — reported as a conflict, never overwritten.
|
|
23
|
+
*/
|
|
24
|
+
import { readFileSync } from "node:fs";
|
|
25
|
+
export const TRELLIS_MEMORY_ENTITY_TYPE = "trellis-memory";
|
|
26
|
+
export function parseMemoryGraph(content) {
|
|
27
|
+
const lines = [];
|
|
28
|
+
for (const raw of content.split("\n")) {
|
|
29
|
+
const trimmed = raw.trim();
|
|
30
|
+
if (!trimmed)
|
|
31
|
+
continue;
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(trimmed);
|
|
34
|
+
if (parsed.type === "entity" || parsed.type === "relation") {
|
|
35
|
+
lines.push(parsed);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// Not valid JSON — skip rather than fail the whole file; a
|
|
40
|
+
// hand-edited or partially-written line shouldn't block every
|
|
41
|
+
// other, unrelated line in the graph.
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return lines;
|
|
45
|
+
}
|
|
46
|
+
export function renderMemoryGraph(lines) {
|
|
47
|
+
if (lines.length === 0)
|
|
48
|
+
return "";
|
|
49
|
+
return `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Pure: computes the next graph state and a human-readable plan, given
|
|
53
|
+
* the current graph's raw content (or `undefined` if the file doesn't
|
|
54
|
+
* exist yet) and canonical's memory entries. Every non-`trellis-memory`
|
|
55
|
+
* entity and every relation passes through completely untouched,
|
|
56
|
+
* regardless of what canonical wants.
|
|
57
|
+
*/
|
|
58
|
+
export function planMemorySync(canonical, currentGraphContent) {
|
|
59
|
+
const existingLines = currentGraphContent !== undefined ? parseMemoryGraph(currentGraphContent) : [];
|
|
60
|
+
const untouchedLines = existingLines.filter((line) => !(line.type === "entity" && line.entityType === TRELLIS_MEMORY_ENTITY_TYPE));
|
|
61
|
+
const existingTrellisEntities = new Map(existingLines.filter((line) => line.type === "entity" && line.entityType === TRELLIS_MEMORY_ENTITY_TYPE).map((e) => [e.name, e]));
|
|
62
|
+
const existingOtherEntityNames = new Set(existingLines.filter((line) => line.type === "entity" && line.entityType !== TRELLIS_MEMORY_ENTITY_TYPE).map((e) => e.name));
|
|
63
|
+
const items = [];
|
|
64
|
+
const nextTrellisEntities = [];
|
|
65
|
+
let changed = false;
|
|
66
|
+
const canonicalNames = new Set(canonical.memories.map((m) => m.name));
|
|
67
|
+
for (const memory of canonical.memories) {
|
|
68
|
+
if (existingOtherEntityNames.has(memory.name)) {
|
|
69
|
+
items.push({ name: memory.name, action: "conflict", detail: `an entity named "${memory.name}" already exists in the graph and wasn't created by Trellis — resolve by hand` });
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
let content;
|
|
73
|
+
try {
|
|
74
|
+
content = readFileSync(memory.file, "utf-8");
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
items.push({ name: memory.name, action: "conflict", detail: `could not read ${memory.file}: ${err instanceof Error ? err.message : String(err)}` });
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const desired = { type: "entity", name: memory.name, entityType: TRELLIS_MEMORY_ENTITY_TYPE, observations: [content] };
|
|
81
|
+
const existing = existingTrellisEntities.get(memory.name);
|
|
82
|
+
nextTrellisEntities.push(desired);
|
|
83
|
+
if (existing && existing.observations.length === 1 && existing.observations[0] === content) {
|
|
84
|
+
items.push({ name: memory.name, action: "already-synced", detail: "graph content is already identical" });
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
items.push({ name: memory.name, action: "create", detail: existing ? "will update the existing entity's observation" : "will create a new entity" });
|
|
88
|
+
changed = true;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
for (const [name] of existingTrellisEntities) {
|
|
92
|
+
if (canonicalNames.has(name))
|
|
93
|
+
continue; // still wanted, handled above
|
|
94
|
+
items.push({ name, action: "remove", detail: "no longer in canonical — will be removed from the graph" });
|
|
95
|
+
changed = true;
|
|
96
|
+
}
|
|
97
|
+
if (!changed) {
|
|
98
|
+
return { items };
|
|
99
|
+
}
|
|
100
|
+
return { items, nextGraph: [...untouchedLines, ...nextTrellisEntities] };
|
|
101
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `--real` sandbox mode's allowlist (trellis-real-sandbox-verification
|
|
3
|
+
* design.md D1): every real dotfile path a probe (src/probes/*.ts) is
|
|
4
|
+
* already known to read, and nothing else. Allowlist, not denylist —
|
|
5
|
+
* anything this project doesn't already know to be structural (real
|
|
6
|
+
* OAuth token storage, session/history logs, credentials) is never
|
|
7
|
+
* copied out of a developer's real `$HOME` because it was never named
|
|
8
|
+
* here, not because it was excluded after the fact. Kept in sync with
|
|
9
|
+
* each probe's own `join(homeDir, ...)` calls by direct source
|
|
10
|
+
* cross-reference, not by assumption.
|
|
11
|
+
*/
|
|
12
|
+
export declare const REAL_HOME_ALLOWLIST: readonly string[];
|
|
13
|
+
/**
|
|
14
|
+
* Copies only allowlisted paths that actually exist under `sourceHome`
|
|
15
|
+
* into `destHome` — a missing entry is a normal, expected state (not
|
|
16
|
+
* every agent is installed), never an error. `dereference: true` is
|
|
17
|
+
* required, not incidental: `sync`'s own real output is symlinks
|
|
18
|
+
* (skills, instructions) pointing back at `sourceHome`'s own
|
|
19
|
+
* `.trellis/` — a container mounting a snapshot that kept those
|
|
20
|
+
* symlinks as symlinks would get a dangling reference to a path that
|
|
21
|
+
* doesn't exist inside it. Copying real content instead is what makes
|
|
22
|
+
* the snapshot self-contained (found by actually running this against
|
|
23
|
+
* a real, already-synced machine — not by inspection). Returns the
|
|
24
|
+
* relative paths actually copied, for the caller to report.
|
|
25
|
+
*/
|
|
26
|
+
export declare function buildRealHomeSnapshot(sourceHome: string, destHome: string): string[];
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `--real` sandbox mode's allowlist (trellis-real-sandbox-verification
|
|
3
|
+
* design.md D1): every real dotfile path a probe (src/probes/*.ts) is
|
|
4
|
+
* already known to read, and nothing else. Allowlist, not denylist —
|
|
5
|
+
* anything this project doesn't already know to be structural (real
|
|
6
|
+
* OAuth token storage, session/history logs, credentials) is never
|
|
7
|
+
* copied out of a developer's real `$HOME` because it was never named
|
|
8
|
+
* here, not because it was excluded after the fact. Kept in sync with
|
|
9
|
+
* each probe's own `join(homeDir, ...)` calls by direct source
|
|
10
|
+
* cross-reference, not by assumption.
|
|
11
|
+
*/
|
|
12
|
+
import { cpSync, existsSync, mkdirSync } from "node:fs";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
export const REAL_HOME_ALLOWLIST = [
|
|
15
|
+
// claude-code (src/probes/claude-code.ts)
|
|
16
|
+
".claude.json",
|
|
17
|
+
".claude/skills",
|
|
18
|
+
".claude/agents",
|
|
19
|
+
".claude/CLAUDE.md",
|
|
20
|
+
// codex (src/probes/codex.ts)
|
|
21
|
+
".codex/config.toml",
|
|
22
|
+
".agents/skills",
|
|
23
|
+
".codex/skills",
|
|
24
|
+
// kiro (src/probes/kiro.ts)
|
|
25
|
+
".kiro/settings/mcp.json",
|
|
26
|
+
".kiro/skills",
|
|
27
|
+
".kiro/steering/CLAUDE.md",
|
|
28
|
+
// pi (src/probes/pi.ts)
|
|
29
|
+
".pi/agent/settings.json",
|
|
30
|
+
".pi/agent/skills",
|
|
31
|
+
".pi/agent/AGENTS.override.md",
|
|
32
|
+
".pi/agent/AGENTS.md",
|
|
33
|
+
".pi/agent/AGENTS.MD",
|
|
34
|
+
".pi/agent/CLAUDE.md",
|
|
35
|
+
".pi/agent/CLAUDE.MD",
|
|
36
|
+
// this machine's own real canonical source, if it already has one —
|
|
37
|
+
// Trellis's own managed data, not a third-party agent's.
|
|
38
|
+
".trellis",
|
|
39
|
+
];
|
|
40
|
+
/**
|
|
41
|
+
* Copies only allowlisted paths that actually exist under `sourceHome`
|
|
42
|
+
* into `destHome` — a missing entry is a normal, expected state (not
|
|
43
|
+
* every agent is installed), never an error. `dereference: true` is
|
|
44
|
+
* required, not incidental: `sync`'s own real output is symlinks
|
|
45
|
+
* (skills, instructions) pointing back at `sourceHome`'s own
|
|
46
|
+
* `.trellis/` — a container mounting a snapshot that kept those
|
|
47
|
+
* symlinks as symlinks would get a dangling reference to a path that
|
|
48
|
+
* doesn't exist inside it. Copying real content instead is what makes
|
|
49
|
+
* the snapshot self-contained (found by actually running this against
|
|
50
|
+
* a real, already-synced machine — not by inspection). Returns the
|
|
51
|
+
* relative paths actually copied, for the caller to report.
|
|
52
|
+
*/
|
|
53
|
+
export function buildRealHomeSnapshot(sourceHome, destHome) {
|
|
54
|
+
const copied = [];
|
|
55
|
+
for (const rel of REAL_HOME_ALLOWLIST) {
|
|
56
|
+
const src = join(sourceHome, rel);
|
|
57
|
+
if (!existsSync(src))
|
|
58
|
+
continue;
|
|
59
|
+
const dest = join(destHome, rel);
|
|
60
|
+
// On a case-insensitive filesystem (macOS default), two distinct
|
|
61
|
+
// allowlist entries (e.g. `AGENTS.md`/`AGENTS.MD` — pi's own
|
|
62
|
+
// case-sensitive candidate list, src/probes/pi.ts) can resolve to
|
|
63
|
+
// the identical real file; a `dest` an earlier entry already
|
|
64
|
+
// created (case-insensitively) needs no second, redundant copy —
|
|
65
|
+
// found by actually running this against a real machine, where it
|
|
66
|
+
// also tripped a Node `cpSync` quirk re-copying a symlink onto its
|
|
67
|
+
// own already-materialized destination.
|
|
68
|
+
if (existsSync(dest)) {
|
|
69
|
+
copied.push(rel);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
73
|
+
cpSync(src, dest, { recursive: true, dereference: true });
|
|
74
|
+
copied.push(rel);
|
|
75
|
+
}
|
|
76
|
+
return copied;
|
|
77
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
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
|
+
import type { Readable, Writable } from "node:stream";
|
|
16
|
+
export interface PickerStreams {
|
|
17
|
+
input: NodeJS.ReadStream | Readable;
|
|
18
|
+
output: NodeJS.WriteStream | Writable;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* True only when both streams are real, raw-mode-capable terminals — the
|
|
22
|
+
* exact gate deciding picker vs. the pre-existing numbered-typing prompt
|
|
23
|
+
* (design.md D2). A stream lacking `setRawMode` (piped input, some
|
|
24
|
+
* minimal TTYs) always falls back, never hangs or guesses.
|
|
25
|
+
*/
|
|
26
|
+
export declare function canUseInteractivePicker(streams?: PickerStreams): boolean;
|
|
27
|
+
/** Wrapping index navigation — pure, exported for direct unit testing. */
|
|
28
|
+
export declare function nextIndex(current: number, delta: number, length: number): number;
|
|
29
|
+
/** Pure single-index toggle — exported for direct unit testing. */
|
|
30
|
+
export declare function toggled(checked: readonly boolean[], index: number): boolean[];
|
|
31
|
+
/**
|
|
32
|
+
* Single-select: Up/Down/j/k moves the highlight, Enter confirms. Resolves
|
|
33
|
+
* the confirmed index, or `null` on Ctrl+C cancel. Caller (onboard.ts)
|
|
34
|
+
* must have already confirmed `canUseInteractivePicker()` — this function
|
|
35
|
+
* does not re-check, and assumes `streams.input` genuinely supports raw
|
|
36
|
+
* mode.
|
|
37
|
+
*/
|
|
38
|
+
export declare function runSingleSelectPicker(items: string[], streams?: PickerStreams): Promise<number | null>;
|
|
39
|
+
/**
|
|
40
|
+
* Multi-select (checkbox): Up/Down/j/k moves the highlight, Space toggles
|
|
41
|
+
* the current row, Enter confirms. Resolves the checked indices at
|
|
42
|
+
* confirm time, or `null` on Ctrl+C cancel. Same precondition as
|
|
43
|
+
* `runSingleSelectPicker`.
|
|
44
|
+
*/
|
|
45
|
+
export declare function runMultiSelectPicker(items: string[], initiallyChecked: readonly boolean[], streams?: PickerStreams): Promise<number[] | null>;
|
|
@@ -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
|
|
27
|
-
* it doesn't exist
|
|
28
|
-
* to decide create/repair vs.
|
|
29
|
-
* no parsing needed on either
|
|
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
|
|
44
|
-
*
|
|
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
|
package/dist/lib/tomlSection.js
CHANGED
|
@@ -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
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
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
|
|
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
|
|
107
|
-
*
|
|
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
|
|
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
|
|
151
|
-
const existing = findSection(content, header);
|
|
217
|
+
const existing = findServerRange(content, name);
|
|
152
218
|
if (!existing) {
|
|
153
219
|
return content;
|
|
154
220
|
}
|