agent-trellis 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +12 -0
  2. package/dist/adapters/claude-code.d.ts +3 -2
  3. package/dist/adapters/claude-code.js +21 -8
  4. package/dist/adapters/codex.d.ts +6 -3
  5. package/dist/adapters/codex.js +41 -10
  6. package/dist/adapters/jsonMcp.d.ts +15 -5
  7. package/dist/adapters/jsonMcp.js +38 -29
  8. package/dist/adapters/kiro.d.ts +3 -2
  9. package/dist/adapters/kiro.js +22 -9
  10. package/dist/adapters/mcpPlan.d.ts +11 -6
  11. package/dist/adapters/mcpPlan.js +39 -6
  12. package/dist/cli.js +123 -10
  13. package/dist/commands/mcp.d.ts +101 -7
  14. package/dist/commands/mcp.js +227 -10
  15. package/dist/commands/memory.d.ts +39 -0
  16. package/dist/commands/memory.js +78 -0
  17. package/dist/commands/migrate.d.ts +30 -4
  18. package/dist/commands/migrate.js +83 -16
  19. package/dist/commands/onboard.d.ts +18 -8
  20. package/dist/commands/onboard.js +124 -14
  21. package/dist/commands/skill.d.ts +51 -0
  22. package/dist/commands/skill.js +104 -0
  23. package/dist/core/adapter.d.ts +18 -8
  24. package/dist/core/canonical.d.ts +26 -1
  25. package/dist/core/canonical.js +81 -3
  26. package/dist/core/types.d.ts +17 -0
  27. package/dist/lib/deepEqual.d.ts +8 -0
  28. package/dist/lib/deepEqual.js +26 -0
  29. package/dist/lib/dirEquals.d.ts +9 -0
  30. package/dist/lib/dirEquals.js +15 -1
  31. package/dist/lib/mcpMigrateRead.d.ts +69 -0
  32. package/dist/lib/mcpMigrateRead.js +188 -0
  33. package/dist/lib/mcpOwnership.d.ts +25 -0
  34. package/dist/lib/mcpOwnership.js +50 -0
  35. package/dist/lib/memoryGraph.d.ts +60 -0
  36. package/dist/lib/memoryGraph.js +101 -0
  37. package/dist/lib/realHomeSnapshot.d.ts +26 -0
  38. package/dist/lib/realHomeSnapshot.js +77 -0
  39. package/dist/lib/terminalPicker.d.ts +45 -0
  40. package/dist/lib/terminalPicker.js +193 -0
  41. package/dist/lib/tomlSection.d.ts +20 -6
  42. package/dist/lib/tomlSection.js +78 -12
  43. package/dist/pi-bridge/bundle.js +76 -46
  44. package/dist/pi-bridge/index.js +7 -2
  45. package/dist/probes/codex.js +10 -2
  46. package/docs/architecture.md +7 -4
  47. package/docs/getting-started.md +166 -10
  48. package/docs/roadmap.md +311 -0
  49. package/package.json +1 -1
  50. package/schema/servers.example.yaml +39 -2
package/dist/cli.js CHANGED
@@ -9,11 +9,13 @@ import { runInit } from "./commands/init.js";
9
9
  import { runMigrate } from "./commands/migrate.js";
10
10
  import { runOnboard } from "./commands/onboard.js";
11
11
  import { runSync } from "./commands/sync.js";
12
- import { runMcpSync } from "./commands/mcp.js";
12
+ import { runMcpSync, runMcpList, runMcpAdd, runMcpRemove, parseMcpAddArgs } from "./commands/mcp.js";
13
13
  import { runSecretsAudit } from "./commands/secretsAudit.js";
14
14
  import { runRollback } from "./commands/rollback.js";
15
+ import { runSkillList, runSkillAdd, runSkillRemove } from "./commands/skill.js";
16
+ import { runMemorySync } from "./commands/memory.js";
15
17
  import { parseSyncArgs } from "./lib/syncArgs.js";
