agent-trellis 0.14.0 → 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 (57) 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/piMcp.d.ts +6 -3
  7. package/dist/adapters/piMcp.js +10 -3
  8. package/dist/adapters/zcode.d.ts +12 -1
  9. package/dist/adapters/zcode.js +7 -6
  10. package/dist/cli.js +38 -5
  11. package/dist/commands/doctor.d.ts +9 -1
  12. package/dist/commands/doctor.js +43 -6
  13. package/dist/commands/init.d.ts +6 -0
  14. package/dist/commands/init.js +24 -0
  15. package/dist/commands/mcp.d.ts +44 -1
  16. package/dist/commands/mcp.js +145 -33
  17. package/dist/commands/migrate.d.ts +12 -0
  18. package/dist/commands/migrate.js +8 -4
  19. package/dist/commands/migrateWorkspace.d.ts +40 -0
  20. package/dist/commands/migrateWorkspace.js +197 -0
  21. package/dist/commands/rollback.js +65 -35
  22. package/dist/commands/skill.d.ts +14 -0
  23. package/dist/commands/skill.js +71 -0
  24. package/dist/commands/sync.d.ts +16 -1
  25. package/dist/commands/sync.js +48 -15
  26. package/dist/core/adapter.d.ts +14 -0
  27. package/dist/core/canonical.d.ts +20 -32
  28. package/dist/core/canonical.js +18 -86
  29. package/dist/core/mcpServerDef.d.ts +39 -0
  30. package/dist/core/mcpServerDef.js +93 -0
  31. package/dist/core/types.d.ts +43 -4
  32. package/dist/lib/mcpMigrateRead.d.ts +25 -0
  33. package/dist/lib/mcpMigrateRead.js +9 -2
  34. package/dist/lib/skillProvider.js +18 -5
  35. package/dist/lib/workspace.d.ts +39 -0
  36. package/dist/lib/workspace.js +158 -0
  37. package/dist/lib/workspaceConfig.d.ts +45 -0
  38. package/dist/lib/workspaceConfig.js +98 -0
  39. package/dist/lib/workspaceDoctor.d.ts +44 -0
  40. package/dist/lib/workspaceDoctor.js +94 -0
  41. package/dist/lib/workspaceFiles.d.ts +38 -0
  42. package/dist/lib/workspaceFiles.js +264 -0
  43. package/dist/lib/workspaceMcp.d.ts +57 -0
  44. package/dist/lib/workspaceMcp.js +277 -0
  45. package/dist/lib/workspaceMerge.d.ts +37 -0
  46. package/dist/lib/workspaceMerge.js +98 -0
  47. package/dist/lib/workspaceMigrate.d.ts +118 -0
  48. package/dist/lib/workspaceMigrate.js +423 -0
  49. package/dist/pi-bridge/bundle.js +262 -96
  50. package/docs/architecture.md +111 -49
  51. package/docs/getting-started.md +102 -14
  52. package/docs/mcp-oauth-matrix.md +12 -1
  53. package/docs/overview.zh-CN.md +17 -0
  54. package/docs/research.md +28 -6
  55. package/docs/roadmap.md +17 -5
  56. package/package.json +1 -1
  57. package/docs/implementation-plan.md +0 -341
@@ -18,6 +18,7 @@ import * as claudeCodeProbe from "../probes/claude-code.js";
18
18
  import { applySymlinkPlan, planSymlinks } from "./symlinkPlan.js";
19
19
  import { applyJsonMcp, planJsonMcp, renderJsonServerEntry } from "./jsonMcp.js";
20
20
  import { loadMcpOwnership, ownedByAgent, recordOwned, forgetOwned, saveMcpOwnership } from "../lib/mcpOwnership.js";
