agent-trellis 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +97 -38
  2. package/dist/adapters/claude-code.js +3 -2
  3. package/dist/adapters/codex.js +3 -2
  4. package/dist/adapters/jsonMcp.d.ts +3 -1
  5. package/dist/adapters/jsonMcp.js +18 -4
  6. package/dist/adapters/kimi-code.d.ts +21 -0
  7. package/dist/adapters/kimi-code.js +107 -0
  8. package/dist/adapters/kiro.js +3 -2
  9. package/dist/adapters/mcpPlan.d.ts +6 -1
  10. package/dist/adapters/mcpPlan.js +106 -8
  11. package/dist/adapters/pi.js +22 -3
  12. package/dist/adapters/symlinkPlan.d.ts +7 -0
  13. package/dist/adapters/symlinkPlan.js +16 -7
  14. package/dist/cli.js +121 -21
  15. package/dist/commands/doctor.d.ts +7 -2
  16. package/dist/commands/doctor.js +38 -2
  17. package/dist/commands/init.d.ts +4 -2
  18. package/dist/commands/init.js +26 -13
  19. package/dist/commands/kimi.d.ts +16 -0
  20. package/dist/commands/kimi.js +64 -0
  21. package/dist/commands/manage.d.ts +39 -0
  22. package/dist/commands/manage.js +126 -0
  23. package/dist/commands/mcp.d.ts +22 -1
  24. package/dist/commands/mcp.js +65 -2
  25. package/dist/commands/mcpGateway.d.ts +6 -0
  26. package/dist/commands/mcpGateway.js +34 -14
  27. package/dist/commands/mcpImport.d.ts +27 -0
  28. package/dist/commands/mcpImport.js +336 -0
  29. package/dist/commands/memory.d.ts +5 -3
  30. package/dist/commands/memory.js +12 -8
  31. package/dist/commands/migrate.d.ts +16 -5
  32. package/dist/commands/migrate.js +90 -17
  33. package/dist/commands/onboard.d.ts +74 -2
  34. package/dist/commands/onboard.js +458 -71
  35. package/dist/commands/rollback.js +4 -1
  36. package/dist/commands/skill.d.ts +17 -0
  37. package/dist/commands/skill.js +47 -2
  38. package/dist/commands/sync.js +2 -0
  39. package/dist/core/canonical.d.ts +15 -7
  40. package/dist/core/canonical.js +101 -13
  41. package/dist/core/capabilitySelection.d.ts +45 -0
  42. package/dist/core/capabilitySelection.js +124 -0
  43. package/dist/core/types.d.ts +25 -2
  44. package/dist/core/types.js +7 -0
  45. package/dist/lib/backup.d.ts +2 -0
  46. package/dist/lib/backup.js +4 -0
  47. package/dist/lib/builtinSkills.d.ts +2 -0
  48. package/dist/lib/builtinSkills.js +4 -0
  49. package/dist/lib/cliMetadata.d.ts +1 -0
  50. package/dist/lib/cliMetadata.js +14 -0
  51. package/dist/lib/gatewayBackend.d.ts +28 -0
  52. package/dist/lib/gatewayBackend.js +72 -1
  53. package/dist/lib/installAgent.d.ts +7 -5
  54. package/dist/lib/installAgent.js +4 -3
  55. package/dist/lib/mcpConnect.js +5 -1
  56. package/dist/lib/mcpMigrateRead.d.ts +1 -0
  57. package/dist/lib/mcpMigrateRead.js +3 -0
  58. package/dist/lib/mcpRuntime.d.ts +46 -0
  59. package/dist/lib/mcpRuntime.js +185 -0
  60. package/dist/lib/mcpStatusProvider.d.ts +10 -0
  61. package/dist/lib/mcpStatusProvider.js +25 -0
  62. package/dist/lib/mcpToolRegistry.d.ts +18 -0
  63. package/dist/lib/mcpToolRegistry.js +107 -14
  64. package/dist/lib/memoryGraph.d.ts +1 -1
  65. package/dist/lib/memoryGraph.js +9 -3
  66. package/dist/lib/memoryProvider.d.ts +58 -0
  67. package/dist/lib/memoryProvider.js +175 -0
  68. package/dist/lib/memoryStatus.d.ts +17 -0
  69. package/dist/lib/memoryStatus.js +88 -0
  70. package/dist/lib/nativeMemory.d.ts +19 -0
  71. package/dist/lib/nativeMemory.js +129 -0
  72. package/dist/lib/runtimeControlProvider.d.ts +16 -0
  73. package/dist/lib/runtimeControlProvider.js +168 -0
  74. package/dist/lib/secretEnv.d.ts +2 -1
  75. package/dist/lib/secretEnv.js +6 -2
  76. package/dist/lib/skillProvider.d.ts +13 -0
  77. package/dist/lib/skillProvider.js +210 -0
  78. package/dist/lib/taskProvider.d.ts +7 -0
  79. package/dist/lib/taskProvider.js +43 -0
  80. package/dist/lib/taskStore.d.ts +46 -0
  81. package/dist/lib/taskStore.js +156 -0
  82. package/dist/lib/terminalPicker.d.ts +23 -49
  83. package/dist/lib/terminalPicker.js +53 -218
  84. package/dist/pi-bridge/bundle.js +475 -37
  85. package/dist/pi-bridge/index.js +42 -21
  86. package/dist/pi-bridge/schemaTranslate.d.ts +4 -8
  87. package/dist/pi-bridge/schemaTranslate.js +4 -8
  88. package/dist/probes/kimi-code.d.ts +12 -0
  89. package/dist/probes/kimi-code.js +55 -0
  90. package/dist/sdk.d.ts +1 -1
  91. package/docs/architecture.md +224 -26
  92. package/docs/assets/agent-trellis-architecture-horizontal.svg +80 -0
  93. package/docs/assets/agent-trellis-architecture-vertical.svg +82 -0
  94. package/docs/assets/trellis-mark.svg +12 -0
  95. package/docs/getting-started.md +328 -35
  96. package/docs/overview.md +131 -0
  97. package/docs/overview.zh-CN.md +100 -0
  98. package/docs/research.md +59 -0
  99. package/docs/roadmap.md +9 -5
  100. package/package.json +3 -2
  101. package/schema/builtin-skills/trellis-runtime/SKILL.md +117 -0
  102. package/schema/capability-selection.example.yaml +23 -0
  103. package/schema/scope.example.yaml +1 -1
  104. package/schema/servers.example.yaml +15 -1
