agent-trellis 0.14.1 → 0.15.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 (55) 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 +38 -5
  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 +44 -1
  14. package/dist/commands/mcp.js +145 -33
  15. package/dist/commands/migrate.d.ts +12 -0
  16. package/dist/commands/migrate.js +8 -4
  17. package/dist/commands/migrateWorkspace.d.ts +40 -0
  18. package/dist/commands/migrateWorkspace.js +197 -0
  19. package/dist/commands/rollback.js +65 -35
  20. package/dist/commands/skill.d.ts +14 -0
  21. package/dist/commands/skill.js +71 -0
  22. package/dist/commands/sync.d.ts +16 -1
  23. package/dist/commands/sync.js +48 -15
  24. package/dist/core/adapter.d.ts +14 -0
  25. package/dist/core/canonical.d.ts +20 -34
  26. package/dist/core/canonical.js +18 -92
  27. package/dist/core/mcpServerDef.d.ts +39 -0
  28. package/dist/core/mcpServerDef.js +93 -0
  29. package/dist/core/types.d.ts +39 -4
  30. package/dist/lib/mcpMigrateRead.d.ts +25 -0
  31. package/dist/lib/mcpMigrateRead.js +9 -2
  32. package/dist/lib/skillProvider.js +18 -5
  33. package/dist/lib/workspace.d.ts +39 -0
  34. package/dist/lib/workspace.js +158 -0
  35. package/dist/lib/workspaceConfig.d.ts +45 -0
  36. package/dist/lib/workspaceConfig.js +98 -0
  37. package/dist/lib/workspaceDoctor.d.ts +44 -0
  38. package/dist/lib/workspaceDoctor.js +94 -0
  39. package/dist/lib/workspaceFiles.d.ts +38 -0
  40. package/dist/lib/workspaceFiles.js +264 -0
  41. package/dist/lib/workspaceMcp.d.ts +57 -0
  42. package/dist/lib/workspaceMcp.js +277 -0
  43. package/dist/lib/workspaceMerge.d.ts +37 -0
  44. package/dist/lib/workspaceMerge.js +98 -0
  45. package/dist/lib/workspaceMigrate.d.ts +118 -0
  46. package/dist/lib/workspaceMigrate.js +423 -0
  47. package/dist/pi-bridge/bundle.js +258 -96
  48. package/docs/architecture.md +111 -49
  49. package/docs/getting-started.md +86 -14
  50. package/docs/mcp-oauth-matrix.md +12 -1
  51. package/docs/overview.zh-CN.md +17 -0
  52. package/docs/research.md +28 -6
  53. package/docs/roadmap.md +17 -5
  54. package/package.json +1 -1
  55. package/docs/implementation-plan.md +0 -341