16
- const KNOWN_COMMANDS = ["onboard", "init", "migrate", "doctor", "sync", "mcp", "secrets", "rollback"];
18
+ const KNOWN_COMMANDS = ["onboard", "init", "migrate", "doctor", "sync", "mcp", "skill", "memory", "secrets", "rollback"];
17
19
  function printUsage() {
18
20
  console.log(`trellis - a single source of capability for every coding agent
19
21
 
@@ -41,9 +43,12 @@ Commands:
41
43
  missing) and prints which agents are present
42
44
  --json machine-readable output, no report text
43
45
  migrate --from <agent>
44
- Import an existing agent's real skills/instructions into
45
- canonical source (claude-code | codex | kiro | pi). Never
46
- overwrites differing content reports a conflict instead.
46
+ Import an existing agent's real skills/instructions/MCP
47
+ servers into canonical source (claude-code | codex | kiro |
48
+ pi pi has no static MCP config, mcp migrate-in is a no-op
49
+ for it). Never overwrites differing content — reports a
50
+ conflict instead.
51
+ --only skills|instructions|mcp restrict to one category
47
52
  --dry-run preview the plan, write nothing
48
53
  --json machine-readable output, no report text
49
54
  doctor Scan Claude Code / Codex / Kiro / pi for drift
@@ -56,7 +61,47 @@ Commands:
56
61
  --dry-run preview the plan, write nothing
57
62
  --json machine-readable output, no report text
58
63
  mcp sync Distribute MCP servers to each agent's native config
59
- (create/repair only no automatic removal, see docs/roadmap.md)
64
+ (create/repair, plus ownership-ledger-gated removal an
65
+ entry is only ever removed when it's still exactly what
66
+ Trellis itself last wrote there; see docs/roadmap.md)
67
+ --dry-run preview the plan, write nothing
68
+ --json machine-readable output, no report text
69
+ mcp list List canonical MCP servers (transport, scope, enabled;
70
+ never prints resolved secret values)
71
+ --json machine-readable output, no report text
72
+ mcp add <name> --transport stdio|http|sse ...
73
+ Add a canonical MCP server (refuses on an existing name,
74
+ no overwrite): --command <cmd> [--args a,b] (stdio) or
75
+ --url <url> (http/sse); [--headers k=v,...]
76
+ [--env NAME,...] [--static-env k=v,...] [--agents id,...]
77
+ [--enabled true|false]
78
+ --dry-run preview the plan, write nothing
79
+ --json machine-readable output, no report text
80
+ mcp remove <name>
81
+ Remove a canonical MCP server (canonical-side only — does
82
+ not touch any agent's already-synced native config)
83
+ --dry-run preview the plan, write nothing
84
+ --json machine-readable output, no report text
85
+ skill list
86
+ List canonical skills with resolved scope
87
+ --json machine-readable output, no report text
88
+ skill add <name> --from <path>
89
+ Import a real skill directory into canonical (refuses on
90
+ an existing name with different content, no overwrite)
91
+ --dry-run preview the plan, write nothing
92
+ --json machine-readable output, no report text
93
+ skill remove <name>
94
+ Remove a canonical skill (the next sync auto-removes the
95
+ now-stale symlink on every managed agent)
96
+ --dry-run preview the plan, write nothing
97
+ --json machine-readable output, no report text
98
+ memory sync
99
+ Ingest canonical memories/*.md into the shared-memory MCP
100
+ server's own on-disk knowledge-graph file (requires a
101
+ "memory" server with static_env.MEMORY_FILE_PATH set in
102
+ servers.yaml — see schema/servers.example.yaml; a no-op,
103
+ not an error, if unconfigured). Never touches an entity
104
+ or relation this didn't create.
60
105
  --dry-run preview the plan, write nothing
61
106
  --json machine-readable output, no report text
62
107
  secrets audit
@@ -104,7 +149,9 @@ async function main(argv) {
104
149
  if (command === "migrate") {
105
150
  const fromIndex = rest.indexOf("--from");
106
151
  const from = fromIndex >= 0 ? rest[fromIndex + 1] : undefined;
107
- const { exitCode } = await runMigrate({ from, dryRun: rest.includes("--dry-run"), json: rest.includes("--json") });
152
+ const onlyIndex = rest.indexOf("--only");
153
+ const only = onlyIndex >= 0 ? rest[onlyIndex + 1] : undefined;
154
+ const { exitCode } = await runMigrate({ from, only, dryRun: rest.includes("--dry-run"), json: rest.includes("--json") });
108
155
  process.exitCode = exitCode;
109
156
  return;
110
157
  }
@@ -126,13 +173,79 @@ async function main(argv) {
126
173
  return;
127
174
  }
128
175
  if (command === "mcp") {
129
- const [subcommand] = rest;
176
+ const [subcommand, ...mcpRest] = rest;
177
+ const json = mcpRest.includes("--json");
178
+ const dryRun = mcpRest.includes("--dry-run");
179
+ if (subcommand === "sync") {
180
+ const { exitCode } = await runMcpSync({ json, dryRun });
181
+ process.exitCode = exitCode;
182
+ return;
183
+ }
184
+ if (subcommand === "list") {
185
+ process.exitCode = runMcpList({ json }).exitCode;
186
+ return;
187
+ }
188
+ if (subcommand === "add") {
189
+ const [name] = mcpRest;
190
+ process.exitCode = runMcpAdd(name, parseMcpAddArgs(mcpRest), { json, dryRun }).exitCode;
191
+ return;
192
+ }
193
+ if (subcommand === "remove") {
194
+ const [name] = mcpRest;
195
+ if (!name) {
196
+ console.error("Usage: trellis mcp remove <name>");
197
+ process.exitCode = 1;
198
+ return;
199
+ }
200
+ process.exitCode = runMcpRemove(name, { json, dryRun }).exitCode;
201
+ return;
202
+ }
203
+ console.error(`Unknown mcp subcommand: ${subcommand ?? "(none)"}\nUsage: trellis mcp sync|list|add <name>|remove <name>\n`);
204
+ process.exitCode = 1;
205
+ return;
206
+ }
207
+ if (command === "skill") {
208
+ const [subcommand, ...skillRest] = rest;
209
+ const json = skillRest.includes("--json");
210
+ const dryRun = skillRest.includes("--dry-run");
211
+ if (subcommand === "list") {
212
+ process.exitCode = runSkillList({ json }).exitCode;
213
+ return;
214
+ }
215
+ if (subcommand === "add") {
216
+ const [name] = skillRest;
217
+ const fromIndex = skillRest.indexOf("--from");
218
+ const from = fromIndex >= 0 ? skillRest[fromIndex + 1] : undefined;
219
+ if (!name || !from) {
220
+ console.error("Usage: trellis skill add <name> --from <path>");
221
+ process.exitCode = 1;
222
+ return;
223
+ }
224
+ process.exitCode = runSkillAdd(name, from, { json, dryRun }).exitCode;
225
+ return;
226
+ }
227
+ if (subcommand === "remove") {
228
+ const [name] = skillRest;
229
+ if (!name) {
230
+ console.error("Usage: trellis skill remove <name>");
231
+ process.exitCode = 1;
232
+ return;
233
+ }
234
+ process.exitCode = runSkillRemove(name, { json, dryRun }).exitCode;
235
+ return;
236
+ }
237
+ console.error(`Unknown skill subcommand: ${subcommand ?? "(none)"}\nUsage: trellis skill list|add <name> --from <path>|remove <name>\n`);
238
+ process.exitCode = 1;
239
+ return;
240
+ }
241
+ if (command === "memory") {
242
+ const [subcommand, ...memoryRest] = rest;
130
243
  if (subcommand !== "sync") {
131
- console.error(`Unknown mcp subcommand: ${subcommand ?? "(none)"}\nUsage: trellis mcp sync\n`);
244
+ console.error(`Unknown memory subcommand: ${subcommand ?? "(none)"}\nUsage: trellis memory sync\n`);
132
245
  process.exitCode = 1;
133
246
  return;
134
247
  }
135
- const { exitCode } = await runMcpSync({ json: rest.includes("--json"), dryRun: rest.includes("--dry-run") });
248
+ const { exitCode } = runMemorySync({ json: memoryRest.includes("--json"), dryRun: memoryRest.includes("--dry-run") });
136
249
  process.exitCode = exitCode;
137
250
  return;
138
251
  }
@@ -1,13 +1,18 @@
1
1
  /**
2
- * `trellis mcp sync` — loads canonical once, runs every present agent's
3
- * adapter, applies only the "mcp" slice of its plan, and reports what
4
- * happened. Same create/repair/refuse shape as `src/commands/sync.ts`,
5
- * kept as a separate command because MCP has no ownership marker for
6
- * automatic removal yet (trellis-mcp-sync-p2 design.md D7) folding it
7
- * into bare `trellis sync` would blur that distinction.
2
+ * `trellis mcp sync|list|add|remove` — `sync` loads canonical once, runs
3
+ * every present agent's adapter, applies its full "mcp" plan (create,
4
+ * repair, and since trellis-mcp-lifecycle-parity, ownership-ledger-
5
+ * gated remove), and reports what happened. Kept as a separate
6
+ * command from bare `trellis sync` (skills/instructions) since the two
7
+ * have different write mechanisms entirely, not because MCP removal is
8
+ * unsafe (see `src/lib/mcpOwnership.ts`). `list`/`add`/`remove`
9
+ * (trellis-canonical-cli-crud) are canonical-side only — an alternative
10
+ * to hand-editing `~/.trellis/mcp/servers.yaml`, never touching any
11
+ * agent's native config (that stays `sync`'s job).
8
12
  */
13
+ import { type ServersYamlWriteResult } from "../core/canonical.js";
9
14
  import type { AdapterPlanItem } from "../core/adapter.js";
10
- import type { AgentId } from "../core/types.js";
15
+ import type { AgentId, McpServerDef, Transport } from "../core/types.js";
11
16
  import { type BackupSession } from "../lib/backup.js";
12
17
  export interface RunMcpSyncOptions {
13
18
  json?: boolean;
@@ -39,3 +44,92 @@ export declare function runMcpSync(opts?: RunMcpSyncOptions): Promise<{
39
44
  * `mcp sync` standalone, instead of a second, easily-drifting copy of this
40
45
  * formatting. */
41
46
  export declare function printReport(report: McpSyncReport, dryRun: boolean): void;
47
+ export interface McpListEntry {
48
+ name: string;
49
+ transport: Transport;
50
+ enabled: boolean;
51
+ agents: readonly AgentId[];
52
+ command?: string;
53
+ args?: string[];
54
+ url?: string;
55
+ /** Reference strings (e.g. `${VAR}`), not resolved secret values — see
56
+ * design.md D6. */
57
+ headers?: Record<string, string>;
58
+ /** Names only — `McpServerDef.env` never stores values in the first
59
+ * place, so there is nothing here to redact. */
60
+ env?: string[];
61
+ /** Non-secret by `McpServerDef`'s own contract (design.md D6) —
62
+ * printed in full. */
63
+ staticEnv?: Record<string, string>;
64
+ }
65
+ export declare function collectMcpListPlan(homeDir?: string): McpListEntry[];
66
+ export declare function runMcpList(opts?: {
67
+ homeDir?: string;
68
+ json?: boolean;
69
+ }): {
70
+ exitCode: number;
71
+ };
72
+ /** Raw, unvalidated CLI flag values — `collectMcpAddPlan` does the actual
73
+ * parsing/validation. Kept as plain strings here so `parseMcpAddArgs`
74
+ * stays a dumb `--flag value` reader, same posture as every other
75
+ * command's own inline flag parsing in src/cli.ts. */
76
+ export interface McpAddRawArgs {
77
+ transport?: string;
78
+ command?: string;
79
+ /** Comma-separated. */
80
+ args?: string;
81
+ url?: string;
82
+ /** Comma-separated `k=v` pairs. */
83
+ headers?: string;
84
+ /** Comma-separated names. */
85
+ env?: string;
86
+ /** Comma-separated `k=v` pairs. */
87
+ staticEnv?: string;
88
+ /** Comma-separated agent ids. */
89
+ agents?: string;
90
+ /** `"true"` or `"false"`. */
91
+ enabled?: string;
92
+ }
93
+ export declare function parseMcpAddArgs(argv: readonly string[]): McpAddRawArgs;
94
+ export type McpAddAction = "create" | "already-present" | "conflict" | "invalid-input";
95
+ export interface McpAddPlan {
96
+ name: string;
97
+ action: McpAddAction;
98
+ detail: string;
99
+ /** Only set when action === "create"; consumed by applyMcpAddPlan. */
100
+ def?: McpServerDef;
101
+ }
102
+ /**
103
+ * Validates transport-specific required flags at this layer (stdio needs
104
+ * `--command`, http/sse need `--url`) rather than deferring to a later
105
+ * write failure — design.md's open question, resolved in favor of
106
+ * refusing early with a specific reason. No `--force`: an existing name
107
+ * always refuses, ever (design.md D4).
108
+ */
109
+ export declare function collectMcpAddPlan(name: string | undefined, raw: McpAddRawArgs, homeDir?: string): McpAddPlan;
110
+ export declare function applyMcpAddPlan(plan: McpAddPlan, homeDir?: string): ServersYamlWriteResult;
111
+ export declare function runMcpAdd(name: string | undefined, raw: McpAddRawArgs, opts?: {
112
+ homeDir?: string;
113
+ json?: boolean;
114
+ dryRun?: boolean;
115
+ }): {
116
+ exitCode: number;
117
+ };
118
+ export type McpRemoveAction = "removed" | "not-found";
119
+ export interface McpRemovePlan {
120
+ name: string;
121
+ action: McpRemoveAction;
122
+ }
123
+ /** Canonical-side only — never touches an agent's native config. Making
124
+ * an already-synced agent forget it is `mcp sync`'s own removal-
125
+ * propagation gap, tracked separately (roadmap.md P14), not this
126
+ * command's job. */
127
+ export declare function collectMcpRemovePlan(name: string, homeDir?: string): McpRemovePlan;
128
+ export declare function applyMcpRemovePlan(plan: McpRemovePlan, homeDir?: string): ServersYamlWriteResult;
129
+ export declare function runMcpRemove(name: string, opts?: {
130
+ homeDir?: string;
131
+ json?: boolean;
132
+ dryRun?: boolean;
133
+ }): {
134
+ exitCode: number;
135
+ };
@@ -1,13 +1,19 @@
1
1
  /**
2
- * `trellis mcp sync` — loads canonical once, runs every present agent's
3
- * adapter, applies only the "mcp" slice of its plan, and reports what
4
- * happened. Same create/repair/refuse shape as `src/commands/sync.ts`,
5
- * kept as a separate command because MCP has no ownership marker for
6
- * automatic removal yet (trellis-mcp-sync-p2 design.md D7) folding it
7
- * into bare `trellis sync` would blur that distinction.
2
+ * `trellis mcp sync|list|add|remove` — `sync` loads canonical once, runs
3
+ * every present agent's adapter, applies its full "mcp" plan (create,
4
+ * repair, and since trellis-mcp-lifecycle-parity, ownership-ledger-
5
+ * gated remove), and reports what happened. Kept as a separate
6
+ * command from bare `trellis sync` (skills/instructions) since the two
7
+ * have different write mechanisms entirely, not because MCP removal is
8
+ * unsafe (see `src/lib/mcpOwnership.ts`). `list`/`add`/`remove`
9
+ * (trellis-canonical-cli-crud) are canonical-side only — an alternative
10
+ * to hand-editing `~/.trellis/mcp/servers.yaml`, never touching any
11
+ * agent's native config (that stays `sync`'s job).
8
12
  */
9
13
  import { homedir } from "node:os";
10
- import { loadCanonicalSource } from "../core/canonical.js";
14
+ import { join } from "node:path";
15
+ import { loadCanonicalSource, upsertServerYaml, removeServerYaml } from "../core/canonical.js";
16
+ import { ALL_AGENTS, resolveScope } from "../core/types.js";
11
17
  import { ClaudeCodeAdapter } from "../adapters/claude-code.js";
12
18
  import { CodexAdapter } from "../adapters/codex.js";
13
19
  import { KiroAdapter } from "../adapters/kiro.js";
@@ -80,15 +86,226 @@ export function printReport(report, dryRun) {
80
86
  continue;
81
87
  }
82
88
  const created = items.filter((i) => i.action === "create");
89
+ const removed = items.filter((i) => i.action === "remove");
83
90
  const conflicts = items.filter((i) => i.action === "conflict");
84
- if (created.length === 0 && conflicts.length === 0) {
91
+ if (created.length === 0 && removed.length === 0 && conflicts.length === 0) {
85
92
  console.log(`✅ ${agent} — already in sync`);
86
93
  continue;
87
94
  }
88
95
  const icon = conflicts.length > 0 ? "⚠️ " : "✅";
89
- console.log(`${icon} ${agent} — ${created.length} created/updated, ${conflicts.length} conflict(s)`);
90
- for (const item of [...created, ...conflicts]) {
96
+ console.log(`${icon} ${agent} — ${created.length} created/updated, ${removed.length} removed, ${conflicts.length} conflict(s)`);
97
+ for (const item of [...created, ...removed, ...conflicts]) {
91
98
  console.log(` - [${item.action}] ${item.description}`);
92
99
  }
93
100
  }
94
101
  }
102
+ function serversYamlPath(homeDir) {
103
+ return join(homeDir, ".trellis", "mcp", "servers.yaml");
104
+ }
105
+ export function collectMcpListPlan(homeDir = homedir()) {
106
+ const canonical = loadCanonicalSource(homeDir);
107
+ return Object.entries(canonical.mcp.servers).map(([name, def]) => ({
108
+ name,
109
+ transport: def.transport,
110
+ enabled: def.enabled ?? true,
111
+ agents: resolveScope(def.agents, canonical.managedAgents),
112
+ command: def.command,
113
+ args: def.args,
114
+ url: def.url,
115
+ headers: def.headers,
116
+ env: def.env,
117
+ staticEnv: def.staticEnv,
118
+ }));
119
+ }
120
+ export function runMcpList(opts = {}) {
121
+ const homeDir = opts.homeDir ?? homedir();
122
+ let entries;
123
+ try {
124
+ entries = collectMcpListPlan(homeDir);
125
+ }
126
+ catch (err) {
127
+ console.error(err instanceof Error ? err.message : String(err));
128
+ return { exitCode: 1 };
129
+ }
130
+ if (opts.json) {
131
+ console.log(JSON.stringify(entries, null, 2));
132
+ }
133
+ else if (entries.length === 0) {
134
+ console.log("No MCP servers in canonical source yet.");
135
+ }
136
+ else {
137
+ for (const e of entries) {
138
+ const scope = e.agents.length > 0 ? e.agents.join(", ") : "(no managed agent reaches it)";
139
+ console.log(`${e.name} (${e.transport})${e.enabled ? "" : " [disabled]"} — ${scope}`);
140
+ if (e.env && e.env.length > 0)
141
+ console.log(` env: ${e.env.join(", ")} (values never read/printed)`);
142
+ if (e.staticEnv)
143
+ console.log(` static_env: ${Object.entries(e.staticEnv).map(([k, v]) => `${k}=${v}`).join(", ")}`);
144
+ }
145
+ }
146
+ return { exitCode: 0 };
147
+ }
148
+ export function parseMcpAddArgs(argv) {
149
+ const flag = (name) => {
150
+ const i = argv.indexOf(name);
151
+ return i >= 0 ? argv[i + 1] : undefined;
152
+ };
153
+ return {
154
+ transport: flag("--transport"),
155
+ command: flag("--command"),
156
+ args: flag("--args"),
157
+ url: flag("--url"),
158
+ headers: flag("--headers"),
159
+ env: flag("--env"),
160
+ staticEnv: flag("--static-env"),
161
+ agents: flag("--agents"),
162
+ enabled: flag("--enabled"),
163
+ };
164
+ }
165
+ function parseCsv(raw) {
166
+ if (!raw)
167
+ return undefined;
168
+ const items = raw.split(",").map((s) => s.trim()).filter(Boolean);
169
+ return items.length > 0 ? items : undefined;
170
+ }
171
+ function parseKvList(raw) {
172
+ if (!raw)
173
+ return undefined;
174
+ const out = {};
175
+ for (const pair of raw.split(",")) {
176
+ const eq = pair.indexOf("=");
177
+ if (eq < 0)
178
+ continue;
179
+ out[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim();
180
+ }
181
+ return Object.keys(out).length > 0 ? out : undefined;
182
+ }
183
+ const TRANSPORTS = ["stdio", "http", "sse"];
184
+ /**
185
+ * Validates transport-specific required flags at this layer (stdio needs
186
+ * `--command`, http/sse need `--url`) rather than deferring to a later
187
+ * write failure — design.md's open question, resolved in favor of
188
+ * refusing early with a specific reason. No `--force`: an existing name
189
+ * always refuses, ever (design.md D4).
190
+ */
191
+ export function collectMcpAddPlan(name, raw, homeDir = homedir()) {
192
+ if (!name) {
193
+ return { name: "(none)", action: "invalid-input", detail: "a name is required: trellis mcp add <name> --transport ..." };
194
+ }
195
+ if (!raw.transport || !TRANSPORTS.includes(raw.transport)) {
196
+ return { name, action: "invalid-input", detail: `--transport must be one of: ${TRANSPORTS.join(", ")}` };
197
+ }
198
+ const transport = raw.transport;
199
+ if (transport === "stdio" && !raw.command) {
200
+ return { name, action: "invalid-input", detail: "--transport stdio requires --command" };
201
+ }
202
+ if ((transport === "http" || transport === "sse") && !raw.url) {
203
+ return { name, action: "invalid-input", detail: `--transport ${transport} requires --url` };
204
+ }
205
+ const agentsList = parseCsv(raw.agents);
206
+ if (agentsList) {
207
+ for (const id of agentsList) {
208
+ if (!ALL_AGENTS.includes(id)) {
209
+ return { name, action: "invalid-input", detail: `--agents "${id}" is not a recognized agent id (${ALL_AGENTS.join(", ")})` };
210
+ }
211
+ }
212
+ }
213
+ if (raw.enabled !== undefined && raw.enabled !== "true" && raw.enabled !== "false") {
214
+ return { name, action: "invalid-input", detail: `--enabled must be "true" or "false" (got ${raw.enabled})` };
215
+ }
216
+ const def = {
217
+ transport,
218
+ ...(raw.command ? { command: raw.command } : {}),
219
+ ...(parseCsv(raw.args) ? { args: parseCsv(raw.args) } : {}),
220
+ ...(raw.url ? { url: raw.url } : {}),
221
+ ...(parseKvList(raw.headers) ? { headers: parseKvList(raw.headers) } : {}),
222
+ ...(parseCsv(raw.env) ? { env: parseCsv(raw.env) } : {}),
223
+ ...(parseKvList(raw.staticEnv) ? { staticEnv: parseKvList(raw.staticEnv) } : {}),
224
+ ...(raw.enabled !== undefined ? { enabled: raw.enabled === "true" } : {}),
225
+ ...(agentsList ? { agents: agentsList } : {}),
226
+ };
227
+ const canonical = loadCanonicalSource(homeDir);
228
+ const existing = canonical.mcp.servers[name];
229
+ if (existing) {
230
+ if (JSON.stringify(existing) === JSON.stringify(def)) {
231
+ return { name, action: "already-present", detail: "canonical entry is already identical" };
232
+ }
233
+ return { name, action: "conflict", detail: `servers.yaml already has "${name}" with different settings — resolve by hand (no --force, design.md D4)` };
234
+ }
235
+ return { name, action: "create", detail: "will add to servers.yaml", def };
236
+ }
237
+ export function applyMcpAddPlan(plan, homeDir = homedir()) {
238
+ if (plan.action !== "create" || !plan.def)
239
+ return { ok: true };
240
+ return upsertServerYaml(serversYamlPath(homeDir), plan.name, plan.def);
241
+ }
242
+ export function runMcpAdd(name, raw, opts = {}) {
243
+ const homeDir = opts.homeDir ?? homedir();
244
+ let plan;
245
+ try {
246
+ plan = collectMcpAddPlan(name, raw, homeDir);
247
+ }
248
+ catch (err) {
249
+ console.error(err instanceof Error ? err.message : String(err));
250
+ return { exitCode: 1 };
251
+ }
252
+ let writeError;
253
+ if (!opts.dryRun && plan.action === "create") {
254
+ const result = applyMcpAddPlan(plan, homeDir);
255
+ if (!result.ok)
256
+ writeError = result.error;
257
+ }
258
+ if (opts.json) {
259
+ console.log(JSON.stringify(writeError ? { ...plan, writeError } : plan, null, 2));
260
+ }
261
+ else {
262
+ console.log(`${opts.dryRun ? "[dry run] " : ""}mcp add ${plan.name}`);
263
+ console.log(` [${plan.action}] ${plan.detail}`);
264
+ if (writeError)
265
+ console.error(` write failed: ${writeError}`);
266
+ }
267
+ return { exitCode: plan.action === "conflict" || plan.action === "invalid-input" || writeError ? 1 : 0 };
268
+ }
269
+ /** Canonical-side only — never touches an agent's native config. Making
270
+ * an already-synced agent forget it is `mcp sync`'s own removal-
271
+ * propagation gap, tracked separately (roadmap.md P14), not this
272
+ * command's job. */
273
+ export function collectMcpRemovePlan(name, homeDir = homedir()) {
274
+ const canonical = loadCanonicalSource(homeDir);
275
+ return { name, action: canonical.mcp.servers[name] ? "removed" : "not-found" };
276
+ }
277
+ export function applyMcpRemovePlan(plan, homeDir = homedir()) {
278
+ if (plan.action !== "removed")
279
+ return { ok: true };
280
+ return removeServerYaml(serversYamlPath(homeDir), plan.name);
281
+ }
282
+ export function runMcpRemove(name, opts = {}) {
283
+ const homeDir = opts.homeDir ?? homedir();
284
+ let plan;
285
+ try {
286
+ plan = collectMcpRemovePlan(name, homeDir);
287
+ }
288
+ catch (err) {
289
+ console.error(err instanceof Error ? err.message : String(err));
290
+ return { exitCode: 1 };
291
+ }
292
+ let writeError;
293
+ if (!opts.dryRun && plan.action === "removed") {
294
+ const result = applyMcpRemovePlan(plan, homeDir);
295
+ if (!result.ok)
296
+ writeError = result.error;
297
+ }
298
+ if (opts.json) {
299
+ console.log(JSON.stringify(writeError ? { ...plan, writeError } : plan, null, 2));
300
+ }
301
+ else if (plan.action === "not-found") {
302
+ console.error(`"${name}" is not a canonical MCP server — nothing to remove.`);
303
+ }
304
+ else if (writeError) {
305
+ console.error(` write failed: ${writeError}`);
306
+ }
307
+ else {
308
+ console.log(`${opts.dryRun ? "[dry run] " : ""}removed MCP server "${name}" from canonical source.`);
309
+ }
310
+ return { exitCode: plan.action === "not-found" || writeError ? 1 : 0 };
311
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * `trellis memory sync` — ingests canonical `memories/*.md` into the
3
+ * on-disk knowledge-graph file `@modelcontextprotocol/server-memory`
4
+ * itself reads at startup, closing P6's explicitly-left-open "auto-
5
+ * ingesting memories/*.md content into the running memory server's
6
+ * store" gap (trellis-memory-sync). Never spawns or talks to a running
7
+ * server process — this is a plain file write, same posture as every
8
+ * other Trellis write.
9
+ */
10
+ import { type MemorySyncPlan } from "../lib/memoryGraph.js";
11
+ export interface RunMemorySyncOptions {
12
+ homeDir?: string;
13
+ json?: boolean;
14
+ dryRun?: boolean;
15
+ }
16
+ export type MemorySyncResult = {
17
+ configured: true;
18
+ graphPath: string;
19
+ plan: MemorySyncPlan;
20
+ } | {
21
+ configured: false;
22
+ reason: string;
23
+ };
24
+ /**
25
+ * Looks up `mcp.servers["memory"]` specifically (matching `schema/
26
+ * servers.example.yaml`'s own naming convention for the default shared-
27
+ * memory backend) — not a heuristic scan for any server whose command
28
+ * happens to mention `@modelcontextprotocol/server-memory`, since a
29
+ * user could reasonably name or configure it differently and this
30
+ * would then silently miss it. `static_env.MEMORY_FILE_PATH` must be
31
+ * set explicitly: the server's own unset-env default resolves relative
32
+ * to wherever `npx` cached the package, a location Trellis has no
33
+ * reliable way to predict.
34
+ */
35
+ export declare function collectMemorySyncResult(homeDir?: string): MemorySyncResult;
36
+ export declare function applyMemorySync(result: MemorySyncResult): void;
37
+ export declare function runMemorySync(opts?: RunMemorySyncOptions): {
38
+ exitCode: number;
39
+ };
@@ -0,0 +1,78 @@
1
+ /**
2
+ * `trellis memory sync` — ingests canonical `memories/*.md` into the
3
+ * on-disk knowledge-graph file `@modelcontextprotocol/server-memory`
4
+ * itself reads at startup, closing P6's explicitly-left-open "auto-
5
+ * ingesting memories/*.md content into the running memory server's
6
+ * store" gap (trellis-memory-sync). Never spawns or talks to a running
7
+ * server process — this is a plain file write, same posture as every
8
+ * other Trellis write.
9
+ */
10
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { dirname } from "node:path";
13
+ import { loadCanonicalSource } from "../core/canonical.js";
14
+ import { planMemorySync, renderMemoryGraph } from "../lib/memoryGraph.js";
15
+ const MEMORY_SERVER_NAME = "memory";
16
+ /**
17
+ * Looks up `mcp.servers["memory"]` specifically (matching `schema/
18
+ * servers.example.yaml`'s own naming convention for the default shared-
19
+ * memory backend) — not a heuristic scan for any server whose command
20
+ * happens to mention `@modelcontextprotocol/server-memory`, since a
21
+ * user could reasonably name or configure it differently and this
22
+ * would then silently miss it. `static_env.MEMORY_FILE_PATH` must be
23
+ * set explicitly: the server's own unset-env default resolves relative
24
+ * to wherever `npx` cached the package, a location Trellis has no
25
+ * reliable way to predict.
26
+ */
27
+ export function collectMemorySyncResult(homeDir = homedir()) {
28
+ const canonical = loadCanonicalSource(homeDir);
29
+ const server = canonical.mcp.servers[MEMORY_SERVER_NAME];
30
+ const rawPath = server?.staticEnv?.MEMORY_FILE_PATH;
31
+ if (!server || !rawPath) {
32
+ return {
33
+ configured: false,
34
+ reason: `no "${MEMORY_SERVER_NAME}" MCP server with static_env.MEMORY_FILE_PATH configured in servers.yaml — see schema/servers.example.yaml`,
35
+ };
36
+ }
37
+ const graphPath = rawPath.replace(/^~(?=$|\/)/, homeDir);
38
+ const currentContent = existsSync(graphPath) ? readFileSync(graphPath, "utf-8") : undefined;
39
+ const plan = planMemorySync(canonical, currentContent);
40
+ return { configured: true, graphPath, plan };
41
+ }
42
+ export function applyMemorySync(result) {
43
+ if (!result.configured || !result.plan.nextGraph)
44
+ return;
45
+ mkdirSync(dirname(result.graphPath), { recursive: true });
46
+ writeFileSync(result.graphPath, renderMemoryGraph(result.plan.nextGraph));
47
+ }
48
+ export function runMemorySync(opts = {}) {
49
+ const homeDir = opts.homeDir ?? homedir();
50
+ let result;
51
+ try {
52
+ result = collectMemorySyncResult(homeDir);
53
+ }
54
+ catch (err) {
55
+ console.error(err instanceof Error ? err.message : String(err));
56
+ return { exitCode: 1 };
57
+ }
58
+ if (!opts.dryRun) {
59
+ applyMemorySync(result);
60
+ }
61
+ if (opts.json) {
62
+ console.log(JSON.stringify(result, null, 2));
63
+ }
64
+ else if (!result.configured) {
65
+ console.log(result.reason);
66
+ }
67
+ else {
68
+ console.log(`${opts.dryRun ? "[dry run] " : ""}memory sync — ${result.graphPath}`);
69
+ if (result.plan.items.length === 0) {
70
+ console.log(" nothing in canonical memories/ yet");
71
+ }
72
+ for (const item of result.plan.items) {
73
+ console.log(` [${item.action}] "${item.name}" — ${item.detail}`);
74
+ }
75
+ }
76
+ const hasConflict = result.configured && result.plan.items.some((i) => i.action === "conflict");
77
+ return { exitCode: hasConflict ? 1 : 0 };
78
+ }