agent-trellis 0.6.0 → 0.6.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.
- package/dist/adapters/mcpPlan.d.ts +16 -0
- package/dist/adapters/mcpPlan.js +16 -1
- package/dist/cli.js +28 -1
- package/dist/commands/memory.d.ts +15 -0
- package/dist/commands/memory.js +19 -1
- package/dist/commands/migrate.js +18 -0
- package/dist/commands/onboard.d.ts +33 -0
- package/dist/commands/onboard.js +173 -4
- package/dist/core/canonical.d.ts +27 -1
- package/dist/core/canonical.js +46 -0
- package/dist/lib/envVarNames.d.ts +23 -6
- package/dist/lib/envVarNames.js +26 -7
- package/docs/architecture.md +25 -11
- package/docs/getting-started.md +40 -6
- package/docs/roadmap.md +1 -0
- package/package.json +2 -2
- package/schema/servers.example.yaml +9 -4
|
@@ -45,4 +45,20 @@ export interface McpPlanResult {
|
|
|
45
45
|
desired: DesiredMcpEntry[];
|
|
46
46
|
conflicts: McpConflict[];
|
|
47
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Exported so `migrate`'s own read path (`src/commands/migrate.ts`) can
|
|
50
|
+
* apply the identical check on the way *into* canonical, not just on the
|
|
51
|
+
* way out to an agent's native config. Found via real-machine dogfooding:
|
|
52
|
+
* `migrate --from kiro --only mcp` happily copied a real, live
|
|
53
|
+
* `MCPR_TOKEN` literal into `~/.trellis/mcp/servers.yaml` because this
|
|
54
|
+
* check previously existed only here, in `resolveMcpPlan` — canonical
|
|
55
|
+
* itself was never guarded, only the sync-out boundary was. A canonical
|
|
56
|
+
* file that briefly held a real secret before a later `mcp sync` refused
|
|
57
|
+
* to propagate it further already violates "never write a literal
|
|
58
|
+
* secret, ever" — the write already happened, in a file meant to be
|
|
59
|
+
* read, diffed, and committed as plain text. One shared implementation,
|
|
60
|
+
* not two that can drift, for the same reason `mcpConnect.ts`/
|
|
61
|
+
* `mcpToolRegistry.ts` are shared between pi-bridge and the gateway.
|
|
62
|
+
*/
|
|
63
|
+
export declare function findLiteralSecret(def: McpServerDef): string | undefined;
|
|
48
64
|
export declare function resolveMcpPlan(agentId: AgentId, mcp: McpConfig, managedAgents: readonly AgentId[], policy: SecretsPolicy): McpPlanResult;
|
package/dist/adapters/mcpPlan.js
CHANGED
|
@@ -57,7 +57,22 @@ const DANGEROUS_LITERAL_PATTERNS = [
|
|
|
57
57
|
{ label: "GitHub personal access token (ghp_)", pattern: /ghp_/ },
|
|
58
58
|
{ label: "mcp-router token (mcpr_)", pattern: /mcpr_/ },
|
|
59
59
|
];
|
|
60
|
-
|
|
60
|
+
/**
|
|
61
|
+
* Exported so `migrate`'s own read path (`src/commands/migrate.ts`) can
|
|
62
|
+
* apply the identical check on the way *into* canonical, not just on the
|
|
63
|
+
* way out to an agent's native config. Found via real-machine dogfooding:
|
|
64
|
+
* `migrate --from kiro --only mcp` happily copied a real, live
|
|
65
|
+
* `MCPR_TOKEN` literal into `~/.trellis/mcp/servers.yaml` because this
|
|
66
|
+
* check previously existed only here, in `resolveMcpPlan` — canonical
|
|
67
|
+
* itself was never guarded, only the sync-out boundary was. A canonical
|
|
68
|
+
* file that briefly held a real secret before a later `mcp sync` refused
|
|
69
|
+
* to propagate it further already violates "never write a literal
|
|
70
|
+
* secret, ever" — the write already happened, in a file meant to be
|
|
71
|
+
* read, diffed, and committed as plain text. One shared implementation,
|
|
72
|
+
* not two that can drift, for the same reason `mcpConnect.ts`/
|
|
73
|
+
* `mcpToolRegistry.ts` are shared between pi-bridge and the gateway.
|
|
74
|
+
*/
|
|
75
|
+
export function findLiteralSecret(def) {
|
|
61
76
|
const candidates = [def.command, def.url, ...(def.args ?? []), ...Object.values(def.headers ?? {}), ...Object.values(def.staticEnv ?? {})].filter((v) => typeof v === "string");
|
|
62
77
|
for (const candidate of candidates) {
|
|
63
78
|
for (const { label, pattern } of DANGEROUS_LITERAL_PATTERNS) {
|
package/dist/cli.js
CHANGED
|
@@ -38,6 +38,16 @@ Commands:
|
|
|
38
38
|
means "add nothing new this run"
|
|
39
39
|
(required with no terminal to prompt
|
|
40
40
|
in, e.g. --json)
|
|
41
|
+
--mcp-mode <direct|hub|gateway>
|
|
42
|
+
change the MCP mode; omit to leave
|
|
43
|
+
whatever's already configured
|
|
44
|
+
untouched (never prompted)
|
|
45
|
+
--hub-url <url> required with --mcp-mode hub
|
|
46
|
+
--gateway-agents <ids>
|
|
47
|
+
optional with --mcp-mode gateway;
|
|
48
|
+
omit for every managed agent
|
|
49
|
+
--memory <on|off> enable/disable the shared memory MCP
|
|
50
|
+
server; omit to leave it untouched
|
|
41
51
|
--dry-run preview the whole flow, write nothing
|
|
42
52
|
--json machine-readable output, no report text
|
|
43
53
|
init Create ~/.trellis/ with a minimal valid skeleton if missing
|
|
@@ -155,7 +165,24 @@ async function main(argv) {
|
|
|
155
165
|
const agent = agentIndex >= 0 ? rest[agentIndex + 1] : undefined;
|
|
156
166
|
const manageIndex = rest.indexOf("--manage");
|
|
157
167
|
const manage = manageIndex >= 0 ? rest[manageIndex + 1] : undefined;
|
|
158
|
-
const
|
|
168
|
+
const mcpModeIndex = rest.indexOf("--mcp-mode");
|
|
169
|
+
const mcpMode = mcpModeIndex >= 0 ? rest[mcpModeIndex + 1] : undefined;
|
|
170
|
+
const hubUrlIndex = rest.indexOf("--hub-url");
|
|
171
|
+
const hubUrl = hubUrlIndex >= 0 ? rest[hubUrlIndex + 1] : undefined;
|
|
172
|
+
const gatewayAgentsIndex = rest.indexOf("--gateway-agents");
|
|
173
|
+
const gatewayAgents = gatewayAgentsIndex >= 0 ? rest[gatewayAgentsIndex + 1] : undefined;
|
|
174
|
+
const memoryIndex = rest.indexOf("--memory");
|
|
175
|
+
const memory = memoryIndex >= 0 ? rest[memoryIndex + 1] : undefined;
|
|
176
|
+
const { exitCode } = await runOnboard({
|
|
177
|
+
agent,
|
|
178
|
+
manage,
|
|
179
|
+
mcpMode,
|
|
180
|
+
hubUrl,
|
|
181
|
+
gatewayAgents,
|
|
182
|
+
memory,
|
|
183
|
+
dryRun: rest.includes("--dry-run"),
|
|
184
|
+
json: rest.includes("--json"),
|
|
185
|
+
});
|
|
159
186
|
process.exitCode = exitCode;
|
|
160
187
|
return;
|
|
161
188
|
}
|
|
@@ -12,7 +12,22 @@
|
|
|
12
12
|
* server process — both are plain file operations, same posture as
|
|
13
13
|
* every other Trellis write.
|
|
14
14
|
*/
|
|
15
|
+
import type { McpServerDef } from "../core/types.js";
|
|
15
16
|
import { type MemoryExtractionPlan, type MemorySyncPlan } from "../lib/memoryGraph.js";
|
|
17
|
+
/** Exported so `onboard` (`trellis onboard --memory on|off`,
|
|
18
|
+
* trellis-onboard-mcp-mode) can look up and write this same entry
|
|
19
|
+
* without hardcoding the name a second time. */
|
|
20
|
+
export declare const MEMORY_SERVER_NAME = "memory";
|
|
21
|
+
/**
|
|
22
|
+
* The literal definition `trellis onboard --memory on` writes — the
|
|
23
|
+
* same shape `schema/servers.example.yaml`'s commented-out `memory:`
|
|
24
|
+
* block documents, now a real, code-owned constant instead of the only
|
|
25
|
+
* place this shape existed being prose a user hand-transcribes
|
|
26
|
+
* (trellis-onboard-mcp-mode design.md D9). `MEMORY_FILE_PATH` must be
|
|
27
|
+
* set explicitly — see `resolveMemoryServerGraphPath`'s own comment for
|
|
28
|
+
* why an unset one isn't safe to leave to the server's own default.
|
|
29
|
+
*/
|
|
30
|
+
export declare const DEFAULT_MEMORY_SERVER_DEF: McpServerDef;
|
|
16
31
|
export interface RunMemorySyncOptions {
|
|
17
32
|
homeDir?: string;
|
|
18
33
|
json?: boolean;
|
package/dist/commands/memory.js
CHANGED
|
@@ -17,7 +17,25 @@ import { homedir } from "node:os";
|
|
|
17
17
|
import { dirname, join } from "node:path";
|
|
18
18
|
import { loadCanonicalSource } from "../core/canonical.js";
|
|
19
19
|
import { parseMemoryGraph, planMemoryExtraction, planMemorySync, renderMemoryGraph } from "../lib/memoryGraph.js";
|
|
20
|
-
|
|
20
|
+
/** Exported so `onboard` (`trellis onboard --memory on|off`,
|
|
21
|
+
* trellis-onboard-mcp-mode) can look up and write this same entry
|
|
22
|
+
* without hardcoding the name a second time. */
|
|
23
|
+
export const MEMORY_SERVER_NAME = "memory";
|
|
24
|
+
/**
|
|
25
|
+
* The literal definition `trellis onboard --memory on` writes — the
|
|
26
|
+
* same shape `schema/servers.example.yaml`'s commented-out `memory:`
|
|
27
|
+
* block documents, now a real, code-owned constant instead of the only
|
|
28
|
+
* place this shape existed being prose a user hand-transcribes
|
|
29
|
+
* (trellis-onboard-mcp-mode design.md D9). `MEMORY_FILE_PATH` must be
|
|
30
|
+
* set explicitly — see `resolveMemoryServerGraphPath`'s own comment for
|
|
31
|
+
* why an unset one isn't safe to leave to the server's own default.
|
|
32
|
+
*/
|
|
33
|
+
export const DEFAULT_MEMORY_SERVER_DEF = {
|
|
34
|
+
transport: "stdio",
|
|
35
|
+
command: "npx",
|
|
36
|
+
args: ["-y", "@modelcontextprotocol/server-memory"],
|
|
37
|
+
staticEnv: { MEMORY_FILE_PATH: "~/.trellis/memories/graph.jsonl" },
|
|
38
|
+
};
|
|
21
39
|
/**
|
|
22
40
|
* Looks up `mcp.servers["memory"]` specifically (matching `schema/
|
|
23
41
|
* servers.example.yaml`'s own naming convention for the default shared-
|
package/dist/commands/migrate.js
CHANGED
|
@@ -15,6 +15,7 @@ import { AGENTS_MD_TEMPLATE } from "./init.js";
|
|
|
15
15
|
import { decideDirImport } from "../lib/dirEquals.js";
|
|
16
16
|
import { deepEqual } from "../lib/deepEqual.js";
|
|
17
17
|
import { readClaudeCodeMcpDefs, readCodexMcpDefs, readKiroMcpDefs, resolvedEnvTextMap } from "../lib/mcpMigrateRead.js";
|
|
18
|
+
import { findLiteralSecret } from "../adapters/mcpPlan.js";
|
|
18
19
|
import { loadCanonicalSource, upsertServerYaml } from "../core/canonical.js";
|
|
19
20
|
import { ALL_AGENTS } from "../core/types.js";
|
|
20
21
|
const PROBES = {
|
|
@@ -110,6 +111,23 @@ export function isSafeReclassification(existing, def) {
|
|
|
110
111
|
return deepEqual(resolvedEnvTextMap(existing), resolvedEnvTextMap(def));
|
|
111
112
|
}
|
|
112
113
|
function planMcpServer(name, def, existing) {
|
|
114
|
+
// Checked first, before any comparison against `existing`: a source
|
|
115
|
+
// agent's real config holding a literal credential must never reach
|
|
116
|
+
// canonical at all, regardless of whether canonical already has
|
|
117
|
+
// something for this name. `resolveMcpPlan`'s own guard only protects
|
|
118
|
+
// the sync-OUT boundary (canonical -> agent) — this is the matching
|
|
119
|
+
// guard for the migrate-IN boundary (agent -> canonical), the gap a
|
|
120
|
+
// real `mcp-router` token in a real `kiro` config exposed.
|
|
121
|
+
const secretLabel = findLiteralSecret(def);
|
|
122
|
+
if (secretLabel) {
|
|
123
|
+
return {
|
|
124
|
+
kind: "mcp",
|
|
125
|
+
name,
|
|
126
|
+
action: "conflict",
|
|
127
|
+
detail: `refusing to import MCP server "${name}": a value matches a known-dangerous literal pattern (${secretLabel}) — canonical must hold variable NAMES only, never real values (docs/research.md "Secrets")`,
|
|
128
|
+
remediation: `move the real value in the source agent's own config to an environment variable (or your secrets manager), leaving only a name behind, then re-run migrate — canonical never accepts a literal credential, even briefly`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
113
131
|
if (existing === undefined) {
|
|
114
132
|
return { kind: "mcp", name, action: "create", detail: "will add to servers.yaml", mcpDef: def };
|
|
115
133
|
}
|
|
@@ -46,6 +46,20 @@ export interface RunOnboardOptions {
|
|
|
46
46
|
* the literal string "none" for "add nothing new this run" — distinct
|
|
47
47
|
* from omitting the flag, which requires a prompt or refuses. */
|
|
48
48
|
manage?: string;
|
|
49
|
+
/** Non-interactive MCP-mode choice (trellis-onboard-mcp-mode). Omitted
|
|
50
|
+
* leaves whatever canonical already has untouched and prompts for
|
|
51
|
+
* nothing — "no change requested," not "please choose" (design.md
|
|
52
|
+
* D1), unlike `agent`/`manage` above. */
|
|
53
|
+
mcpMode?: string;
|
|
54
|
+
/** Required when `mcpMode === "hub"`; refused if given otherwise. */
|
|
55
|
+
hubUrl?: string;
|
|
56
|
+
/** Optional when `mcpMode === "gateway"` (comma-separated agent ids;
|
|
57
|
+
* omitted = every managed agent); refused if given otherwise. */
|
|
58
|
+
gatewayAgents?: string;
|
|
59
|
+
/** Non-interactive shared-memory-server toggle: `"on"` or `"off"`.
|
|
60
|
+
* Omitted leaves whatever's already configured untouched — same
|
|
61
|
+
* no-prompt posture as `mcpMode` (design.md D12). */
|
|
62
|
+
memory?: string;
|
|
49
63
|
dryRun?: boolean;
|
|
50
64
|
json?: boolean;
|
|
51
65
|
/** Defaults to the real `~`; overridable for tests only. */
|
|
@@ -117,6 +131,25 @@ export interface OnboardResult {
|
|
|
117
131
|
* inconsistent across agents independent of this run.
|
|
118
132
|
*/
|
|
119
133
|
doctorReport?: DoctorReport;
|
|
134
|
+
/**
|
|
135
|
+
* The resolved MCP mode after this run (trellis-onboard-mcp-mode
|
|
136
|
+
* design.md D7) — `current` reflects canonical's state whether or not
|
|
137
|
+
* this run changed it, `changed` is `true` only when `--mcp-mode` was
|
|
138
|
+
* given and differed from what canonical had before this run. Absent
|
|
139
|
+
* on every early-refusal path before mode resolution is reached.
|
|
140
|
+
*/
|
|
141
|
+
mcpMode?: {
|
|
142
|
+
current: "direct" | "hub" | "gateway";
|
|
143
|
+
previous: "direct" | "hub" | "gateway";
|
|
144
|
+
changed: boolean;
|
|
145
|
+
};
|
|
146
|
+
/** The resolved shared-memory-server state after this run, mirroring
|
|
147
|
+
* `mcpMode` (design.md D13). */
|
|
148
|
+
memory?: {
|
|
149
|
+
current: "on" | "off";
|
|
150
|
+
previous: "on" | "off";
|
|
151
|
+
changed: boolean;
|
|
152
|
+
};
|
|
120
153
|
refusal?: string;
|
|
121
154
|
/** Only set when no agent is present — the same values `--json` and
|
|
122
155
|
* text output both surface, so a machine caller doesn't have to
|
package/dist/commands/onboard.js
CHANGED
|
@@ -22,13 +22,13 @@ import * as codexProbe from "../probes/codex.js";
|
|
|
22
22
|
import * as kiroProbe from "../probes/kiro.js";
|
|
23
23
|
import * as piProbe from "../probes/pi.js";
|
|
24
24
|
import { ALL_AGENTS } from "../core/types.js";
|
|
25
|
-
import { loadCanonicalSource } from "../core/canonical.js";
|
|
25
|
+
import { loadCanonicalSource, removeServerYaml, upsertServerYaml, writeMcpModeYaml } from "../core/canonical.js";
|
|
26
26
|
import { INSTALL_HINTS, collectInitReport } from "./init.js";
|
|
27
27
|
import { applyMigratePlan, collectMigratePlan, printPlan as printMigratePlan } from "./migrate.js";
|
|
28
28
|
import { collectSyncReport, printReport as printSyncReport } from "./sync.js";
|
|
29
29
|
import { collectMcpSyncReport, printReport as printMcpSyncReport } from "./mcp.js";
|
|
30
30
|
import { collectSecretsAuditReport, printReport as printSecretsAuditReport } from "./secretsAudit.js";
|
|
31
|
-
import { applyMemorySync, collectMemorySyncResult, printMemorySyncResult } from "./memory.js";
|
|
31
|
+
import { applyMemorySync, collectMemorySyncResult, printMemorySyncResult, DEFAULT_MEMORY_SERVER_DEF, MEMORY_SERVER_NAME } from "./memory.js";
|
|
32
32
|
import { collectDoctorReport, printReport as printDoctorReport, resolveKnownHostInjected } from "./doctor.js";
|
|
33
33
|
import { openBackupSession } from "../lib/backup.js";
|
|
34
34
|
import { confirmAndInstall } from "../lib/installAgent.js";
|
|
@@ -230,6 +230,120 @@ function parseManagedSelection(raw, candidates) {
|
|
|
230
230
|
}
|
|
231
231
|
return { agents: [...new Set(agents)] };
|
|
232
232
|
}
|
|
233
|
+
/** Comma-separated agent ids only — unlike `parseManagedSelection`, no
|
|
234
|
+
* numbered-index shorthand, since `--gateway-agents` never has a
|
|
235
|
+
* numbered list on screen to refer back to (it's flag-only, never
|
|
236
|
+
* prompted; design.md D1/D4). */
|
|
237
|
+
function parseAgentIdList(raw) {
|
|
238
|
+
const tokens = raw.split(",").map((t) => t.trim()).filter((t) => t.length > 0);
|
|
239
|
+
const agents = [];
|
|
240
|
+
for (const token of tokens) {
|
|
241
|
+
if (!ALL_AGENTS.includes(token)) {
|
|
242
|
+
return { error: `"${token}" is not a valid agent id — use one of ${ALL_AGENTS.join(", ")}` };
|
|
243
|
+
}
|
|
244
|
+
agents.push(token);
|
|
245
|
+
}
|
|
246
|
+
return [...new Set(agents)];
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Validates `--mcp-mode`/`--hub-url`/`--gateway-agents` together, before
|
|
250
|
+
* any other work (trellis-onboard-mcp-mode design.md D4) — a bad
|
|
251
|
+
* combination refuses the whole run rather than being discovered
|
|
252
|
+
* partway through. `mode` is only set when `mcpMode` was actually
|
|
253
|
+
* given; omitted resolves to `{}`, meaning "no change requested," not
|
|
254
|
+
* an error (design.md D1) — distinct from every other field on
|
|
255
|
+
* `RunOnboardOptions`, where omission usually requires a prompt.
|
|
256
|
+
*/
|
|
257
|
+
function validateMcpModeOption(opts) {
|
|
258
|
+
const { mcpMode, hubUrl, gatewayAgents } = opts;
|
|
259
|
+
if (mcpMode === undefined) {
|
|
260
|
+
if (hubUrl !== undefined)
|
|
261
|
+
return { error: "--hub-url was given without --mcp-mode hub" };
|
|
262
|
+
if (gatewayAgents !== undefined)
|
|
263
|
+
return { error: "--gateway-agents was given without --mcp-mode gateway" };
|
|
264
|
+
return {};
|
|
265
|
+
}
|
|
266
|
+
if (mcpMode !== "direct" && mcpMode !== "hub" && mcpMode !== "gateway") {
|
|
267
|
+
return { error: `--mcp-mode must be one of direct, hub, gateway (got "${mcpMode}")` };
|
|
268
|
+
}
|
|
269
|
+
if (mcpMode !== "hub" && hubUrl !== undefined) {
|
|
270
|
+
return { error: "--hub-url was given without --mcp-mode hub" };
|
|
271
|
+
}
|
|
272
|
+
if (mcpMode !== "gateway" && gatewayAgents !== undefined) {
|
|
273
|
+
return { error: "--gateway-agents was given without --mcp-mode gateway" };
|
|
274
|
+
}
|
|
275
|
+
if (mcpMode === "direct")
|
|
276
|
+
return { mode: { kind: "direct" } };
|
|
277
|
+
if (mcpMode === "hub") {
|
|
278
|
+
if (!hubUrl)
|
|
279
|
+
return { error: "--mcp-mode hub requires --hub-url <url>" };
|
|
280
|
+
return { mode: { kind: "hub", url: hubUrl } };
|
|
281
|
+
}
|
|
282
|
+
// mcpMode === "gateway"
|
|
283
|
+
if (gatewayAgents === undefined)
|
|
284
|
+
return { mode: { kind: "gateway" } };
|
|
285
|
+
const agents = parseAgentIdList(gatewayAgents);
|
|
286
|
+
if ("error" in agents)
|
|
287
|
+
return agents;
|
|
288
|
+
return { mode: { kind: "gateway", agents } };
|
|
289
|
+
}
|
|
290
|
+
/** Same "omitted means no change requested" posture as
|
|
291
|
+
* `validateMcpModeOption` (design.md D12). */
|
|
292
|
+
function validateMemoryOption(memory) {
|
|
293
|
+
if (memory === undefined)
|
|
294
|
+
return {};
|
|
295
|
+
if (memory !== "on" && memory !== "off")
|
|
296
|
+
return { error: `--memory must be "on" or "off" (got "${memory}")` };
|
|
297
|
+
return { value: memory };
|
|
298
|
+
}
|
|
299
|
+
function mcpModeValueOf(mcp) {
|
|
300
|
+
if (mcp.gateway?.enabled)
|
|
301
|
+
return "gateway";
|
|
302
|
+
if (mcp.hub)
|
|
303
|
+
return "hub";
|
|
304
|
+
return "direct";
|
|
305
|
+
}
|
|
306
|
+
function resolveMcpModeChange(mode, currentValue) {
|
|
307
|
+
if (!mode)
|
|
308
|
+
return { current: currentValue, previous: currentValue, changed: false };
|
|
309
|
+
return { current: mode.kind, previous: currentValue, changed: mode.kind !== currentValue, write: mode };
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* `value === "on"` against an already-host-injected `"memory"` refuses
|
|
313
|
+
* outright rather than writing a definition `mcp sync` would refuse to
|
|
314
|
+
* propagate to any agent a moment later (design.md D10) — the same fact
|
|
315
|
+
* `schema/servers.example.yaml`'s own comment already explains to a
|
|
316
|
+
* human reading it by hand.
|
|
317
|
+
*/
|
|
318
|
+
function resolveMemoryToggle(value, configuredBefore, knownHostInjected) {
|
|
319
|
+
const previous = configuredBefore ? "on" : "off";
|
|
320
|
+
if (value === undefined)
|
|
321
|
+
return { current: previous, previous, changed: false };
|
|
322
|
+
if (value === "on") {
|
|
323
|
+
if (configuredBefore)
|
|
324
|
+
return { current: "on", previous, changed: false };
|
|
325
|
+
if (knownHostInjected.includes(MEMORY_SERVER_NAME)) {
|
|
326
|
+
return {
|
|
327
|
+
refusal: `refusing to enable the memory server: "${MEMORY_SERVER_NAME}" is already listed in known_host_injected — a host on this machine is presumed to already inject its own memory connector under that name, so a static definition here would only collide the next time \`mcp sync\` runs`,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
return { current: "on", previous, changed: true };
|
|
331
|
+
}
|
|
332
|
+
// value === "off"
|
|
333
|
+
if (!configuredBefore)
|
|
334
|
+
return { current: "off", previous, changed: false };
|
|
335
|
+
return { current: "off", previous, changed: true };
|
|
336
|
+
}
|
|
337
|
+
function mcpModeStatusLine(mode) {
|
|
338
|
+
if (!mode.changed)
|
|
339
|
+
return `mcp mode: ${mode.current} (unchanged) — pass --mcp-mode direct|hub|gateway to change`;
|
|
340
|
+
return `mcp mode: ${mode.current} (changed from ${mode.previous})`;
|
|
341
|
+
}
|
|
342
|
+
function memoryStatusLine(memory) {
|
|
343
|
+
if (!memory.changed)
|
|
344
|
+
return `memory: ${memory.current} (unchanged) — pass --memory ${memory.current === "on" ? "off" : "on"} to change`;
|
|
345
|
+
return `memory: ${memory.current} (changed from ${memory.previous})`;
|
|
346
|
+
}
|
|
233
347
|
async function resolveManagedAgents(opts, summary, alreadyManaged) {
|
|
234
348
|
if (opts.manage !== undefined) {
|
|
235
349
|
const parsed = parseManagedSelection(opts.manage, summary);
|
|
@@ -262,6 +376,20 @@ export async function collectOnboardPlan(opts = {}) {
|
|
|
262
376
|
if (present.length === 0) {
|
|
263
377
|
return { summary, installHints: { ...INSTALL_HINTS }, verdict: [] };
|
|
264
378
|
}
|
|
379
|
+
// Validated up front, before any of this run's own writes (managed.yaml
|
|
380
|
+
// included) — a bad `--mcp-mode`/`--hub-url`/`--gateway-agents`/
|
|
381
|
+
// `--memory` combination refuses the whole run cleanly rather than
|
|
382
|
+
// being discovered partway through (design.md D4). The parsed results
|
|
383
|
+
// are reused later, once managed-set resolution has run (design.md
|
|
384
|
+
// D5/D11).
|
|
385
|
+
const mcpModeValidation = validateMcpModeOption(opts);
|
|
386
|
+
if ("error" in mcpModeValidation) {
|
|
387
|
+
return { summary, refusal: mcpModeValidation.error, verdict: [] };
|
|
388
|
+
}
|
|
389
|
+
const memoryValidation = validateMemoryOption(opts.memory);
|
|
390
|
+
if ("error" in memoryValidation) {
|
|
391
|
+
return { summary, refusal: memoryValidation.error, verdict: [] };
|
|
392
|
+
}
|
|
265
393
|
const sourceCandidates = present.filter(hasContent);
|
|
266
394
|
let source;
|
|
267
395
|
let sourceReason;
|
|
@@ -341,6 +469,34 @@ export async function collectOnboardPlan(opts = {}) {
|
|
|
341
469
|
if (!opts.dryRun) {
|
|
342
470
|
writeManagedYaml(homeDir, managedAgents);
|
|
343
471
|
}
|
|
472
|
+
// MCP mode + memory-server resolution (design.md D5/D11) — grouped
|
|
473
|
+
// with managed-set above as "how is this machine configured"
|
|
474
|
+
// decisions resolved once per run, ahead of migrate/sync/mcp-sync/
|
|
475
|
+
// memory-sync below, which act on the result. Both flags were already
|
|
476
|
+
// validated before managed-set was even touched; omitting either
|
|
477
|
+
// resolves to "no change requested" here too (design.md D1/D12) — no
|
|
478
|
+
// writer call at all, on either a first run or a later one.
|
|
479
|
+
const canonicalForModeAndMemory = loadCanonicalSource(homeDir);
|
|
480
|
+
const mcpModeResolution = resolveMcpModeChange(mcpModeValidation.mode, mcpModeValueOf(canonicalForModeAndMemory.mcp));
|
|
481
|
+
const mcpMode = { current: mcpModeResolution.current, previous: mcpModeResolution.previous, changed: mcpModeResolution.changed };
|
|
482
|
+
const serversYamlPath = join(homeDir, ".trellis", "mcp", "servers.yaml");
|
|
483
|
+
if (mcpModeResolution.write && !opts.dryRun) {
|
|
484
|
+
writeMcpModeYaml(serversYamlPath, mcpModeResolution.write);
|
|
485
|
+
}
|
|
486
|
+
const memoryConfiguredBefore = canonicalForModeAndMemory.mcp.servers[MEMORY_SERVER_NAME] !== undefined;
|
|
487
|
+
const memoryResolution = resolveMemoryToggle(memoryValidation.value, memoryConfiguredBefore, canonicalForModeAndMemory.mcp.knownHostInjected);
|
|
488
|
+
if ("refusal" in memoryResolution) {
|
|
489
|
+
return { summary, source, sourceReason, managedAgents, mcpMode, refusal: memoryResolution.refusal, verdict: [] };
|
|
490
|
+
}
|
|
491
|
+
const memory = { current: memoryResolution.current, previous: memoryResolution.previous, changed: memoryResolution.changed };
|
|
492
|
+
if (memory.changed && !opts.dryRun) {
|
|
493
|
+
if (memory.current === "on") {
|
|
494
|
+
upsertServerYaml(serversYamlPath, MEMORY_SERVER_NAME, DEFAULT_MEMORY_SERVER_DEF);
|
|
495
|
+
}
|
|
496
|
+
else {
|
|
497
|
+
removeServerYaml(serversYamlPath, MEMORY_SERVER_NAME);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
344
500
|
let migratePlan;
|
|
345
501
|
let migrateSkipped;
|
|
346
502
|
logProgress(opts, "migrate");
|
|
@@ -427,6 +583,8 @@ export async function collectOnboardPlan(opts = {}) {
|
|
|
427
583
|
memorySyncResult,
|
|
428
584
|
secretsAuditReport,
|
|
429
585
|
doctorReport,
|
|
586
|
+
mcpMode,
|
|
587
|
+
memory,
|
|
430
588
|
verdict,
|
|
431
589
|
};
|
|
432
590
|
}
|
|
@@ -453,7 +611,7 @@ export async function runOnboard(opts = {}) {
|
|
|
453
611
|
console.log(JSON.stringify(result, null, 2));
|
|
454
612
|
}
|
|
455
613
|
else {
|
|
456
|
-
printResult(result, opts.dryRun ?? false);
|
|
614
|
+
printResult(result, opts.dryRun ?? false, shouldShowProgress(opts));
|
|
457
615
|
// Always last — including on a fully clean run and on a refusal —
|
|
458
616
|
// so the terminal state of the run is never a green line from
|
|
459
617
|
// whichever stage happened to print last (spec: "Every run
|
|
@@ -482,7 +640,7 @@ export async function runOnboard(opts = {}) {
|
|
|
482
640
|
const hasBlocked = result.verdict.some((item) => item.severity === "blocked");
|
|
483
641
|
return { exitCode: hasBlocked ? 1 : 0 };
|
|
484
642
|
}
|
|
485
|
-
function printResult(result, dryRun) {
|
|
643
|
+
function printResult(result, dryRun, showStatusLine) {
|
|
486
644
|
if (dryRun)
|
|
487
645
|
console.log("[dry run]");
|
|
488
646
|
const present = result.summary.filter((s) => s.present);
|
|
@@ -517,6 +675,17 @@ function printResult(result, dryRun) {
|
|
|
517
675
|
: `${install.agent} has no npm package to install (see \`trellis init\`'s install hint) — left out of this run's managed set.`);
|
|
518
676
|
}
|
|
519
677
|
console.log(`Managed agents: ${result.managedAgents && result.managedAgents.length > 0 ? result.managedAgents.join(", ") : "(none)"}`);
|
|
678
|
+
// Informational only, never a prompt (design.md D6/D12,
|
|
679
|
+
// trellis-onboard-mcp-mode) — this is what keeps `--mcp-mode`/
|
|
680
|
+
// `--memory` discoverable without costing every run a keystroke.
|
|
681
|
+
// Suppressed under `--json` and non-TTY, same gate stage progress
|
|
682
|
+
// already uses.
|
|
683
|
+
if (showStatusLine && result.mcpMode) {
|
|
684
|
+
console.log(mcpModeStatusLine(result.mcpMode));
|
|
685
|
+
}
|
|
686
|
+
if (showStatusLine && result.memory) {
|
|
687
|
+
console.log(memoryStatusLine(result.memory));
|
|
688
|
+
}
|
|
520
689
|
// Reuse `migrate`/`sync`'s own printing verbatim (including the
|
|
521
690
|
// "nothing to migrate" / "already in sync" cases) rather than a second,
|
|
522
691
|
// easily-drifting copy of this formatting. `dryRun: false` here since
|
package/dist/core/canonical.d.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* workspace scope"). See openspec/changes/trellis-sync-p1/specs/
|
|
5
5
|
* canonical-source-loading/spec.md for the exact contract this implements.
|
|
6
6
|
*/
|
|
7
|
-
import type { CanonicalSource, McpServerDef } from "./types.js";
|
|
7
|
+
import type { AgentId, CanonicalSource, McpServerDef } from "./types.js";
|
|
8
8
|
/**
|
|
9
9
|
* The on-disk shape for one server entry — `static_env`/`env_aliases`
|
|
10
10
|
* (snake_case, like every other multi-word key across `.trellis/*.yaml`)
|
|
@@ -31,6 +31,32 @@ export declare function upsertServerYaml(path: string, name: string, def: McpSer
|
|
|
31
31
|
/** Inverse of `upsertServerYaml` — same preservation guarantee, same
|
|
32
32
|
* refusal posture on a missing/unparseable file. */
|
|
33
33
|
export declare function removeServerYaml(path: string, name: string): ServersYamlWriteResult;
|
|
34
|
+
/**
|
|
35
|
+
* `trellis onboard --mcp-mode <value>` (trellis-onboard-mcp-mode) —
|
|
36
|
+
* everything needed to select or clear the top-level `hub`/`gateway`
|
|
37
|
+
* mode fields, mutually exclusive by construction (design.md D3): a
|
|
38
|
+
* `"direct"` mode carries nothing to set, only two keys to clear.
|
|
39
|
+
*/
|
|
40
|
+
export type McpMode = {
|
|
41
|
+
kind: "direct";
|
|
42
|
+
} | {
|
|
43
|
+
kind: "hub";
|
|
44
|
+
url: string;
|
|
45
|
+
} | {
|
|
46
|
+
kind: "gateway";
|
|
47
|
+
agents?: AgentId[];
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Sets or clears `servers.yaml`'s top-level `hub`/`gateway` keys — the
|
|
51
|
+
* write path `upsertServerYaml`/`removeServerYaml` don't cover, since
|
|
52
|
+
* both are scoped to one `servers` entry (design.md D2). Same
|
|
53
|
+
* `Document`-based mechanism, same refusal posture on a missing or
|
|
54
|
+
* unparseable file, same `forceBlockStyle` correction for a key set for
|
|
55
|
+
* the first time into a still-flow-style document (`trellis init`'s
|
|
56
|
+
* starter file). Selecting one mode always clears the other two
|
|
57
|
+
* (design.md D3) — never left for the caller to remember.
|
|
58
|
+
*/
|
|
59
|
+
export declare function writeMcpModeYaml(path: string, mode: McpMode): ServersYamlWriteResult;
|
|
34
60
|
/**
|
|
35
61
|
* `homeDir` defaults to the real `~` and is only ever overridden for tests
|
|
36
62
|
* and `scripts/sandbox.sh` — the same seam P0's probes use
|
package/dist/core/canonical.js
CHANGED
|
@@ -165,6 +165,52 @@ export function removeServerYaml(path, name) {
|
|
|
165
165
|
writeFileSync(path, doc.toString());
|
|
166
166
|
return { ok: true };
|
|
167
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* Sets or clears `servers.yaml`'s top-level `hub`/`gateway` keys — the
|
|
170
|
+
* write path `upsertServerYaml`/`removeServerYaml` don't cover, since
|
|
171
|
+
* both are scoped to one `servers` entry (design.md D2). Same
|
|
172
|
+
* `Document`-based mechanism, same refusal posture on a missing or
|
|
173
|
+
* unparseable file, same `forceBlockStyle` correction for a key set for
|
|
174
|
+
* the first time into a still-flow-style document (`trellis init`'s
|
|
175
|
+
* starter file). Selecting one mode always clears the other two
|
|
176
|
+
* (design.md D3) — never left for the caller to remember.
|
|
177
|
+
*/
|
|
178
|
+
export function writeMcpModeYaml(path, mode) {
|
|
179
|
+
if (!existsSync(path)) {
|
|
180
|
+
return { ok: false, error: `${path} does not exist — run \`trellis init\` first` };
|
|
181
|
+
}
|
|
182
|
+
let doc;
|
|
183
|
+
try {
|
|
184
|
+
doc = parseDocument(readFileSync(path, "utf-8"));
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
return { ok: false, error: `could not parse ${path}: ${err instanceof Error ? err.message : String(err)}` };
|
|
188
|
+
}
|
|
189
|
+
switch (mode.kind) {
|
|
190
|
+
case "direct":
|
|
191
|
+
doc.delete("hub");
|
|
192
|
+
doc.delete("gateway");
|
|
193
|
+
break;
|
|
194
|
+
case "hub":
|
|
195
|
+
doc.setIn(["hub", "url"], mode.url);
|
|
196
|
+
doc.delete("gateway");
|
|
197
|
+
forceBlockStyle(doc.get("hub", true));
|
|
198
|
+
break;
|
|
199
|
+
case "gateway":
|
|
200
|
+
doc.setIn(["gateway", "enabled"], true);
|
|
201
|
+
if (mode.agents !== undefined) {
|
|
202
|
+
doc.setIn(["gateway", "agents"], mode.agents);
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
doc.deleteIn(["gateway", "agents"]);
|
|
206
|
+
}
|
|
207
|
+
doc.delete("hub");
|
|
208
|
+
forceBlockStyle(doc.get("gateway", true));
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
writeFileSync(path, doc.toString());
|
|
212
|
+
return { ok: true };
|
|
213
|
+
}
|
|
168
214
|
function loadSecretsPolicyYaml(path, homeDir) {
|
|
169
215
|
if (!existsSync(path)) {
|
|
170
216
|
return { allowedVars: [], rejectPatterns: [] };
|
|
@@ -20,12 +20,29 @@ export declare function extractTemplateVarNames(value: string): string[];
|
|
|
20
20
|
* same answer to "what names does this def reference," not two
|
|
21
21
|
* separately-maintained collections. */
|
|
22
22
|
export declare function declaredEnvNames(def: McpServerDef): string[];
|
|
23
|
-
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
23
|
+
/**
|
|
24
|
+
* Claude Code / Kiro's config is real JSON — parse it and walk each
|
|
25
|
+
* server's `env` object *values*, plus names embedded in `headers`
|
|
26
|
+
* object values. Both read the same way: the variable name lives inside
|
|
27
|
+
* a `${...}` reference in the value, never the key alone.
|
|
28
|
+
*
|
|
29
|
+
* This matters because `env`/`staticEnv` share one JSON object with no
|
|
30
|
+
* structural tag distinguishing them (`renderJsonServerEntry`'s own doc
|
|
31
|
+
* comment) — `env`/`envAliases` render as a `${NAME}` reference,
|
|
32
|
+
* `staticEnv` renders as its literal value verbatim, same map. Reading
|
|
33
|
+
* every KEY unconditionally (as this function did before) swept
|
|
34
|
+
* `staticEnv`'s literal, never-a-secret keys into the same
|
|
35
|
+
* `allowed_vars` check `env` names need, and got `envAliases` wrong in
|
|
36
|
+
* the opposite direction — its value's `${sourceName}` differs from its
|
|
37
|
+
* own key, so a key-based read checked the wrong name against
|
|
38
|
+
* `allowed_vars` entirely. Extracting from the value fixes both at once:
|
|
39
|
+
* a literal `staticEnv` value has no `${...}` to match, so it
|
|
40
|
+
* contributes nothing; an `envAliases` entry correctly contributes its
|
|
41
|
+
* referenced source name, not its target key.
|
|
42
|
+
*
|
|
43
|
+
* Returns `[]` (not a throw) on invalid JSON; that's a diagnostic for a
|
|
44
|
+
* different command, not this extractor's job.
|
|
45
|
+
*/
|
|
29
46
|
export declare function extractJsonEnvVarNames(content: string): string[];
|
|
30
47
|
/** Codex's config is TOML. The shapes audit needs: every `env_vars = [...]`
|
|
31
48
|
* line's quoted string contents, plus a `bearer_token_env_var = "VAR"`
|
package/dist/lib/envVarNames.js
CHANGED
|
@@ -29,12 +29,29 @@ export function declaredEnvNames(def) {
|
|
|
29
29
|
}
|
|
30
30
|
return [...names];
|
|
31
31
|
}
|
|
32
|
-
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
32
|
+
/**
|
|
33
|
+
* Claude Code / Kiro's config is real JSON — parse it and walk each
|
|
34
|
+
* server's `env` object *values*, plus names embedded in `headers`
|
|
35
|
+
* object values. Both read the same way: the variable name lives inside
|
|
36
|
+
* a `${...}` reference in the value, never the key alone.
|
|
37
|
+
*
|
|
38
|
+
* This matters because `env`/`staticEnv` share one JSON object with no
|
|
39
|
+
* structural tag distinguishing them (`renderJsonServerEntry`'s own doc
|
|
40
|
+
* comment) — `env`/`envAliases` render as a `${NAME}` reference,
|
|
41
|
+
* `staticEnv` renders as its literal value verbatim, same map. Reading
|
|
42
|
+
* every KEY unconditionally (as this function did before) swept
|
|
43
|
+
* `staticEnv`'s literal, never-a-secret keys into the same
|
|
44
|
+
* `allowed_vars` check `env` names need, and got `envAliases` wrong in
|
|
45
|
+
* the opposite direction — its value's `${sourceName}` differs from its
|
|
46
|
+
* own key, so a key-based read checked the wrong name against
|
|
47
|
+
* `allowed_vars` entirely. Extracting from the value fixes both at once:
|
|
48
|
+
* a literal `staticEnv` value has no `${...}` to match, so it
|
|
49
|
+
* contributes nothing; an `envAliases` entry correctly contributes its
|
|
50
|
+
* referenced source name, not its target key.
|
|
51
|
+
*
|
|
52
|
+
* Returns `[]` (not a throw) on invalid JSON; that's a diagnostic for a
|
|
53
|
+
* different command, not this extractor's job.
|
|
54
|
+
*/
|
|
38
55
|
export function extractJsonEnvVarNames(content) {
|
|
39
56
|
let parsed;
|
|
40
57
|
try {
|
|
@@ -45,7 +62,9 @@ export function extractJsonEnvVarNames(content) {
|
|
|
45
62
|
}
|
|
46
63
|
const names = [];
|
|
47
64
|
for (const server of Object.values(parsed.mcpServers ?? {})) {
|
|
48
|
-
|
|
65
|
+
for (const value of Object.values(server.env ?? {})) {
|
|
66
|
+
names.push(...extractTemplateVarNames(value));
|
|
67
|
+
}
|
|
49
68
|
for (const value of Object.values(server.headers ?? {})) {
|
|
50
69
|
names.push(...extractTemplateVarNames(value));
|
|
51
70
|
}
|
package/docs/architecture.md
CHANGED
|
@@ -176,9 +176,13 @@ regardless of hub mode.
|
|
|
176
176
|
## MCP hub mode
|
|
177
177
|
|
|
178
178
|
Every agent's MCP surface can be either N direct server definitions
|
|
179
|
-
(default) or one static entry pointing at a single HTTP endpoint —
|
|
180
|
-
|
|
181
|
-
`
|
|
179
|
+
(default) or one static entry pointing at a single HTTP endpoint — `mcp.hub.url`
|
|
180
|
+
in `mcp/servers.yaml` is the field, but the supported way to set it is
|
|
181
|
+
`trellis onboard --mcp-mode hub --hub-url <url>` (trellis-onboard-mcp-mode),
|
|
182
|
+
not hand-editing the file: onboard's own writer clears any conflicting
|
|
183
|
+
`gateway` block for you and is the same command that later turns hub mode
|
|
184
|
+
back off again (`--mcp-mode direct`). `schema/servers.example.yaml` still
|
|
185
|
+
documents the raw field shape for reference. Nothing about *what* runs behind that URL
|
|
182
186
|
is part of Trellis's design: a self-hosted
|
|
183
187
|
[mcp-hub](https://github.com/ravitemer/mcp-hub) instance, a hosted
|
|
184
188
|
mcp-router account, anything else speaking MCP over HTTP all look
|
|
@@ -275,11 +279,19 @@ native OAuth flow, exactly as before.
|
|
|
275
279
|
|
|
276
280
|
Gateway mode collapses an agent's MCP config to one entry, like hub mode
|
|
277
281
|
— but what sits behind it is a local subprocess Trellis owns
|
|
278
|
-
(`trellis mcp-gateway --agent <id>`), not a URL someone operates.
|
|
279
|
-
|
|
280
|
-
narrow it to specific agents
|
|
281
|
-
|
|
282
|
-
|
|
282
|
+
(`trellis mcp-gateway --agent <id>`), not a URL someone operates. Turn it
|
|
283
|
+
on with `trellis onboard --mcp-mode gateway` (optionally
|
|
284
|
+
`--gateway-agents <ids>` to narrow it to specific agents instead of every
|
|
285
|
+
managed agent); `trellis onboard --mcp-mode direct` turns it back off.
|
|
286
|
+
The underlying fields (`mcp.gateway.enabled`/`.agents` in
|
|
287
|
+
`mcp/servers.yaml`) are what onboard's writer sets — hand-editing them is
|
|
288
|
+
unsupported now that a real command exists for it
|
|
289
|
+
(trellis-onboard-mcp-mode). Selecting `hub` instead always clears
|
|
290
|
+
`gateway`, and vice versa (a mode selector, not two independently
|
|
291
|
+
toggleable fields) — see "MCP hub mode" above for why onboard treats them
|
|
292
|
+
as one mutually-exclusive choice rather than exposing the field-level
|
|
293
|
+
"both set, gateway wins" flexibility `resolveMcpPlan` itself still
|
|
294
|
+
tolerates for anyone who reaches this state some other way.
|
|
283
295
|
|
|
284
296
|
**Lifecycle: there isn't one.** The agent spawns it like any other stdio
|
|
285
297
|
MCP server and it exits when the session ends. No daemon, no
|
|
@@ -326,9 +338,11 @@ re-sync and nothing the user notices.
|
|
|
326
338
|
subprocess with no lifecycle; hub mode points at something you operate.
|
|
327
339
|
Neither is a service Trellis starts, supervises, or keeps running.
|
|
328
340
|
- A memory backend (defaults to `@modelcontextprotocol/server-memory`,
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
341
|
+
turned on with `trellis onboard --memory on` (trellis-onboard-mcp-mode)
|
|
342
|
+
rather than hand-editing `servers.yaml`, documented in
|
|
343
|
+
`schema/servers.example.yaml`; mem0/OpenMemory and totalrecallai-class
|
|
344
|
+
semantic-search servers documented as opt-in upgrades — see
|
|
345
|
+
docs/research.md "Shared memory")
|
|
332
346
|
- A secret vault (reads `${VAR}` from whatever the environment already
|
|
333
347
|
provides — `~/.config/agent-env/secrets.env`, 1Password's `op run`,
|
|
334
348
|
anything that populates `process.env` before an adapter's generated
|
package/docs/getting-started.md
CHANGED
|
@@ -16,10 +16,15 @@ $ trellis onboard
|
|
|
16
16
|
```
|
|
17
17
|
|
|
18
18
|
Runs `init`, detects which of Claude Code/Codex/Kiro/pi are on this machine,
|
|
19
|
-
then resolves two
|
|
20
|
-
`mcp sync`, `memory sync`, `secrets audit`, and a final
|
|
21
|
-
whole onboarding path, no follow-up commands to type by
|
|
22
|
-
to run `trellis doctor` separately to know whether it
|
|
19
|
+
then resolves two interactive choices and two flag-only ones before running
|
|
20
|
+
`migrate`, `sync`, `mcp sync`, `memory sync`, `secrets audit`, and a final
|
|
21
|
+
health scan — the whole onboarding path, no follow-up commands to type by
|
|
22
|
+
hand, and no need to run `trellis doctor` separately to know whether it
|
|
23
|
+
actually worked. This is also the one command for reconfiguring an
|
|
24
|
+
already-onboarded machine later: every one of the four choices below reads
|
|
25
|
+
what's already there first, so a bare `trellis onboard` re-run changes
|
|
26
|
+
nothing about them, and re-running with a new flag value updates exactly
|
|
27
|
+
that.
|
|
23
28
|
|
|
24
29
|
1. **Migration source** — read from, at most one, never written back to.
|
|
25
30
|
"Real content" means skills, custom instructions, *or* real MCP servers
|
|
@@ -44,11 +49,36 @@ to run `trellis doctor` separately to know whether it actually worked.
|
|
|
44
49
|
Selecting an agent that isn't installed yet is itself the authorization to
|
|
45
50
|
install it (one confirmation, then a real `npm install -g <package>`);
|
|
46
51
|
Kiro has no CLI package and is refused with its download URL instead.
|
|
52
|
+
3. **MCP mode** — direct (the default), hub, or gateway. Flag-only, never
|
|
53
|
+
prompted: pass `--mcp-mode direct|hub|gateway` to change it (`--hub-url
|
|
54
|
+
<url>` is required with `hub`; `--gateway-agents <ids>` is optional with
|
|
55
|
+
`gateway`, omitted meaning every managed agent). Omitting `--mcp-mode`
|
|
56
|
+
entirely leaves whatever's already configured untouched — on a fresh
|
|
57
|
+
machine that's direct, on one you've already switched that's whatever
|
|
58
|
+
you last set — so a plain `trellis onboard` re-run never resets it. This
|
|
59
|
+
is the only supported way to turn gateway or hub mode on or off; there's
|
|
60
|
+
no reason to hand-edit `~/.trellis/mcp/servers.yaml`'s `hub`/`gateway`
|
|
61
|
+
keys directly.
|
|
62
|
+
4. **The shared memory server** — off by default. Pass `--memory on` to add
|
|
63
|
+
the default `@modelcontextprotocol/server-memory` definition (refused if
|
|
64
|
+
a host on this machine already injects a connector named `memory` — see
|
|
65
|
+
[`trellis memory sync`](#trellis-memory-sync) below) or
|
|
66
|
+
`--memory off` to remove it. Omitting `--memory` preserves whatever's
|
|
67
|
+
already configured, same rule as MCP mode. Turning it on and seeing it
|
|
68
|
+
actually reach every managed agent and get populated from canonical
|
|
69
|
+
`memories/*.md` happens in the very same run — no second command needed.
|
|
70
|
+
|
|
71
|
+
Both flags print a one-line status on a real terminal (`mcp mode: direct
|
|
72
|
+
(unchanged) — pass --mcp-mode hub|gateway to change`, or `memory: on
|
|
73
|
+
(changed from off)`) so they stay discoverable without turning into a
|
|
74
|
+
prompt you have to answer on every run.
|
|
47
75
|
|
|
48
76
|
```
|
|
49
77
|
$ trellis onboard --agent claude-code --manage pi
|
|
50
78
|
Using claude-code as the migration source (--agent).
|
|
51
79
|
Managed agents: pi
|
|
80
|
+
mcp mode: direct (unchanged) — pass --mcp-mode direct|hub|gateway to change
|
|
81
|
+
memory: off (unchanged) — pass --memory on to change
|
|
52
82
|
|
|
53
83
|
migrate --from claude-code
|
|
54
84
|
[create] skill "my-skill" — will copy from /Users/you/.claude/skills/my-skill
|
|
@@ -123,7 +153,8 @@ entirely under `--json` or when stdout isn't a real terminal.
|
|
|
123
153
|
|
|
124
154
|
Add `--dry-run` to preview the entire chain — init/migrate/sync/mcp
|
|
125
155
|
sync/memory sync, including what would be written to
|
|
126
|
-
`~/.trellis/managed.yaml`
|
|
156
|
+
`~/.trellis/managed.yaml` and to `servers.yaml`'s mode/memory keys — with
|
|
157
|
+
zero writes anywhere (secrets audit and
|
|
127
158
|
the health scan are always read-only, with or without the flag; the
|
|
128
159
|
write-verification step above has nothing to check on a dry run and is
|
|
129
160
|
skipped entirely). On a real terminal, a `--dry-run` ends by offering to
|
|
@@ -490,7 +521,10 @@ the gap between "memory entries exist in canonical" and "the running
|
|
|
490
521
|
memory server actually knows about them." Requires a `memory` server in
|
|
491
522
|
`servers.yaml` with `static_env.MEMORY_FILE_PATH` set explicitly (see
|
|
492
523
|
[`schema/servers.example.yaml`](../schema/servers.example.yaml)); without
|
|
493
|
-
one, this is a no-op, not an error.
|
|
524
|
+
one, this is a no-op, not an error. `trellis onboard --memory on` is the
|
|
525
|
+
easiest way to get that entry there — no hand-editing `servers.yaml`
|
|
526
|
+
required — and this stage is already chained into `onboard`, so enabling
|
|
527
|
+
it and syncing real canonical content into it happens in one run.
|
|
494
528
|
|
|
495
529
|
```
|
|
496
530
|
$ trellis memory sync
|
package/docs/roadmap.md
CHANGED
|
@@ -953,6 +953,7 @@ tests passing.
|
|
|
953
953
|
| P20 | ✅ Memory extraction: closes P15's own explicitly-named "per-agent extraction into canonical remains a separate, open gap." New `trellis memory extract` — the reverse of `memory sync` — reads the shared graph's real, non-`trellis-memory` entities (the same ownership marker `memory sync` already uses to leave agent-created content untouched, read the other direction) and writes each as a new canonical `memories/*.md` file: readable markdown, not a serialization format, not meant to round-trip byte-for-byte back through `memory sync`. Same create/no-op/conflict discipline as every other Trellis write; not wired into `onboard`'s automatic chain (a deliberate, occasional action, like `migrate`) | P15 |
|
|
954
954
|
| P21 | ✅ MCP gateway hosting: collapses each agent's MCP config to ONE native stdio entry (`trellis mcp-gateway --agent <id>`) that Trellis itself spawns — no Docker, no daemon, no lifecycle command. Four third-party gateways were evaluated and excluded on hard constraints (MetaMCP is Docker-only; mcp-local-hub is Windows-only today; `@samanhappy/mcphub` is a 43-dependency authenticated web product with no importable SDK surface; `@pcandido/mcphub` has correct OAuth but zero `${VAR}` expansion in its stdio `env`, verified by inspecting what a spawned child actually received — adopting it would mean plaintext secrets or a second resolution path outside `secrets audit`). Built instead on `pi-bridge`'s already-proven connect logic, extracted to `src/lib/mcpConnect.ts`, behind a `GatewayBackend` seam so the confirmed next step — one shared service for all agents, with the per-session process becoming a thin MCP-over-Unix-socket client — is a substitution at one construction site, with every agent's written config identical either way. Includes Trellis's own OAuth client (RFC 8414/9728 discovery, RFC 7591 DCR, RFC 7636 PKCE S256, refresh), tested against a hand-built Authorization Server that *enforces* those RFCs rather than rubber-stamping them; tokens live 0600 one-file-per-server outside canonical, never the OS keychain (macOS `security add-generic-password` measurably fails exit 152/154 with no GUI session — the gateway's exact context). Two bugs the design caught before shipping: the MCP SDK's stdio transport reports no EOF, so without explicit teardown every agent session would leak a gateway plus its whole upstream process set; and concurrent OAuth refresh silently kills a rotated refresh token, so it is serialized by a per-server lock. Remaining: the shared service itself (v2) | P2 |
|
|
955
955
|
| P22 | ✅ Onboard closed loop: onboard used to stop the moment files were written, never checking whether any of it took effect, and never telling the user how the run went — a real run with a genuine sync conflict still ended its printed output on a later stage's unrelated green line. Two distinct fixes, not one: a **self-verification re-plan** (the mechanism that actually closes the loop) runs `sync`/`mcp sync` a second time, dry-run, immediately after their real apply, and treats anything still outstanding as its own blocking finding — `trellis doctor` was tried first and rejected for this job, since its six detectors compare agents against each other and never read canonical, so they cannot prove a write took effect even in principle. Doctor still runs, last, as a secondary whole-machine health scan (never `--probe-mcp` — the worst moment to spawn every configured MCP server is a user's first run of this command), with findings on an unmanaged agent demoted to a warning rather than hidden. Every stage's conflicts and findings normalize into one `VerdictItem[]`, printed as a terminal verdict block that is always the last thing on screen and always agrees with the exit code, each blocking item carrying a concrete remediation instead of a restatement of the problem. Also adds stage progress to stderr (never stdout, so `onboard > report.txt` stays exactly the report) and, on a real terminal, a `--dry-run` that ends by offering to apply (declining is the default; accepting re-plans against current state rather than replaying a now-stale preview) | P0 |
|
|
956
|
+
| P23 | ✅ Onboard MCP mode + memory toggle: closes the last hand-editing gap — turning on gateway or hub mode, or the shared memory server, meant directly editing `~/.trellis/mcp/servers.yaml`'s top-level `hub`/`gateway` keys or its commented-out `memory:` example, since no command wrote them (`trellis mcp add`'s flags are all per-server). New `writeMcpModeYaml` in `src/core/canonical.ts` (`Document`-based, mirrors `upsertServerYaml`'s comment-preserving edit) plus `trellis onboard --mcp-mode direct\|hub\|gateway` (`--hub-url`, `--gateway-agents`) and `--memory on\|off` close it. Both follow onboard's existing managed-set idempotency shape rather than a new one: read current state first, omitting the flag always preserves it untouched with zero prompting — identical on a first run (nothing configured) and a later one (something already is), which is the actual point: one command for both, not a setup path and a separate reconfigure path. Deliberately flag-only, never an interactive picker (a mode change reroutes every managed agent's MCP transport at once — too wide a blast radius for Enter-mashing through a prompt sequence to trigger by accident); a one-line, non-prompting status line on a real terminal keeps the flags discoverable anyway. `--memory on` refuses outright if `"memory"` is already in `known_host_injected`, rather than writing a definition the very next `mcp sync` would refuse to propagate to any agent. Because mode/memory resolution runs before onboard's existing `mcp sync`/`memory sync` stages in the same chain, `trellis onboard --memory on` enables the server, syncs it to every managed agent, and populates it from canonical `memories/*.md` — all in one run | P17, P21, P22 |
|
|
956
957
|
|
|
957
958
|
**Cross-machine / cloud sharing — confirmed future direction, not yet
|
|
958
959
|
scoped as a phase.** Surveyed 2026-09-15 (`docs/research.md`'s "Cross-
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-trellis",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
4
4
|
"description": "A single source of capability for every coding agent — skills, MCP, subagents, memory, and secret policy, adapted natively into Claude Code, Codex, Kiro, and pi.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Paul Leo",
|
|
@@ -47,7 +47,6 @@
|
|
|
47
47
|
"agents.md"
|
|
48
48
|
],
|
|
49
49
|
"devDependencies": {
|
|
50
|
-
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
51
50
|
"@types/node": "^22.0.0",
|
|
52
51
|
"esbuild": "^0.28.2",
|
|
53
52
|
"tsx": "^4.19.0",
|
|
@@ -55,6 +54,7 @@
|
|
|
55
54
|
"typescript": "^5.6.0"
|
|
56
55
|
},
|
|
57
56
|
"dependencies": {
|
|
57
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
58
58
|
"yaml": "^2.9.1"
|
|
59
59
|
}
|
|
60
60
|
}
|
|
@@ -35,8 +35,12 @@
|
|
|
35
35
|
servers:
|
|
36
36
|
# Default shared-memory backend (docs/research.md "Shared memory") —
|
|
37
37
|
# @modelcontextprotocol/server-memory: zero-dependency, local JSON
|
|
38
|
-
# knowledge graph, MIT-licensed.
|
|
39
|
-
#
|
|
38
|
+
# knowledge graph, MIT-licensed. `trellis onboard --memory on` writes
|
|
39
|
+
# exactly this shape for you (src/commands/memory.ts's
|
|
40
|
+
# DEFAULT_MEMORY_SERVER_DEF) — uncommenting it by hand below is only
|
|
41
|
+
# for understanding what that flag does, not required to use it.
|
|
42
|
+
# Left commented out here because this exact example ALSO lists
|
|
43
|
+
# "memory" under known_host_injected below —
|
|
40
44
|
# uncommenting both would self-collide (Trellis's own collision guard
|
|
41
45
|
# would correctly refuse to write it). Which situation you're actually
|
|
42
46
|
# in depends on your host:
|
|
@@ -44,8 +48,9 @@ servers:
|
|
|
44
48
|
# under this name — check with `trellis doctor` — leave this
|
|
45
49
|
# commented out and keep "memory" in known_host_injected below;
|
|
46
50
|
# you already have it, just not from a static definition.
|
|
47
|
-
# - If your host injects nothing under this name,
|
|
48
|
-
#
|
|
51
|
+
# - If your host injects nothing under this name, run `trellis
|
|
52
|
+
# onboard --memory on` (or uncomment this entry by hand) AND
|
|
53
|
+
# remove "memory" from known_host_injected below (it's
|
|
49
54
|
# only there as inherited example content, not a fact about your
|
|
50
55
|
# environment) — that's what actually wires the default.
|
|
51
56
|
# mem0/OpenMemory or a totalrecallai-class semantic-search server are
|