agent-trellis 0.14.1 → 0.15.1

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 (60) hide show
  1. package/dist/adapters/claude-code.js +4 -3
  2. package/dist/adapters/codex.js +4 -3
  3. package/dist/adapters/kimi-code.js +4 -3
  4. package/dist/adapters/kiro.js +5 -4
  5. package/dist/adapters/pi.js +4 -3
  6. package/dist/adapters/zcode.d.ts +12 -1
  7. package/dist/adapters/zcode.js +7 -6
  8. package/dist/cli.js +52 -37
  9. package/dist/commands/doctor.d.ts +9 -1
  10. package/dist/commands/doctor.js +43 -6
  11. package/dist/commands/init.d.ts +6 -0
  12. package/dist/commands/init.js +24 -0
  13. package/dist/commands/mcp.d.ts +69 -3
  14. package/dist/commands/mcp.js +255 -56
  15. package/dist/commands/mcpGateway.js +3 -3
  16. package/dist/commands/migrate.d.ts +12 -0
  17. package/dist/commands/migrate.js +8 -4
  18. package/dist/commands/migrateWorkspace.d.ts +40 -0
  19. package/dist/commands/migrateWorkspace.js +197 -0
  20. package/dist/commands/rollback.js +65 -35
  21. package/dist/commands/skill.d.ts +14 -0
  22. package/dist/commands/skill.js +71 -0
  23. package/dist/commands/sync.d.ts +16 -1
  24. package/dist/commands/sync.js +48 -15
  25. package/dist/core/adapter.d.ts +14 -0
  26. package/dist/core/canonical.d.ts +20 -34
  27. package/dist/core/canonical.js +18 -92
  28. package/dist/core/mcpServerDef.d.ts +39 -0
  29. package/dist/core/mcpServerDef.js +93 -0
  30. package/dist/core/types.d.ts +39 -4
  31. package/dist/lib/cliArgs.d.ts +17 -0
  32. package/dist/lib/cliArgs.js +36 -0
  33. package/dist/lib/mcpMigrateRead.d.ts +25 -0
  34. package/dist/lib/mcpMigrateRead.js +9 -2
  35. package/dist/lib/scopeSelection.d.ts +1 -1
  36. package/dist/lib/scopeSelection.js +4 -3
  37. package/dist/lib/skillProvider.js +18 -5
  38. package/dist/lib/workspace.d.ts +39 -0
  39. package/dist/lib/workspace.js +158 -0
  40. package/dist/lib/workspaceConfig.d.ts +51 -0
  41. package/dist/lib/workspaceConfig.js +117 -0
  42. package/dist/lib/workspaceDoctor.d.ts +44 -0
  43. package/dist/lib/workspaceDoctor.js +94 -0
  44. package/dist/lib/workspaceFiles.d.ts +38 -0
  45. package/dist/lib/workspaceFiles.js +264 -0
  46. package/dist/lib/workspaceMcp.d.ts +57 -0
  47. package/dist/lib/workspaceMcp.js +277 -0
  48. package/dist/lib/workspaceMerge.d.ts +37 -0
  49. package/dist/lib/workspaceMerge.js +98 -0
  50. package/dist/lib/workspaceMigrate.d.ts +118 -0
  51. package/dist/lib/workspaceMigrate.js +423 -0
  52. package/dist/pi-bridge/bundle.js +258 -96
  53. package/docs/architecture.md +111 -49
  54. package/docs/getting-started.md +86 -14
  55. package/docs/mcp-oauth-matrix.md +12 -1
  56. package/docs/overview.zh-CN.md +17 -0
  57. package/docs/research.md +28 -6
  58. package/docs/roadmap.md +17 -5
  59. package/package.json +1 -1
  60. package/docs/implementation-plan.md +0 -341
@@ -33,14 +33,23 @@ export interface RunMcpSyncOptions {
33
33
  /** Onboard-only seam — see RunSyncOptions.backupSession. Never a CLI
34
34
  * flag. */
35
35
  backupSession?: BackupSession;
36
+ /** Force global-only behavior even inside a workspace (`--global-only`). */
37
+ globalOnly?: boolean;
38
+ /** Explicit workspace root — the test/sandbox seam (see RunSyncOptions). */
39
+ workspaceDir?: string | null;
40
+ /** `--check`: plan only, write nothing, exit non-zero on any stale or
41
+ * missing rendered artifact (trellis-workspace-scope design D7). */
42
+ check?: boolean;
36
43
  }
37
44
  export interface AgentMcpSyncReport {
38
45
  agent: AgentId;
39
46
  present: boolean;
40
47
  items: AdapterPlanItem[];
48
+ warnings?: string[];
41
49
  }
42
50
  export interface McpSyncReport {
43
51
  reports: AgentMcpSyncReport[];
52
+ workspaceRoot?: string;
44
53
  }
45
54
  export declare function collectMcpSyncReport(opts?: RunMcpSyncOptions): Promise<McpSyncReport>;