@@ -27,6 +27,10 @@ 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, upsertWorkspaceServer } from "../lib/workspaceConfig.js";
33
+ import { toServerDefYaml } from "../core/mcpServerDef.js";
30
34
  const ADAPTER_FACTORY = {
31
35
  "claude-code": (homeDir) => new ClaudeCodeAdapter(homeDir),
32
36
  codex: (homeDir) => new CodexAdapter(homeDir),
@@ -42,14 +46,16 @@ function buildAdapters(homeDir, managedAgents) {
42
46
  }
43
47
  export async function collectMcpSyncReport(opts = {}) {
44
48
  const homeDir = opts.homeDir ?? homedir();
45
- const loaded = loadCanonicalSource(homeDir);
49
+ const workspaceDir = opts.globalOnly ? null : (opts.workspaceDir !== undefined ? opts.workspaceDir : findWorkspaceRoot(process.cwd()));
50
+ const loaded = loadCanonicalSource({ homeDir, workspaceDir });
46
51
  const canonical = {
47
52
  ...loaded,
48
53
  ...(opts.managedAgents ? { managedAgents: opts.managedAgents } : {}),
49
54
  ...(opts.mcp ? { mcp: opts.mcp } : {}),
50
55
  };
51
56
  const reports = [];
52
- const ownSession = !opts.dryRun && !opts.backupSession ? openBackupSession(homeDir, "mcp-sync") : undefined;
57
+ const writeMode = !opts.dryRun && !opts.check;
58
+ const ownSession = writeMode && !opts.backupSession ? openBackupSession(homeDir, "mcp-sync") : undefined;
53
59
  const backup = opts.backupSession ?? ownSession;
54
60
  for (const adapter of buildAdapters(homeDir, canonical.managedAgents)) {
55
61
  const probeResult = await adapter.probe();
@@ -57,14 +63,17 @@ export async function collectMcpSyncReport(opts = {}) {
57
63
  reports.push({ agent: adapter.id, present: false, items: [] });
58
64
  continue;
59
65
  }
60
- const items = (await adapter.plan(canonical)).filter((item) => item.kind === "mcp");
61
- if (!opts.dryRun) {
62
- await adapter.apply(items, backup);
66
+ const globalItems = (await adapter.plan(canonical)).filter((item) => item.kind === "mcp");
67
+ const wsItems = planWorkspaceMcp(adapter.id, canonical);
68
+ if (writeMode) {
69
+ await adapter.apply(globalItems, backup);
70
+ applyWorkspaceMcp(adapter.id, canonical, wsItems, backup);
63
71
  }
64
- reports.push({ agent: adapter.id, present: true, items });
72
+ const warnings = workspacePrecedenceWarnings(adapter.id, canonical);
73
+ reports.push({ agent: adapter.id, present: true, items: [...globalItems, ...wsItems], ...(warnings.length > 0 ? { warnings } : {}) });
65
74
  }
66
75
  ownSession?.finalize();
67
- return { reports };
76
+ return { reports, ...(canonical.workspace ? { workspaceRoot: canonical.workspace.root } : {}) };
68
77
  }
69
78
  export async function runMcpSync(opts = {}) {
70
79
  let report;
@@ -79,22 +88,31 @@ export async function runMcpSync(opts = {}) {
79
88
  console.log(JSON.stringify(report, null, 2));
80
89
  }
81
90
  else {
82
- printReport(report, opts.dryRun ?? false);
91
+ printReport(report, opts.dryRun ?? false, opts.check ?? false);
83
92
  }
84
93
  const hasConflict = report.reports.some((r) => r.items.some((i) => i.action === "conflict"));
94
+ if (opts.check) {
95
+ const hasPending = report.reports.some((r) => r.items.some((i) => i.action !== "conflict"));
96
+ return { exitCode: hasPending || hasConflict ? 1 : 0 };
97
+ }
85
98
  return { exitCode: hasConflict ? 1 : 0 };
86
99
  }
87
100
  /** Exported so `onboard` prints an mcp-sync report identically to running
88
101
  * `mcp sync` standalone, instead of a second, easily-drifting copy of this
89
102
  * formatting. */
90
- export function printReport(report, dryRun) {
103
+ export function printReport(report, dryRun, check = false) {
91
104
  if (dryRun)
92
105
  console.log("[dry run]");
106
+ if (check)
107
+ console.log("[check]");
108
+ if (report.workspaceRoot) {
109
+ console.log(`Workspace layer: ${report.workspaceRoot} (.agents/)`);
110
+ }
93
111
  if (report.reports.length === 0) {
94
112
  console.log("No managed agents yet — run `trellis onboard` or list agent ids in ~/.trellis/managed.yaml.");
95
113
  return;
96
114
  }
97
- for (const { agent, present, items } of report.reports) {
115
+ for (const { agent, present, items, warnings } of report.reports) {
98
116
  if (!present) {
99
117
  console.log(`— ${agent} (not installed)`);
100
118
  continue;
@@ -104,18 +122,27 @@ export function printReport(report, dryRun) {
104
122
  const conflicts = items.filter((i) => i.action === "conflict");
105
123
  if (created.length === 0 && removed.length === 0 && conflicts.length === 0) {
106
124
  console.log(`✅ ${agent} — already in sync`);
107
- continue;
108
125
  }
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}`);
126
+ else {
127
+ const icon = conflicts.length > 0 ? "⚠️ " : "✅";
128
+ console.log(`${icon} ${agent} — ${created.length} created/updated, ${removed.length} removed, ${conflicts.length} conflict(s)`);
129
+ for (const item of [...created, ...removed, ...conflicts]) {
130
+ console.log(` - [${item.action}]${item.layer === "workspace" ? " (workspace)" : ""} ${item.description}`);
131
+ // Conflicts carry the actionable fix (e.g. the unmarked-overwrite
132
+ // guard's "run migrate --workspace or remove the entry") — printing
133
+ // only the description would leave the user stuck at "left untouched".
134
+ if (item.remediation)
135
+ console.log(` → ${item.remediation}`);
136
+ }
137
+ // pi does not watch mcp.json (verified 2026-10-11): without this hint
138
+ // a changed config silently only takes effect on the next pi launch
139
+ // (trellis-pi-native-mcp-migration design.md D5).
140
+ if (agent === "pi" && (created.length > 0 || removed.length > 0)) {
141
+ console.log(" pi does not watch mcp.json — restart pi or run /reload to pick up MCP changes");
142
+ }
113
143
  }
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");
144
+ for (const warning of warnings ?? []) {
145
+ console.log(` ⚠ ${warning}`);
119
146
  }
120
147
  }
121
148
  }
@@ -260,37 +287,43 @@ const TRANSPORTS = ["stdio", "http", "sse"];
260
287
  * write failure — design.md's open question, resolved in favor of
261
288
  * refusing early with a specific reason. No `--force`: an existing name
262
289
  * always refuses, ever (design.md D4).
290
+ *
291
+ * Split into two steps so `mcp add --workspace` reuses the identical
292
+ * flag validation/def construction and only swaps which file's existing
293
+ * entries it conflicts against (trellis-workspace-scope task 3.4):
294
+ * `buildMcpAddDef` is the pure part, `collectMcpAddPlan` adds the
295
+ * global-canonical existence check.
263
296
  */
264
- export function collectMcpAddPlan(name, raw, homeDir = homedir()) {
297
+ export function buildMcpAddDef(name, raw) {
265
298
  if (!name) {
266
- return { name: "(none)", action: "invalid-input", detail: "a name is required: trellis mcp add <name> --transport ..." };
299
+ return { ok: false, plan: { name: "(none)", action: "invalid-input", detail: "a name is required: trellis mcp add <name> --transport ..." } };
267
300
  }
268
301
  if (!raw.transport || !TRANSPORTS.includes(raw.transport)) {
269
- return { name, action: "invalid-input", detail: `--transport must be one of: ${TRANSPORTS.join(", ")}` };
302
+ return { ok: false, plan: { name, action: "invalid-input", detail: `--transport must be one of: ${TRANSPORTS.join(", ")}` } };
270
303
  }
271
304
  const transport = raw.transport;
272
305
  if (transport === "stdio" && !raw.command) {
273
- return { name, action: "invalid-input", detail: "--transport stdio requires --command" };
306
+ return { ok: false, plan: { name, action: "invalid-input", detail: "--transport stdio requires --command" } };
274
307
  }
275
308
  if ((transport === "http" || transport === "sse") && !raw.url) {
276
- return { name, action: "invalid-input", detail: `--transport ${transport} requires --url` };
309
+ return { ok: false, plan: { name, action: "invalid-input", detail: `--transport ${transport} requires --url` } };
277
310
  }
278
311
  if (raw.auth !== undefined && raw.auth !== "oauth") {
279
- return { name, action: "invalid-input", detail: `--auth must be "oauth" when provided (got ${raw.auth})` };
312
+ return { ok: false, plan: { name, action: "invalid-input", detail: `--auth must be "oauth" when provided (got ${raw.auth})` } };
280
313
  }
281
314
  if (raw.auth === "oauth" && transport === "stdio") {
282
- return { name, action: "invalid-input", detail: "--auth oauth requires an http or sse MCP server" };
315
+ return { ok: false, plan: { name, action: "invalid-input", detail: "--auth oauth requires an http or sse MCP server" } };
283
316
  }
284
317
  const agentsList = parseCsv(raw.agents);
285
318
  if (agentsList) {
286
319
  for (const id of agentsList) {
287
320
  if (!ALL_AGENTS.includes(id)) {
288
- return { name, action: "invalid-input", detail: `--agents "${id}" is not a recognized agent id (${ALL_AGENTS.join(", ")})` };
321
+ return { ok: false, plan: { name, action: "invalid-input", detail: `--agents "${id}" is not a recognized agent id (${ALL_AGENTS.join(", ")})` } };
289
322
  }
290
323
  }
291
324
  }
292
325
  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})` };
326
+ return { ok: false, plan: { name, action: "invalid-input", detail: `--enabled must be "true" or "false" (got ${raw.enabled})` } };
294
327
  }
295
328
  const def = {
296
329
  transport,
@@ -304,15 +337,46 @@ export function collectMcpAddPlan(name, raw, homeDir = homedir()) {
304
337
  ...(raw.enabled !== undefined ? { enabled: raw.enabled === "true" } : {}),
305
338
  ...(agentsList ? { agents: agentsList } : {}),
306
339
  };
340
+ return { ok: true, name, def };
341
+ }
342
+ export function collectMcpAddPlan(name, raw, homeDir = homedir()) {
343
+ const built = buildMcpAddDef(name, raw);
344
+ if (!built.ok)
345
+ return built.plan;
346
+ const def = built.def;
307
347
  const canonical = loadCanonicalSource(homeDir);
308
- const existing = canonical.mcp.servers[name];
348
+ const existing = canonical.mcp.servers[built.name];
309
349
  if (existing) {
310
350
  if (JSON.stringify(existing) === JSON.stringify(def)) {
311
- return { name, action: "already-present", detail: "canonical entry is already identical" };
351
+ return { name: built.name, action: "already-present", detail: "canonical entry is already identical" };
312
352
  }
313
- return { name, action: "conflict", detail: `servers.yaml already has "${name}" with different settings — resolve by hand (no --force, design.md D4)` };
353
+ 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
354
  }
315
- return { name, action: "create", detail: "will add to servers.yaml", def };
355
+ return { name: built.name, action: "create", detail: "will add to servers.yaml", def };
356
+ }
357
+ /** The workspace twin of `collectMcpAddPlan` (trellis-workspace-scope
358
+ * task 3.4): identical validation, but the existence check runs against
359
+ * `.agents/config.json` — compared through the same snake_case
360
+ * serialization the file stores, so a re-add of the same definition is
361
+ * a clean "already-present" no-op. A global same-name entry is NOT a
362
+ * conflict here (workspace shadowing a global entry is a legal,
363
+ * documented merge outcome), just noted in the detail. */
364
+ export function collectMcpAddWorkspacePlan(name, raw, workspaceRoot, homeDir = homedir()) {
365
+ const built = buildMcpAddDef(name, raw);
366
+ if (!built.ok)
367
+ return built.plan;
368
+ const def = built.def;
369
+ const config = readWorkspaceConfigJson(workspaceRoot);
370
+ const existing = config.mcp?.servers?.[built.name];
371
+ if (existing) {
372
+ if (JSON.stringify(existing) === JSON.stringify(toServerDefYaml(def))) {
373
+ return { name: built.name, action: "already-present", detail: ".agents/config.json entry is already identical" };
374
+ }
375
+ return { name: built.name, action: "conflict", detail: `.agents/config.json already has "${built.name}" with different settings — resolve by hand (no --force)` };
376
+ }
377
+ const global = loadCanonicalSource(homeDir).mcp.servers[built.name];
378
+ const shadowNote = global ? ` (shadows the global "${built.name}" for this project)` : "";
379
+ return { name: built.name, action: "create", detail: `will add to .agents/config.json${shadowNote}`, def };
316
380
  }
317
381
  /**
318
382
  * Sets or clears the OAuth classification, and optionally the
@@ -587,8 +651,56 @@ export async function applyMcpAddWithSync(plan, opts = {}) {
587
651
  }
588
652
  return { plan, ...(writeError ? { writeError } : {}), ...(syncReport ? { sync: syncReport } : {}) };
589
653
  }
654
+ /** Workspace twin of `applyMcpAddWithSync` (trellis-workspace-scope task
655
+ * 3.4): writes `.agents/config.json` instead of `servers.yaml`, then
656
+ * cascades the same mcp sync — which, run inside the workspace, renders
657
+ * only the project-level targets for the workspace layer (plus the usual
658
+ * global-layer targets; the agent does its own layering). */
659
+ export async function applyMcpAddWorkspaceWithSync(plan, workspaceRoot, opts = {}) {
660
+ const homeDir = opts.homeDir ?? homedir();
661
+ const ownSession = !opts.dryRun && !opts.backupSession && plan.action === "create" ? openBackupSession(homeDir, "mcp-add") : undefined;
662
+ const session = opts.backupSession ?? ownSession;
663
+ if (!opts.dryRun && plan.action === "create" && plan.def) {
664
+ upsertWorkspaceServer(workspaceRoot, plan.name, plan.def, session);
665
+ }
666
+ ownSession?.finalize();
667
+ const planFailed = plan.action === "conflict" || plan.action === "invalid-input";
668
+ let syncReport;
669
+ if (!planFailed && plan.action === "create" && existsSync(join(homeDir, ".trellis"))) {
670
+ syncReport = await collectMcpSyncReport({ homeDir, workspaceDir: workspaceRoot, dryRun: opts.dryRun });
671
+ }
672
+ return { plan, ...(syncReport ? { sync: syncReport } : {}) };
673
+ }
590
674
  export async function runMcpAdd(name, raw, opts = {}) {
591
675
  const homeDir = opts.homeDir ?? homedir();
676
+ if (opts.workspace) {
677
+ const workspaceRoot = opts.workspaceDir ?? findWorkspaceRoot(process.cwd());
678
+ if (!workspaceRoot) {
679
+ console.error("no .agents/ found here or above — run `trellis init --workspace` first (or pass --workspace from inside the repo)");
680
+ return { exitCode: 1 };
681
+ }
682
+ let plan;
683
+ try {
684
+ plan = collectMcpAddWorkspacePlan(name, raw, workspaceRoot, homeDir);
685
+ }
686
+ catch (err) {
687
+ console.error(err instanceof Error ? err.message : String(err));
688
+ return { exitCode: 1 };
689
+ }
690
+ const { sync: syncReport } = await applyMcpAddWorkspaceWithSync(plan, workspaceRoot, { homeDir, dryRun: opts.dryRun });
691
+ const planFailed = plan.action === "conflict" || plan.action === "invalid-input";
692
+ if (opts.json) {
693
+ console.log(JSON.stringify(syncReport ? { ...plan, workspace: workspaceRoot, sync: syncReport } : { ...plan, workspace: workspaceRoot }, null, 2));
694
+ }
695
+ else {
696
+ console.log(`${opts.dryRun ? "[dry run] " : ""}mcp add ${plan.name} --workspace`);
697
+ console.log(` [${plan.action}] ${plan.detail}`);
698
+ if (syncReport)
699
+ printReport(syncReport, opts.dryRun ?? false);
700
+ }
701
+ const syncConflict = syncReport?.reports.some((r) => r.items.some((i) => i.action === "conflict")) ?? false;
702
+ return { exitCode: planFailed || syncConflict ? 1 : 0 };
703
+ }
592
704
  let plan;
593
705
  try {
594
706
  plan = collectMcpAddPlan(name, raw, homeDir);
@@ -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
@@ -29,8 +29,11 @@ import { discoverNativeMemory, MAX_NATIVE_MEMORY_BYTES, renderNativeMemory } fro
29
29
  * a second, disconnected location. `~`-form for what gets written into
30
30
  * `secrets.policy.yaml` itself (portable across machines); resolved form
31
31
  * for real file I/O. */
32
- const DEFAULT_LOCAL_SECRETS_ENV_FILE_TILDE = "~/.trellis/mcp/servers.local.env";
33
- function defaultLocalSecretsEnvFilePath(homeDir) {
32
+ export const DEFAULT_LOCAL_SECRETS_ENV_FILE_TILDE = "~/.trellis/mcp/servers.local.env";
33
+ /** Exported for `migrate --workspace` (trellis-workspace-scope task 4.2) —
34
+ * its staticEnv extraction lands in the same default store under the same
35
+ * naming scheme, never a second location. */
36
+ export function defaultLocalSecretsEnvFilePath(homeDir) {
34
37
  return join(homeDir, ".trellis", "mcp", "servers.local.env");
35
38
  }
36
39
  /** Picks the rc file `ensureShellEnvSource` writes its one pointer block
@@ -54,8 +57,9 @@ const PROBES = {
54
57
  "kimi-code": (homeDir) => kimiCodeProbe.probe(homeDir),
55
58
  zcode: (homeDir) => zcodeProbe.probe(homeDir),
56
59
  };
57
- /** pi has no static MCP config to read at all (roadmap.md P14/
58
- * trellis-migrate-mcp-servers) — deliberately absent, not an oversight;
60
+ /** pi's mcp.json migrate-in is not implemented (roadmap.md P14/
61
+ * trellis-migrate-mcp-servers predates pi 1.1.0's native MCP config) —
62
+ * deliberately absent, not an oversight;
59
63
  * `collectMigratePlan` skips the `mcp` category entirely for pi. */
60
64
  const MCP_READERS = {
61
65
  "claude-code": readClaudeCodeMcpDefs,
@@ -0,0 +1,40 @@
1
+ /**
2
+ * `trellis migrate --workspace` — absorbs a repo's existing project-level
3
+ * agent config (all six agents' MCP files + root AGENTS.md) into
4
+ * `.agents/`, then re-renders (trellis-workspace-scope group 4).
5
+ *
6
+ * Flow: plan (pure, `src/lib/workspaceMigrate.ts`) → refuse on unresolved
7
+ * conflicts → one backup session named `workspace-<repo-hash>` covering
8
+ * every write (task 4.6 — `.agents/` writes, the secret-store extraction,
9
+ * the marker-headed AGENTS.md rewrite, and the cascade render, so a
10
+ * single `trellis rollback` restores originals and removes generated
11
+ * `.agents/` content) → post-migration equivalence report (task 4.7).
12
+ */
13
+ import type { AgentId } from "../core/types.js";
14
+ import type { WorkspaceMigratePlan } from "../lib/workspaceMigrate.js";
15
+ export interface RunMigrateWorkspaceOptions {
16
+ /** Test/sandbox seam — CLI resolves via findWorkspaceRoot(cwd), falling
17
+ * back to cwd itself so a legacy project without `.agents/` yet can
18
+ * still be scanned (apply then creates `.agents/` through backup). */
19
+ workspaceDir?: string;
20
+ /** Raw `name=agentId` strings from repeated `--choose`. */
21
+ choose?: string[];
22
+ /** Raw `--dedup` value: `drop` (default) or `override`. */
23
+ dedup?: string;
24
+ dryRun?: boolean;
25
+ json?: boolean;
26
+ homeDir?: string;
27
+ }
28
+ export interface WorkspaceMigrateEquivalence {
29
+ /** Per agent: server names found in its project config before the
30
+ * migration vs. what the post-migration render now delivers. */
31
+ agents: {
32
+ agent: AgentId;
33
+ before: string[];
34
+ after: string[];
35
+ }[];
36
+ }
37
+ export declare function runMigrateWorkspace(opts?: RunMigrateWorkspaceOptions): Promise<{
38
+ exitCode: number;
39
+ }>;
40
+ export declare function printWorkspaceMigratePlan(plan: WorkspaceMigratePlan, dryRun: boolean): void;
@@ -0,0 +1,197 @@
1
+ /**
2
+ * `trellis migrate --workspace` — absorbs a repo's existing project-level
3
+ * agent config (all six agents' MCP files + root AGENTS.md) into
4
+ * `.agents/`, then re-renders (trellis-workspace-scope group 4).
5
+ *
6
+ * Flow: plan (pure, `src/lib/workspaceMigrate.ts`) → refuse on unresolved
7
+ * conflicts → one backup session named `workspace-<repo-hash>` covering
8
+ * every write (task 4.6 — `.agents/` writes, the secret-store extraction,
9
+ * the marker-headed AGENTS.md rewrite, and the cascade render, so a
10
+ * single `trellis rollback` restores originals and removes generated
11
+ * `.agents/` content) → post-migration equivalence report (task 4.7).
12
+ */
13
+ import { createHash } from "node:crypto";
14
+ import { mkdirSync } from "node:fs";
15
+ import { homedir } from "node:os";
16
+ import { dirname, join } from "node:path";
17
+ import { ALL_AGENTS } from "../core/types.js";
18
+ import { ensureGitignoreEntry, loadCanonicalSource, writeSecretsPolicyExtraction } from "../core/canonical.js";
19
+ import { openBackupSession } from "../lib/backup.js";
20
+ import { writeLocalSecretValue } from "../lib/secretEnv.js";
21
+ import { findWorkspaceRoot } from "../lib/workspace.js";
22
+ import { upsertWorkspaceServer } from "../lib/workspaceConfig.js";
23
+ import { INSTRUCTIONS_MARKER } from "../lib/workspaceFiles.js";
24
+ import { recordWorkspaceRendered, renderWorkspaceManifestEntry } from "../lib/workspaceMcp.js";
25
+ import { collectWorkspaceMigratePlan, readWorkspaceAgentMcpDefsForApply } from "../lib/workspaceMigrate.js";
26
+ import { collectMcpSyncReport } from "./mcp.js";
27
+ import { collectSyncReport } from "./sync.js";
28
+ import { defaultLocalSecretsEnvFilePath, DEFAULT_LOCAL_SECRETS_ENV_FILE_TILDE } from "./migrate.js";
29
+ /** Task 4.6's `<repo-hash>`: stable per checkout path, short enough to
30
+ * stay readable in a backups listing. */
31
+ function repoHash(workspaceRoot) {
32
+ return createHash("sha256").update(workspaceRoot).digest("hex").slice(0, 12);
33
+ }
34
+ /**
35
+ * Applies one plan item. MCP items go through `upsertWorkspaceServer`
36
+ * (which serializes the snake_case shape and fills `version`); the
37
+ * extract-secret companion write mirrors the global migrate's
38
+ * `applyStaticEnvExtraction` — real value re-read from the SOURCE file at
39
+ * apply time, never carried on the plan (D9). Instructions import writes
40
+ * `.agents/AGENTS.md` and rewrites the root file marker-headed in place
41
+ * (a `file-overwrite` op — rollback restores the original unmarked file).
42
+ */
43
+ function applyWorkspaceMigrateItem(item, plan, homeDir, backup) {
44
+ if (item.kind === "mcp" && (item.action === "create" || item.action === "extract-secret") && item.mcpDef) {
45
+ upsertWorkspaceServer(plan.workspaceRoot, item.name, item.mcpDef, backup);
46
+ // Adopt the source entries: every agent file this name came from gets
47
+ // a manifest ownership record, so the cascade render owns the entry
48
+ // (bringing it to Trellis's canonical render) instead of conflict-
49
+ // guarding on it as user-authored content. Without this the migration
50
+ // would import into .agents/ and then immediately conflict on the
51
+ // very files it absorbed.
52
+ for (const agent of item.sources) {
53
+ recordWorkspaceRendered(plan.workspaceRoot, agent, item.name, renderWorkspaceManifestEntry(agent, item.name, item.mcpDef), backup);
54
+ }
55
+ if (item.action === "extract-secret" && item.extractVarName && item.extractTargetPath && item.extractSourceKey && item.extractSourceAgent) {
56
+ const realValue = readWorkspaceAgentMcpDefsForApply(plan.workspaceRoot, item.extractSourceAgent)
57
+ .entries.find((e) => e.name === item.name)?.def.staticEnv?.[item.extractSourceKey];
58
+ if (realValue !== undefined) {
59
+ if (item.extractTargetPath === defaultLocalSecretsEnvFilePath(homeDir)) {
60
+ ensureGitignoreEntry(join(homeDir, ".trellis", ".gitignore"), "mcp/servers.local.env", backup);
61
+ }
62
+ writeLocalSecretValue(item.extractTargetPath, item.extractVarName, realValue, backup);
63
+ const policyResult = writeSecretsPolicyExtraction(join(homeDir, ".trellis", "secrets.policy.yaml"), { varName: item.extractVarName, envFilePath: DEFAULT_LOCAL_SECRETS_ENV_FILE_TILDE }, backup);
64
+ // A failed policy write leaves the extracted var unresolvable —
65
+ // the cascade render then refuses with a confusing conflict. Say
66
+ // why, plainly, while the run is still honest about it.
67
+ if (!policyResult.ok) {
68
+ console.error(`warning: extracted ${item.extractVarName} but could not record it in secrets.policy.yaml: ${policyResult.error}`);
69
+ }
70
+ }
71
+ // undefined = the source changed between plan and apply; the safe
72
+ // def (name-only reference) is still imported, matching the global
73
+ // migrate's "nothing left to extract" behavior.
74
+ }
75
+ return;
76
+ }
77
+ if (item.kind === "instructions" && item.action === "create" && item.sourceContent !== undefined) {
78
+ const target = join(plan.workspaceRoot, ".agents", "AGENTS.md");
79
+ mkdirSync(dirname(target), { recursive: true });
80
+ backup.writeFile(target, item.sourceContent);
81
+ // Rewrite the root file marker-headed NOW so the cascade instructions
82
+ // sync sees current === rendered (no-op) instead of conflicting on an
83
+ // unmarked file. Byte-identical to planWorkspaceInstructions' render.
84
+ backup.writeFile(join(plan.workspaceRoot, "AGENTS.md"), `${INSTRUCTIONS_MARKER}\n\n${item.sourceContent}`);
85
+ }
86
+ }
87
+ export async function runMigrateWorkspace(opts = {}) {
88
+ const homeDir = opts.homeDir ?? homedir();
89
+ const workspaceRoot = opts.workspaceDir ?? findWorkspaceRoot(process.cwd()) ?? process.cwd();
90
+ // --- flag validation (raw strings arrive from cli.ts) ---
91
+ const choose = {};
92
+ for (const raw of opts.choose ?? []) {
93
+ const eq = raw.indexOf("=");
94
+ const agent = eq === -1 ? "" : raw.slice(eq + 1);
95
+ if (eq === -1 || !ALL_AGENTS.includes(agent)) {
96
+ console.error(`--choose must be name=agentId with agentId one of: ${ALL_AGENTS.join(", ")} (got "${raw}")`);
97
+ return { exitCode: 1 };
98
+ }
99
+ choose[raw.slice(0, eq)] = agent;
100
+ }
101
+ if (opts.dedup !== undefined && opts.dedup !== "drop" && opts.dedup !== "override") {
102
+ console.error(`--dedup must be drop or override (got ${opts.dedup})`);
103
+ return { exitCode: 1 };
104
+ }
105
+ const secretsPolicy = loadCanonicalSource({ homeDir, workspaceDir: null }).secretsPolicy;
106
+ const plan = collectWorkspaceMigratePlan({ workspaceRoot, homeDir, secretsPolicy, choose, dedup: opts.dedup });
107
+ // Task 4.3/4.2: a real run with ANY unresolved conflict writes nothing —
108
+ // the user resolves (via --choose, or by fixing the source) and re-runs.
109
+ const conflicts = plan.items.filter((i) => i.action === "conflict");
110
+ if (conflicts.length > 0 && !opts.dryRun) {
111
+ printWorkspaceMigratePlan(plan, false);
112
+ console.error(`\n${conflicts.length} unresolved conflict(s) — nothing was written. Resolve each and re-run.`);
113
+ return { exitCode: 1 };
114
+ }
115
+ // Task 4.7's "before" half: what each agent's project config held.
116
+ const beforeByAgent = new Map();
117
+ for (const item of plan.items) {
118
+ if (item.kind !== "mcp" || item.action === "skip-local")
119
+ continue;
120
+ for (const agent of item.sources) {
121
+ beforeByAgent.set(agent, [...new Set([...(beforeByAgent.get(agent) ?? []), item.name])]);
122
+ }
123
+ }
124
+ let equivalence;
125
+ if (!opts.dryRun) {
126
+ const actionable = plan.items.filter((i) => i.action === "create" || i.action === "extract-secret");
127
+ const backup = openBackupSession(homeDir, `workspace-${repoHash(workspaceRoot)}`);
128
+ for (const item of actionable) {
129
+ applyWorkspaceMigrateItem(item, plan, homeDir, backup);
130
+ }
131
+ // Cascade render through the SAME session (task 4.6): MCP artifacts
132
+ // for every managed agent, so one `trellis rollback` undoes the whole
133
+ // migration. Instructions only cascade when an instructions item was
134
+ // actually applied — scoping the side effects to what migration
135
+ // touched, same discipline as `mcp add --workspace`'s MCP-only
136
+ // cascade.
137
+ await collectMcpSyncReport({ homeDir, workspaceDir: workspaceRoot, backupSession: backup });
138
+ if (actionable.some((i) => i.kind === "instructions")) {
139
+ await collectSyncReport({ target: "instructions", homeDir, workspaceDir: workspaceRoot, backupSession: backup });
140
+ }
141
+ backup.finalize();
142
+ // Task 4.7's "after" half: a fresh dry-run render against the
143
+ // migrated `.agents/` proves the absorbed config now delivers.
144
+ const after = await collectMcpSyncReport({ homeDir, workspaceDir: workspaceRoot, dryRun: true });
145
+ equivalence = {
146
+ agents: after.reports
147
+ .filter((r) => r.present)
148
+ .map((r) => ({
149
+ agent: r.agent,
150
+ before: (beforeByAgent.get(r.agent) ?? []).sort(),
151
+ after: r.items.filter((i) => i.kind === "mcp" && i.layer === "workspace" && i.mcpWrite).map((i) => i.mcpWrite.name).sort(),
152
+ }))
153
+ .filter((a) => a.before.length > 0 || a.after.length > 0),
154
+ };
155
+ }
156
+ if (opts.json) {
157
+ console.log(JSON.stringify({ ...plan, ...(equivalence ? { equivalence } : {}) }, null, 2));
158
+ }
159
+ else {
160
+ printWorkspaceMigratePlan(plan, opts.dryRun ?? false);
161
+ if (equivalence)
162
+ printEquivalence(equivalence);
163
+ }
164
+ return { exitCode: conflicts.length > 0 ? 1 : 0 };
165
+ }
166
+ export function printWorkspaceMigratePlan(plan, dryRun) {
167
+ console.log(`${dryRun ? "[dry run] " : ""}migrate --workspace ${plan.workspaceRoot}`);
168
+ if (plan.items.length === 0) {
169
+ console.log(" nothing to migrate");
170
+ return;
171
+ }
172
+ for (const item of plan.items) {
173
+ const label = item.kind === "mcp" ? `mcp server "${item.name}"` : "instructions (AGENTS.md)";
174
+ console.log(` [${item.action}] ${label} — ${item.detail}`);
175
+ if (item.defs) {
176
+ for (const [agent, def] of Object.entries(item.defs)) {
177
+ console.log(` ${agent}: ${JSON.stringify(def)}`);
178
+ }
179
+ }
180
+ if (item.rotationAdvisory) {
181
+ console.log(` ⚠ ${item.rotationAdvisory}`);
182
+ }
183
+ if (item.remediation) {
184
+ console.log(` → ${item.remediation}`);
185
+ }
186
+ }
187
+ }
188
+ function printEquivalence(equivalence) {
189
+ console.log("equivalence (before → rendered after):");
190
+ if (equivalence.agents.length === 0) {
191
+ console.log(" no managed agent renders workspace servers on this machine");
192
+ return;
193
+ }
194
+ for (const { agent, before, after } of equivalence.agents) {
195
+ console.log(` ${agent}: [${before.join(", ") || "—"}] → [${after.join(", ") || "—"}]`);
196
+ }
197
+ }