@@ -14,17 +14,23 @@
14
14
  * an in-process backend without this file, or any agent's config,
15
15
  * changing.
16
16
  */
17
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
18
17
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
19
- import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
20
18
  import { homedir } from "node:os";
21
19
  import { loadCanonicalSource } from "../core/canonical.js";
22
- import { resolveMcpPlan } from "../adapters/mcpPlan.js";
20
+ import { isGatewayAgent, isOAuthMcpServer, resolveMcpPlan } from "../adapters/mcpPlan.js";
23
21
  import { LocalBackend } from "../lib/gatewayBackend.js";
24
22
  import { DEFAULT_CONNECT_TIMEOUT_MS } from "../lib/mcpConnect.js";
23
+ import { BuiltinRegistry, createRuntimeServer, UpstreamProvider } from "../lib/mcpRuntime.js";
24
+ import { McpStatusProvider } from "../lib/mcpStatusProvider.js";
25
+ import { RuntimeControlProvider } from "../lib/runtimeControlProvider.js";
26
+ import { TaskProvider } from "../lib/taskProvider.js";
27
+ import { RuntimeMemoryProvider } from "../lib/memoryProvider.js";
28
+ import { SkillProvider } from "../lib/skillProvider.js";
25
29
  import { ALL_AGENTS } from "../core/types.js";
26
30
  const CLIENT_INFO = { name: "trellis-mcp-gateway", version: "0.0.0" };
27
31
  const SERVER_INFO = { name: "trellis-mcp-gateway", version: "0.0.0" };
32
+ const RUNTIME_CLIENT_INFO = { name: "trellis-mcp-runtime", version: "0.0.0" };
33
+ const RUNTIME_SERVER_INFO = { name: "trellis-mcp-runtime", version: "0.0.0" };
28
34
  export function parseMcpGatewayArgs(argv) {
29
35
  const index = argv.indexOf("--agent");
30
36
  if (index === -1 || index === argv.length - 1) {
@@ -50,7 +56,16 @@ export function parseMcpGatewayArgs(argv) {
50
56
  * canonical gives the set gateway mode replaced.
51
57
  */
52
58
  export function resolveGatewayUpstreams(agentId, canonical) {
53
- const direct = { ...canonical.mcp, gateway: undefined };
59
+ const route = canonical.mcp.routes?.[agentId];
60
+ const hubActive = route ? route.mode === "hub" : Boolean(canonical.mcp.hub) && !isGatewayAgent(agentId, canonical.mcp, canonical.managedAgents);
61
+ if (hubActive) {
62
+ return { upstreams: [], conflicts: [] };
63
+ }
64
+ const directRoutes = canonical.mcp.routes && route
65
+ ? { ...canonical.mcp.routes, [agentId]: { mode: "direct", ...(route.servers ? { servers: route.servers } : {}) } }
66
+ : canonical.mcp.routes;
67
+ const ordinaryServers = Object.fromEntries(Object.entries(canonical.mcp.servers).filter(([, def]) => !isOAuthMcpServer(def)));
68
+ const direct = { ...canonical.mcp, servers: ordinaryServers, gateway: undefined, hub: undefined, routes: directRoutes, runtime: undefined };
54
69
  // The managed set is passed through unchanged, so an unmanaged agent
55
70
  // reaches nothing. Being spawned is NOT treated as consent here, which
56
71
  // is a deliberate divergence from `src/pi-bridge/index.ts` (which passes
@@ -69,6 +84,15 @@ export function resolveGatewayUpstreams(agentId, canonical) {
69
84
  };
70
85
  }
71
86
  export async function runMcpGateway(opts) {
87
+ return runMcpEdge(opts, CLIENT_INFO, SERVER_INFO);
88
+ }
89
+ /** The first-class runtime entrypoint. mcp-gateway remains a compatibility
90
+ * alias for existing native agent config, but both edges use the same
91
+ * provider registry and backend construction path. */
92
+ export async function runMcpRuntime(opts) {
93
+ return runMcpEdge(opts, RUNTIME_CLIENT_INFO, RUNTIME_SERVER_INFO);
94
+ }
95
+ async function runMcpEdge(opts, clientInfo, serverInfo) {
72
96
  const homeDir = opts.homeDir ?? homedir();
73
97
  const connectTimeoutMs = opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
74
98
  let canonical;
@@ -90,22 +114,18 @@ export async function runMcpGateway(opts) {
90
114
  ? await opts.backendFactory(upstreams, homeDir)
91
115
  : await LocalBackend.connect(upstreams, {
92
116
  secretsPolicy: canonical.secretsPolicy,
93
- clientInfo: CLIENT_INFO,
117
+ clientInfo,
94
118
  connectTimeoutMs,
95
119
  // Enables silent OAuth refresh-before-connect. Never an
96
120
  // interactive grant: that is `trellis mcp auth`'s job, and this
97
121
  // process has no terminal to run one in (design.md D9).
98
122
  homeDir,
99
123
  });
100
- const server = new Server(SERVER_INFO, { capabilities: { tools: {} } });
101
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: await backend.listTools() }));
102
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
103
- const result = await backend.callTool(request.params.name, request.params.arguments);
104
- return result;
105
- });
124
+ const registry = new BuiltinRegistry([new SkillProvider(), new RuntimeMemoryProvider(), new RuntimeControlProvider(backend), new McpStatusProvider(backend), new TaskProvider(), new UpstreamProvider(backend)]);
125
+ const server = createRuntimeServer(serverInfo, { agentId: opts.agentId, homeDir }, registry);
106
126
  const transport = new StdioServerTransport();