46
55
  export declare function runMcpSync(opts?: RunMcpSyncOptions): Promise<{
@@ -49,7 +58,7 @@ export declare function runMcpSync(opts?: RunMcpSyncOptions): Promise<{
49
58
  /** Exported so `onboard` prints an mcp-sync report identically to running
50
59
  * `mcp sync` standalone, instead of a second, easily-drifting copy of this
51
60
  * formatting. */
52
- export declare function printReport(report: McpSyncReport, dryRun: boolean): void;
61
+ export declare function printReport(report: McpSyncReport, dryRun: boolean, check?: boolean): void;
53
62
  export interface McpListEntry {
54
63
  name: string;
55
64
  transport: Transport;
@@ -111,8 +120,9 @@ export declare function runMcpList(opts?: {
111
120
  };
112
121
  /** Raw, unvalidated CLI flag values — `collectMcpAddPlan` does the actual
113
122
  * parsing/validation. Kept as plain strings here so `parseMcpAddArgs`
114
- * stays a dumb `--flag value` reader, same posture as every other
115
- * command's own inline flag parsing in src/cli.ts. */
123
+ * stays a dumb `--flag value` reader. Both `--flag value` and
124
+ * `--flag=value` are accepted (see src/lib/cliArgs.ts — the `=` form used
125
+ * to be silently dropped). */
116
126
  export interface McpAddRawArgs {
117
127
  transport?: string;
118
128
  auth?: string;
@@ -130,6 +140,9 @@ export interface McpAddRawArgs {
130
140
  agents?: string;
131
141
  /** `"true"` or `"false"`. */
132
142
  enabled?: string;
143
+ /** `--*` tokens no flag claimed (typos, wrong command); `runMcpAdd`
144
+ * warns about them instead of letting them vanish silently. */
145
+ unknown?: string[];
133
146
  }
134
147
  export declare function parseMcpAddArgs(argv: readonly string[]): McpAddRawArgs;
135
148
  export declare function parseMcpSetArgs(argv: readonly string[]): McpSetAuthRawArgs;
@@ -147,8 +160,30 @@ export interface McpAddPlan {
147
160
  * write failure — design.md's open question, resolved in favor of
148
161
  * refusing early with a specific reason. No `--force`: an existing name
149
162
  * always refuses, ever (design.md D4).
163
+ *
164
+ * Split into two steps so `mcp add --workspace` reuses the identical
165
+ * flag validation/def construction and only swaps which file's existing
166
+ * entries it conflicts against (trellis-workspace-scope task 3.4):
167
+ * `buildMcpAddDef` is the pure part, `collectMcpAddPlan` adds the
168
+ * global-canonical existence check.
150
169
  */
170
+ export declare function buildMcpAddDef(name: string | undefined, raw: McpAddRawArgs): {
171
+ ok: true;
172
+ name: string;
173
+ def: McpServerDef;
174
+ } | {
175
+ ok: false;
176
+ plan: McpAddPlan;
177
+ };
151
178
  export declare function collectMcpAddPlan(name: string | undefined, raw: McpAddRawArgs, homeDir?: string): McpAddPlan;
179
+ /** The workspace twin of `collectMcpAddPlan` (trellis-workspace-scope
180
+ * task 3.4): identical validation, but the existence check runs against
181
+ * `.agents/config.json` — compared through the same snake_case
182
+ * serialization the file stores, so a re-add of the same definition is
183
+ * a clean "already-present" no-op. A global same-name entry is NOT a
184
+ * conflict here (workspace shadowing a global entry is a legal,
185
+ * documented merge outcome), just noted in the detail. */
186
+ export declare function collectMcpAddWorkspacePlan(name: string | undefined, raw: McpAddRawArgs, workspaceRoot: string, homeDir?: string): McpAddPlan;
152
187
  export type McpSetAuthAction = "updated" | "already-set" | "not-found" | "invalid-input";
153
188
  export interface McpSetAuthPlan {
154
189
  name: string;
@@ -168,6 +203,8 @@ export interface McpSetAuthRawArgs {
168
203
  clientId?: string;
169
204
  /** A variable NAME, never a secret value. */
170
205
  clientSecretEnv?: string;
206
+ /** See `McpAddRawArgs.unknown`. */
207
+ unknown?: string[];
171
208
  }
172
209
  /**
173
210
  * Sets or clears the OAuth classification, and optionally the
@@ -269,10 +306,22 @@ export declare function applyMcpAddWithSync(plan: McpAddPlan, opts?: {
269
306
  dryRun?: boolean;
270
307
  backupSession?: BackupSession;
271
308
  }): Promise<McpAddOutcome>;
309
+ /** Workspace twin of `applyMcpAddWithSync` (trellis-workspace-scope task
310
+ * 3.4): writes `.agents/config.json` instead of `servers.yaml`, then
311
+ * cascades the same mcp sync — which, run inside the workspace, renders
312
+ * only the project-level targets for the workspace layer (plus the usual
313
+ * global-layer targets; the agent does its own layering). */
314
+ export declare function applyMcpAddWorkspaceWithSync(plan: McpAddPlan, workspaceRoot: string, opts?: {
315
+ homeDir?: string;
316
+ dryRun?: boolean;
317
+ backupSession?: BackupSession;
318
+ }): Promise<McpAddOutcome>;
272
319
  export declare function runMcpAdd(name: string | undefined, raw: McpAddRawArgs, opts?: {
273
320
  homeDir?: string;
274
321
  json?: boolean;
275
322
  dryRun?: boolean;
323
+ workspace?: boolean;
324
+ workspaceDir?: string;
276
325
  }): Promise<{
277
326
  exitCode: number;
278
327
  }>;
@@ -299,10 +348,27 @@ export declare function applyMcpRemoveWithSync(plan: McpRemovePlan, opts?: {
299
348
  dryRun?: boolean;
300
349
  backupSession?: BackupSession;
301
350
  }): Promise<McpRemoveOutcome>;
351
+ /** Workspace twin of `collectMcpRemovePlan`: presence is judged against
352
+ * `.agents/config.json` only — a global entry of the same name is this
353
+ * entry's shadow target, not a reason to touch the global file. */
354
+ export declare function collectMcpRemoveWorkspacePlan(name: string, workspaceRoot: string): McpRemovePlan;
355
+ /** Workspace twin of `applyMcpRemoveWithSync`: deletes the entry from
356
+ * `.agents/config.json` (backup-session wrapped), then cascades the same
357
+ * mcp sync — run inside the workspace, so `planWorkspaceMcp` sees the
358
+ * entry is gone and removes the rendered project-level copies (the
359
+ * ledger-gated propagation already exists; this command was the missing
360
+ * corner). A shadowed global entry simply takes effect again. */
361
+ export declare function applyMcpRemoveWorkspaceWithSync(plan: McpRemovePlan, workspaceRoot: string, opts?: {
362
+ homeDir?: string;
363
+ dryRun?: boolean;
364
+ backupSession?: BackupSession;
365
+ }): Promise<McpRemoveOutcome>;
302
366
  export declare function runMcpRemove(name: string, opts?: {
303
367
  homeDir?: string;
304
368
  json?: boolean;
305
369
  dryRun?: boolean;
370
+ workspace?: boolean;
371
+ workspaceDir?: string;
306
372
  }): Promise<{
307
373
  exitCode: number;
308
374
  }>;
@@ -27,6 +27,11 @@ import { PiAdapter } from "../adapters/pi.js";
27
27
  import { KimiCodeAdapter } from "../adapters/kimi-code.js";
28
28
  import { ZcodeAdapter } from "../adapters/zcode.js";
29
29
  import { openBackupSession } from "../lib/backup.js";
30
+ import { findWorkspaceRoot } from "../lib/workspace.js";
31
+ import { applyWorkspaceMcp, planWorkspaceMcp, workspacePrecedenceWarnings } from "../lib/workspaceMcp.js";
32
+ import { readWorkspaceConfigJson, removeWorkspaceServer, upsertWorkspaceServer } from "../lib/workspaceConfig.js";
33
+ import { toServerDefYaml } from "../core/mcpServerDef.js";
34
+ import { flagValue, unknownFlags } from "../lib/cliArgs.js";
30
35
  const ADAPTER_FACTORY = {
31
36
  "claude-code": (homeDir) => new ClaudeCodeAdapter(homeDir),
32
37
  codex: (homeDir) => new CodexAdapter(homeDir),
@@ -42,14 +47,16 @@ function buildAdapters(homeDir, managedAgents) {
42
47
  }
43
48
  export async function collectMcpSyncReport(opts = {}) {
44
49
  const homeDir = opts.homeDir ?? homedir();
45
- const loaded = loadCanonicalSource(homeDir);
50
+ const workspaceDir = opts.globalOnly ? null : (opts.workspaceDir !== undefined ? opts.workspaceDir : findWorkspaceRoot(process.cwd()));
51
+ const loaded = loadCanonicalSource({ homeDir, workspaceDir });
46
52
  const canonical = {
47
53
  ...loaded,
48
54
  ...(opts.managedAgents ? { managedAgents: opts.managedAgents } : {}),
49
55
  ...(opts.mcp ? { mcp: opts.mcp } : {}),
50
56
  };
51
57
  const reports = [];
52
- const ownSession = !opts.dryRun && !opts.backupSession ? openBackupSession(homeDir, "mcp-sync") : undefined;
58
+ const writeMode = !opts.dryRun && !opts.check;
59
+ const ownSession = writeMode && !opts.backupSession ? openBackupSession(homeDir, "mcp-sync") : undefined;
53
60
  const backup = opts.backupSession ?? ownSession;
54
61
  for (const adapter of buildAdapters(homeDir, canonical.managedAgents)) {
55
62
  const probeResult = await adapter.probe();
@@ -57,14 +64,17 @@ export async function collectMcpSyncReport(opts = {}) {
57
64
  reports.push({ agent: adapter.id, present: false, items: [] });
58
65
  continue;
59
66
  }
60
- const items = (await adapter.plan(canonical)).filter((item) => item.kind === "mcp");
61
- if (!opts.dryRun) {
62
- await adapter.apply(items, backup);
67
+ const globalItems = (await adapter.plan(canonical)).filter((item) => item.kind === "mcp");
68
+ const wsItems = planWorkspaceMcp(adapter.id, canonical);
69
+ if (writeMode) {
70
+ await adapter.apply(globalItems, backup);
71
+ applyWorkspaceMcp(adapter.id, canonical, wsItems, backup);
63
72
  }
64
- reports.push({ agent: adapter.id, present: true, items });
73
+ const warnings = workspacePrecedenceWarnings(adapter.id, canonical);
74
+ reports.push({ agent: adapter.id, present: true, items: [...globalItems, ...wsItems], ...(warnings.length > 0 ? { warnings } : {}) });
65
75
  }
66
76
  ownSession?.finalize();
67
- return { reports };
77
+ return { reports, ...(canonical.workspace ? { workspaceRoot: canonical.workspace.root } : {}) };
68
78
  }
69
79
  export async function runMcpSync(opts = {}) {
70
80
  let report;
@@ -79,22 +89,31 @@ export async function runMcpSync(opts = {}) {
79
89
  console.log(JSON.stringify(report, null, 2));
80
90
  }
81
91
  else {
82
- printReport(report, opts.dryRun ?? false);
92
+ printReport(report, opts.dryRun ?? false, opts.check ?? false);
83
93
  }
84
94
  const hasConflict = report.reports.some((r) => r.items.some((i) => i.action === "conflict"));
95
+ if (opts.check) {
96
+ const hasPending = report.reports.some((r) => r.items.some((i) => i.action !== "conflict"));
97
+ return { exitCode: hasPending || hasConflict ? 1 : 0 };
98
+ }
85
99
  return { exitCode: hasConflict ? 1 : 0 };
86
100
  }
87
101
  /** Exported so `onboard` prints an mcp-sync report identically to running
88
102
  * `mcp sync` standalone, instead of a second, easily-drifting copy of this
89
103
  * formatting. */
90
- export function printReport(report, dryRun) {
104
+ export function printReport(report, dryRun, check = false) {
91
105
  if (dryRun)
92
106
  console.log("[dry run]");
107
+ if (check)
108
+ console.log("[check]");
109
+ if (report.workspaceRoot) {
110
+ console.log(`Workspace layer: ${report.workspaceRoot} (.agents/)`);
111
+ }
93
112
  if (report.reports.length === 0) {
94
113
  console.log("No managed agents yet — run `trellis onboard` or list agent ids in ~/.trellis/managed.yaml.");
95
114
  return;
96
115
  }
97
- for (const { agent, present, items } of report.reports) {
116
+ for (const { agent, present, items, warnings } of report.reports) {
98
117
  if (!present) {
99
118
  console.log(`— ${agent} (not installed)`);
100
119
  continue;
@@ -104,18 +123,27 @@ export function printReport(report, dryRun) {
104
123
  const conflicts = items.filter((i) => i.action === "conflict");
105
124
  if (created.length === 0 && removed.length === 0 && conflicts.length === 0) {
106
125
  console.log(`✅ ${agent} — already in sync`);
107
- continue;
108
126
  }
109
- const icon = conflicts.length > 0 ? "⚠️ " : "✅";
110
- console.log(`${icon} ${agent} — ${created.length} created/updated, ${removed.length} removed, ${conflicts.length} conflict(s)`);
111
- for (const item of [...created, ...removed, ...conflicts]) {
112
- console.log(` - [${item.action}] ${item.description}`);
127
+ else {
128
+ const icon = conflicts.length > 0 ? "⚠️ " : "✅";
129
+ console.log(`${icon} ${agent} — ${created.length} created/updated, ${removed.length} removed, ${conflicts.length} conflict(s)`);
130
+ for (const item of [...created, ...removed, ...conflicts]) {
131
+ console.log(` - [${item.action}]${item.layer === "workspace" ? " (workspace)" : ""} ${item.description}`);
132
+ // Conflicts carry the actionable fix (e.g. the unmarked-overwrite
133
+ // guard's "run migrate --workspace or remove the entry") — printing
134
+ // only the description would leave the user stuck at "left untouched".
135
+ if (item.remediation)
136
+ console.log(` → ${item.remediation}`);
137
+ }
138
+ // pi does not watch mcp.json (verified 2026-10-11): without this hint
139
+ // a changed config silently only takes effect on the next pi launch
140
+ // (trellis-pi-native-mcp-migration design.md D5).
141
+ if (agent === "pi" && (created.length > 0 || removed.length > 0)) {
142
+ console.log(" pi does not watch mcp.json — restart pi or run /reload to pick up MCP changes");
143
+ }
113
144
  }
114
- // pi does not watch mcp.json (verified 2026-10-11): without this hint
115
- // a changed config silently only takes effect on the next pi launch
116
- // (trellis-pi-native-mcp-migration design.md D5).
117
- if (agent === "pi" && (created.length > 0 || removed.length > 0)) {
118
- console.log(" pi does not watch mcp.json — restart pi or run /reload to pick up MCP changes");
145
+ for (const warning of warnings ?? []) {
146
+ console.log(` ⚠ ${warning}`);
119
147
  }
120
148
  }
121
149
  }
@@ -205,34 +233,39 @@ export function runMcpList(opts = {}) {
205
233
  }
206
234
  return { exitCode: 0 };
207
235
  }
236
+ const MCP_ADD_KNOWN_FLAGS = [
237
+ "--transport", "--auth", "--command", "--args", "--url", "--headers",
238
+ "--env", "--static-env", "--agents", "--enabled",
239
+ "--workspace", "--dry-run", "--json",
240
+ ];
208
241
  export function parseMcpAddArgs(argv) {
209
- const flag = (name) => {
210
- const i = argv.indexOf(name);
211
- return i >= 0 ? argv[i + 1] : undefined;
212
- };
242
+ const unknown = unknownFlags(argv, MCP_ADD_KNOWN_FLAGS);
213
243
  return {
214
- transport: flag("--transport"),
215
- auth: flag("--auth"),
216
- command: flag("--command"),
217
- args: flag("--args"),
218
- url: flag("--url"),
219
- headers: flag("--headers"),
220
- env: flag("--env"),
221
- staticEnv: flag("--static-env"),
222
- agents: flag("--agents"),
223
- enabled: flag("--enabled"),
244
+ transport: flagValue(argv, "--transport"),
245
+ auth: flagValue(argv, "--auth"),
246
+ command: flagValue(argv, "--command"),
247
+ args: flagValue(argv, "--args"),
248
+ url: flagValue(argv, "--url"),
249
+ headers: flagValue(argv, "--headers"),
250
+ env: flagValue(argv, "--env"),
251
+ staticEnv: flagValue(argv, "--static-env"),
252
+ agents: flagValue(argv, "--agents"),
253
+ enabled: flagValue(argv, "--enabled"),
254
+ ...(unknown.length > 0 ? { unknown } : {}),
224
255
  };
225
256
  }
257
+ const MCP_SET_KNOWN_FLAGS = [
258
+ "--auth", "--client-id", "--client-secret-env", "--auth-owner",
259
+ "--dry-run", "--json",
260
+ ];
226
261
  export function parseMcpSetArgs(argv) {
227
- const flag = (name) => {
228
- const i = argv.indexOf(name);
229
- return i >= 0 ? argv[i + 1] : undefined;
230
- };
262
+ const unknown = unknownFlags(argv, MCP_SET_KNOWN_FLAGS);
231
263
  return {
232
- auth: flag("--auth"),
233
- clientId: flag("--client-id"),
234
- clientSecretEnv: flag("--client-secret-env"),
235
- authOwner: flag("--auth-owner"),
264
+ auth: flagValue(argv, "--auth"),
265
+ clientId: flagValue(argv, "--client-id"),
266
+ clientSecretEnv: flagValue(argv, "--client-secret-env"),
267
+ authOwner: flagValue(argv, "--auth-owner"),
268
+ ...(unknown.length > 0 ? { unknown } : {}),
236
269
  };
237
270
  }
238
271
  function parseCsv(raw) {
@@ -260,37 +293,43 @@ const TRANSPORTS = ["stdio", "http", "sse"];
260
293
  * write failure — design.md's open question, resolved in favor of
261
294
  * refusing early with a specific reason. No `--force`: an existing name
262
295
  * always refuses, ever (design.md D4).
296
+ *
297
+ * Split into two steps so `mcp add --workspace` reuses the identical
298
+ * flag validation/def construction and only swaps which file's existing
299
+ * entries it conflicts against (trellis-workspace-scope task 3.4):
300
+ * `buildMcpAddDef` is the pure part, `collectMcpAddPlan` adds the
301
+ * global-canonical existence check.
263
302
  */
264
- export function collectMcpAddPlan(name, raw, homeDir = homedir()) {
303
+ export function buildMcpAddDef(name, raw) {
265
304
  if (!name) {
266
- return { name: "(none)", action: "invalid-input", detail: "a name is required: trellis mcp add <name> --transport ..." };
305
+ return { ok: false, plan: { name: "(none)", action: "invalid-input", detail: "a name is required: trellis mcp add <name> --transport ..." } };
267
306
  }
268
307
  if (!raw.transport || !TRANSPORTS.includes(raw.transport)) {
269
- return { name, action: "invalid-input", detail: `--transport must be one of: ${TRANSPORTS.join(", ")}` };
308
+ return { ok: false, plan: { name, action: "invalid-input", detail: `--transport must be one of: ${TRANSPORTS.join(", ")}` } };
270
309
  }
271
310
  const transport = raw.transport;
272
311
  if (transport === "stdio" && !raw.command) {
273
- return { name, action: "invalid-input", detail: "--transport stdio requires --command" };
312
+ return { ok: false, plan: { name, action: "invalid-input", detail: "--transport stdio requires --command" } };
274
313
  }
275
314
  if ((transport === "http" || transport === "sse") && !raw.url) {
276
- return { name, action: "invalid-input", detail: `--transport ${transport} requires --url` };
315
+ return { ok: false, plan: { name, action: "invalid-input", detail: `--transport ${transport} requires --url` } };
277
316
  }
278
317
  if (raw.auth !== undefined && raw.auth !== "oauth") {
279
- return { name, action: "invalid-input", detail: `--auth must be "oauth" when provided (got ${raw.auth})` };
318
+ return { ok: false, plan: { name, action: "invalid-input", detail: `--auth must be "oauth" when provided (got ${raw.auth})` } };
280
319
  }
281
320
  if (raw.auth === "oauth" && transport === "stdio") {
282
- return { name, action: "invalid-input", detail: "--auth oauth requires an http or sse MCP server" };
321
+ return { ok: false, plan: { name, action: "invalid-input", detail: "--auth oauth requires an http or sse MCP server" } };
283
322
  }
284
323
  const agentsList = parseCsv(raw.agents);
285
324
  if (agentsList) {
286
325
  for (const id of agentsList) {
287
326
  if (!ALL_AGENTS.includes(id)) {
288
- return { name, action: "invalid-input", detail: `--agents "${id}" is not a recognized agent id (${ALL_AGENTS.join(", ")})` };
327
+ return { ok: false, plan: { name, action: "invalid-input", detail: `--agents "${id}" is not a recognized agent id (${ALL_AGENTS.join(", ")})` } };
289
328
  }
290
329
  }
291
330
  }
292
331
  if (raw.enabled !== undefined && raw.enabled !== "true" && raw.enabled !== "false") {
293
- return { name, action: "invalid-input", detail: `--enabled must be "true" or "false" (got ${raw.enabled})` };
332
+ return { ok: false, plan: { name, action: "invalid-input", detail: `--enabled must be "true" or "false" (got ${raw.enabled})` } };
294
333
  }
295
334
  const def = {
296
335
  transport,
@@ -304,15 +343,46 @@ export function collectMcpAddPlan(name, raw, homeDir = homedir()) {
304
343
  ...(raw.enabled !== undefined ? { enabled: raw.enabled === "true" } : {}),
305
344
  ...(agentsList ? { agents: agentsList } : {}),
306
345
  };
346
+ return { ok: true, name, def };
347
+ }
348
+ export function collectMcpAddPlan(name, raw, homeDir = homedir()) {
349
+ const built = buildMcpAddDef(name, raw);
350
+ if (!built.ok)
351
+ return built.plan;
352
+ const def = built.def;
307
353
  const canonical = loadCanonicalSource(homeDir);
308
- const existing = canonical.mcp.servers[name];
354
+ const existing = canonical.mcp.servers[built.name];
309
355
  if (existing) {
310
356
  if (JSON.stringify(existing) === JSON.stringify(def)) {
311
- return { name, action: "already-present", detail: "canonical entry is already identical" };
357
+ return { name: built.name, action: "already-present", detail: "canonical entry is already identical" };
312
358
  }
313
- return { name, action: "conflict", detail: `servers.yaml already has "${name}" with different settings — resolve by hand (no --force, design.md D4)` };
359
+ return { name: built.name, action: "conflict", detail: `servers.yaml already has "${built.name}" with different settings — resolve by hand (no --force, design.md D4)` };
314
360
  }
315
- return { name, action: "create", detail: "will add to servers.yaml", def };
361
+ return { name: built.name, action: "create", detail: "will add to servers.yaml", def };
362
+ }
363
+ /** The workspace twin of `collectMcpAddPlan` (trellis-workspace-scope
364
+ * task 3.4): identical validation, but the existence check runs against
365
+ * `.agents/config.json` — compared through the same snake_case
366
+ * serialization the file stores, so a re-add of the same definition is
367
+ * a clean "already-present" no-op. A global same-name entry is NOT a
368
+ * conflict here (workspace shadowing a global entry is a legal,
369
+ * documented merge outcome), just noted in the detail. */
370
+ export function collectMcpAddWorkspacePlan(name, raw, workspaceRoot, homeDir = homedir()) {
371
+ const built = buildMcpAddDef(name, raw);
372
+ if (!built.ok)
373
+ return built.plan;
374
+ const def = built.def;
375
+ const config = readWorkspaceConfigJson(workspaceRoot);
376
+ const existing = config.mcp?.servers?.[built.name];
377
+ if (existing) {
378
+ if (JSON.stringify(existing) === JSON.stringify(toServerDefYaml(def))) {
379
+ return { name: built.name, action: "already-present", detail: ".agents/config.json entry is already identical" };
380
+ }
381
+ return { name: built.name, action: "conflict", detail: `.agents/config.json already has "${built.name}" with different settings — resolve by hand (no --force)` };
382
+ }
383
+ const global = loadCanonicalSource(homeDir).mcp.servers[built.name];
384
+ const shadowNote = global ? ` (shadows the global "${built.name}" for this project)` : "";
385
+ return { name: built.name, action: "create", detail: `will add to .agents/config.json${shadowNote}`, def };
316
386
  }
317
387
  /**
318
388
  * Sets or clears the OAuth classification, and optionally the
@@ -427,6 +497,7 @@ export function collectMcpSetAuthPlan(name, raw, homeDir = homedir()) {
427
497
  }
428
498
  export function runMcpSetAuth(name, raw, opts = {}) {
429
499
  const homeDir = opts.homeDir ?? homedir();
500
+ warnUnknownFlags("mcp set", raw.unknown);
430
501
  let plan;
431
502
  try {
432
503
  plan = collectMcpSetAuthPlan(name, raw, homeDir);
@@ -587,8 +658,64 @@ export async function applyMcpAddWithSync(plan, opts = {}) {
587
658
  }
588
659
  return { plan, ...(writeError ? { writeError } : {}), ...(syncReport ? { sync: syncReport } : {}) };
589
660
  }
661
+ /** Workspace twin of `applyMcpAddWithSync` (trellis-workspace-scope task
662
+ * 3.4): writes `.agents/config.json` instead of `servers.yaml`, then
663
+ * cascades the same mcp sync — which, run inside the workspace, renders
664
+ * only the project-level targets for the workspace layer (plus the usual
665
+ * global-layer targets; the agent does its own layering). */
666
+ export async function applyMcpAddWorkspaceWithSync(plan, workspaceRoot, opts = {}) {
667
+ const homeDir = opts.homeDir ?? homedir();
668
+ const ownSession = !opts.dryRun && !opts.backupSession && plan.action === "create" ? openBackupSession(homeDir, "mcp-add") : undefined;
669
+ const session = opts.backupSession ?? ownSession;
670
+ if (!opts.dryRun && plan.action === "create" && plan.def) {
671
+ upsertWorkspaceServer(workspaceRoot, plan.name, plan.def, session);
672
+ }
673
+ ownSession?.finalize();
674
+ const planFailed = plan.action === "conflict" || plan.action === "invalid-input";
675
+ let syncReport;
676
+ if (!planFailed && plan.action === "create" && existsSync(join(homeDir, ".trellis"))) {
677
+ syncReport = await collectMcpSyncReport({ homeDir, workspaceDir: workspaceRoot, dryRun: opts.dryRun });
678
+ }
679
+ return { plan, ...(syncReport ? { sync: syncReport } : {}) };
680
+ }
681
+ /** Warn about `--*` tokens the parser didn't claim — a typo like
682
+ * `--tranport http` must not exit 0 in silence. */
683
+ function warnUnknownFlags(commandLabel, unknown) {
684
+ if (unknown && unknown.length > 0) {
685
+ console.error(`warning: ${commandLabel}: unrecognized flag(s) ${unknown.map((f) => `"${f}"`).join(", ")} — ignored`);
686
+ }
687
+ }
590
688
  export async function runMcpAdd(name, raw, opts = {}) {
591
689
  const homeDir = opts.homeDir ?? homedir();
690
+ warnUnknownFlags("mcp add", raw.unknown);
691
+ if (opts.workspace) {
692
+ const workspaceRoot = opts.workspaceDir ?? findWorkspaceRoot(process.cwd());
693
+ if (!workspaceRoot) {
694
+ console.error("no .agents/ found here or above — run `trellis init --workspace` first (or pass --workspace from inside the repo)");
695
+ return { exitCode: 1 };
696
+ }
697
+ let plan;
698
+ try {
699
+ plan = collectMcpAddWorkspacePlan(name, raw, workspaceRoot, homeDir);
700
+ }
701
+ catch (err) {
702
+ console.error(err instanceof Error ? err.message : String(err));
703
+ return { exitCode: 1 };
704
+ }
705
+ const { sync: syncReport } = await applyMcpAddWorkspaceWithSync(plan, workspaceRoot, { homeDir, dryRun: opts.dryRun });
706
+ const planFailed = plan.action === "conflict" || plan.action === "invalid-input";
707
+ if (opts.json) {
708
+ console.log(JSON.stringify(syncReport ? { ...plan, workspace: workspaceRoot, sync: syncReport } : { ...plan, workspace: workspaceRoot }, null, 2));
709
+ }
710
+ else {
711
+ console.log(`${opts.dryRun ? "[dry run] " : ""}mcp add ${plan.name} --workspace`);
712
+ console.log(` [${plan.action}] ${plan.detail}`);
713
+ if (syncReport)
714
+ printReport(syncReport, opts.dryRun ?? false);
715
+ }
716
+ const syncConflict = syncReport?.reports.some((r) => r.items.some((i) => i.action === "conflict")) ?? false;
717
+ return { exitCode: planFailed || syncConflict ? 1 : 0 };
718
+ }
592
719
  let plan;
593
720
  try {
594
721
  plan = collectMcpAddPlan(name, raw, homeDir);
@@ -655,8 +782,65 @@ export async function applyMcpRemoveWithSync(plan, opts = {}) {
655
782
  }
656
783
  return { plan, ...(writeError ? { writeError } : {}), ...(syncReport ? { sync: syncReport } : {}) };
657
784
  }
785
+ /** Workspace twin of `collectMcpRemovePlan`: presence is judged against
786
+ * `.agents/config.json` only — a global entry of the same name is this
787
+ * entry's shadow target, not a reason to touch the global file. */
788
+ export function collectMcpRemoveWorkspacePlan(name, workspaceRoot) {
789
+ const config = readWorkspaceConfigJson(workspaceRoot);
790
+ return { name, action: config.mcp?.servers?.[name] ? "removed" : "not-found" };
791
+ }
792
+ /** Workspace twin of `applyMcpRemoveWithSync`: deletes the entry from
793
+ * `.agents/config.json` (backup-session wrapped), then cascades the same
794
+ * mcp sync — run inside the workspace, so `planWorkspaceMcp` sees the
795
+ * entry is gone and removes the rendered project-level copies (the
796
+ * ledger-gated propagation already exists; this command was the missing
797
+ * corner). A shadowed global entry simply takes effect again. */
798
+ export async function applyMcpRemoveWorkspaceWithSync(plan, workspaceRoot, opts = {}) {
799
+ const homeDir = opts.homeDir ?? homedir();
800
+ const ownSession = !opts.dryRun && !opts.backupSession && plan.action === "removed" ? openBackupSession(homeDir, "mcp-remove") : undefined;
801
+ const session = opts.backupSession ?? ownSession;
802
+ if (!opts.dryRun && plan.action === "removed") {
803
+ removeWorkspaceServer(workspaceRoot, plan.name, session);
804
+ }
805
+ ownSession?.finalize();
806
+ const planFailed = plan.action === "not-found";
807
+ let syncReport;
808
+ if (!planFailed && existsSync(join(homeDir, ".trellis"))) {
809
+ syncReport = await collectMcpSyncReport({ homeDir, workspaceDir: workspaceRoot, dryRun: opts.dryRun });
810
+ }
811
+ return { plan, ...(syncReport ? { sync: syncReport } : {}) };
812
+ }
658
813
  export async function runMcpRemove(name, opts = {}) {
659
814
  const homeDir = opts.homeDir ?? homedir();
815
+ if (opts.workspace) {
816
+ const workspaceRoot = opts.workspaceDir ?? findWorkspaceRoot(process.cwd());
817
+ if (!workspaceRoot) {
818
+ console.error("no .agents/ found here or above — run `trellis init --workspace` first (or pass --workspace from inside the repo)");
819
+ return { exitCode: 1 };
820
+ }
821
+ let plan;
822
+ try {
823
+ plan = collectMcpRemoveWorkspacePlan(name, workspaceRoot);
824
+ }
825
+ catch (err) {
826
+ console.error(err instanceof Error ? err.message : String(err));
827
+ return { exitCode: 1 };
828
+ }
829
+ const { sync: syncReport } = await applyMcpRemoveWorkspaceWithSync(plan, workspaceRoot, { homeDir, dryRun: opts.dryRun });
830
+ if (opts.json) {
831
+ console.log(JSON.stringify(syncReport ? { ...plan, workspace: workspaceRoot, sync: syncReport } : { ...plan, workspace: workspaceRoot }, null, 2));
832
+ }
833
+ else if (plan.action === "not-found") {
834
+ console.error(`"${name}" is not in the workspace layer (${workspaceRoot}/.agents/config.json) — nothing to remove.`);
835
+ }
836
+ else {
837
+ console.log(`${opts.dryRun ? "[dry run] " : ""}removed MCP server "${name}" from ${workspaceRoot}/.agents/config.json.`);
838
+ if (syncReport)
839
+ printReport(syncReport, opts.dryRun ?? false);
840
+ }
841
+ const syncConflict = syncReport?.reports.some((r) => r.items.some((i) => i.action === "conflict")) ?? false;
842
+ return { exitCode: plan.action === "not-found" || syncConflict ? 1 : 0 };
843
+ }
660
844
  let plan;
661
845
  try {
662
846
  plan = collectMcpRemovePlan(name, homeDir);
@@ -680,7 +864,22 @@ export async function runMcpRemove(name, opts = {}) {
680
864
  console.log(JSON.stringify(syncReport ? { ...payload, sync: syncReport } : payload, null, 2));
681
865
  }
682
866
  else if (plan.action === "not-found") {
683
- console.error(`"${name}" is not a canonical MCP server — nothing to remove.`);
867
+ // A name that lives only in the workspace layer used to get this same
868
+ // blunt message (found by the v0.15.0 real-machine smoke test) —
869
+ // point at the flag instead of implying the entry doesn't exist.
870
+ let workspaceHint = "";
871
+ const workspaceRoot = opts.workspaceDir !== undefined ? opts.workspaceDir : findWorkspaceRoot(process.cwd());
872
+ if (workspaceRoot) {
873
+ try {
874
+ if (readWorkspaceConfigJson(workspaceRoot).mcp?.servers?.[name]) {
875
+ workspaceHint = ` here, but it exists in the workspace layer — re-run with --workspace to remove it from ${workspaceRoot}/.agents/config.json`;
876
+ }
877
+ }
878
+ catch {
879
+ // An unreadable workspace config must not mask the primary answer.
880
+ }
881
+ }
882
+ console.error(`"${name}" is not a canonical MCP server${workspaceHint} — nothing to remove.`);
684
883
  }
685
884
  else if (writeError) {
686
885
  console.error(` write failed: ${writeError}`);
@@ -28,16 +28,16 @@ import { AgentBridgeProvider } from "../lib/agentBridgeProvider.js";
28
28
  import { RuntimeMemoryProvider } from "../lib/memoryProvider.js";
29
29
  import { SkillProvider } from "../lib/skillProvider.js";
30
30
  import { ALL_AGENTS, isOAuthAuth } from "../core/types.js";
31
+ import { flagValue } from "../lib/cliArgs.js";
31
32
  const CLIENT_INFO = { name: "trellis-mcp-gateway", version: "0.0.0" };
32
33
  const SERVER_INFO = { name: "trellis-mcp-gateway", version: "0.0.0" };
33
34
  const RUNTIME_CLIENT_INFO = { name: "trellis-mcp-runtime", version: "0.0.0" };
34
35
  const RUNTIME_SERVER_INFO = { name: "trellis-mcp-runtime", version: "0.0.0" };
35
36
  export function parseMcpGatewayArgs(argv) {
36
- const index = argv.indexOf("--agent");
37
- if (index === -1 || index === argv.length - 1) {
37
+ const value = flagValue(argv, "--agent");
38
+ if (value === undefined) {
38
39
  return { error: `mcp-gateway requires --agent <${ALL_AGENTS.join("|")}>` };
39
40
  }
40
- const value = argv[index + 1];
41
41
  if (!ALL_AGENTS.includes(value)) {
42
42
  return { error: `unknown agent "${value}" — expected one of ${ALL_AGENTS.join(", ")}` };
43
43
  }
@@ -7,6 +7,18 @@
7
7
  import type { AgentId, McpServerDef } from "../core/types.js";
8
8
  import type { BackupSession } from "../lib/backup.js";
9
9
  import { type NativeMemoryCandidate, type NativeMemoryDiscovery } from "../lib/nativeMemory.js";
10
+ /** Where a `staticEnv` literal secret's real value goes when
11
+ * `secrets.policy.yaml` doesn't already have its own `env_file`
12
+ * (trellis-migrate-extract-static-env-secrets design.md D3) — a sibling
13
+ * of `servers.yaml`, so "the mcp stuff" stays one directory rather than
14
+ * a second, disconnected location. `~`-form for what gets written into
15
+ * `secrets.policy.yaml` itself (portable across machines); resolved form
16
+ * for real file I/O. */
17
+ export declare const DEFAULT_LOCAL_SECRETS_ENV_FILE_TILDE = "~/.trellis/mcp/servers.local.env";
18
+ /** Exported for `migrate --workspace` (trellis-workspace-scope task 4.2) —
19
+ * its staticEnv extraction lands in the same default store under the same
20
+ * naming scheme, never a second location. */
21
+ export declare function defaultLocalSecretsEnvFilePath(homeDir: string): string;
10
22
  export type MigrateAction = "create" | "skip-symlink" | "skip-case-broken" | "skip-unsupported" | "already-migrated" | "reclassify" | "conflict" | "extract-secret";
11
23
  /** Internal kind naming, unchanged since before `--only` existed
12
24
  * (trellis-migrate-category-selection design.md D2) — the CLI-facing