agent-trellis 0.4.0 → 0.6.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/dist/adapters/codex.js +4 -1
- package/dist/adapters/jsonMcp.js +6 -3
- package/dist/adapters/kiro.js +9 -1
- package/dist/adapters/mcpPlan.d.ts +15 -0
- package/dist/adapters/mcpPlan.js +59 -3
- package/dist/adapters/symlinkPlan.js +3 -0
- package/dist/cli.js +56 -8
- package/dist/commands/doctor.d.ts +13 -3
- package/dist/commands/doctor.js +20 -10
- package/dist/commands/mcpAuth.d.ts +28 -0
- package/dist/commands/mcpAuth.js +99 -0
- package/dist/commands/mcpGateway.d.ts +53 -0
- package/dist/commands/mcpGateway.js +148 -0
- package/dist/commands/memory.d.ts +42 -19
- package/dist/commands/memory.js +99 -14
- package/dist/commands/migrate.d.ts +18 -1
- package/dist/commands/migrate.js +52 -6
- package/dist/commands/onboard.d.ts +34 -0
- package/dist/commands/onboard.js +110 -10
- package/dist/commands/onboardVerdict.d.ts +55 -0
- package/dist/commands/onboardVerdict.js +175 -0
- package/dist/core/adapter.d.ts +7 -0
- package/dist/core/canonical.d.ts +8 -6
- package/dist/core/canonical.js +26 -3
- package/dist/core/types.d.ts +42 -4
- package/dist/lib/backup.js +32 -3
- package/dist/lib/gatewayBackend.d.ts +75 -0
- package/dist/lib/gatewayBackend.js +131 -0
- package/dist/lib/mcpConnect.d.ts +53 -0
- package/dist/lib/mcpConnect.js +123 -0
- package/dist/lib/mcpMigrateRead.d.ts +12 -0
- package/dist/lib/mcpMigrateRead.js +47 -12
- package/dist/lib/mcpToolRegistry.d.ts +89 -0
- package/dist/lib/mcpToolRegistry.js +94 -0
- package/dist/lib/memoryGraph.d.ts +35 -0
- package/dist/lib/memoryGraph.js +62 -0
- package/dist/lib/oauth/discovery.d.ts +53 -0
- package/dist/lib/oauth/discovery.js +95 -0
- package/dist/lib/oauth/flow.d.ts +47 -0
- package/dist/lib/oauth/flow.js +193 -0
- package/dist/lib/oauth/lock.d.ts +25 -0
- package/dist/lib/oauth/lock.js +98 -0
- package/dist/lib/oauth/pkce.d.ts +27 -0
- package/dist/lib/oauth/pkce.js +40 -0
- package/dist/lib/oauth/refresh.d.ts +33 -0
- package/dist/lib/oauth/refresh.js +93 -0
- package/dist/lib/oauth/store.d.ts +55 -0
- package/dist/lib/oauth/store.js +85 -0
- package/dist/lib/slug.d.ts +9 -0
- package/dist/lib/slug.js +15 -0
- package/dist/lib/tomlSection.js +7 -1
- package/dist/pi-bridge/bundle.js +14383 -14338
- package/dist/pi-bridge/index.d.ts +5 -9
- package/dist/pi-bridge/index.js +6 -99
- package/dist/pi-bridge/schemaTranslate.d.ts +8 -2
- package/dist/pi-bridge/schemaTranslate.js +8 -4
- package/docs/architecture.md +88 -19
- package/docs/getting-started.md +174 -11
- package/docs/research.md +190 -0
- package/docs/roadmap.md +20 -0
- package/package.json +4 -3
- package/schema/servers.example.yaml +42 -7
|
@@ -12,7 +12,12 @@
|
|
|
12
12
|
* this bridge actually uses are declared here.
|
|
13
13
|
*/
|
|
14
14
|
import type { TSchema } from "typebox";
|
|
15
|
+
import { withTimeout } from "../lib/mcpConnect.js";
|
|
15
16
|
import { toPiContent } from "./schemaTranslate.js";
|
|
17
|
+
/** Re-exported so `trellis-mcp-connect-timeout`'s existing test keeps
|
|
18
|
+
* importing it from the module it was written against — proving the
|
|
19
|
+
* extraction changed nothing it could observe. */
|
|
20
|
+
export { withTimeout };
|
|
16
21
|
interface PiToolResult {
|
|
17
22
|
content: ReturnType<typeof toPiContent>;
|
|
18
23
|
details: unknown;
|
|
@@ -28,14 +33,6 @@ interface PiExtensionAPI {
|
|
|
28
33
|
registerTool(tool: PiToolDefinition): void;
|
|
29
34
|
on?(event: "session_shutdown", handler: () => Promise<void> | void): void;
|
|
30
35
|
}
|
|
31
|
-
/**
|
|
32
|
-
* A hanging server (process alive, protocol response never sent) leaves
|
|
33
|
-
* `promise` permanently unsettled — indistinguishable from "still
|
|
34
|
-
* starting up" without a bound. Racing against a timeout converts that
|
|
35
|
-
* into an ordinary rejection, which every caller here already knows how
|
|
36
|
-
* to isolate (design.md D1, trellis-mcp-connect-timeout).
|
|
37
|
-
*/
|
|
38
|
-
export declare function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T>;
|
|
39
36
|
/**
|
|
40
37
|
* `homeDir`/`connectTimeoutMs` default to the real `~` / 10s — only
|
|
41
38
|
* overridable for tests, same seam every other Trellis entry point
|
|
@@ -45,4 +42,3 @@ export declare function withTimeout<T>(promise: Promise<T>, timeoutMs: number, m
|
|
|
45
42
|
* `factory(pi)` — what pi actually does — works unchanged).
|
|
46
43
|
*/
|
|
47
44
|
export default function trellisMcpBridge(pi: PiExtensionAPI, homeDir?: string, connectTimeoutMs?: number): Promise<void>;
|
|
48
|
-
export {};
|
package/dist/pi-bridge/index.js
CHANGED
|
@@ -11,102 +11,17 @@
|
|
|
11
11
|
* from Trellis's own install tree). Only the handful of fields/methods
|
|
12
12
|
* this bridge actually uses are declared here.
|
|
13
13
|
*/
|
|
14
|
-
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
15
|
-
import { StdioClientTransport, getDefaultEnvironment } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
16
|
-
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
17
|
-
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
18
14
|
import { homedir } from "node:os";
|
|
19
15
|
import { loadCanonicalSource } from "../core/canonical.js";
|
|
20
16
|
import { resolveMcpPlan } from "../adapters/mcpPlan.js";
|
|
21
|
-
import {
|
|
22
|
-
import { extractTemplateVarNames } from "../lib/envVarNames.js";
|
|
17
|
+
import { connectServer, withTimeout, DEFAULT_CONNECT_TIMEOUT_MS } from "../lib/mcpConnect.js";
|
|
23
18
|
import { ALL_AGENTS } from "../core/types.js";
|
|
24
19
|
import { bridgedToolName, toParametersSchema, toPiContent } from "./schemaTranslate.js";
|
|
20
|
+
/** Re-exported so `trellis-mcp-connect-timeout`'s existing test keeps
|
|
21
|
+
* importing it from the module it was written against — proving the
|
|
22
|
+
* extraction changed nothing it could observe. */
|
|
23
|
+
export { withTimeout };
|
|
25
24
|
const CLIENT_INFO = { name: "trellis-mcp-bridge", version: "0.0.0" };
|
|
26
|
-
const DEFAULT_CONNECT_TIMEOUT_MS = 10_000;
|
|
27
|
-
/**
|
|
28
|
-
* A hanging server (process alive, protocol response never sent) leaves
|
|
29
|
-
* `promise` permanently unsettled — indistinguishable from "still
|
|
30
|
-
* starting up" without a bound. Racing against a timeout converts that
|
|
31
|
-
* into an ordinary rejection, which every caller here already knows how
|
|
32
|
-
* to isolate (design.md D1, trellis-mcp-connect-timeout).
|
|
33
|
-
*/
|
|
34
|
-
export function withTimeout(promise, timeoutMs, message) {
|
|
35
|
-
return new Promise((resolve, reject) => {
|
|
36
|
-
const timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
|
37
|
-
promise.then((value) => {
|
|
38
|
-
clearTimeout(timer);
|
|
39
|
-
resolve(value);
|
|
40
|
-
}, (err) => {
|
|
41
|
-
clearTimeout(timer);
|
|
42
|
-
reject(err);
|
|
43
|
-
});
|
|
44
|
-
});
|
|
45
|
-
}
|
|
46
|
-
/**
|
|
47
|
-
* A timed-out (or otherwise failed) connect attempt must not leak the
|
|
48
|
-
* transport's own resources — for `StdioClientTransport` specifically,
|
|
49
|
-
* an unclosed transport means an orphaned child process that outlives
|
|
50
|
-
* this failed attempt indefinitely (confirmed by a real leaked
|
|
51
|
-
* subprocess during this change's own test run). `transport.close()` is
|
|
52
|
-
* best-effort: a transport that never fully connected may itself error
|
|
53
|
-
* on close, but the original connect failure is what the caller needs
|
|
54
|
-
* to see, not a secondary cleanup error.
|
|
55
|
-
*/
|
|
56
|
-
async function connectWithCleanup(client, transport, timeoutMs, message) {
|
|
57
|
-
try {
|
|
58
|
-
await withTimeout(client.connect(transport), timeoutMs, message);
|
|
59
|
-
return client;
|
|
60
|
-
}
|
|
61
|
-
catch (err) {
|
|
62
|
-
try {
|
|
63
|
-
await transport.close();
|
|
64
|
-
}
|
|
65
|
-
catch {
|
|
66
|
-
// best-effort; the original connect failure is what matters
|
|
67
|
-
}
|
|
68
|
-
throw err;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
async function connectStdio(def, secretsPolicy, timeoutMs) {
|
|
72
|
-
const client = new Client(CLIENT_INFO, { capabilities: {} });
|
|
73
|
-
const resolved = resolveSecretEnv(def.env ?? [], secretsPolicy);
|
|
74
|
-
const namedEnv = Object.fromEntries((def.env ?? []).map((name) => [name, resolved[name] ?? ""]));
|
|
75
|
-
const transport = new StdioClientTransport({
|
|
76
|
-
command: def.command,
|
|
77
|
-
args: def.args,
|
|
78
|
-
// staticEnv merges last: a literal, intentionally-plain value (an
|
|
79
|
-
// email, an environment tag) always wins over an unresolved name-only
|
|
80
|
-
// entry's empty-string fallback for the same key — though in practice
|
|
81
|
-
// resolveMcpPlan's D6 refusal never lets an unresolved name reach
|
|
82
|
-
// this point at all (trellis-mcp-static-env-and-disabled-servers).
|
|
83
|
-
env: { ...getDefaultEnvironment(), ...namedEnv, ...(def.staticEnv ?? {}) },
|
|
84
|
-
});
|
|
85
|
-
return connectWithCleanup(client, transport, timeoutMs, `connect timed out after ${timeoutMs}ms`);
|
|
86
|
-
}
|
|
87
|
-
function resolveHeaders(def, secretsPolicy) {
|
|
88
|
-
if (!def.headers || Object.keys(def.headers).length === 0)
|
|
89
|
-
return undefined;
|
|
90
|
-
const names = Object.keys(def.headers).flatMap((key) => extractTemplateVarNames(def.headers[key]));
|
|
91
|
-
const resolved = resolveSecretEnv(names, secretsPolicy);
|
|
92
|
-
const result = {};
|
|
93
|
-
for (const [key, template] of Object.entries(def.headers)) {
|
|
94
|
-
result[key] = template.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_match, name) => resolved[name] ?? "");
|
|
95
|
-
}
|
|
96
|
-
return result;
|
|
97
|
-
}
|
|
98
|
-
async function connectHttp(def, secretsPolicy, timeoutMs) {
|
|
99
|
-
const client = new Client(CLIENT_INFO, { capabilities: {} });
|
|
100
|
-
const headers = resolveHeaders(def, secretsPolicy);
|
|
101
|
-
const opts = headers ? { requestInit: { headers } } : undefined;
|
|
102
|
-
return connectWithCleanup(client, new StreamableHTTPClientTransport(new URL(def.url), opts), timeoutMs, `connect timed out after ${timeoutMs}ms`);
|
|
103
|
-
}
|
|
104
|
-
async function connectSse(def, secretsPolicy, timeoutMs) {
|
|
105
|
-
const client = new Client(CLIENT_INFO, { capabilities: {} });
|
|
106
|
-
const headers = resolveHeaders(def, secretsPolicy);
|
|
107
|
-
const opts = headers ? { requestInit: { headers } } : undefined;
|
|
108
|
-
return connectWithCleanup(client, new SSEClientTransport(new URL(def.url), opts), timeoutMs, `connect timed out after ${timeoutMs}ms`);
|
|
109
|
-
}
|
|
110
25
|
function registerServerTools(pi, serverName, client, timeoutMs) {
|
|
111
26
|
return withTimeout(client.listTools(), timeoutMs, `listTools timed out after ${timeoutMs}ms`).then((result) => {
|
|
112
27
|
for (const tool of result.tools) {
|
|
@@ -171,15 +86,7 @@ export default async function trellisMcpBridge(pi, homeDir = homedir(), connectT
|
|
|
171
86
|
await Promise.all(desired.map(async ({ name, def }) => {
|
|
172
87
|
let client;
|
|
173
88
|
try {
|
|
174
|
-
|
|
175
|
-
client = await connectHttp(def, canonical.secretsPolicy, connectTimeoutMs);
|
|
176
|
-
}
|
|
177
|
-
else if (def.transport === "sse") {
|
|
178
|
-
client = await connectSse(def, canonical.secretsPolicy, connectTimeoutMs);
|
|
179
|
-
}
|
|
180
|
-
else {
|
|
181
|
-
client = await connectStdio(def, canonical.secretsPolicy, connectTimeoutMs);
|
|
182
|
-
}
|
|
89
|
+
client = await connectServer(def, canonical.secretsPolicy, connectTimeoutMs, CLIENT_INFO);
|
|
183
90
|
}
|
|
184
91
|
catch (err) {
|
|
185
92
|
// One unreachable/misconfigured (including permanently hanging —
|
|
@@ -51,5 +51,11 @@ export declare function toParametersSchema(inputSchema: unknown): TSchema;
|
|
|
51
51
|
* (design.md D3). */
|
|
52
52
|
export declare function toPiContent(items: McpContentItem[]): PiContent[];
|
|
53
53
|
/** `${serverName}__${toolName}` — deterministic, collision-free across
|
|
54
|
-
* servers sharing pi.registerTool()'s single flat namespace (design.md D5).
|
|
55
|
-
|
|
54
|
+
* servers sharing pi.registerTool()'s single flat namespace (design.md D5).
|
|
55
|
+
*
|
|
56
|
+
* Now one implementation shared with the gateway's own aggregation rather
|
|
57
|
+
* than two identical ones: the naming convention is the contract between
|
|
58
|
+
* a prefixed name and the server it routes to, and two copies of it could
|
|
59
|
+
* drift into routing tools to the wrong upstream
|
|
60
|
+
* (trellis-mcp-gateway-hosting design.md D3). */
|
|
61
|
+
export { prefixedToolName as bridgedToolName } from "../lib/mcpToolRegistry.js";
|
|
@@ -34,7 +34,11 @@ export function toPiContent(items) {
|
|
|
34
34
|
});
|
|
35
35
|
}
|
|
36
36
|
/** `${serverName}__${toolName}` — deterministic, collision-free across
|
|
37
|
-
* servers sharing pi.registerTool()'s single flat namespace (design.md D5).
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
37
|
+
* servers sharing pi.registerTool()'s single flat namespace (design.md D5).
|
|
38
|
+
*
|
|
39
|
+
* Now one implementation shared with the gateway's own aggregation rather
|
|
40
|
+
* than two identical ones: the naming convention is the contract between
|
|
41
|
+
* a prefixed name and the server it routes to, and two copies of it could
|
|
42
|
+
* drift into routing tools to the wrong upstream
|
|
43
|
+
* (trellis-mcp-gateway-hosting design.md D3). */
|
|
44
|
+
export { prefixedToolName as bridgedToolName } from "../lib/mcpToolRegistry.js";
|
package/docs/architecture.md
CHANGED
|
@@ -234,28 +234,97 @@ one header simply can't reach Codex through Trellis, and is refused
|
|
|
234
234
|
there (not silently dropped) while still reaching every other agent.
|
|
235
235
|
|
|
236
236
|
**Real OAuth** (browser redirect, short-lived access token, refresh
|
|
237
|
-
token)
|
|
238
|
-
native-config agents already have their own
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
237
|
+
token) was, for most of this project's life, deliberately not built:
|
|
238
|
+
all three native-config agents already have their own working flow —
|
|
239
|
+
Claude Code's `claude mcp add --client-id/--client-secret/--callback-port`,
|
|
240
|
+
Codex's `codex mcp login`/`logout` pair, Kiro's `oauth`/`oauthScopes`
|
|
241
|
+
fields — and pi, whose bridge is Trellis's own code, simply couldn't
|
|
242
|
+
reach such a server at all.
|
|
243
|
+
|
|
244
|
+
That changed with gateway mode (below), which made it unavoidable: in
|
|
245
|
+
gateway mode the agent connects to Trellis, and Trellis connects to the
|
|
246
|
+
remote server, so the agent's own OAuth flow is no longer on the path.
|
|
247
|
+
Trellis now implements the client half itself — discovery (RFC 8414 /
|
|
248
|
+
RFC 9728), dynamic client registration (RFC 7591), authorization code
|
|
249
|
+
with PKCE S256 (RFC 7636), and the refresh grant — in `src/lib/oauth/`.
|
|
250
|
+
|
|
251
|
+
Two rules shape it, both from the constraint that the gateway is spawned
|
|
252
|
+
silently by an agent with no terminal attached:
|
|
253
|
+
|
|
254
|
+
- **Only `trellis mcp auth <server>` ever opens a browser.** It is run by
|
|
255
|
+
a human, once. The gateway never initiates an interactive flow under
|
|
256
|
+
any circumstance; a server with no stored credential is skipped the
|
|
257
|
+
same way an unreachable one is.
|
|
258
|
+
- **Refreshing is silent, and serialized by a lock.** A `refresh_token`
|
|
259
|
+
grant needs neither a browser nor a callback, so the gateway does it
|
|
260
|
+
at connect time. The lock (`~/.trellis/mcp/oauth/<name>.lock`) exists
|
|
261
|
+
because most authorization servers rotate the refresh token and
|
|
262
|
+
invalidate its predecessor — two concurrent refreshes would leave one
|
|
263
|
+
party holding a credential that is already dead.
|
|
264
|
+
|
|
265
|
+
Tokens live one-file-per-server at `~/.trellis/mcp/oauth/<name>.json`,
|
|
266
|
+
mode 0600, never in `servers.yaml` and never in the OS keychain (macOS's
|
|
267
|
+
`security add-generic-password` fails with exit 152/154 in exactly the
|
|
268
|
+
non-interactive, no-GUI-session context the gateway runs in — measured,
|
|
269
|
+
not assumed).
|
|
270
|
+
|
|
271
|
+
In direct mode nothing here applies: each agent keeps using its own
|
|
272
|
+
native OAuth flow, exactly as before.
|
|
273
|
+
|
|
274
|
+
## MCP gateway mode
|
|
275
|
+
|
|
276
|
+
Gateway mode collapses an agent's MCP config to one entry, like hub mode
|
|
277
|
+
— but what sits behind it is a local subprocess Trellis owns
|
|
278
|
+
(`trellis mcp-gateway --agent <id>`), not a URL someone operates. Set
|
|
279
|
+
`mcp.gateway.enabled` in `mcp/servers.yaml`; add `mcp.gateway.agents` to
|
|
280
|
+
narrow it to specific agents, or omit it to cover every managed agent.
|
|
281
|
+
`gateway` and `hub` are independent fields and may both be set — gateway
|
|
282
|
+
wins for any agent it covers.
|
|
283
|
+
|
|
284
|
+
**Lifecycle: there isn't one.** The agent spawns it like any other stdio
|
|
285
|
+
MCP server and it exits when the session ends. No daemon, no
|
|
286
|
+
start/stop/status command, nothing to monitor. Two things make that
|
|
287
|
+
true rather than aspirational:
|
|
288
|
+
|
|
289
|
+
- The gateway watches its own stdin for EOF and tears everything down.
|
|
290
|
+
The MCP SDK's `StdioServerTransport` binds only `'data'` and
|
|
291
|
+
`'error'`, so it never reports EOF, and POSIX re-parents orphans to
|
|
292
|
+
launchd rather than killing them — without explicit teardown every
|
|
293
|
+
session would leak a gateway plus its whole upstream set, forever.
|
|
294
|
+
- Upstream failures are isolated. One server that is unreachable,
|
|
295
|
+
misconfigured, or permanently hanging is logged and skipped; every
|
|
296
|
+
other server's tools are still served.
|
|
297
|
+
|
|
298
|
+
**What it does:** resolves the same in-scope server set direct mode
|
|
299
|
+
would have written for that agent (same `resolveMcpPlan`, so per-server
|
|
300
|
+
`agents:` scope, `enabled: false`, host-injected collisions,
|
|
301
|
+
literal-secret refusals and unresolved `env` names all behave
|
|
302
|
+
identically), connects each one, and exposes their tools as
|
|
303
|
+
`<server>__<tool>`. Secrets resolve through `resolveSecretEnv` exactly as
|
|
304
|
+
in direct mode — the gateway introduces no second path by which a value
|
|
305
|
+
could reach disk.
|
|
306
|
+
|
|
307
|
+
**What it changes for the agents:** Codex's single-`bearer_token_env_var`
|
|
308
|
+
limitation stops applying, because Codex no longer receives any server's
|
|
309
|
+
`headers` — the gateway holds them. Remote servers needing real OAuth
|
|
310
|
+
become reachable from every agent, including pi, for the same reason.
|
|
311
|
+
|
|
312
|
+
**The seam.** `src/commands/mcpGateway.ts` depends on a `GatewayBackend`
|
|
313
|
+
interface and nothing below it — never a connection, transport, or
|
|
314
|
+
server definition. Today the only implementation connects upstreams
|
|
315
|
+
in-process, one gateway per agent session. The confirmed direction is a
|
|
316
|
+
single shared service holding one connection set for all agents, with
|
|
317
|
+
the per-session process becoming a thin client forwarding over a Unix
|
|
318
|
+
socket, speaking MCP itself (so no second protocol to own). That is a
|
|
319
|
+
substitution at one construction site: the entry written into each
|
|
320
|
+
agent's config is identical either way, so converging later requires no
|
|
321
|
+
re-sync and nothing the user notices.
|
|
251
322
|
|
|
252
323
|
## What Trellis explicitly does not build
|
|
253
324
|
|
|
254
|
-
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
refresh) — every agent that has its own native flow keeps using it;
|
|
258
|
-
see "Static header auth vs. real OAuth" above
|
|
325
|
+
- A resident MCP gateway daemon. Gateway mode above is a per-session
|
|
326
|
+
subprocess with no lifecycle; hub mode points at something you operate.
|
|
327
|
+
Neither is a service Trellis starts, supervises, or keeps running.
|
|
259
328
|
- A memory backend (defaults to `@modelcontextprotocol/server-memory`,
|
|
260
329
|
documented in `schema/servers.example.yaml`; mem0/OpenMemory and
|
|
261
330
|
totalrecallai-class semantic-search servers documented as opt-in
|
package/docs/getting-started.md
CHANGED
|
@@ -17,8 +17,9 @@ $ trellis onboard
|
|
|
17
17
|
|
|
18
18
|
Runs `init`, detects which of Claude Code/Codex/Kiro/pi are on this machine,
|
|
19
19
|
then resolves two independent choices before running `migrate`, `sync`,
|
|
20
|
-
`mcp sync`, `memory sync`,
|
|
21
|
-
no follow-up commands to type by hand
|
|
20
|
+
`mcp sync`, `memory sync`, `secrets audit`, and a final health scan — the
|
|
21
|
+
whole onboarding path, no follow-up commands to type by hand, and no need
|
|
22
|
+
to run `trellis doctor` separately to know whether it actually worked.
|
|
22
23
|
|
|
23
24
|
1. **Migration source** — read from, at most one, never written back to.
|
|
24
25
|
"Real content" means skills, custom instructions, *or* real MCP servers
|
|
@@ -65,16 +66,72 @@ memory sync
|
|
|
65
66
|
|
|
66
67
|
secrets audit
|
|
67
68
|
✅ no findings — every present agent's real config and every declared env var passed all checks
|
|
69
|
+
|
|
70
|
+
health scan (trellis doctor)
|
|
71
|
+
✅ no findings
|
|
72
|
+
|
|
73
|
+
verdict
|
|
74
|
+
✅ nothing needs attention — every stage completed cleanly and verified
|
|
75
|
+
exit code: 0 — nothing blocked this run
|
|
68
76
|
```
|
|
69
77
|
|
|
70
78
|
No agent named `claude-code` appears in the `sync`/`mcp sync` output above —
|
|
71
79
|
it's present and was the migration source, but it isn't managed, so it's
|
|
72
80
|
never even probed as a sync target, not just left with zero items.
|
|
73
81
|
|
|
82
|
+
**The `verdict` block is always the last thing printed**, including on a
|
|
83
|
+
run that had nothing wrong — the exit code and the last line on screen
|
|
84
|
+
always agree, so a conflict earlier in the run can never be hidden behind
|
|
85
|
+
a later stage's own unrelated success line. Every conflict from every
|
|
86
|
+
stage is collected there, each with a concrete next action, not just a
|
|
87
|
+
restatement of what went wrong:
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
verdict
|
|
91
|
+
❌ 1 blocking issue(s):
|
|
92
|
+
- [sync/claude-code] ~/.claude/CLAUDE.md exists and is not a Trellis-managed symlink — left untouched
|
|
93
|
+
→ back up ~/.claude/CLAUDE.md's real content if you need it, remove the file, then re-run sync
|
|
94
|
+
exit code: 1 — at least one blocking issue above
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
**A real (non-`--dry-run`) run verifies its own writes**, immediately
|
|
98
|
+
after making them — a dry-run re-plan of `sync`/`mcp sync` against exactly
|
|
99
|
+
what was just written, checked for anything still outstanding. This is
|
|
100
|
+
what actually answers "did this take effect," distinct from the health
|
|
101
|
+
scan below it: `trellis doctor`'s own checks compare agents against each
|
|
102
|
+
other and never read canonical, so they cannot prove a write held — only
|
|
103
|
+
a re-plan against canonical can. If a write somehow didn't hold (a
|
|
104
|
+
filesystem permission problem, a race), it surfaces in the verdict as its
|
|
105
|
+
own `blocked` item, separate from whatever the write's own stage reported.
|
|
106
|
+
|
|
107
|
+
**The health scan is `trellis doctor` itself**, run at the end against
|
|
108
|
+
every agent (not just the ones this run manages) — so a genuinely new
|
|
109
|
+
problem this run happened to create or reveal shows up without a separate
|
|
110
|
+
`trellis doctor` invocation. It never spawns a configured MCP server to
|
|
111
|
+
check it (`--probe-mcp` is never passed): the worst possible moment to
|
|
112
|
+
start reaching real external services with real credentials is a user's
|
|
113
|
+
first-ever run of this command. A finding about an agent this run doesn't
|
|
114
|
+
manage is shown, labelled as such, and doesn't fail the run — onboard
|
|
115
|
+
didn't touch that agent, so its drift isn't this run's problem to fix.
|
|
116
|
+
|
|
117
|
+
On a real terminal, each stage prints a `[n/6] <stage>` progress line to
|
|
118
|
+
stderr as it starts — `sync`/`mcp sync` spawn real processes and can take
|
|
119
|
+
a few seconds, and this keeps the screen from going silent while that
|
|
120
|
+
happens. It never appears on stdout, so `trellis onboard > report.txt`
|
|
121
|
+
still captures exactly the report and verdict, nothing else; it's silent
|
|
122
|
+
entirely under `--json` or when stdout isn't a real terminal.
|
|
123
|
+
|
|
74
124
|
Add `--dry-run` to preview the entire chain — init/migrate/sync/mcp
|
|
75
125
|
sync/memory sync, including what would be written to
|
|
76
|
-
`~/.trellis/managed.yaml` — with zero writes anywhere (secrets audit
|
|
77
|
-
always read-only, with or without the flag
|
|
126
|
+
`~/.trellis/managed.yaml` — with zero writes anywhere (secrets audit and
|
|
127
|
+
the health scan are always read-only, with or without the flag; the
|
|
128
|
+
write-verification step above has nothing to check on a dry run and is
|
|
129
|
+
skipped entirely). On a real terminal, a `--dry-run` ends by offering to
|
|
130
|
+
apply the plan you just read — "No" is the highlighted default, so Enter
|
|
131
|
+
declines; accepting re-plans against current state and applies for real,
|
|
132
|
+
rather than replaying the exact preview you saw (state may have changed
|
|
133
|
+
while you were reading it). `--json` and non-interactive runs are never
|
|
134
|
+
offered anything.
|
|
78
135
|
|
|
79
136
|
**A managed agent's own real content still isn't overwritten.** If you
|
|
80
137
|
explicitly include the source in `--manage`, sync still never overwrites
|
|
@@ -315,7 +372,8 @@ below.
|
|
|
315
372
|
Distributes `~/.trellis/mcp/servers.yaml` to every **managed** agent's
|
|
316
373
|
native MCP config — same restriction as `sync`, see above. See [`schema/servers.example.yaml`](../schema/servers.example.yaml)
|
|
317
374
|
for the full documented shape — server definitions, per-agent scoping,
|
|
318
|
-
known-host-injected collision avoidance, and
|
|
375
|
+
known-host-injected collision avoidance, hub mode, and gateway mode
|
|
376
|
+
(below).
|
|
319
377
|
|
|
320
378
|
```
|
|
321
379
|
$ trellis mcp sync
|
|
@@ -354,6 +412,76 @@ not currently active anywhere; remove the line (or set it `true`) to
|
|
|
354
412
|
turn it back on everywhere at once. See
|
|
355
413
|
[`schema/servers.example.yaml`](../schema/servers.example.yaml) for both.
|
|
356
414
|
|
|
415
|
+
If a server's own env var name differs from the one it should resolve
|
|
416
|
+
(a real example: Notion's MCP server wants `OPENAPI_MCP_HEADERS`, but
|
|
417
|
+
your shell/secret manager holds it under `NOTION_OPENAPI_MCP_HEADERS`),
|
|
418
|
+
use `env_aliases` instead of either `env` or `static_env` —
|
|
419
|
+
`target_key: source_variable_name`. It resolves through the exact same
|
|
420
|
+
mechanism as `env` (the same pre-write refusal check, the same agent
|
|
421
|
+
`${VAR}` reference on every target), just delivered under a different
|
|
422
|
+
key. Writing that kind of reference as `static_env` looks like it works
|
|
423
|
+
(the placeholder text is still just a string) but ships the literal,
|
|
424
|
+
unexpanded `${SOURCE_NAME}` text to any agent with no `${VAR}` runtime
|
|
425
|
+
of its own — which is exactly what broke pi's Notion connection before
|
|
426
|
+
this field existed.
|
|
427
|
+
|
|
428
|
+
## Gateway mode — one entry per agent instead of N
|
|
429
|
+
|
|
430
|
+
Add this to `servers.yaml` and every managed agent's config collapses to
|
|
431
|
+
a single MCP entry:
|
|
432
|
+
|
|
433
|
+
```yaml
|
|
434
|
+
gateway:
|
|
435
|
+
enabled: true
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
Then re-run `trellis mcp sync`. Instead of one native entry per server,
|
|
439
|
+
each agent gets one stdio entry pointing at `trellis mcp-gateway`. The
|
|
440
|
+
agent spawns it like any other stdio MCP server; it connects out to your
|
|
441
|
+
servers, and exits when the session ends. There is nothing to start,
|
|
442
|
+
stop, or monitor.
|
|
443
|
+
|
|
444
|
+
Your servers' tools appear to the agent as `<server>__<tool>` — the
|
|
445
|
+
`gitlab` server's `search` becomes `gitlab__search`.
|
|
446
|
+
|
|
447
|
+
Add `agents: [claude-code, codex]` under `gateway:` to narrow it, leaving
|
|
448
|
+
the rest in direct mode. Gateway mode and `hub` are independent; both can
|
|
449
|
+
be set, and gateway wins for any agent it covers.
|
|
450
|
+
|
|
451
|
+
Two things stop being problems in gateway mode. Codex can normally only
|
|
452
|
+
express a single `Authorization: Bearer ${VAR}` header, so a server
|
|
453
|
+
needing more than one was refused for Codex — in gateway mode Codex never
|
|
454
|
+
sees any server's headers, so it just works. And remote servers requiring
|
|
455
|
+
real OAuth become reachable from every agent, including pi.
|
|
456
|
+
|
|
457
|
+
### `trellis mcp auth <server-name>`
|
|
458
|
+
|
|
459
|
+
For a remote (`http`/`sse`) server that uses OAuth, authorize it once:
|
|
460
|
+
|
|
461
|
+
```
|
|
462
|
+
$ trellis mcp auth notion-remote
|
|
463
|
+
✅ notion-remote — authorized (expires 2026-09-16T11:24:03.000Z)
|
|
464
|
+
credentials: ~/.trellis/mcp/oauth/notion-remote.json
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
This opens your browser, completes the flow, and stores the result 0600
|
|
468
|
+
under `~/.trellis/mcp/oauth/` — never in `servers.yaml`, so canonical
|
|
469
|
+
stays safe to read, diff, and commit.
|
|
470
|
+
|
|
471
|
+
Run it once per server. After that the gateway refreshes the token
|
|
472
|
+
silently whenever it expires; it never opens a browser and never prompts,
|
|
473
|
+
because it is spawned by an agent with no terminal attached. Re-running
|
|
474
|
+
this command when the stored token is still valid does nothing (`--force`
|
|
475
|
+
overrides); when it has expired but is renewable, it refreshes without a
|
|
476
|
+
browser.
|
|
477
|
+
|
|
478
|
+
If a server has no stored credential, the gateway skips that one server
|
|
479
|
+
and keeps serving every other — it does not fail to start.
|
|
480
|
+
|
|
481
|
+
This command only applies to `http`/`sse` servers. stdio servers get
|
|
482
|
+
their credentials from `env`/`env_aliases` as described above; running
|
|
483
|
+
`mcp auth` on one tells you so rather than opening a pointless browser.
|
|
484
|
+
|
|
357
485
|
## `trellis memory sync`
|
|
358
486
|
|
|
359
487
|
Ingests `~/.trellis/memories/*.md` into the actual on-disk file
|
|
@@ -378,12 +506,47 @@ the graph that Trellis didn't create, the command refuses that one entry
|
|
|
378
506
|
(reported as a conflict) rather than overwriting it, same posture as
|
|
379
507
|
every other conflict in this project.
|
|
380
508
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
`
|
|
509
|
+
This only ingests canonical's *own* `memories/*.md` files into the shared
|
|
510
|
+
store — the reverse direction (an agent's own already-accumulated content
|
|
511
|
+
in that same shared graph, not yet represented in canonical) is
|
|
512
|
+
`trellis memory extract`, below.
|
|
513
|
+
|
|
514
|
+
## `trellis memory extract`
|
|
515
|
+
|
|
516
|
+
The other direction: reads the same graph file's real entities — not
|
|
517
|
+
Trellis's own (anything an agent created directly, via its own MCP tool
|
|
518
|
+
calls against the shared memory server, before or outside of canonical) —
|
|
519
|
+
and writes each as a new canonical `memories/*.md` file. Requires the
|
|
520
|
+
same `memory` server configuration `memory sync` does; refuses with the
|
|
521
|
+
identical message when it's missing.
|
|
522
|
+
|
|
523
|
+
```
|
|
524
|
+
$ trellis memory extract
|
|
525
|
+
memory extract — /Users/you/.trellis/memories/graph.jsonl
|
|
526
|
+
[create] "Sprint Tasks Q2" (sprint-tasks-q2.md) — will create a new canonical memory file
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
A rendered file is a readable markdown transcription — heading,
|
|
530
|
+
observations as a list, relations as a short list when the entity has
|
|
531
|
+
any — not a format meant to round-trip byte-for-byte back through
|
|
532
|
+
`memory sync`: re-syncing an extracted file later turns it into a single,
|
|
533
|
+
flat observation, the same as any other canonical memory file. This is a
|
|
534
|
+
one-way trip from "richer, live graph" to "durable, reviewable,
|
|
535
|
+
version-controllable prose," not a lossless mirror.
|
|
536
|
+
|
|
537
|
+
Same conflict discipline as everywhere else: a target file that already
|
|
538
|
+
exists with different content is reported as a conflict and left
|
|
539
|
+
untouched, never silently overwritten — including when two different
|
|
540
|
+
entities' names would slug to the same filename. Not wired into
|
|
541
|
+
`trellis onboard`'s automatic chain (unlike `memory sync`) — this is a
|
|
542
|
+
deliberate, occasional action you run, the same posture `migrate` already
|
|
543
|
+
has.
|
|
544
|
+
|
|
545
|
+
**Note:** this is unrelated to any project-level "auto memory" feature an
|
|
546
|
+
agent may have of its own (e.g. Claude Code's own per-project memory
|
|
547
|
+
files) — those are a different, agent-specific mechanism entirely, not
|
|
548
|
+
the shared `@modelcontextprotocol/server-memory` server Trellis manages
|
|
549
|
+
here.
|
|
387
550
|
|
|
388
551
|
## `trellis secrets audit`
|
|
389
552
|
|