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
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Merges a workspace `.agents/` layer over the global `CanonicalSource`
3
+ * (trellis-workspace-scope design D3): workspace wins by ID for MCP
4
+ * servers and skills, `enabled: false` tombstones disable a same-ID global
5
+ * entry, instructions are NOT content-merged (each layer is delivered to
6
+ * its own level's paths; agents layer them natively). The result carries
7
+ * per-entry provenance plus the shadowed/tombstoned list for doctor and
8
+ * status attribution.
9
+ */
10
+ import type { CanonicalSource, McpConfig, SkillRef } from "../core/types.js";
11
+ import type { WorkspaceLayer } from "./workspace.js";
12
+ /**
13
+ * The global layer's view of MCP config: exactly the servers the global
14
+ * (`~/.trellis`) layer declares, with their global definitions. Global
15
+ * (user-level) delivery MUST render from this view, never from the merged
16
+ * `canonical.mcp` — otherwise workspace servers leak into user-level files
17
+ * (the bug the workspace e2e caught: repo `.agents/` servers written into
18
+ * `~/.codex/config.toml`, `~/.pi/agent/mcp.json`, `~/.claude.json`).
19
+ * Tombstoned servers stay: a tombstone only removes the server from this
20
+ * workspace's effective view, and the shared user-level files serve every
21
+ * other context. Without a workspace layer this is `source.mcp` unchanged.
22
+ */
23
+ export declare function globalLayerMcp(source: CanonicalSource): McpConfig;
24
+ /**
25
+ * The global layer's view of skills: exactly the skills the global
26
+ * (`~/.trellis`) layer declares, with their global definitions. Global
27
+ * (user-level) skill delivery MUST symlink from this view, never from the
28
+ * merged `canonical.skills` — otherwise workspace skills leak into
29
+ * user-level dirs as symlinks pointing into one repo's `.agents/skills/`,
30
+ * which breaks the moment the repo is cloned elsewhere (the clone's
31
+ * `sync --check` then conflicts on the stale cross-repo link — found by
32
+ * the closed-loop e2e). Entries the workspace shadows keep their global
33
+ * definitions here, mirroring `globalLayerMcp`. Without a workspace layer
34
+ * this is `source.skills` unchanged.
35
+ */
36
+ export declare function globalLayerSkills(source: CanonicalSource): SkillRef[];
37
+ export declare function mergeWorkspaceLayer(base: CanonicalSource, layer: WorkspaceLayer): CanonicalSource;
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Merges a workspace `.agents/` layer over the global `CanonicalSource`
3
+ * (trellis-workspace-scope design D3): workspace wins by ID for MCP
4
+ * servers and skills, `enabled: false` tombstones disable a same-ID global
5
+ * entry, instructions are NOT content-merged (each layer is delivered to
6
+ * its own level's paths; agents layer them natively). The result carries
7
+ * per-entry provenance plus the shadowed/tombstoned list for doctor and
8
+ * status attribution.
9
+ */
10
+ /**
11
+ * The global layer's view of MCP config: exactly the servers the global
12
+ * (`~/.trellis`) layer declares, with their global definitions. Global
13
+ * (user-level) delivery MUST render from this view, never from the merged
14
+ * `canonical.mcp` — otherwise workspace servers leak into user-level files
15
+ * (the bug the workspace e2e caught: repo `.agents/` servers written into
16
+ * `~/.codex/config.toml`, `~/.pi/agent/mcp.json`, `~/.claude.json`).
17
+ * Tombstoned servers stay: a tombstone only removes the server from this
18
+ * workspace's effective view, and the shared user-level files serve every
19
+ * other context. Without a workspace layer this is `source.mcp` unchanged.
20
+ */
21
+ export function globalLayerMcp(source) {
22
+ const info = source.workspace;
23
+ if (!info)
24
+ return source.mcp;
25
+ return { ...source.mcp, servers: info.mcpGlobalServers };
26
+ }
27
+ /**
28
+ * The global layer's view of skills: exactly the skills the global
29
+ * (`~/.trellis`) layer declares, with their global definitions. Global
30
+ * (user-level) skill delivery MUST symlink from this view, never from the
31
+ * merged `canonical.skills` — otherwise workspace skills leak into
32
+ * user-level dirs as symlinks pointing into one repo's `.agents/skills/`,
33
+ * which breaks the moment the repo is cloned elsewhere (the clone's
34
+ * `sync --check` then conflicts on the stale cross-repo link — found by
35
+ * the closed-loop e2e). Entries the workspace shadows keep their global
36
+ * definitions here, mirroring `globalLayerMcp`. Without a workspace layer
37
+ * this is `source.skills` unchanged.
38
+ */
39
+ export function globalLayerSkills(source) {
40
+ return source.workspace?.globalSkills ?? source.skills;
41
+ }
42
+ export function mergeWorkspaceLayer(base, layer) {
43
+ const shadowed = [];
44
+ // Captured before any mutation: the global delivery path renders from
45
+ // this map (see globalLayerMcp), so workspace entries never reach
46
+ // user-level config files.
47
+ const mcpGlobalServers = { ...base.mcp.servers };
48
+ const servers = { ...base.mcp.servers };
49
+ const mcpProvenance = Object.fromEntries(Object.keys(base.mcp.servers).map((name) => [name, "global"]));
50
+ for (const name of layer.tombstones) {
51
+ if (servers[name] !== undefined) {
52
+ delete servers[name];
53
+ delete mcpProvenance[name];
54
+ shadowed.push(`mcp:${name}`);
55
+ }
56
+ else {
57
+ layer.diagnostics.push(`.agents/config.json: tombstone "${name}" matches no global server — no-op`);
58
+ }
59
+ }
60
+ for (const [name, def] of Object.entries(layer.servers)) {
61
+ if (servers[name] !== undefined)
62
+ shadowed.push(`mcp:${name}`);
63
+ servers[name] = def;
64
+ mcpProvenance[name] = "workspace";
65
+ }
66
+ const skills = [...base.skills];
67
+ // Captured before any mutation: the global delivery path symlinks from
68
+ // this list (see globalLayerSkills), so workspace skills never reach
69
+ // user-level skill dirs.
70
+ const globalSkills = [...base.skills];
71
+ const skillProvenance = Object.fromEntries(base.skills.map((s) => [s.name, "global"]));
72
+ for (const wsSkill of layer.skills) {
73
+ const idx = skills.findIndex((s) => s.name === wsSkill.name);
74
+ if (idx >= 0) {
75
+ shadowed.push(`skill:${wsSkill.name}`);
76
+ skills[idx] = wsSkill;
77
+ }
78
+ else {
79
+ skills.push(wsSkill);
80
+ }
81
+ skillProvenance[wsSkill.name] = "workspace";
82
+ }
83
+ return {
84
+ ...base,
85
+ skills,
86
+ mcp: { ...base.mcp, servers },
87
+ diagnostics: [...base.diagnostics, ...layer.diagnostics],
88
+ workspace: {
89
+ root: layer.root,
90
+ ...(layer.instructionsFile !== undefined ? { instructionsFile: layer.instructionsFile } : {}),
91
+ mcpProvenance,
92
+ mcpGlobalServers,
93
+ skillProvenance,
94
+ globalSkills,
95
+ shadowed,
96
+ },
97
+ };
98
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * `trellis migrate --workspace`'s scanner + planner (trellis-workspace-
3
+ * scope tasks 4.1–4.5): inventories every managed agent's project-level
4
+ * MCP config plus the root `AGENTS.md`, classifies each finding, and
5
+ * produces a pure plan — no writes anywhere, so `--dry-run` prints the
6
+ * byte-identical plan a real run would execute.
7
+ *
8
+ * Classification (one `action` per item):
9
+ * - `already-managed` — the in-repo manifest (`.agents/.trellis-
10
+ * rendered.json`) records Trellis having rendered
11
+ * this entry, or `.agents/` already holds it
12
+ * - `skip-local` — a `*.local.*` sibling of a scanned config path;
13
+ * listed, never read (task 4.5)
14
+ * - `skip-unsupported` — malformed entry (e.g. stdio without command)
15
+ * - `extract-secret` — a reject-pattern literal in `staticEnv`: value
16
+ * goes to the global secret store, the committed
17
+ * `.agents/config.json` only ever gets `${VAR}`
18
+ * - `identical-to-global` — deep-equal to the global canonical entry;
19
+ * default drop, `--dedup override` imports it as
20
+ * an intentional shadow (task 4.4)
21
+ * - `conflict` — same name with differing defs across agents
22
+ * (task 4.3, resolved only by `--choose
23
+ * name=agentId`), or a reject-pattern literal in
24
+ * a field other than `staticEnv` (unlike the
25
+ * global migrate, `.agents/config.json` is
26
+ * COMMITTED — a plaintext literal must never be
27
+ * written into it)
28
+ * - `create` — will import into `.agents/`
29
+ *
30
+ * Secret handling invariant (task 4.2): no literal secret value ever rides
31
+ * on a plan item — extraction re-reads the source file at apply time,
32
+ * exactly like the global migrate's design.md D9.
33
+ */
34
+ import type { AgentId, McpServerDef, SecretsPolicy } from "../core/types.js";
35
+ import type { McpMigrateReadResult } from "./mcpMigrateRead.js";
36
+ export type WorkspaceMigrateAction = "create" | "already-managed" | "skip-local" | "skip-unsupported" | "extract-secret" | "identical-to-global" | "conflict";
37
+ export interface WorkspaceMigrateItem {
38
+ kind: "mcp" | "instructions";
39
+ name: string;
40
+ action: WorkspaceMigrateAction;
41
+ detail: string;
42
+ /** Only set (and only meaningful) on `conflict`: the concrete next
43
+ * action, matching the global migrate's convention. */
44
+ remediation?: string;
45
+ /** Agents whose project config this name was found in. */
46
+ sources: AgentId[];
47
+ /** Set from `--choose name=agentId` — which agent's def won a cross-
48
+ * agent conflict. */
49
+ chosenSource?: AgentId;
50
+ /** Side-by-side defs for a cross-agent conflict, keyed by agent. */
51
+ defs?: Partial<Record<AgentId, McpServerDef>>;
52
+ /** The def to import. On `extract-secret` this is already the SAFE
53
+ * form — the flagged staticEnv value replaced by a name-only `env`
54
+ * reference; the literal never appears on this object. */
55
+ mcpDef?: McpServerDef;
56
+ /** Instructions body to import (root AGENTS.md content). */
57
+ sourceContent?: string;
58
+ /** `extract-secret` only — see the global migrate's D9/D11: the
59
+ * synthesized variable name, the resolved secret-store path, the
60
+ * original staticEnv dict key, and which agent's file to re-read the
61
+ * real value from at apply time. Never the value itself. */
62
+ extractVarName?: string;
63
+ extractTargetPath?: string;
64
+ extractSourceKey?: string;
65
+ extractSourceAgent?: AgentId;
66
+ /** Task 4.2: the source file holding the literal is git-tracked, so the
67
+ * value must be considered compromised — rotate it. */
68
+ rotationAdvisory?: string;
69
+ /** Task 4.4: how an `identical-to-global` item was (or would be)
70
+ * handled — `drop` (default) or `override` (`--dedup override`). */
71
+ dedupChoice?: "drop" | "override";
72
+ }
73
+ export interface WorkspaceMigratePlan {
74
+ workspaceRoot: string;
75
+ items: WorkspaceMigrateItem[];
76
+ }
77
+ export interface WorkspaceMigrateScanEntry {
78
+ agent: AgentId;
79
+ name: string;
80
+ def: McpServerDef;
81
+ sourcePath: string;
82
+ }
83
+ export interface WorkspaceMigrateScan {
84
+ entries: WorkspaceMigrateScanEntry[];
85
+ unsupported: {
86
+ agent: AgentId;
87
+ name: string;
88
+ reason: string;
89
+ sourcePath: string;
90
+ }[];
91
+ /** `*.local.*` siblings found next to scanned config paths — paths
92
+ * only, contents never read. */
93
+ localFiles: string[];
94
+ }
95
+ /**
96
+ * Apply-time re-read of one agent's project MCP defs (task 4.2, mirroring
97
+ * the global migrate's D9): an extraction's real value is recovered from
98
+ * the source file HERE, at apply time — it never rides on the plan object
99
+ * a `--json`/`--dry-run` caller might log or serialize.
100
+ */
101
+ export declare function readWorkspaceAgentMcpDefsForApply(workspaceRoot: string, agentId: AgentId): McpMigrateReadResult;
102
+ /**
103
+ * Inventories all six agents' project-level MCP config paths (the same
104
+ * paths workspace delivery writes, so migration and delivery can never
105
+ * disagree about where an agent reads) plus their `*.local.*` siblings.
106
+ * Pure: reads files, writes nothing.
107
+ */
108
+ export declare function scanWorkspaceMcp(workspaceRoot: string): WorkspaceMigrateScan;
109
+ export interface CollectWorkspaceMigrateOptions {
110
+ workspaceRoot: string;
111
+ homeDir: string;
112
+ secretsPolicy: SecretsPolicy;
113
+ /** `--choose name=agentId` resolutions for cross-agent conflicts. */
114
+ choose?: Record<string, AgentId>;
115
+ /** `--dedup` handling for `identical-to-global` items; default `drop`. */
116
+ dedup?: "drop" | "override";
117
+ }
118
+ export declare function collectWorkspaceMigratePlan(opts: CollectWorkspaceMigrateOptions): WorkspaceMigratePlan;
@@ -0,0 +1,423 @@
1
+ /**
2
+ * `trellis migrate --workspace`'s scanner + planner (trellis-workspace-
3
+ * scope tasks 4.1–4.5): inventories every managed agent's project-level
4
+ * MCP config plus the root `AGENTS.md`, classifies each finding, and
5
+ * produces a pure plan — no writes anywhere, so `--dry-run` prints the
6
+ * byte-identical plan a real run would execute.
7
+ *
8
+ * Classification (one `action` per item):
9
+ * - `already-managed` — the in-repo manifest (`.agents/.trellis-
10
+ * rendered.json`) records Trellis having rendered
11
+ * this entry, or `.agents/` already holds it
12
+ * - `skip-local` — a `*.local.*` sibling of a scanned config path;
13
+ * listed, never read (task 4.5)
14
+ * - `skip-unsupported` — malformed entry (e.g. stdio without command)
15
+ * - `extract-secret` — a reject-pattern literal in `staticEnv`: value
16
+ * goes to the global secret store, the committed
17
+ * `.agents/config.json` only ever gets `${VAR}`
18
+ * - `identical-to-global` — deep-equal to the global canonical entry;
19
+ * default drop, `--dedup override` imports it as
20
+ * an intentional shadow (task 4.4)
21
+ * - `conflict` — same name with differing defs across agents
22
+ * (task 4.3, resolved only by `--choose
23
+ * name=agentId`), or a reject-pattern literal in
24
+ * a field other than `staticEnv` (unlike the
25
+ * global migrate, `.agents/config.json` is
26
+ * COMMITTED — a plaintext literal must never be
27
+ * written into it)
28
+ * - `create` — will import into `.agents/`
29
+ *
30
+ * Secret handling invariant (task 4.2): no literal secret value ever rides
31
+ * on a plan item — extraction re-reads the source file at apply time,
32
+ * exactly like the global migrate's design.md D9.
33
+ */
34
+ import { execFileSync } from "node:child_process";
35
+ import { existsSync, readFileSync } from "node:fs";
36
+ import { basename, join } from "node:path";
37
+ import { ALL_AGENTS } from "../core/types.js";
38
+ import { loadCanonicalSource } from "../core/canonical.js";
39
+ import { findLiteralSecret } from "../adapters/mcpPlan.js";
40
+ import { fromServerDefYaml } from "../core/mcpServerDef.js";
41
+ import { deepEqual } from "./deepEqual.js";
42
+ import { fromRichJsonServerDef, readRichJsonMcpDefs } from "./mcpMigrateRead.js";
43
+ import { findSection, readServerEnvTable } from "./tomlSection.js";
44
+ import { loadWorkspaceRendered, workspaceMcpConfigPath } from "./workspaceMcp.js";
45
+ import { readWorkspaceConfigJson, workspaceConfigPath, WORKSPACE_AGENTS_MD_TEMPLATE } from "./workspaceConfig.js";
46
+ import { INSTRUCTIONS_MARKER } from "./workspaceFiles.js";
47
+ const VAR_REF_RE = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/;
48
+ /**
49
+ * Inverse of `renderServerSection` for a project-level `.codex/config.toml`
50
+ * (task 4.1): no TOML parser dependency exists in this repo (design D2,
51
+ * trellis-mcp-sync-p2), and the global codex reader shells out to `codex
52
+ * mcp list --json` — useless for an arbitrary project file. Hand-rolled
53
+ * against the exact bounded shape `renderServerSection` emits, tolerant of
54
+ * anything else (unknown keys skipped, malformed lines skipped).
55
+ */
56
+ const CODEX_SERVER_HEADER_RE = /^\[mcp_servers\.((?:"(?:[^"\\]|\\.)*"|[A-Za-z0-9_-]+))\]\s*$/gm;
57
+ const CODEX_KV_RE = /^([A-Za-z0-9_-]+)\s*=\s*(.+)$/;
58
+ function readCodexTomlMcpDefs(configPath) {
59
+ const result = { entries: [], unsupported: [] };
60
+ if (!existsSync(configPath))
61
+ return result;
62
+ const content = readFileSync(configPath, "utf-8");
63
+ for (const match of content.matchAll(CODEX_SERVER_HEADER_RE)) {
64
+ const segment = match[1];
65
+ const name = segment.startsWith('"') ? JSON.parse(segment) : segment;
66
+ const range = findSection(content, `mcp_servers.${segment}`);
67
+ if (!range)
68
+ continue;
69
+ const fields = {};
70
+ for (const line of content.split("\n").slice(range.start + 1, range.end + 1)) {
71
+ const kv = CODEX_KV_RE.exec(line.trim());
72
+ if (!kv)
73
+ continue;
74
+ try {
75
+ fields[kv[1]] = JSON.parse(kv[2]);
76
+ }
77
+ catch {
78
+ // not a JSON-shaped literal (a date, a bare string…) — not a shape
79
+ // renderServerSection ever emits; skip the key, keep the rest
80
+ }
81
+ }
82
+ let def;
83
+ if (typeof fields.url === "string") {
84
+ def = { transport: "http", url: fields.url };
85
+ if (typeof fields.bearer_token_env_var === "string") {
86
+ def.headers = { Authorization: `Bearer \${${fields.bearer_token_env_var}}` };
87
+ }
88
+ }
89
+ else if (typeof fields.command === "string") {
90
+ def = { transport: "stdio", command: fields.command };
91
+ if (Array.isArray(fields.args) && fields.args.every((a) => typeof a === "string") && fields.args.length > 0) {
92
+ def.args = fields.args;
93
+ }
94
+ if (Array.isArray(fields.env_vars) && fields.env_vars.every((a) => typeof a === "string") && fields.env_vars.length > 0) {
95
+ def.env = fields.env_vars;
96
+ }
97
+ }
98
+ else {
99
+ result.unsupported.push({ name, reason: "entry has neither url nor command — malformed, skipped" });
100
+ continue;
101
+ }
102
+ // The adjacent `[mcp_servers.<name>.env]` table inverts to envAliases
103
+ // (`${VAR}`-shaped values) + staticEnv (everything else) — the exact
104
+ // split renderServerSection performs on the way out.
105
+ const envTable = readServerEnvTable(content, name);
106
+ for (const [key, value] of Object.entries(envTable ?? {})) {
107
+ const ref = VAR_REF_RE.exec(value);
108
+ if (ref) {
109
+ def.envAliases = { ...(def.envAliases ?? {}), [key]: ref[1] };
110
+ }
111
+ else {
112
+ def.staticEnv = { ...(def.staticEnv ?? {}), [key]: value };
113
+ }
114
+ }
115
+ result.entries.push({ name, def });
116
+ }
117
+ return result;
118
+ }
119
+ /** zcode's project config nests the rich-JSON shape under `mcp.servers`. */
120
+ function readZcodeWorkspaceMcpDefs(configPath) {
121
+ const result = { entries: [], unsupported: [] };
122
+ if (!existsSync(configPath))
123
+ return result;
124
+ let parsed;
125
+ try {
126
+ parsed = JSON.parse(readFileSync(configPath, "utf-8"));
127
+ }
128
+ catch {
129
+ return result; // unparsable file — nothing to scan, the sync guard reports it
130
+ }
131
+ const servers = parsed?.mcp?.servers ?? {};
132
+ for (const [name, raw] of Object.entries(servers)) {
133
+ const def = fromRichJsonServerDef(raw);
134
+ if (def) {
135
+ result.entries.push({ name, def });
136
+ }
137
+ else {
138
+ result.unsupported.push({ name, reason: "stdio entry has no command — malformed, skipped" });
139
+ }
140
+ }
141
+ return result;
142
+ }
143
+ function readAgentProjectMcpDefs(agentId, configPath) {
144
+ if (!existsSync(configPath))
145
+ return { entries: [], unsupported: [] };
146
+ if (agentId === "codex")
147
+ return readCodexTomlMcpDefs(configPath);
148
+ if (agentId === "zcode")
149
+ return readZcodeWorkspaceMcpDefs(configPath);
150
+ return readRichJsonMcpDefs(configPath);
151
+ }
152
+ /** `.mcp.json` → `.mcp.local.json`; `config.toml` → `config.local.toml`. */
153
+ function localSiblingPath(configPath) {
154
+ return configPath.replace(/\.(json|toml)$/, ".local.$1");
155
+ }
156
+ /**
157
+ * Apply-time re-read of one agent's project MCP defs (task 4.2, mirroring
158
+ * the global migrate's D9): an extraction's real value is recovered from
159
+ * the source file HERE, at apply time — it never rides on the plan object
160
+ * a `--json`/`--dry-run` caller might log or serialize.
161
+ */
162
+ export function readWorkspaceAgentMcpDefsForApply(workspaceRoot, agentId) {
163
+ return readAgentProjectMcpDefs(agentId, workspaceMcpConfigPath(agentId, workspaceRoot));
164
+ }
165
+ /**
166
+ * Inventories all six agents' project-level MCP config paths (the same
167
+ * paths workspace delivery writes, so migration and delivery can never
168
+ * disagree about where an agent reads) plus their `*.local.*` siblings.
169
+ * Pure: reads files, writes nothing.
170
+ */
171
+ export function scanWorkspaceMcp(workspaceRoot) {
172
+ const scan = { entries: [], unsupported: [], localFiles: [] };
173
+ for (const agentId of ALL_AGENTS) {
174
+ const configPath = workspaceMcpConfigPath(agentId, workspaceRoot);
175
+ const localPath = localSiblingPath(configPath);
176
+ if (existsSync(localPath))
177
+ scan.localFiles.push(localPath);
178
+ const { entries, unsupported } = readAgentProjectMcpDefs(agentId, configPath);
179
+ for (const { name, def } of entries)
180
+ scan.entries.push({ agent: agentId, name, def, sourcePath: configPath });
181
+ for (const { name, reason } of unsupported)
182
+ scan.unsupported.push({ agent: agentId, name, reason, sourcePath: configPath });
183
+ }
184
+ return scan;
185
+ }
186
+ /** `TRELLIS_<SERVER>_<KEY>` — the global migrate's D11 scheme, reused
187
+ * verbatim so a server extracted at either scope follows one convention. */
188
+ function synthesizeVarName(serverName, key) {
189
+ const part = (s) => s.toUpperCase().replace(/[^A-Z0-9]+/g, "_").replace(/^_+|_+$/g, "") || "X";
190
+ return `TRELLIS_${part(serverName)}_${part(key)}`;
191
+ }
192
+ /** Moves `sourceKey` out of `staticEnv` into a name-only `env` reference —
193
+ * the same safe-def transform as the global migrate's `extractedMcpDef`. */
194
+ function extractedWorkspaceMcpDef(def, sourceKey, varName) {
195
+ const remainingStaticEnv = { ...(def.staticEnv ?? {}) };
196
+ delete remainingStaticEnv[sourceKey];
197
+ const result = { ...def, env: [...(def.env ?? []), varName] };
198
+ if (Object.keys(remainingStaticEnv).length > 0) {
199
+ result.staticEnv = remainingStaticEnv;
200
+ }
201
+ else {
202
+ delete result.staticEnv;
203
+ }
204
+ return result;
205
+ }
206
+ /** Task 4.2: is this source file git-tracked? A literal secret in a
207
+ * tracked file has already been published to every clone — the report
208
+ * must advise rotation. Any failure (not a repo, git absent) means "not
209
+ * tracked", never an error. */
210
+ function isGitTracked(workspaceRoot, path) {
211
+ try {
212
+ execFileSync("git", ["ls-files", "--error-unmatch", "--", path], { cwd: workspaceRoot, stdio: "pipe" });
213
+ return true;
214
+ }
215
+ catch {
216
+ return false;
217
+ }
218
+ }
219
+ export function collectWorkspaceMigratePlan(opts) {
220
+ const { workspaceRoot, homeDir, secretsPolicy } = opts;
221
+ const choose = opts.choose ?? {};
222
+ const dedup = opts.dedup ?? "drop";
223
+ const items = [];
224
+ const scan = scanWorkspaceMcp(workspaceRoot);
225
+ const manifest = loadWorkspaceRendered(workspaceRoot);
226
+ const workspaceConfig = readWorkspaceConfigJson(workspaceRoot);
227
+ const existingWorkspaceServers = workspaceConfig.mcp?.servers ?? {};
228
+ let globalServers = {};
229
+ try {
230
+ globalServers = loadCanonicalSource({ homeDir, workspaceDir: null }).mcp.servers;
231
+ }
232
+ catch {
233
+ // No usable global canonical — dedup simply has nothing to compare
234
+ // against; migration into `.agents/` still works.
235
+ }
236
+ for (const localPath of scan.localFiles) {
237
+ items.push({
238
+ kind: "mcp",
239
+ name: basename(localPath),
240
+ action: "skip-local",
241
+ detail: "personal local file — listed, never read into canonical",
242
+ sources: [],
243
+ });
244
+ }
245
+ for (const entry of scan.unsupported) {
246
+ items.push({
247
+ kind: "mcp",
248
+ name: entry.name,
249
+ action: "skip-unsupported",
250
+ detail: `${entry.agent}: ${entry.reason}`,
251
+ sources: [entry.agent],
252
+ });
253
+ }
254
+ // Group scanned entries by server name; classification then happens
255
+ // once per name, not once per (agent, name) pair.
256
+ const byName = new Map();
257
+ for (const entry of scan.entries) {
258
+ byName.set(entry.name, [...(byName.get(entry.name) ?? []), entry]);
259
+ }
260
+ for (const [name, entries] of [...byName.entries()].sort(([a], [b]) => a.localeCompare(b))) {
261
+ const sources = entries.map((e) => e.agent);
262
+ // Task 4.1: entries Trellis itself rendered (in-repo manifest) or
263
+ // already imported (`.agents/config.json`) need no migration.
264
+ const unrecorded = entries.filter((e) => manifest.mcp?.[e.agent]?.[e.name] === undefined);
265
+ if (unrecorded.length === 0) {
266
+ items.push({ kind: "mcp", name, action: "already-managed", detail: "Trellis-rendered already (in-repo manifest)", sources });
267
+ continue;
268
+ }
269
+ // Task 4.3: one name, differing defs across agents → the user must
270
+ // pick a side explicitly; the plan shows them side by side.
271
+ const distinct = unrecorded.filter((e, i) => unrecorded.findIndex((o) => deepEqual(o.def, e.def)) === i);
272
+ let chosen;
273
+ if (distinct.length > 1) {
274
+ const picked = choose[name];
275
+ const pickedEntry = picked !== undefined ? unrecorded.find((e) => e.agent === picked) : undefined;
276
+ if (!pickedEntry) {
277
+ const defs = {};
278
+ for (const e of distinct)
279
+ defs[e.agent] = e.def;
280
+ items.push({
281
+ kind: "mcp",
282
+ name,
283
+ action: "conflict",
284
+ detail: `defined differently by ${distinct.map((e) => e.agent).join(" and ")} — no def is imported until one is chosen`,
285
+ remediation: `re-run with \`--choose ${name}=<${distinct.map((e) => e.agent).join("|")}>\` after comparing the side-by-side definitions`,
286
+ sources,
287
+ defs,
288
+ });
289
+ continue;
290
+ }
291
+ chosen = pickedEntry;
292
+ }
293
+ else {
294
+ chosen = distinct[0];
295
+ }
296
+ const def = chosen.def;
297
+ const chosenSource = distinct.length > 1 ? chosen.agent : undefined;
298
+ // Already in `.agents/config.json` with an identical def → nothing to
299
+ // do. The file stores the snake_case yaml shape — convert before
300
+ // comparing against the scanned `McpServerDef`.
301
+ const existingYaml = existingWorkspaceServers[name];
302
+ if (existingYaml !== undefined && deepEqual(fromServerDefYaml(name, existingYaml, workspaceConfigPath(workspaceRoot)), def)) {
303
+ items.push({ kind: "mcp", name, action: "already-managed", detail: "already in .agents/config.json with an identical definition", sources, ...(chosenSource ? { chosenSource } : {}) });
304
+ continue;
305
+ }
306
+ // Task 4.2: reject-pattern literal interception. `staticEnv` has a
307
+ // natural extraction target (its own dict key → TRELLIS_<SERVER>_<KEY>
308
+ // in the global secret store); a literal anywhere else can never be
309
+ // written into the COMMITTED `.agents/config.json`, so it conflicts.
310
+ const secretMatch = findLiteralSecret(def);
311
+ if (secretMatch && secretMatch.field !== "staticEnv") {
312
+ items.push({
313
+ kind: "mcp",
314
+ name,
315
+ action: "conflict",
316
+ detail: `${secretMatch.field} holds a value matching a secrets reject pattern (${secretMatch.label}) — .agents/config.json is committed, so the literal is never written`,
317
+ remediation: `rotate the value, replace it with a \${VAR} reference in the source file, then re-run migrate`,
318
+ sources,
319
+ ...(chosenSource ? { chosenSource } : {}),
320
+ });
321
+ continue;
322
+ }
323
+ const tracked = isGitTracked(workspaceRoot, chosen.sourcePath);
324
+ const rotationAdvisory = secretMatch && tracked
325
+ ? `${chosen.sourcePath} is git-tracked — the ${secretMatch.label} value in it was already committed; rotate it`
326
+ : undefined;
327
+ if (secretMatch) {
328
+ const key = secretMatch.key;
329
+ const varName = synthesizeVarName(name, key);
330
+ items.push({
331
+ kind: "mcp",
332
+ name,
333
+ action: "extract-secret",
334
+ detail: `will extract "${key}" to the global secret store as "${varName}" and import the server with a \${${varName}} reference — the literal never lands under .agents/`,
335
+ sources,
336
+ ...(chosenSource ? { chosenSource } : {}),
337
+ mcpDef: extractedWorkspaceMcpDef(def, key, varName),
338
+ extractVarName: varName,
339
+ extractTargetPath: secretsPolicy.envFile ?? join(homeDir, ".trellis", "mcp", "servers.local.env"),
340
+ extractSourceKey: key,
341
+ extractSourceAgent: chosen.agent,
342
+ ...(rotationAdvisory ? { rotationAdvisory } : {}),
343
+ });
344
+ continue;
345
+ }
346
+ // Task 4.4: deep-equal to the global layer — importing would create a
347
+ // redundant shadow. Default drop; `--dedup override` records the
348
+ // intentional-shadow choice and imports anyway.
349
+ const global = globalServers[name];
350
+ if (global !== undefined && deepEqual(global, def)) {
351
+ if (dedup === "override") {
352
+ items.push({
353
+ kind: "mcp",
354
+ name,
355
+ action: "create",
356
+ detail: "identical to the global entry — imported anyway as an intentional shadow (--dedup override)",
357
+ sources,
358
+ ...(chosenSource ? { chosenSource } : {}),
359
+ mcpDef: def,
360
+ dedupChoice: "override",
361
+ });
362
+ }
363
+ else {
364
+ items.push({
365
+ kind: "mcp",
366
+ name,
367
+ action: "identical-to-global",
368
+ detail: "identical to the global entry — dropped (the global layer already provides it); re-run with --dedup override to import it as an intentional shadow",
369
+ sources,
370
+ dedupChoice: "drop",
371
+ });
372
+ }
373
+ continue;
374
+ }
375
+ items.push({
376
+ kind: "mcp",
377
+ name,
378
+ action: "create",
379
+ detail: `will import into .agents/config.json (from ${chosen.agent}${unrecorded.length > 1 ? "; other agents agree" : ""})`,
380
+ sources,
381
+ ...(chosenSource ? { chosenSource } : {}),
382
+ mcpDef: def,
383
+ });
384
+ }
385
+ // Root AGENTS.md → .agents/AGENTS.md. A marker-headed file is Trellis's
386
+ // own rendering — already managed. An unmarked one is imported, unless
387
+ // `.agents/AGENTS.md` already holds real (non-template) content.
388
+ const rootAgentsMd = join(workspaceRoot, "AGENTS.md");
389
+ if (existsSync(rootAgentsMd)) {
390
+ const content = readFileSync(rootAgentsMd, "utf-8");
391
+ if (content.startsWith(INSTRUCTIONS_MARKER)) {
392
+ items.push({ kind: "instructions", name: "AGENTS.md", action: "already-managed", detail: "root AGENTS.md is already Trellis-rendered", sources: [] });
393
+ }
394
+ else {
395
+ const targetPath = join(workspaceRoot, ".agents", "AGENTS.md");
396
+ const existingInstructions = existsSync(targetPath) ? readFileSync(targetPath, "utf-8") : undefined;
397
+ if (existingInstructions === content) {
398
+ items.push({ kind: "instructions", name: "AGENTS.md", action: "already-managed", detail: ".agents/AGENTS.md already holds the same content", sources: [] });
399
+ }
400
+ else if (existingInstructions !== undefined && existingInstructions !== WORKSPACE_AGENTS_MD_TEMPLATE) {
401
+ items.push({
402
+ kind: "instructions",
403
+ name: "AGENTS.md",
404
+ action: "conflict",
405
+ detail: "both root AGENTS.md and .agents/AGENTS.md hold real content — not merged automatically",
406
+ remediation: "merge the two by hand into .agents/AGENTS.md, remove the root file, then re-run migrate",
407
+ sources: [],
408
+ });
409
+ }
410
+ else {
411
+ items.push({
412
+ kind: "instructions",
413
+ name: "AGENTS.md",
414
+ action: "create",
415
+ detail: "will import root AGENTS.md into .agents/AGENTS.md and re-render the root file marker-headed",
416
+ sources: [],
417
+ sourceContent: content,
418
+ });
419
+ }
420
+ }
421
+ }
422
+ return { workspaceRoot, items };
423
+ }