21
+ import { globalLayerMcp, globalLayerSkills } from "../lib/workspaceMerge.js";
21
22
  export class ClaudeCodeAdapter {
22
23
  homeDir;
23
24
  name = "Claude Code";
@@ -32,7 +33,7 @@ export class ClaudeCodeAdapter {
32
33
  async plan(canonical) {
33
34
  const canonicalRoot = dirname(canonical.instructionsFile);
34
35
  const skillsRoot = join(this.homeDir, ".claude", "skills");
35
- const desiredSkills = canonical.skills
36
+ const desiredSkills = globalLayerSkills(canonical)
36
37
  .filter((skill) => usesNativeCapabilityDelivery(this.id, canonical.mcp) && isInScope(this.id, skill.scope, canonical.managedAgents))
37
38
  .map((skill) => ({ name: skill.name, target: skill.dir }));
38
39
  const skillItems = planSymlinks({
@@ -54,7 +55,7 @@ export class ClaudeCodeAdapter {
54
55
  const configPath = join(this.homeDir, ".claude.json");
55
56
  const parsed = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : undefined;
56
57
  const ownership = ownedByAgent(loadMcpOwnership(this.homeDir), this.id);
57
- return planJsonMcp({ configPath, parsed, mcp: canonical.mcp, agentId: this.id, managedAgents: canonical.managedAgents, policy: canonical.secretsPolicy, ownership });
58
+ return planJsonMcp({ configPath, parsed, mcp: globalLayerMcp(canonical), agentId: this.id, managedAgents: canonical.managedAgents, policy: canonical.secretsPolicy, ownership });
58
59
  }
59
60
  async apply(plan, backup) {
60
61
  await applySymlinkPlan(plan.filter((item) => item.kind !== "mcp"), backup);
@@ -83,7 +84,7 @@ export class ClaudeCodeAdapter {
83
84
  return { ok: false, mismatches: ["claude-code is not present on this machine"] };
84
85
  }
85
86
  const mismatches = [];
86
- const desiredNames = new Set(canonical.skills.filter((s) => usesNativeCapabilityDelivery(this.id, canonical.mcp) && isInScope(this.id, s.scope, canonical.managedAgents)).map((s) => s.name));
87
+ const desiredNames = new Set(globalLayerSkills(canonical).filter((s) => usesNativeCapabilityDelivery(this.id, canonical.mcp) && isInScope(this.id, s.scope, canonical.managedAgents)).map((s) => s.name));
87
88
  const actualSkills = new Set(snapshot.skillRoots.flatMap((root) => root.skills).map((s) => s.name));
88
89
  for (const name of desiredNames) {
89
90
  if (!actualSkills.has(name)) {
@@ -26,6 +26,7 @@ import { applySymlinkPlan, planSymlinks } from "./symlinkPlan.js";
26
26
  import { resolveMcpPlan } from "./mcpPlan.js";
27
27
  import { currentServerSectionText, removeSection, renderServerSection, upsertSection } from "../lib/tomlSection.js";
28
28
  import { loadMcpOwnership, ownedByAgent, recordOwned, forgetOwned, saveMcpOwnership } from "../lib/mcpOwnership.js";
29
+ import { globalLayerMcp, globalLayerSkills } from "../lib/workspaceMerge.js";
29
30
  export class CodexAdapter {
30
31
  homeDir;
31
32
  name = "Codex";
@@ -40,7 +41,7 @@ export class CodexAdapter {
40
41
  async plan(canonical) {
41
42
  const canonicalRoot = dirname(canonical.instructionsFile);
42
43
  const skillsRoot = join(this.homeDir, ".agents", "skills");
43
- const desiredSkills = canonical.skills
44
+ const desiredSkills = globalLayerSkills(canonical)
44
45
  .filter((skill) => usesNativeCapabilityDelivery(this.id, canonical.mcp) && isInScope(this.id, skill.scope, canonical.managedAgents))
45
46
  .map((skill) => ({ name: skill.name, target: skill.dir }));
46
47
  const skillItems = planSymlinks({
@@ -67,7 +68,7 @@ export class CodexAdapter {
67
68
  planMcp(canonical) {
68
69
  const configTomlPath = join(this.homeDir, ".codex", "config.toml");
69
70
  const content = existsSync(configTomlPath) ? readFileSync(configTomlPath, "utf-8") : "";
70
- const { desired, conflicts } = resolveMcpPlan(this.id, canonical.mcp, canonical.managedAgents, canonical.secretsPolicy);
71
+ const { desired, conflicts } = resolveMcpPlan(this.id, globalLayerMcp(canonical), canonical.managedAgents, canonical.secretsPolicy);
71
72
  const items = [];
72
73
  for (const { name, def } of desired) {
73
74
  const current = currentServerSectionText(content, name);
@@ -137,7 +138,7 @@ export class CodexAdapter {
137
138
  return { ok: false, mismatches: ["codex is not present on this machine"] };
138
139
  }
139
140
  const mismatches = [];
140
- const desiredNames = new Set(canonical.skills.filter((s) => usesNativeCapabilityDelivery(this.id, canonical.mcp) && isInScope(this.id, s.scope, canonical.managedAgents)).map((s) => s.name));
141
+ const desiredNames = new Set(globalLayerSkills(canonical).filter((s) => usesNativeCapabilityDelivery(this.id, canonical.mcp) && isInScope(this.id, s.scope, canonical.managedAgents)).map((s) => s.name));
141
142
  const actualSkills = new Set(snapshot.skillRoots.flatMap((root) => root.skills).map((s) => s.name));
142
143
  for (const name of desiredNames) {
143
144
  if (!actualSkills.has(name)) {
@@ -16,6 +16,7 @@ import { applyJsonMcp, planJsonMcp, renderJsonServerEntry } from "./jsonMcp.js";
16
16
  import { RUNTIME_COMMAND, RUNTIME_ENTRY_NAME } from "./mcpPlan.js";
17
17
  import { applySymlinkPlan, planSymlinks } from "./symlinkPlan.js";
18
18
  import { loadMcpOwnership, ownedByAgent, recordOwned, forgetOwned, saveMcpOwnership } from "../lib/mcpOwnership.js";
19
+ import { globalLayerMcp, globalLayerSkills } from "../lib/workspaceMerge.js";
19
20
  const KIMI_RUNTIME_STARTUP_TIMEOUT_MS = 30_000;
20
21
  function renderKimiServerEntry(def, name) {
21
22
  const entry = renderJsonServerEntry(def);
@@ -46,7 +47,7 @@ export class KimiCodeAdapter {
46
47
  items.push(...planSymlinks({
47
48
  rootDir: join(root, "skills"),
48
49
  desired: native
49
- ? canonical.skills
50
+ ? globalLayerSkills(canonical)
50
51
  .filter((skill) => isInScope(this.id, skill.scope, canonical.managedAgents))
51
52
  .map((skill) => ({ name: skill.name, target: skill.dir }))
52
53
  : [],
@@ -65,7 +66,7 @@ export class KimiCodeAdapter {
65
66
  items.push(...planJsonMcp({
66
67
  configPath,
67
68
  parsed,
68
- mcp: canonical.mcp,
69
+ mcp: globalLayerMcp(canonical),
69
70
  agentId: this.id,
70
71
  managedAgents: canonical.managedAgents,
71
72
  policy: canonical.secretsPolicy,
@@ -96,7 +97,7 @@ export class KimiCodeAdapter {
96
97
  const snapshot = await kimiProbe.probe(this.homeDir);
97
98
  if (!snapshot.present)
98
99
  return { ok: false, mismatches: ["kimi-code is not present on this machine"] };
99
- const desired = new Set(canonical.skills.filter((skill) => usesNativeCapabilityDelivery(this.id, canonical.mcp) && isInScope(this.id, skill.scope, canonical.managedAgents)).map((skill) => skill.name));
100
+ const desired = new Set(globalLayerSkills(canonical).filter((skill) => usesNativeCapabilityDelivery(this.id, canonical.mcp) && isInScope(this.id, skill.scope, canonical.managedAgents)).map((skill) => skill.name));
100
101
  const actual = new Set(snapshot.skillRoots.flatMap((root) => root.skills).filter((skill) => skill.dir.includes("/.kimi-code/skills/")).map((skill) => skill.name));
101
102
  const mismatches = [
102
103
  ...[...desired].filter((name) => !actual.has(name)).map((name) => `skill "${name}" is missing on Kimi Code`),
@@ -26,6 +26,7 @@ import { applyJsonMcp, planJsonMcp, renderJsonServerEntry } from "./jsonMcp.js";
26
26
  import { resolveMcpPlan } from "./mcpPlan.js";
27
27
  import { declaredEnvNames } from "../lib/envVarNames.js";
28
28
  import { loadMcpOwnership, ownedByAgent, recordOwned, forgetOwned, saveMcpOwnership } from "../lib/mcpOwnership.js";
29
+ import { globalLayerMcp, globalLayerSkills } from "../lib/workspaceMerge.js";
29
30
  /** VS-Code-family global settings path. macOS only — see
30
31
  * trellis-kiro-approved-env-vars proposal.md Non-Goals: Linux/Windows
31
32
  * equivalents are the well-known convention but unverified against a
@@ -48,7 +49,7 @@ export class KiroAdapter {
48
49
  async plan(canonical) {
49
50
  const canonicalRoot = dirname(canonical.instructionsFile);
50
51
  const skillsRoot = join(this.homeDir, ".kiro", "skills");
51
- const desiredSkills = canonical.skills
52
+ const desiredSkills = globalLayerSkills(canonical)
52
53
  .filter((skill) => usesNativeCapabilityDelivery(this.id, canonical.mcp) && isInScope(this.id, skill.scope, canonical.managedAgents))
53
54
  .map((skill) => ({ name: skill.name, target: skill.dir }));
54
55
  const skillItems = planSymlinks({
@@ -71,13 +72,13 @@ export class KiroAdapter {
71
72
  const configPath = join(this.homeDir, ".kiro", "settings", "mcp.json");
72
73
  const parsed = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : undefined;
73
74
  const ownership = ownedByAgent(loadMcpOwnership(this.homeDir), this.id);
74
- return planJsonMcp({ configPath, parsed, mcp: canonical.mcp, agentId: this.id, managedAgents: canonical.managedAgents, policy: canonical.secretsPolicy, ownership });
75
+ return planJsonMcp({ configPath, parsed, mcp: globalLayerMcp(canonical), agentId: this.id, managedAgents: canonical.managedAgents, policy: canonical.secretsPolicy, ownership });
75
76
  }
76
77
  /** Names come from `resolveMcpPlan`, not a raw scan of `canonical.mcp.servers`
77
78
  * (design.md D2) — a server scoped away from Kiro, or refused for a
78
79
  * known_host_injected collision, never contributes a name here either. */
79
80
  desiredApprovedEnvVars(canonical) {
80
- const { desired } = resolveMcpPlan(this.id, canonical.mcp, canonical.managedAgents, canonical.secretsPolicy);
81
+ const { desired } = resolveMcpPlan(this.id, globalLayerMcp(canonical), canonical.managedAgents, canonical.secretsPolicy);
81
82
  const names = new Set();
82
83
  for (const { def } of desired) {
83
84
  for (const name of declaredEnvNames(def))
@@ -162,7 +163,7 @@ export class KiroAdapter {
162
163
  return { ok: false, mismatches: ["kiro is not present on this machine"] };
163
164
  }
164
165
  const mismatches = [];
165
- const desiredNames = new Set(canonical.skills.filter((s) => usesNativeCapabilityDelivery(this.id, canonical.mcp) && isInScope(this.id, s.scope, canonical.managedAgents)).map((s) => s.name));
166
+ const desiredNames = new Set(globalLayerSkills(canonical).filter((s) => usesNativeCapabilityDelivery(this.id, canonical.mcp) && isInScope(this.id, s.scope, canonical.managedAgents)).map((s) => s.name));
166
167
  const actualSkills = new Set(snapshot.skillRoots.flatMap((root) => root.skills).map((s) => s.name));
167
168
  for (const name of desiredNames) {
168
169
  if (!actualSkills.has(name)) {
@@ -36,6 +36,7 @@ import { applyJsonMcp, planJsonMcp } from "./jsonMcp.js";
36
36
  import { piRenderer, supportsNativeMcp } from "./piMcp.js";
37
37
  import { loadMcpOwnership, ownedByAgent, recordOwned, forgetOwned, saveMcpOwnership } from "../lib/mcpOwnership.js";
38
38
  import { currentLinkTarget } from "../lib/backup.js";
39
+ import { globalLayerMcp, globalLayerSkills } from "../lib/workspaceMerge.js";
39
40
  const BRIDGE_SYMLINK_NAME = "trellis-mcp-bridge.js";
40
41
  /**
41
42
  * A source-checkout bridge can survive a switch to the packaged CLI. Adopt it
@@ -97,7 +98,7 @@ export class PiAdapter {
97
98
  async plan(canonical) {
98
99
  const canonicalRoot = dirname(canonical.instructionsFile);
99
100
  const agentDir = join(this.homeDir, ".pi", "agent");
100
- const desiredSkills = canonical.skills
101
+ const desiredSkills = globalLayerSkills(canonical)
101
102
  .filter((skill) => usesNativeCapabilityDelivery(this.id, canonical.mcp) && isInScope(this.id, skill.scope, canonical.managedAgents))
102
103
  .map((skill) => ({ name: skill.name, target: skill.dir }));
103
104
  const skillItems = planSymlinks({
@@ -146,7 +147,7 @@ export class PiAdapter {
146
147
  const items = planJsonMcp({
147
148
  configPath,
148
149
  parsed,
149
- mcp: canonical.mcp,
150
+ mcp: globalLayerMcp(canonical),
150
151
  agentId: this.id,
151
152
  managedAgents: canonical.managedAgents,
152
153
  policy: canonical.secretsPolicy,
@@ -233,7 +234,7 @@ export class PiAdapter {
233
234
  return { ok: false, mismatches: ["pi is not present on this machine"] };
234
235
  }
235
236
  const mismatches = [];
236
- const desiredNames = new Set(canonical.skills.filter((s) => usesNativeCapabilityDelivery(this.id, canonical.mcp) && isInScope(this.id, s.scope, canonical.managedAgents)).map((s) => s.name));
237
+ const desiredNames = new Set(globalLayerSkills(canonical).filter((s) => usesNativeCapabilityDelivery(this.id, canonical.mcp) && isInScope(this.id, s.scope, canonical.managedAgents)).map((s) => s.name));
237
238
  const actualSkills = new Set(snapshot.skillRoots.flatMap((root) => root.skills).map((s) => s.name));
238
239
  for (const name of desiredNames) {
239
240
  if (!actualSkills.has(name)) {
@@ -28,8 +28,11 @@ export declare function supportsNativeMcp(version: string | undefined): boolean;
28
28
  * `pi mcp add` writes (observed 2026-10-11): stdio as
29
29
  * `{command, args, env?}`, http as `{url, headers?}`, no `type` field.
30
30
  * `env` names render as `${VAR}` references exactly like the other JSON
31
- * agents; OAuth `auth` metadata is Trellis-side routing information and
32
- * is never rendered (an agent-owned OAuth server becomes a bare `{url}`
33
- * entry that `pi mcp login <name>` authorizes into pi's own token store).
31
+ * agents; OAuth `auth` metadata is Trellis-side routing information and is
32
+ * never rendered — with one exception: a declared `auth.client_name` becomes
33
+ * the entry's `oauth.clientName`, the field `pi mcp add --oauth-client-name`
34
+ * writes, so providers that gate dynamic client registration by client name
35
+ * (Figma) accept `pi mcp login`'s registration and later syncs compare equal
36
+ * instead of stripping the block (oauth-client-name design.md D2).
34
37
  */
35
38
  export declare function piRenderer(def: McpServerDef, _name: string): Record<string, unknown>;
@@ -11,6 +11,7 @@
11
11
  * unchanged. sse is not a pi transport — the adapter turns sse servers
12
12
  * into conflict items rather than writing entries pi cannot connect.
13
13
  */
14
+ import { isOAuthAuth } from "../core/types.js";
14
15
  import { resolvedEnvTextMap } from "../lib/mcpMigrateRead.js";
15
16
  /** The pi release that introduced the native MCP client (`pi mcp`,
16
17
  * `~/.pi/agent/mcp.json`). */
@@ -37,9 +38,12 @@ export function supportsNativeMcp(version) {
37
38
  * `pi mcp add` writes (observed 2026-10-11): stdio as
38
39
  * `{command, args, env?}`, http as `{url, headers?}`, no `type` field.
39
40
  * `env` names render as `${VAR}` references exactly like the other JSON
40
- * agents; OAuth `auth` metadata is Trellis-side routing information and
41
- * is never rendered (an agent-owned OAuth server becomes a bare `{url}`
42
- * entry that `pi mcp login <name>` authorizes into pi's own token store).
41
+ * agents; OAuth `auth` metadata is Trellis-side routing information and is
42
+ * never rendered — with one exception: a declared `auth.client_name` becomes
43
+ * the entry's `oauth.clientName`, the field `pi mcp add --oauth-client-name`
44
+ * writes, so providers that gate dynamic client registration by client name
45
+ * (Figma) accept `pi mcp login`'s registration and later syncs compare equal
46
+ * instead of stripping the block (oauth-client-name design.md D2).
43
47
  */
44
48
  export function piRenderer(def, _name) {
45
49
  if (def.transport === "http" || def.transport === "sse") {
@@ -47,6 +51,9 @@ export function piRenderer(def, _name) {
47
51
  if (def.headers && Object.keys(def.headers).length > 0) {
48
52
  entry.headers = def.headers;
49
53
  }
54
+ if (isOAuthAuth(def.auth) && typeof def.auth === "object" && def.auth.clientName !== undefined) {
55
+ entry.oauth = { clientName: def.auth.clientName };
56
+ }
50
57
  return entry;
51
58
  }
52
59
  const entry = { command: def.command, args: def.args ?? [] };
@@ -6,8 +6,18 @@
6
6
  * native Skill switches through a ledger-backed, reversible config change.
7
7
  */
8
8
  import type { AdapterPlanItem, AdapterProbeResult, AdapterVerifyResult, TrellisAdapter } from "../core/adapter.js";
9
- import type { CanonicalSource } from "../core/types.js";
9
+ import type { CanonicalSource, McpConfig, SecretsPolicy } from "../core/types.js";
10
10
  import type { BackupSession } from "../lib/backup.js";
11
+ type JsonRecord = Record<string, unknown>;
12
+ export declare function planZcodeMcp(opts: {
13
+ configPath: string;
14
+ parsed: JsonRecord | undefined;
15
+ mcp: McpConfig;
16
+ managedAgents: readonly import("../core/types.js").AgentId[];
17
+ policy: SecretsPolicy;
18
+ ownership: JsonRecord;
19
+ }): AdapterPlanItem[];
20
+ export declare function applyZcodeMcp(parsed: JsonRecord | undefined, items: readonly AdapterPlanItem[]): JsonRecord;
11
21
  export declare class ZcodeAdapter implements TrellisAdapter {
12
22
  private readonly homeDir;
13
23
  private readonly env;
@@ -19,3 +29,4 @@ export declare class ZcodeAdapter implements TrellisAdapter {
19
29
  apply(plan: AdapterPlanItem[], backup: BackupSession): Promise<void>;
20
30
  verify(canonical: CanonicalSource): Promise<AdapterVerifyResult>;
21
31
  }
32
+ export {};
@@ -17,6 +17,7 @@ import { deepEqual } from "../lib/deepEqual.js";
17
17
  import { forgetOwned, loadMcpOwnership, ownedByAgent, recordOwned, saveMcpOwnership } from "../lib/mcpOwnership.js";
18
18
  import { forgetZcodeSettingsOwnership, loadZcodeSettingsOwnership, saveZcodeSettingsOwnership } from "../lib/zcodeOwnership.js";
19
19
  import * as zcodeProbe from "../probes/zcode.js";
20
+ import { globalLayerMcp, globalLayerSkills } from "../lib/workspaceMerge.js";
20
21
  function readConfig(path) {
21
22
  if (!existsSync(path))
22
23
  return undefined;
@@ -28,7 +29,7 @@ function record(value) {
28
29
  function zcodeServers(parsed) {
29
30
  return record(record(parsed?.mcp).servers);
30
31
  }
31
- function planZcodeMcp(opts) {
32
+ export function planZcodeMcp(opts) {
32
33
  const { desired, conflicts } = resolveMcpPlan("zcode", opts.mcp, opts.managedAgents, opts.policy);
33
34
  const existing = zcodeServers(opts.parsed);
34
35
  const items = [];
@@ -65,7 +66,7 @@ function planZcodeMcp(opts) {
65
66
  }
66
67
  return items;
67
68
  }
68
- function applyZcodeMcp(parsed, items) {
69
+ export function applyZcodeMcp(parsed, items) {
69
70
  const base = parsed ?? {};
70
71
  const mcp = record(base.mcp);
71
72
  const servers = { ...record(mcp.servers) };
@@ -124,7 +125,7 @@ export class ZcodeAdapter {
124
125
  items.push(...planSymlinks({
125
126
  rootDir: join(this.homeDir, ".zcode", "skills"),
126
127
  desired: usesNativeCapabilityDelivery(this.id, canonical.mcp)
127
- ? canonical.skills.filter((skill) => isInScope(this.id, skill.scope, canonical.managedAgents)).map((skill) => ({ name: skill.name, target: skill.dir }))
128
+ ? globalLayerSkills(canonical).filter((skill) => isInScope(this.id, skill.scope, canonical.managedAgents)).map((skill) => ({ name: skill.name, target: skill.dir }))
128
129
  : [],
129
130
  canonicalRoot: join(canonicalRoot, "skills"),
130
131
  kind: "skill",
@@ -138,7 +139,7 @@ export class ZcodeAdapter {
138
139
  items.push(...planZcodeMcp({
139
140
  configPath: profile.configPath,
140
141
  parsed,
141
- mcp: canonical.mcp,
142
+ mcp: globalLayerMcp(canonical),
142
143
  managedAgents: canonical.managedAgents,
143
144
  policy: canonical.secretsPolicy,
144
145
  ownership: ownedByAgent(loadMcpOwnership(this.homeDir), this.id),
@@ -206,7 +207,7 @@ export class ZcodeAdapter {
206
207
  if (!profile)
207
208
  return { ok: false, mismatches: ["zcode profile cannot be selected"] };
208
209
  const mismatches = [];
209
- const desiredNativeSkills = canonical.skills
210
+ const desiredNativeSkills = globalLayerSkills(canonical)
210
211
  .filter((skill) => usesNativeCapabilityDelivery(this.id, canonical.mcp) && isInScope(this.id, skill.scope, canonical.managedAgents))
211
212
  .map((skill) => skill.name);
212
213
  const actualNativeSkills = new Set(snapshot.skillRoots.filter((root) => root.path === join(this.homeDir, ".zcode", "skills")).flatMap((root) => root.skills.map((skill) => skill.name)));
@@ -216,7 +217,7 @@ export class ZcodeAdapter {
216
217
  const expectedMcp = planZcodeMcp({
217
218
  configPath: profile.configPath,
218
219
  parsed: readConfig(profile.configPath),
219
- mcp: canonical.mcp,
220
+ mcp: globalLayerMcp(canonical),
220
221
  managedAgents: canonical.managedAgents,
221
222
  policy: canonical.secretsPolicy,
222
223
  ownership: ownedByAgent(loadMcpOwnership(this.homeDir), this.id),
package/dist/cli.js CHANGED
@@ -7,6 +7,7 @@
7
7
  import { runDoctor } from "./commands/doctor.js";
8
8
  import { runInit } from "./commands/init.js";
9
9
  import { runMigrate } from "./commands/migrate.js";
10
+ import { runMigrateWorkspace } from "./commands/migrateWorkspace.js";
10
11
  import { runOnboard } from "./commands/onboard.js";
11
12
  import { runSync } from "./commands/sync.js";
12
13
  import { runMcpSync, runMcpList, runMcpAdd, runMcpRemove, runMcpSetAuth, runMcpScope, parseMcpAddArgs, parseMcpSetArgs } from "./commands/mcp.js";
@@ -74,6 +75,7 @@ Commands:
74
75
  (never overwrites an existing file — fills in only what's
75
76
  missing) and prints which agents are present
76
77
  --json machine-readable output, no report text
78
+ --workspace scaffold .agents/ in the current project instead
77
79
  migrate --from <agent>
78
80
  Import an existing agent's real skills/instructions/MCP/memory
79
81
  servers into canonical source (claude-code | codex | kiro |
@@ -84,6 +86,18 @@ Commands:
84
86
  restrict to one category
85
87
  --dry-run preview the plan, write nothing
86
88
  --json machine-readable output, no report text
89
+ migrate --workspace
90
+ Absorb the repo's existing project-level agent config (all six
91
+ agents' MCP files + root AGENTS.md) into .agents/, then
92
+ re-render. Plaintext secrets are intercepted into the global
93
+ secret store as \${VAR} references, never written under
94
+ .agents/; *.local.* files are listed, never read.
95
+ --choose name=agentId pick a side for a cross-agent conflict
96
+ --dedup drop|override identical-to-global entries: skip
97
+ (default) or import as an intentional
98
+ shadow
99
+ --dry-run preview the plan, write nothing
100
+ --json machine-readable output, no report text
87
101
  doctor Scan Claude Code / Codex / Kiro / pi for drift
88
102
  --json machine-readable output, no table text
89
103
  --probe-mcp also handshake every configured MCP server
@@ -123,6 +137,7 @@ Commands:
123
137
  no overwrite): --command <cmd> [--args a,b] (stdio) or
124
138
  --url <url> (http/sse); [--headers k=v,...]
125
139
  [--auth oauth] [--env NAME,...] [--static-env k=v,...] [--agents id,...]
140
+ [--workspace] write to .agents/config.json instead of servers.yaml
126
141
  [--enabled true|false]
127
142
  --dry-run preview the plan, write nothing
128
143
  --json machine-readable output, no report text
@@ -186,6 +201,7 @@ Commands:
186
201
  skill add <name> --from <path>
187
202
  Import a real skill directory into canonical (refuses on
188
203
  an existing name with different content, no overwrite)
204
+ --workspace copy into .agents/skills/ instead of canonical
189
205
  --dry-run preview the plan, write nothing
190
206
  --json machine-readable output, no report text
191
207
  skill remove <name>
@@ -341,11 +357,28 @@ async function main(argv) {
341
357
  return;
342
358
  }
343
359
  if (command === "init") {
344
- const { exitCode } = await runInit({ json: rest.includes("--json") });
360
+ const { exitCode } = await runInit({ json: rest.includes("--json"), workspace: rest.includes("--workspace") });
345
361
  process.exitCode = exitCode;
346
362
  return;
347
363
  }
348
364
  if (command === "migrate") {
365
+ if (rest.includes("--workspace")) {
366
+ // Repeated `--choose name=agentId` flags collect into an array.
367
+ const choose = [];
368
+ for (let i = 0; i < rest.length; i++) {
369
+ if (rest[i] === "--choose" && rest[i + 1] !== undefined)
370
+ choose.push(rest[i + 1]);
371
+ }
372
+ const dedupIndex = rest.indexOf("--dedup");
373
+ const { exitCode } = await runMigrateWorkspace({
374
+ choose,
375
+ dedup: dedupIndex >= 0 ? rest[dedupIndex + 1] : undefined,
376
+ dryRun: rest.includes("--dry-run"),
377
+ json: rest.includes("--json"),
378
+ });
379
+ process.exitCode = exitCode;
380
+ return;
381
+ }
349
382
  const fromIndex = rest.indexOf("--from");
350
383
  const from = fromIndex >= 0 ? rest[fromIndex + 1] : undefined;
351
384
  const onlyIndex = rest.indexOf("--only");
@@ -383,7 +416,7 @@ async function main(argv) {
383
416
  process.exitCode = 1;
384
417
  return;
385
418
  }
386
- const { exitCode } = await runSync({ target, json: rest.includes("--json"), dryRun: rest.includes("--dry-run") });
419
+ const { exitCode } = await runSync({ target, json: rest.includes("--json"), dryRun: rest.includes("--dry-run"), check: rest.includes("--check"), globalOnly: rest.includes("--global-only") });
387
420
  process.exitCode = exitCode;
388
421
  return;
389
422
  }
@@ -443,7 +476,7 @@ async function main(argv) {
443
476
  const json = mcpRest.includes("--json");
444
477
  const dryRun = mcpRest.includes("--dry-run");
445
478
  if (subcommand === "sync") {
446
- const { exitCode } = await runMcpSync({ json, dryRun });
479
+ const { exitCode } = await runMcpSync({ json, dryRun, check: mcpRest.includes("--check"), globalOnly: mcpRest.includes("--global-only") });
447
480
  process.exitCode = exitCode;
448
481
  return;
449
482
  }
@@ -453,7 +486,7 @@ async function main(argv) {
453
486
  }
454
487
  if (subcommand === "add") {
455
488
  const [name] = mcpRest;
456
- process.exitCode = (await runMcpAdd(name, parseMcpAddArgs(mcpRest), { json, dryRun })).exitCode;
489
+ process.exitCode = (await runMcpAdd(name, parseMcpAddArgs(mcpRest), { json, dryRun, workspace: mcpRest.includes("--workspace") })).exitCode;
457
490
  return;
458
491
  }
459
492
  if (subcommand === "import") {
@@ -518,7 +551,7 @@ async function main(argv) {
518
551
  process.exitCode = 1;
519
552
  return;
520
553
  }
521
- process.exitCode = (await runSkillAdd(name, from, { json, dryRun })).exitCode;
554
+ process.exitCode = (await runSkillAdd(name, from, { json, dryRun, workspace: skillRest.includes("--workspace") })).exitCode;
522
555
  return;
523
556
  }
524
557
  if (subcommand === "remove") {
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { loadCanonicalSource } from "../core/canonical.js";
8
8
  import type { AgentId, AgentSnapshot, CanonicalSource } from "../core/types.js";
9
+ import { type WorkspaceDoctorSection } from "../lib/workspaceDoctor.js";
9
10
  /**
10
11
  * Interim default until P1 wires this from `.trellis/mcp/servers.yaml`'s
11
12
  * real `known_host_injected` field (P0 has no canonical source at all —
@@ -47,6 +48,10 @@ export interface RunDoctorOptions {
47
48
  /** Defaults to the real `~`; overridable for tests only — same seam
48
49
  * every other Trellis entry point uses. */
49
50
  homeDir?: string;
51
+ /** Explicit workspace root for layer attribution — the test/sandbox
52
+ * seam. CLI discovery walks up from cwd via `findWorkspaceRoot`;
53
+ * null = no workspace layer. */
54
+ workspaceDir?: string | null;
50
55
  }
51
56
  export interface OAuthHealthEntry {
52
57
  server: string;
@@ -61,6 +66,9 @@ export interface DoctorReport {
61
66
  * server is OAuth-classified. Read-only by construction
62
67
  * (trellis-mcp-oauth-renew design.md D4). */
63
68
  oauth?: OAuthHealthEntry[];
69
+ /** Per-entry layer attribution when a `.agents/` workspace layer
70
+ * participated (trellis-workspace-scope task 3.5). */
71
+ workspace?: WorkspaceDoctorSection;
64
72
  }
65
73
  /** Exported so tests can assert on this resolution directly, without
66
74
  * routing through real per-agent probes `runDoctor` also runs. */
@@ -76,7 +84,7 @@ export declare function runDoctor(opts?: RunDoctorOptions): Promise<{
76
84
  * existing call site (which now passes `opts.homeDir`, itself normally
77
85
  * `undefined`) is unaffected.
78
86
  */
79
- export declare function collectDoctorReport(homeDir?: string, knownHostInjected?: readonly string[], probeMcp?: boolean): Promise<DoctorReport>;
87
+ export declare function collectDoctorReport(homeDir?: string, knownHostInjected?: readonly string[], probeMcp?: boolean, workspaceDir?: string | null): Promise<DoctorReport>;
80
88
  /**
81
89
  * Read-only OAuth credential health (trellis-mcp-oauth-renew design.md
82
90
  * D4): classified from the stored token file alone — no token endpoint,
@@ -16,6 +16,8 @@ import { GATEWAY_ENTRY_NAME, RUNTIME_ENTRY_NAME, resolveMcpPlan } from "../adapt
16
16
  import { ALL_AGENTS, isOAuthAuth, oauthOwner } from "../core/types.js";
17
17
  import { classifyStoredToken, remediationFor } from "../lib/oauth/renew.js";
18
18
  import { readToken } from "../lib/oauth/store.js";
19
+ import { findWorkspaceRoot } from "../lib/workspace.js";
20
+ import { collectWorkspaceDoctorSection } from "../lib/workspaceDoctor.js";
19
21
  /**
20
22
  * Interim default until P1 wires this from `.trellis/mcp/servers.yaml`'s
21
23
  * real `known_host_injected` field (P0 has no canonical source at all —
@@ -58,7 +60,8 @@ export function resolveKnownHostInjected(opts) {
58
60
  }
59
61
  }
60
62
  export async function runDoctor(opts = {}) {
61
- const report = await collectDoctorReport(opts.homeDir, resolveKnownHostInjected(opts), opts.probeMcp);
63
+ const workspaceDir = opts.workspaceDir !== undefined ? opts.workspaceDir : findWorkspaceRoot(process.cwd());
64
+ const report = await collectDoctorReport(opts.homeDir, resolveKnownHostInjected(opts), opts.probeMcp, workspaceDir);
62
65
  const exitCode = report.findings.some((f) => EXIT_NONZERO_KINDS.has(f.kind)) ? 1 : 0;
63
66
  if (opts.json) {
64
67
  console.log(JSON.stringify(report, null, 2));
@@ -76,7 +79,7 @@ export async function runDoctor(opts = {}) {
76
79
  * existing call site (which now passes `opts.homeDir`, itself normally
77
80
  * `undefined`) is unaffected.
78
81
  */
79
- export async function collectDoctorReport(homeDir = homedir(), knownHostInjected = DEFAULT_KNOWN_HOST_INJECTED, probeMcp = false) {
82
+ export async function collectDoctorReport(homeDir = homedir(), knownHostInjected = DEFAULT_KNOWN_HOST_INJECTED, probeMcp = false, workspaceDir) {
80
83
  const probeList = [
81
84
  { agent: "claude-code", run: () => claudeCodeProbe.probe(homeDir, { probeMcp }) },
82
85
  { agent: "codex", run: () => codexProbe.probe(homeDir, { probeMcp }) },
@@ -103,15 +106,24 @@ export async function collectDoctorReport(homeDir = homedir(), knownHostInjected
103
106
  });
104
107
  let canonical;
105
108
  try {
106
- canonical = loadCanonicalSource(homeDir);
109
+ // Workspace-merged when a layer is in play — doctor's attribution is
110
+ // the merged view, and a broken workspace config degrades doctor to
111
+ // the global-only view rather than failing the whole scan.
112
+ canonical = loadCanonicalSource({ homeDir, workspaceDir: workspaceDir ?? null });
107
113
  }
108
114
  catch {
109
- // Doctor remains useful as a standalone cross-agent scanner when
110
- // canonical has not been initialized yet.
115
+ try {
116
+ canonical = loadCanonicalSource(homeDir);
117
+ }
118
+ catch {
119
+ // Doctor remains useful as a standalone cross-agent scanner when
120
+ // canonical has not been initialized yet.
121
+ }
111
122
  }
123
+ const workspace = canonical?.workspace ? collectWorkspaceDoctorSection(canonical) : undefined;
112
124
  const oauth = canonical ? collectOAuthHealth(homeDir, canonical) : [];
113
125
  findings.push(...detectDuplication(snapshots), ...detectCollisions(snapshots, knownHostInjected), ...detectCrossAgentDrift(snapshots), ...detectCaseMismatches(snapshots), ...detectParseDiagnostics(snapshots), ...(canonical ? detectRuntimeDrift(snapshots, canonical) : []), ...detectMcpUnreachable(snapshots), ...oauth.flatMap((entry) => (entry.status === "valid" ? [] : [oauthFinding(entry)])));
114
- return { snapshots, findings, ...(oauth.length > 0 ? { oauth } : {}) };
126
+ return { snapshots, findings, ...(oauth.length > 0 ? { oauth } : {}), ...(workspace ? { workspace } : {}) };
115
127
  }
116
128
  /**
117
129
  * Read-only OAuth credential health (trellis-mcp-oauth-renew design.md
@@ -362,4 +374,29 @@ export function printReport(report) {
362
374
  console.log(`⚠️ ${finding.message}`);
363
375
  }
364
376
  }
377
+ if (report.workspace) {
378
+ const ws = report.workspace;
379
+ console.log(`\nWorkspace layer: ${ws.root} (.agents/)`);
380
+ for (const entry of ws.mcp) {
381
+ console.log(` mcp ${entry.name} — ${entry.layer}`);
382
+ }
383
+ for (const entry of ws.skills) {
384
+ console.log(` skill ${entry.name} — ${entry.layer}`);
385
+ }
386
+ if (ws.shadowed.length > 0) {
387
+ console.log(` shadowed by workspace: ${ws.shadowed.join(", ")}`);
388
+ }
389
+ if (ws.tombstoned.length > 0) {
390
+ console.log(` tombstoned (disabled for this project): ${ws.tombstoned.join(", ")}`);
391
+ }
392
+ for (const ref of ws.unresolvedVars) {
393
+ console.log(` ⚠ ${ref.server} ${ref.field}: \${${ref.name}} does not resolve under the current secrets policy`);
394
+ }
395
+ for (const hint of ws.unmigrated) {
396
+ console.log(` ⚠ ${hint.path} holds ${hint.entries.length} entr${hint.entries.length === 1 ? "y" : "ies"} Trellis didn't render (${hint.entries.join(", ")}) — run \`trellis migrate --workspace\``);
397
+ }
398
+ for (const warning of ws.precedenceWarnings) {
399
+ console.log(` ⚠ ${warning}`);
400
+ }
401
+ }
365
402
  }
@@ -12,6 +12,12 @@ export interface RunInitOptions {
12
12
  /** Defaults to the real `~`; overridable for tests only — same seam
13
13
  * every other Trellis entry point uses. */
14
14
  homeDir?: string;
15
+ /** `--workspace`: scaffold `.agents/` in the project instead of the
16
+ * global `~/.trellis/` (trellis-workspace-scope task 3.3). */
17
+ workspace?: boolean;
18
+ /** Workspace scaffold target — the test/sandbox seam. CLI discovery is
19
+ * simply `process.cwd()`. */
20
+ workspaceDir?: string;
15
21
  }
16
22
  export interface InitFileResult {
17
23
  path: string;
@@ -18,6 +18,7 @@ import * as piProbe from "../probes/pi.js";
18
18
  import * as kimiCodeProbe from "../probes/kimi-code.js";
19
19
  import * as zcodeProbe from "../probes/zcode.js";
20
20
  import { ALL_AGENTS } from "../core/types.js";
21
+ import { scaffoldWorkspace } from "../lib/workspaceConfig.js";
21
22
  /** Exported so `migrate` can byte-compare canonical `agents.md` against
22
23
  * this exact string (design.md D3, trellis-cli-migrate) rather than
23
24
  * retyping an equivalent one that could silently drift from this. */
@@ -148,6 +149,29 @@ export async function collectInitReport(homeDir = homedir(), options = {}) {
148
149
  return { files, agents };
149
150
  }
150
151
  export async function runInit(opts = {}) {
152
+ if (opts.workspace) {
153
+ const dir = opts.workspaceDir ?? process.cwd();
154
+ const files = scaffoldWorkspace(dir);
155
+ if (opts.json) {
156
+ console.log(JSON.stringify({ workspace: dir, files }, null, 2));
157
+ }
158
+ else {
159
+ const created = files.filter((f) => f.action === "create");
160
+ if (created.length === 0) {
161
+ console.log(`${dir}/.agents already fully initialized — nothing to create.`);
162
+ }
163
+ else {
164
+ console.log(`Created ${created.length} file(s) under ${dir}/.agents:`);
165
+ for (const f of created)
166
+ console.log(` - ${f.path}`);
167
+ }
168
+ for (const f of files.filter((f) => f.action === "already-present")) {
169
+ console.log(` - ${f.path} (already present, left untouched)`);
170
+ }
171
+ console.log("\nNext: commit .agents/ to share it, then `trellis sync` renders per-agent project files.");
172
+ }
173
+ return { exitCode: 0 };
174
+ }
151
175
  const homeDir = opts.homeDir ?? homedir();
152
176
  const report = await collectInitReport(homeDir);
153
177
  if (opts.json) {