agent-trellis 0.6.0 → 0.6.2

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.
@@ -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;
@@ -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
- function findLiteralSecret(def) {
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 { exitCode } = await runOnboard({ agent, manage, dryRun: rest.includes("--dry-run"), json: rest.includes("--json") });
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;
@@ -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
- const MEMORY_SERVER_NAME = "memory";
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-
@@ -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
@@ -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
@@ -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
@@ -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
- /** Claude Code / Kiro's config is real JSON — parse it and walk each
24
- * server's `env` object keys plus names embedded in `headers` object
25
- * *values* (unlike `env`, a header's key is an arbitrary header name,
26
- * not a variable name — the variable name lives inside the `${...}`
27
- * value). Returns `[]` (not a throw) on invalid JSON; that's a
28
- * diagnostic for a different command, not this extractor's job. */
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"`
@@ -29,12 +29,29 @@ export function declaredEnvNames(def) {
29
29
  }
30
30
  return [...names];
31
31
  }
32
- /** Claude Code / Kiro's config is real JSON — parse it and walk each
33
- * server's `env` object keys plus names embedded in `headers` object
34
- * *values* (unlike `env`, a header's key is an arbitrary header name,
35
- * not a variable name — the variable name lives inside the `${...}`
36
- * value). Returns `[]` (not a throw) on invalid JSON; that's a
37
- * diagnostic for a different command, not this extractor's job. */
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
- names.push(...Object.keys(server.env ?? {}));
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
  }
@@ -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 — set
180
- `mcp.hub.url` in `mcp/servers.yaml` to switch (see
181
- `schema/servers.example.yaml`). Nothing about *what* runs behind that URL
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. 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.
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
- documented in `schema/servers.example.yaml`; mem0/OpenMemory and
330
- totalrecallai-class semantic-search servers documented as opt-in
331
- upgrades — see docs/research.md "Shared memory")
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
@@ -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 independent choices before running `migrate`, `sync`,
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.
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` — with zero writes anywhere (secrets audit and
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.0",
3
+ "version": "0.6.2",
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",
@@ -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. Left commented out here because this
39
- # exact example ALSO lists "memory" under known_host_injected below —
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, uncomment this
48
- # entry AND remove "memory" from known_host_injected below (it's
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