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.
- package/README.md +12 -0
- package/dist/adapters/claude-code.d.ts +3 -2
- package/dist/adapters/claude-code.js +21 -8
- package/dist/adapters/codex.d.ts +6 -3
- package/dist/adapters/codex.js +41 -10
- package/dist/adapters/jsonMcp.d.ts +15 -5
- package/dist/adapters/jsonMcp.js +38 -29
- package/dist/adapters/kiro.d.ts +3 -2
- package/dist/adapters/kiro.js +22 -9
- package/dist/adapters/mcpPlan.d.ts +11 -6
- package/dist/adapters/mcpPlan.js +39 -6
- package/dist/cli.js +123 -10
- package/dist/commands/mcp.d.ts +101 -7
- package/dist/commands/mcp.js +227 -10
- package/dist/commands/memory.d.ts +39 -0
- package/dist/commands/memory.js +78 -0
- package/dist/commands/migrate.d.ts +30 -4
- package/dist/commands/migrate.js +83 -16
- package/dist/commands/onboard.d.ts +18 -8
- package/dist/commands/onboard.js +124 -14
- package/dist/commands/skill.d.ts +51 -0
- package/dist/commands/skill.js +104 -0
- package/dist/core/adapter.d.ts +18 -8
- package/dist/core/canonical.d.ts +26 -1
- package/dist/core/canonical.js +81 -3
- package/dist/core/types.d.ts +17 -0
- package/dist/lib/deepEqual.d.ts +8 -0
- package/dist/lib/deepEqual.js +26 -0
- package/dist/lib/dirEquals.d.ts +9 -0
- package/dist/lib/dirEquals.js +15 -1
- package/dist/lib/mcpMigrateRead.d.ts +69 -0
- package/dist/lib/mcpMigrateRead.js +188 -0
- package/dist/lib/mcpOwnership.d.ts +25 -0
- package/dist/lib/mcpOwnership.js +50 -0
- package/dist/lib/memoryGraph.d.ts +60 -0
- package/dist/lib/memoryGraph.js +101 -0
- package/dist/lib/realHomeSnapshot.d.ts +26 -0
- package/dist/lib/realHomeSnapshot.js +77 -0
- package/dist/lib/terminalPicker.d.ts +45 -0
- package/dist/lib/terminalPicker.js +193 -0
- package/dist/lib/tomlSection.d.ts +20 -6
- package/dist/lib/tomlSection.js +78 -12
- package/dist/pi-bridge/bundle.js +76 -46
- package/dist/pi-bridge/index.js +7 -2
- package/dist/probes/codex.js +10 -2
- package/docs/architecture.md +7 -4
- package/docs/getting-started.md +166 -10
- package/docs/roadmap.md +311 -0
- package/package.json +1 -1
- 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
|
|
45
|
-
canonical source (claude-code | codex | kiro |
|
|
46
|
-
|
|
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
|
|
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
|
|
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
|
|
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 } =
|
|
248
|
+
const { exitCode } = runMemorySync({ json: memoryRest.includes("--json"), dryRun: memoryRest.includes("--dry-run") });
|
|
136
249
|
process.exitCode = exitCode;
|
|
137
250
|
return;
|
|
138
251
|
}
|
package/dist/commands/mcp.d.ts
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `trellis mcp sync` — loads canonical once, runs
|
|
3
|
-
* adapter, applies
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
+
};
|
package/dist/commands/mcp.js
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `trellis mcp sync` — loads canonical once, runs
|
|
3
|
-
* adapter, applies
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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 {
|
|
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
|
+
}
|