107
127
  await server.connect(transport);
108
- await waitForShutdown(backend, server);
128
+ await waitForShutdown(registry, server);
109
129
  return { exitCode: 0 };
110
130
  }
111
131
  /**
@@ -118,7 +138,7 @@ export async function runMcpGateway(opts) {
118
138
  * Without this, each agent session would permanently leak a gateway plus
119
139
  * its entire upstream process set (design.md D13).
120
140
  */
121
- function waitForShutdown(backend, server) {
141
+ function waitForShutdown(registry, server) {
122
142
  return new Promise((resolve) => {
123
143
  let settled = false;
124
144
  const shutdown = async () => {
@@ -126,7 +146,7 @@ function waitForShutdown(backend, server) {
126
146
  return;
127
147
  settled = true;
128
148
  try {
129
- await backend.close();
149
+ await registry.close();
130
150
  }
131
151
  catch (err) {
132
152
  console.error(`trellis-mcp-gateway: failed to close backend: ${err instanceof Error ? err.message : String(err)}`);
@@ -0,0 +1,27 @@
1
+ /** Safe import of standard JSON `mcpServers` exports.
2
+ *
3
+ * This is deliberately separate from `mcp add`: an export may contain real
4
+ * credentials, multiple differently-named copies of one server, and shapes
5
+ * that cannot be expressed safely for every native Agent. The source file is
6
+ * read-only; plans never carry secret values.
7
+ */
8
+ import { type BackupSession } from "../lib/backup.js";
9
+ export type McpImportAction = "create" | "already-present" | "duplicate" | "skipped" | "conflict" | "invalid-input";
10
+ export interface McpImportItem {
11
+ name: string;
12
+ action: McpImportAction;
13
+ detail: string;
14
+ }
15
+ export interface McpImportPlan {
16
+ source: string;
17
+ items: McpImportItem[];
18
+ }
19
+ export declare function collectMcpImportPlan(path: string, homeDir?: string): McpImportPlan;
20
+ export declare function applyMcpImportPlan(path: string, homeDir?: string, backup?: BackupSession): McpImportPlan;
21
+ export declare function runMcpImport(path: string | undefined, opts?: {
22
+ homeDir?: string;
23
+ json?: boolean;
24
+ dryRun?: boolean;
25
+ }): {
26
+ exitCode: number;
27
+ };
@@ -0,0 +1,336 @@
1
+ /** Safe import of standard JSON `mcpServers` exports.
2
+ *
3
+ * This is deliberately separate from `mcp add`: an export may contain real
4
+ * credentials, multiple differently-named copies of one server, and shapes
5
+ * that cannot be expressed safely for every native Agent. The source file is
6
+ * read-only; plans never carry secret values.
7
+ */
8
+ import { existsSync, readFileSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { loadCanonicalSource, ensureGitignoreEntry, ensureShellEnvSource, upsertServerYaml, writeSecretsPolicyExtraction } from "../core/canonical.js";
12
+ import { openBackupSession } from "../lib/backup.js";
13
+ import { parseDotenv, writeLocalSecretValue } from "../lib/secretEnv.js";
14
+ const DEFAULT_ENV_FILE_TILDE = "~/.trellis/mcp/servers.local.env";
15
+ class SkipImport extends Error {
16
+ }
17
+ function isRecord(value) {
18
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
19
+ }
20
+ function stringValue(value, field) {
21
+ if (value === undefined)
22
+ return undefined;
23
+ if (typeof value !== "string" || value.length === 0)
24
+ throw new Error(`${field} must be a non-empty string`);
25
+ return value;
26
+ }
27
+ function stringArray(value, field) {
28
+ if (value === undefined)
29
+ return undefined;
30
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string"))
31
+ throw new Error(`${field} must be an array of strings`);
32
+ return value;
33
+ }
34
+ function looksLikeCredentialKey(key) {
35
+ return /(token|secret|password|authorization|api.?key|bearer|credential|private.?key|headers?)/i.test(key);
36
+ }
37
+ function looksLikeCredentialValue(value) {
38
+ return /(mcpr_|ghp_|glpat-|sk-[A-Za-z0-9]{8,}|basic\s+[A-Za-z0-9+/=]{12,}|bearer\s+\S+)/i.test(value);
39
+ }
40
+ function exactReference(value) {
41
+ const match = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/.exec(value);
42
+ return match?.[1];
43
+ }
44
+ function sanitizeEnvPart(value) {
45
+ return value.toUpperCase().replace(/[^A-Z0-9]+/g, "_").replace(/^_+|_+$/g, "") || "X";
46
+ }
47
+ function generatedSecretName(server, key) {
48
+ return `TRELLIS_${sanitizeEnvPart(server)}_${sanitizeEnvPart(key)}`;
49
+ }
50
+ function defaultShellRcPath(homeDir) {
51
+ const shell = process.env.SHELL ?? "";
52
+ if (shell.includes("zsh"))
53
+ return join(homeDir, ".zshrc");
54
+ if (shell.includes("bash"))
55
+ return join(homeDir, ".bash_profile");
56
+ return join(homeDir, ".profile");
57
+ }
58
+ function inferTransport(raw) {
59
+ const value = raw.transport ?? raw.type;
60
+ if (value === "http" || value === "sse" || value === "stdio")
61
+ return value;
62
+ if (raw.url !== undefined)
63
+ return "http";
64
+ return "stdio";
65
+ }
66
+ function sorted(values) {
67
+ return [...values].sort();
68
+ }
69
+ function identityArgs(def) {
70
+ if (!def.args)
71
+ return undefined;
72
+ if (def.command !== "npx")
73
+ return def.args;
74
+ // MCP exports often omit npx's non-interactive confirmation flag while the
75
+ // canonical adapter adds it. It changes prompting, not server identity.
76
+ const normalized = def.args.filter((arg) => arg !== "-y" && arg !== "--yes");
77
+ return normalized.length > 0 ? normalized : undefined;
78
+ }
79
+ function normalizeMcpRemote(serverName, args, secrets) {
80
+ const remoteIndex = args.indexOf("mcp-remote");
81
+ if (remoteIndex < 0)
82
+ return undefined;
83
+ const url = args[remoteIndex + 1];
84
+ if (!url || !/^https?:\/\//.test(url))
85
+ throw new Error(`MCP server "${serverName}" has an invalid mcp-remote URL`);
86
+ const headerIndex = args.indexOf("--header", remoteIndex + 2);
87
+ const allowed = new Set(["-y", "--yes", "mcp-remote", url, "--header", ...(headerIndex >= 0 && args[headerIndex + 1] ? [args[headerIndex + 1]] : [])]);
88
+ if (args.some((arg) => !allowed.has(arg)))
89
+ throw new Error(`MCP server "${serverName}" has unsupported mcp-remote arguments; import it manually after reviewing the shape`);
90
+ if (headerIndex < 0)
91
+ return { transport: "http", url };
92
+ const header = args[headerIndex + 1];
93
+ const match = /^Authorization:\s*Basic\s+(.+)$/i.exec(header ?? "");
94
+ if (!match)
95
+ throw new Error(`MCP server "${serverName}" has an unsupported mcp-remote header; only Basic Authorization can be migrated safely`);
96
+ const rawCredential = match[1];
97
+ const reference = exactReference(rawCredential);
98
+ const sourceName = generatedSecretName(serverName, "AUTHORIZATION");
99
+ if (reference) {
100
+ return { transport: "http", url, headers: { Authorization: `Basic \${${reference}}` } };
101
+ }
102
+ secrets.push({ name: sourceName, value: rawCredential });
103
+ return { transport: "http", url, headers: { Authorization: `Basic \${${sourceName}}` } };
104
+ }
105
+ /** Identity deliberately ignores secret values and generated source names but
106
+ * retains target env/header keys, so notion and notionApi do not collapse. */
107
+ function semanticIdentity(def) {
108
+ return JSON.stringify({
109
+ transport: def.transport,
110
+ auth: def.auth,
111
+ command: def.command,
112
+ args: identityArgs(def),
113
+ url: def.url,
114
+ enabled: def.enabled,
115
+ agents: def.agents ? sorted(def.agents) : undefined,
116
+ staticEnv: def.staticEnv,
117
+ envKeys: sorted([...(def.env ?? []), ...Object.keys(def.envAliases ?? {})]),
118
+ headerKeys: sorted(Object.keys(def.headers ?? {})),
119
+ });
120
+ }
121
+ function normalizeEntry(serverName, raw, secrets) {
122
+ if (!isRecord(raw))
123
+ throw new Error(`MCP server "${serverName}" must be an object`);
124
+ const transport = inferTransport(raw);
125
+ const command = stringValue(raw.command, `${serverName}.command`);
126
+ const parsedArgs = stringArray(raw.args, `${serverName}.args`);
127
+ const args = parsedArgs && parsedArgs.length > 0 ? parsedArgs : undefined;
128
+ const url = stringValue(raw.url, `${serverName}.url`);
129
+ if (transport === "stdio" && !command)
130
+ throw new Error(`MCP server "${serverName}" is stdio but has no command`);
131
+ if ((transport === "http" || transport === "sse") && !url)
132
+ throw new Error(`MCP server "${serverName}" is ${transport} but has no url`);
133
+ if (command === "npx" && args) {
134
+ const remote = normalizeMcpRemote(serverName, args, secrets);
135
+ if (remote) {
136
+ if (raw.env !== undefined || raw.headers !== undefined) {
137
+ throw new Error(`MCP server "${serverName}" combines mcp-remote arguments with env/headers fields; import it manually after reviewing the merge`);
138
+ }
139
+ return {
140
+ ...remote,
141
+ ...(raw.auth === "oauth" ? { auth: "oauth" } : {}),
142
+ ...(raw.disabled === true || raw.enabled === false ? { enabled: false } : {}),
143
+ };
144
+ }
145
+ }
146
+ const env = [];
147
+ const envAliases = {};
148
+ const staticEnv = {};
149
+ if (raw.env !== undefined) {
150
+ if (!isRecord(raw.env))
151
+ throw new Error(`${serverName}.env must be an object`);
152
+ for (const [targetKey, rawValue] of Object.entries(raw.env)) {
153
+ const value = stringValue(rawValue, `${serverName}.env.${targetKey}`);
154
+ const reference = exactReference(value);
155
+ if (reference) {
156
+ if (reference === targetKey)
157
+ env.push(reference);
158
+ else
159
+ envAliases[targetKey] = reference;
160
+ continue;
161
+ }
162
+ if (looksLikeCredentialKey(targetKey) || looksLikeCredentialValue(value)) {
163
+ const sourceName = generatedSecretName(serverName, targetKey);
164
+ envAliases[targetKey] = sourceName;
165
+ secrets.push({ name: sourceName, value });
166
+ }
167
+ else {
168
+ staticEnv[targetKey] = value;
169
+ }
170
+ }
171
+ }
172
+ const headers = {};
173
+ if (raw.headers !== undefined) {
174
+ if (!isRecord(raw.headers))
175
+ throw new Error(`${serverName}.headers must be an object`);
176
+ for (const [key, rawValue] of Object.entries(raw.headers)) {
177
+ const value = stringValue(rawValue, `${serverName}.headers.${key}`);
178
+ const reference = exactReference(value);
179
+ if (reference) {
180
+ headers[key] = `\${${reference}}`;
181
+ continue;
182
+ }
183
+ throw new Error(`MCP server "${serverName}" has a literal header "${key}"; move it to an environment variable before importing`);
184
+ }
185
+ }
186
+ const inlineText = [command, ...(args ?? []), url].filter(Boolean).join(" ");
187
+ if (looksLikeCredentialValue(inlineText)) {
188
+ throw new Error(`MCP server "${serverName}" has a credential embedded in command, args, or url; importer will not store it in canonical`);
189
+ }
190
+ for (const arg of args ?? []) {
191
+ if (/^(\/Users\/|\/home\/|[A-Za-z]:[\\/])/.test(arg) && !existsSync(arg)) {
192
+ throw new SkipImport(`MCP server "${serverName}" references missing absolute path "${arg}"; skipped as unavailable on this machine`);
193
+ }
194
+ }
195
+ const disabled = raw.disabled === true || raw.enabled === false;
196
+ const auth = raw.auth === "oauth" ? "oauth" : undefined;
197
+ return {
198
+ transport,
199
+ ...(auth ? { auth } : {}),
200
+ ...(command ? { command } : {}),
201
+ ...(args ? { args } : {}),
202
+ ...(url ? { url } : {}),
203
+ ...(Object.keys(headers).length > 0 ? { headers } : {}),
204
+ ...(env.length > 0 ? { env } : {}),
205
+ ...(Object.keys(envAliases).length > 0 ? { envAliases } : {}),
206
+ ...(Object.keys(staticEnv).length > 0 ? { staticEnv } : {}),
207
+ ...(disabled ? { enabled: false } : {}),
208
+ };
209
+ }
210
+ function readExport(path) {
211
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
212
+ if (!isRecord(parsed.mcpServers))
213
+ throw new Error(`MCP import requires a top-level "mcpServers" object in ${path}`);
214
+ return parsed.mcpServers;
215
+ }
216
+ function publicPlan(plan) {
217
+ return { source: plan.source, items: plan.items };
218
+ }
219
+ export function collectMcpImportPlan(path, homeDir = homedir()) {
220
+ return publicPlan(collectInternalPlan(path, homeDir));
221
+ }
222
+ function collectInternalPlan(path, homeDir) {
223
+ if (!existsSync(path))
224
+ throw new Error(`MCP import file does not exist: ${path}`);
225
+ const source = readExport(path);
226
+ const canonical = loadCanonicalSource(homeDir);
227
+ const existingByIdentity = new Map(Object.entries(canonical.mcp.servers).map(([name, def]) => [semanticIdentity(def), name]));
228
+ const additions = [];
229
+ const secrets = [];
230
+ const items = [];
231
+ const envFilePath = canonical.secretsPolicy.envFile ?? join(homeDir, ".trellis", "mcp", "servers.local.env");
232
+ const existingSecrets = existsSync(envFilePath) ? parseDotenv(readFileSync(envFilePath, "utf8")) : {};
233
+ for (const [name, raw] of Object.entries(source)) {
234
+ let def;
235
+ const beforeSecretCount = secrets.length;
236
+ try {
237
+ def = normalizeEntry(name, raw, secrets);
238
+ }
239
+ catch (err) {
240
+ secrets.splice(beforeSecretCount);
241
+ if (err instanceof SkipImport) {
242
+ items.push({ name, action: "skipped", detail: err.message });
243
+ continue;
244
+ }
245
+ items.push({ name, action: "conflict", detail: err instanceof Error ? err.message : String(err) });
246
+ continue;
247
+ }
248
+ const identity = semanticIdentity(def);
249
+ const existing = canonical.mcp.servers[name];
250
+ if (existing) {
251
+ secrets.splice(beforeSecretCount);
252
+ items.push(existing && semanticIdentity(existing) === identity
253
+ ? { name, action: "already-present", detail: "canonical entry is already semantically identical; existing credentials are preserved" }
254
+ : { name, action: "conflict", detail: `servers.yaml already has "${name}" with different non-secret settings — left unchanged` });
255
+ continue;
256
+ }
257
+ const duplicate = existingByIdentity.get(identity) ?? additions.find((item) => semanticIdentity(item.def) === identity)?.name;
258
+ if (duplicate) {
259
+ secrets.splice(beforeSecretCount);
260
+ items.push({ name, action: "duplicate", detail: `same non-secret MCP definition is already represented by "${duplicate}" — not added` });
261
+ continue;
262
+ }
263
+ const newSecrets = secrets.slice(beforeSecretCount);
264
+ const secretConflict = newSecrets.find((secret) => Object.hasOwn(existingSecrets, secret.name) && existingSecrets[secret.name] !== secret.value);
265
+ if (secretConflict) {
266
+ secrets.splice(beforeSecretCount);
267
+ items.push({ name, action: "conflict", detail: `local secret variable "${secretConflict.name}" already has a different value — not overwritten` });
268
+ continue;
269
+ }
270
+ additions.push({ name, def });
271
+ items.push({ name, action: "create", detail: `will add to canonical and extract ${newSecrets.length} credential value(s) to the ignored local secrets file` });
272
+ }
273
+ return { source: path, items, additions, secrets, envFilePath, envFileIsDefault: !canonical.secretsPolicy.envFile };
274
+ }
275
+ function applySecrets(plan, homeDir, backup) {
276
+ if (plan.secrets.length === 0)
277
+ return;
278
+ if (plan.envFileIsDefault)
279
+ ensureGitignoreEntry(join(homeDir, ".trellis", ".gitignore"), "mcp/servers.local.env", backup);
280
+ for (const secret of plan.secrets) {
281
+ const secretResult = writeLocalSecretValue(plan.envFilePath, secret.name, secret.value, backup);
282
+ if (secretResult === "conflict")
283
+ throw new Error(`local secret variable "${secret.name}" changed during import — refusing to overwrite it`);
284
+ const policyResult = writeSecretsPolicyExtraction(join(homeDir, ".trellis", "secrets.policy.yaml"), { varName: secret.name, envFilePath: DEFAULT_ENV_FILE_TILDE }, backup);
285
+ if (!policyResult.ok)
286
+ throw new Error(policyResult.error);
287
+ }
288
+ ensureShellEnvSource(defaultShellRcPath(homeDir), plan.envFilePath, backup);
289
+ }
290
+ export function applyMcpImportPlan(path, homeDir = homedir(), backup) {
291
+ const plan = collectInternalPlan(path, homeDir);
292
+ const ownSession = backup ?? openBackupSession(homeDir, "mcp-import");
293
+ try {
294
+ for (const addition of plan.additions) {
295
+ const result = upsertServerYaml(join(homeDir, ".trellis", "mcp", "servers.yaml"), addition.name, addition.def, ownSession);
296
+ if (!result.ok)
297
+ throw new Error(result.error);
298
+ }
299
+ applySecrets(plan, homeDir, ownSession);
300
+ }
301
+ catch (error) {
302
+ // Keep the manifest even on failure so the caller can use the normal
303
+ // `trellis rollback` path to undo writes that happened before the error.
304
+ if (!backup)
305
+ ownSession.finalize();
306
+ throw error;
307
+ }
308
+ if (!backup)
309
+ ownSession.finalize();
310
+ return publicPlan(plan);
311
+ }
312
+ export function runMcpImport(path, opts = {}) {
313
+ const homeDir = opts.homeDir ?? homedir();
314
+ if (!path) {
315
+ console.error("Usage: trellis mcp import <json-file> [--dry-run]");
316
+ return { exitCode: 1 };
317
+ }
318
+ try {
319
+ const internal = collectInternalPlan(path, homeDir);
320
+ if (!opts.dryRun)
321
+ applyMcpImportPlan(path, homeDir);
322
+ const result = publicPlan(internal);
323
+ if (opts.json)
324
+ console.log(JSON.stringify(result, null, 2));
325
+ else {
326
+ console.log(`${opts.dryRun ? "[dry run] " : ""}mcp import ${path}`);
327
+ for (const item of result.items)
328
+ console.log(` [${item.action}] ${item.name} — ${item.detail}`);
329
+ }
330
+ return { exitCode: result.items.some((item) => item.action === "conflict" || item.action === "invalid-input") ? 1 : 0 };
331
+ }
332
+ catch (err) {
333
+ console.error(err instanceof Error ? err.message : String(err));
334
+ return { exitCode: 1 };
335
+ }
336
+ }
@@ -12,7 +12,8 @@
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
+ import type { McpConfig, McpServerDef } from "../core/types.js";
16
+ import type { BackupSession } from "../lib/backup.js";
16
17
  import { type MemoryExtractionPlan, type MemorySyncPlan } from "../lib/memoryGraph.js";
17
18
  /** Exported so `onboard` (`trellis onboard --memory on|off`,
18
19
  * trellis-onboard-mcp-mode) can look up and write this same entry
@@ -32,6 +33,7 @@ export interface RunMemorySyncOptions {
32
33
  homeDir?: string;
33
34
  json?: boolean;
34
35
  dryRun?: boolean;
36
+ selectedMemoryNames?: readonly string[];
35
37
  }
36
38
  export type MemorySyncResult = {
37
39
  configured: true;
@@ -41,8 +43,8 @@ export type MemorySyncResult = {
41
43
  configured: false;
42
44
  reason: string;
43
45
  };
44
- export declare function collectMemorySyncResult(homeDir?: string): MemorySyncResult;
45
- export declare function applyMemorySync(result: MemorySyncResult): void;
46
+ export declare function collectMemorySyncResult(homeDir?: string, selectedMemoryNames?: readonly string[], mcpOverride?: McpConfig): MemorySyncResult;
47
+ export declare function applyMemorySync(result: MemorySyncResult, backup?: BackupSession): void;
46
48
  /**
47
49
  * Extracted from `runMemorySync` (trellis-onboard-mcp-memory) so
48
50
  * `onboard`'s own chained output can print this stage in the exact same
@@ -50,9 +50,9 @@ export const DEFAULT_MEMORY_SERVER_DEF = {
50
50
  * about which graph file they're reading/writing
51
51
  * (trellis-memory-extraction).
52
52
  */
53
- function resolveMemoryServerGraphPath(homeDir) {
53
+ function resolveMemoryServerGraphPath(homeDir, mcpOverride) {
54
54
  const canonical = loadCanonicalSource(homeDir);
55
- const server = canonical.mcp.servers[MEMORY_SERVER_NAME];
55
+ const server = (mcpOverride ?? canonical.mcp).servers[MEMORY_SERVER_NAME];
56
56
  const rawPath = server?.staticEnv?.MEMORY_FILE_PATH;
57
57
  if (!server || !rawPath) {
58
58
  return {
@@ -62,20 +62,24 @@ function resolveMemoryServerGraphPath(homeDir) {
62
62
  }
63
63
  return { configured: true, graphPath: rawPath.replace(/^~(?=$|\/)/, homeDir) };
64
64
  }
65
- export function collectMemorySyncResult(homeDir = homedir()) {
66
- const lookup = resolveMemoryServerGraphPath(homeDir);
65
+ export function collectMemorySyncResult(homeDir = homedir(), selectedMemoryNames, mcpOverride) {
66
+ const lookup = resolveMemoryServerGraphPath(homeDir, mcpOverride);
67
67
  if (!lookup.configured)
68
68
  return lookup;
69
69
  const canonical = loadCanonicalSource(homeDir);
70
70
  const currentContent = existsSync(lookup.graphPath) ? readFileSync(lookup.graphPath, "utf-8") : undefined;
71
- const plan = planMemorySync(canonical, currentContent);
71
+ const plan = planMemorySync(canonical, currentContent, selectedMemoryNames);
72
72
  return { configured: true, graphPath: lookup.graphPath, plan };
73
73
  }
74
- export function applyMemorySync(result) {
74
+ export function applyMemorySync(result, backup) {
75
75
  if (!result.configured || !result.plan.nextGraph)
76
76
  return;
77
77
  mkdirSync(dirname(result.graphPath), { recursive: true });
78
- writeFileSync(result.graphPath, renderMemoryGraph(result.plan.nextGraph));
78
+ const content = renderMemoryGraph(result.plan.nextGraph);
79
+ if (backup)
80
+ backup.writeFile(result.graphPath, content);
81
+ else
82
+ writeFileSync(result.graphPath, content);
79
83
  }
80
84
  /**
81
85
  * Extracted from `runMemorySync` (trellis-onboard-mcp-memory) so
@@ -101,7 +105,7 @@ export function runMemorySync(opts = {}) {
101
105
  const homeDir = opts.homeDir ?? homedir();
102
106
  let result;
103
107
  try {
104
- result = collectMemorySyncResult(homeDir);
108
+ result = collectMemorySyncResult(homeDir, opts.selectedMemoryNames);
105
109
  }
106
110
  catch (err) {
107
111
  console.error(err instanceof Error ? err.message : String(err));
@@ -5,13 +5,15 @@
5
5
  * plan-then-apply split every adapter already uses.
6
6
  */
7
7
  import type { AgentId, McpServerDef } from "../core/types.js";
8
+ import type { BackupSession } from "../lib/backup.js";
9
+ import { type NativeMemoryCandidate, type NativeMemoryDiscovery } from "../lib/nativeMemory.js";
8
10
  export type MigrateAction = "create" | "skip-symlink" | "skip-case-broken" | "skip-unsupported" | "already-migrated" | "reclassify" | "conflict" | "extract-secret";
9
11
  /** Internal kind naming, unchanged since before `--only` existed
10
12
  * (trellis-migrate-category-selection design.md D2) — the CLI-facing
11
13
  * flag value is the plural `"skills"`, mapped to this singular `"skill"`
12
14
  * in `runMigrate`, not renamed here to avoid touching every existing
13
15
  * `MigratePlanItem.kind` comparison for no functional reason. */
14
- export type MigrateKind = "skill" | "instructions" | "mcp";
16
+ export type MigrateKind = "skill" | "instructions" | "mcp" | "memory";
15
17
  export interface MigratePlanItem {
16
18
  kind: MigrateKind;
17
19
  name: string;
@@ -45,11 +47,19 @@ export interface MigratePlanItem {
45
47
  * already-migrated server extracted before this naming scheme existed,
46
48
  * which never reaches this action at all (already-migrated instead). */
47
49
  extractSourceKey?: string;
50
+ /** Memory migration only: source is re-read at apply time and the target
51
+ * path is canonical, so the plan never serializes the real body. */
52
+ memorySourcePath?: string;
53
+ memorySourceId?: string;
54
+ memoryTargetPath?: string;
48
55
  }
49
56
  export interface MigratePlan {
50
57
  agent: AgentId;
51
58
  present: boolean;
52
59
  items: MigratePlanItem[];
60
+ memoryDiscovery?: Omit<NativeMemoryDiscovery, "candidates"> & {
61
+ candidates: Array<Omit<NativeMemoryCandidate, "content">>;
62
+ };
53
63
  }
54
64
  /**
55
65
  * True when `existing` and `def` differ ONLY in which of `env`/
@@ -72,14 +82,15 @@ export declare function isSafeReclassification(existing: McpServerDef, def: McpS
72
82
  * conflict. Omitting `only` (or passing both kinds) is exactly today's
73
83
  * behavior.
74
84
  */
75
- export declare function collectMigratePlan(agent: AgentId, homeDir?: string, only?: readonly MigrateKind[]): Promise<MigratePlan>;
76
- export declare function applyMigratePlan(plan: MigratePlan, homeDir?: string): void;
85
+ export declare function collectMigratePlan(agent: AgentId, homeDir?: string, only?: readonly MigrateKind[], workspaceDir?: string): Promise<MigratePlan>;
86
+ export declare function applyMigratePlan(plan: MigratePlan, homeDir?: string, backup?: BackupSession): void;
77
87
  /** CLI-facing spelling: `"skills"` (plural — a run usually touches more
78
- * than one), `"instructions"` (already singular-shaped), or `"mcp"`
88
+ * than one), `"instructions"` (already singular-shaped), `"mcp"`, or
89
+ * `"memory"`
79
90
  * (already the CLI's own convention, matching `trellis mcp`'s own
80
91
  * command name). Mapped to `MigrateKind` in `runMigrate`, the one place
81
92
  * this translation lives. */
82
- export type MigrateOnlyValue = "skills" | "instructions" | "mcp";
93
+ export type MigrateOnlyValue = "skills" | "instructions" | "mcp" | "memory";
83
94
  export interface RunMigrateOptions {
84
95
  from?: string;
85
96
  /** Restricts the run to one category (`"skills"`, `"instructions"`, or