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
@@ -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)) {
@@ -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";
@@ -23,6 +24,7 @@ import { collectMemoryList, runMemoryExtraction, runMemorySync } from "./command
23
24
  import { parseManageArgs, runManage } from "./commands/manage.js";
24
25
  import { runKimi } from "./commands/kimi.js";
25
26
  import { TRELLIS_VERSION } from "./lib/cliMetadata.js";
27
+ import { flagValue } from "./lib/cliArgs.js";
26
28
  import { parseSyncArgs } from "./lib/syncArgs.js";
27
29
  const KNOWN_COMMANDS = ["onboard", "init", "migrate", "doctor", "sync", "mcp", "mcp-gateway", "mcp-runtime", "skill", "add", "update", "memory", "manage", "kimi", "secrets", "rollback"];
28
30
  function printUsage() {
@@ -74,6 +76,7 @@ Commands:
74
76
  (never overwrites an existing file — fills in only what's
75
77
  missing) and prints which agents are present
76
78
  --json machine-readable output, no report text
79
+ --workspace scaffold .agents/ in the current project instead
77
80
  migrate --from <agent>
78
81
  Import an existing agent's real skills/instructions/MCP/memory
79
82
  servers into canonical source (claude-code | codex | kiro |
@@ -84,6 +87,18 @@ Commands:
84
87
  restrict to one category
85
88
  --dry-run preview the plan, write nothing
86
89
  --json machine-readable output, no report text
90
+ migrate --workspace
91
+ Absorb the repo's existing project-level agent config (all six
92
+ agents' MCP files + root AGENTS.md) into .agents/, then
93
+ re-render. Plaintext secrets are intercepted into the global
94
+ secret store as \${VAR} references, never written under
95
+ .agents/; *.local.* files are listed, never read.
96
+ --choose name=agentId pick a side for a cross-agent conflict
97
+ --dedup drop|override identical-to-global entries: skip
98
+ (default) or import as an intentional
99
+ shadow
100
+ --dry-run preview the plan, write nothing
101
+ --json machine-readable output, no report text
87
102
  doctor Scan Claude Code / Codex / Kiro / pi for drift
88
103
  --json machine-readable output, no table text
89
104
  --probe-mcp also handshake every configured MCP server
@@ -123,6 +138,7 @@ Commands:
123
138
  no overwrite): --command <cmd> [--args a,b] (stdio) or
124
139
  --url <url> (http/sse); [--headers k=v,...]
125
140
  [--auth oauth] [--env NAME,...] [--static-env k=v,...] [--agents id,...]
141
+ [--workspace] write to .agents/config.json instead of servers.yaml
126
142
  [--enabled true|false]
127
143
  --dry-run preview the plan, write nothing
128
144
  --json machine-readable output, no report text
@@ -178,6 +194,9 @@ Commands:
178
194
  Remove a canonical MCP server, then run the same sync as
179
195
  mcp sync so every managed agent's Trellis-owned entry for
180
196
  it is removed too
197
+ --workspace remove from .agents/config.json instead of
198
+ servers.yaml (a shadowed global entry takes
199
+ effect again, it is not deleted)
181
200
  --dry-run preview the plan, write nothing
182
201
  --json machine-readable output, no report text
183
202
  skill list
@@ -186,6 +205,7 @@ Commands:
186
205
  skill add <name> --from <path>
187
206
  Import a real skill directory into canonical (refuses on
188
207
  an existing name with different content, no overwrite)
208
+ --workspace copy into .agents/skills/ instead of canonical
189
209
  --dry-run preview the plan, write nothing
190
210
  --json machine-readable output, no report text
191
211
  skill remove <name>
@@ -309,31 +329,15 @@ async function main(argv) {
309
329
  return;
310
330
  }
311
331
  if (command === "onboard") {
312
- const agentIndex = rest.indexOf("--agent");
313
- const agent = agentIndex >= 0 ? rest[agentIndex + 1] : undefined;
314
- const manageIndex = rest.indexOf("--manage");
315
- const manage = manageIndex >= 0 ? rest[manageIndex + 1] : undefined;
316
- const mcpModeIndex = rest.indexOf("--mcp-mode");
317
- const mcpMode = mcpModeIndex >= 0 ? rest[mcpModeIndex + 1] : undefined;
318
- const hubUrlIndex = rest.indexOf("--hub-url");
319
- const hubUrl = hubUrlIndex >= 0 ? rest[hubUrlIndex + 1] : undefined;
320
- const gatewayAgentsIndex = rest.indexOf("--gateway-agents");
321
- const gatewayAgents = gatewayAgentsIndex >= 0 ? rest[gatewayAgentsIndex + 1] : undefined;
322
- const memoryIndex = rest.indexOf("--memory");
323
- const memory = memoryIndex >= 0 ? rest[memoryIndex + 1] : undefined;
324
- const memoryMigrateIndex = rest.indexOf("--memory-migrate");
325
- const memoryMigrate = memoryMigrateIndex >= 0 ? rest[memoryMigrateIndex + 1] : undefined;
326
- const selectionIndex = rest.indexOf("--selection");
327
- const selectionFile = selectionIndex >= 0 ? rest[selectionIndex + 1] : undefined;
328
332
  const { exitCode } = await runOnboard({
329
- agent,
330
- manage,
331
- mcpMode,
332
- hubUrl,
333
- gatewayAgents,
334
- memory,
335
- memoryMigrate,
336
- selectionFile,
333
+ agent: flagValue(rest, "--agent"),
334
+ manage: flagValue(rest, "--manage"),
335
+ mcpMode: flagValue(rest, "--mcp-mode"),
336
+ hubUrl: flagValue(rest, "--hub-url"),
337
+ gatewayAgents: flagValue(rest, "--gateway-agents"),
338
+ memory: flagValue(rest, "--memory"),
339
+ memoryMigrate: flagValue(rest, "--memory-migrate"),
340
+ selectionFile: flagValue(rest, "--selection"),
337
341
  dryRun: rest.includes("--dry-run"),
338
342
  json: rest.includes("--json"),
339
343
  });
@@ -341,16 +345,28 @@ async function main(argv) {
341
345
  return;
342
346
  }
343
347
  if (command === "init") {
344
- const { exitCode } = await runInit({ json: rest.includes("--json") });
348
+ const { exitCode } = await runInit({ json: rest.includes("--json"), workspace: rest.includes("--workspace") });
345
349
  process.exitCode = exitCode;
346
350
  return;
347
351
  }
348
352
  if (command === "migrate") {
349
- const fromIndex = rest.indexOf("--from");
350
- const from = fromIndex >= 0 ? rest[fromIndex + 1] : undefined;
351
- const onlyIndex = rest.indexOf("--only");
352
- const only = onlyIndex >= 0 ? rest[onlyIndex + 1] : undefined;
353
- const { exitCode } = await runMigrate({ from, only, dryRun: rest.includes("--dry-run"), json: rest.includes("--json") });
353
+ if (rest.includes("--workspace")) {
354
+ // Repeated `--choose name=agentId` flags collect into an array.
355
+ const choose = [];
356
+ for (let i = 0; i < rest.length; i++) {
357
+ if (rest[i] === "--choose" && rest[i + 1] !== undefined)
358
+ choose.push(rest[i + 1]);
359
+ }
360
+ const { exitCode } = await runMigrateWorkspace({
361
+ choose,
362
+ dedup: flagValue(rest, "--dedup"),
363
+ dryRun: rest.includes("--dry-run"),
364
+ json: rest.includes("--json"),
365
+ });
366
+ process.exitCode = exitCode;
367
+ return;
368
+ }
369
+ const { exitCode } = await runMigrate({ from: flagValue(rest, "--from"), only: flagValue(rest, "--only"), dryRun: rest.includes("--dry-run"), json: rest.includes("--json") });
354
370
  process.exitCode = exitCode;
355
371
  return;
356
372
  }
@@ -383,7 +399,7 @@ async function main(argv) {
383
399
  process.exitCode = 1;
384
400
  return;
385
401
  }
386
- const { exitCode } = await runSync({ target, json: rest.includes("--json"), dryRun: rest.includes("--dry-run") });
402
+ const { exitCode } = await runSync({ target, json: rest.includes("--json"), dryRun: rest.includes("--dry-run"), check: rest.includes("--check"), globalOnly: rest.includes("--global-only") });
387
403
  process.exitCode = exitCode;
388
404
  return;
389
405
  }
@@ -443,7 +459,7 @@ async function main(argv) {
443
459
  const json = mcpRest.includes("--json");
444
460
  const dryRun = mcpRest.includes("--dry-run");
445
461
  if (subcommand === "sync") {
446
- const { exitCode } = await runMcpSync({ json, dryRun });
462
+ const { exitCode } = await runMcpSync({ json, dryRun, check: mcpRest.includes("--check"), globalOnly: mcpRest.includes("--global-only") });
447
463
  process.exitCode = exitCode;
448
464
  return;
449
465
  }
@@ -453,7 +469,7 @@ async function main(argv) {
453
469
  }
454
470
  if (subcommand === "add") {
455
471
  const [name] = mcpRest;
456
- process.exitCode = (await runMcpAdd(name, parseMcpAddArgs(mcpRest), { json, dryRun })).exitCode;
472
+ process.exitCode = (await runMcpAdd(name, parseMcpAddArgs(mcpRest), { json, dryRun, workspace: mcpRest.includes("--workspace") })).exitCode;
457
473
  return;
458
474
  }
459
475
  if (subcommand === "import") {
@@ -478,7 +494,7 @@ async function main(argv) {
478
494
  process.exitCode = 1;
479
495
  return;
480
496
  }
481
- process.exitCode = (await runMcpRemove(name, { json, dryRun })).exitCode;
497
+ process.exitCode = (await runMcpRemove(name, { json, dryRun, workspace: mcpRest.includes("--workspace") })).exitCode;
482
498
  return;
483
499
  }
484
500
  if (subcommand === "renew") {
@@ -511,14 +527,13 @@ async function main(argv) {
511
527
  }
512
528
  if (subcommand === "add") {
513
529
  const [name] = skillRest;
514
- const fromIndex = skillRest.indexOf("--from");
515
- const from = fromIndex >= 0 ? skillRest[fromIndex + 1] : undefined;
530
+ const from = flagValue(skillRest, "--from");
516
531
  if (!name || !from) {
517
532
  console.error("Usage: trellis skill add <name> --from <path>");
518
533
  process.exitCode = 1;
519
534
  return;
520
535
  }
521
- process.exitCode = (await runSkillAdd(name, from, { json, dryRun })).exitCode;
536
+ process.exitCode = (await runSkillAdd(name, from, { json, dryRun, workspace: skillRest.includes("--workspace") })).exitCode;
522
537
  return;
523
538
  }
524
539
  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) {