agent-trellis 0.20.0 → 0.22.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 (54) hide show
  1. package/dist/adapters/claude-code.js +2 -2
  2. package/dist/adapters/codex.js +3 -3
  3. package/dist/adapters/jsonMcp.d.ts +1 -1
  4. package/dist/adapters/jsonMcp.js +2 -2
  5. package/dist/adapters/kimi-code.js +2 -2
  6. package/dist/adapters/kiro.js +11 -9
  7. package/dist/adapters/mcpPlan.d.ts +3 -1
  8. package/dist/adapters/mcpPlan.js +65 -15
  9. package/dist/adapters/pi.js +5 -5
  10. package/dist/adapters/zcode.d.ts +1 -1
  11. package/dist/adapters/zcode.js +6 -6
  12. package/dist/cli.js +9 -6
  13. package/dist/commands/doctor.d.ts +1 -1
  14. package/dist/commands/doctor.js +5 -5
  15. package/dist/commands/mcp.js +1 -1
  16. package/dist/commands/mcpAuth.js +1 -1
  17. package/dist/commands/mcpGateway.d.ts +2 -2
  18. package/dist/commands/mcpGateway.js +4 -4
  19. package/dist/commands/mcpImport.js +2 -1
  20. package/dist/commands/migrate.js +11 -1
  21. package/dist/commands/remoteSkill.js +18 -1
  22. package/dist/commands/secretsAudit.d.ts +1 -1
  23. package/dist/commands/secretsAudit.js +49 -13
  24. package/dist/commands/skill.d.ts +11 -6
  25. package/dist/commands/skill.js +85 -32
  26. package/dist/commands/sync.d.ts +1 -0
  27. package/dist/commands/sync.js +3 -1
  28. package/dist/core/canonical.d.ts +1 -2
  29. package/dist/core/canonical.js +16 -9
  30. package/dist/core/types.d.ts +8 -7
  31. package/dist/lib/envVarNames.d.ts +5 -1
  32. package/dist/lib/envVarNames.js +22 -2
  33. package/dist/lib/mcpConnect.d.ts +1 -1
  34. package/dist/lib/mcpConnect.js +35 -10
  35. package/dist/lib/mcpMigrateRead.js +14 -2
  36. package/dist/lib/secretEnv.d.ts +57 -8
  37. package/dist/lib/secretEnv.js +108 -16
  38. package/dist/lib/skillScope.d.ts +14 -0
  39. package/dist/lib/skillScope.js +40 -0
  40. package/dist/lib/tomlSection.js +3 -2
  41. package/dist/lib/workspace.js +7 -2
  42. package/dist/lib/workspaceDoctor.d.ts +1 -1
  43. package/dist/lib/workspaceDoctor.js +8 -4
  44. package/dist/lib/workspaceMcp.d.ts +1 -1
  45. package/dist/lib/workspaceMcp.js +5 -5
  46. package/dist/pi-bridge/bundle.js +42424 -2793
  47. package/dist/pi-bridge/index.js +2 -2
  48. package/docs/architecture.md +27 -7
  49. package/docs/getting-started.md +29 -9
  50. package/docs/research.md +17 -1
  51. package/package.json +2 -1
  52. package/schema/scope.example.yaml +15 -3
  53. package/schema/secrets.policy.example.yaml +5 -0
  54. package/schema/servers.example.yaml +11 -1
@@ -48,10 +48,10 @@ export class ClaudeCodeAdapter {
48
48
  canonicalRoot,
49
49
  kind: "instructions",
50
50
  });
51
- const mcpItems = this.planMcp(canonical);
51
+ const mcpItems = await this.planMcp(canonical);
52
52
  return [...skillItems, ...instructionsItems, ...mcpItems];
53
53
  }
54
- planMcp(canonical) {
54
+ async planMcp(canonical) {
55
55
  const configPath = join(this.homeDir, ".claude.json");
56
56
  const parsed = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : undefined;
57
57
  const ownership = ownedByAgent(loadMcpOwnership(this.homeDir), this.id);
@@ -50,7 +50,7 @@ export class CodexAdapter {
50
50
  canonicalRoot: join(canonicalRoot, "skills"),
51
51
  kind: "skill",
52
52
  });
53
- const mcpItems = this.planMcp(canonical);
53
+ const mcpItems = await this.planMcp(canonical);
54
54
  const configuredInstructionsPath = readInstructionsPath(join(this.homeDir, ".codex", "config.toml"));
55
55
  if (!configuredInstructionsPath) {
56
56
  // Codex has no `instructions` key set — not Trellis's to decide.
@@ -65,10 +65,10 @@ export class CodexAdapter {
65
65
  });
66
66
  return [...skillItems, ...instructionsItems, ...mcpItems];
67
67
  }
68
- planMcp(canonical) {
68
+ async planMcp(canonical) {
69
69
  const configTomlPath = join(this.homeDir, ".codex", "config.toml");
70
70
  const content = existsSync(configTomlPath) ? readFileSync(configTomlPath, "utf-8") : "";
71
- const { desired, conflicts } = resolveMcpPlan(this.id, globalLayerMcp(canonical), canonical.managedAgents, canonical.secretsPolicy);
71
+ const { desired, conflicts } = await resolveMcpPlan(this.id, globalLayerMcp(canonical), canonical.managedAgents, canonical.secretsPolicy);
72
72
  const items = [];
73
73
  for (const { name, def } of desired) {
74
74
  const current = currentServerSectionText(content, name);
@@ -29,7 +29,7 @@ export declare function planJsonMcp(opts: {
29
29
  * zero removal candidates. */
30
30
  ownership?: Record<string, unknown>;
31
31
  render?: JsonMcpEntryRenderer;
32
- }): AdapterPlanItem[];
32
+ }): Promise<AdapterPlanItem[]>;
33
33
  /** Merges every `"create"` MCP item into `parsed` (or a fresh `{}` if the
34
34
  * file didn't exist) and deletes every `"remove"` item's key, returning
35
35
  * the object to stringify — every sibling top-level key on `parsed` is
@@ -30,10 +30,10 @@ export function renderJsonServerEntry(def) {
30
30
  }
31
31
  return entry;
32
32
  }
33
- export function planJsonMcp(opts) {
33
+ export async function planJsonMcp(opts) {
34
34
  const { configPath, parsed, mcp, agentId, managedAgents, policy, ownership } = opts;
35
35
  const render = opts.render ?? ((def, _name) => renderJsonServerEntry(def));
36
- const { desired, conflicts } = resolveMcpPlan(agentId, mcp, managedAgents, policy);
36
+ const { desired, conflicts } = await resolveMcpPlan(agentId, mcp, managedAgents, policy);
37
37
  const existingServers = parsed?.mcpServers ?? {};
38
38
  const items = [];
39
39
  for (const { name, def } of desired) {
@@ -63,7 +63,7 @@ export class KimiCodeAdapter {
63
63
  const configPath = join(this.homeDir, ".kimi-code", "mcp.json");
64
64
  const parsed = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : undefined;
65
65
  const ownership = ownedByAgent(loadMcpOwnership(this.homeDir), this.id);
66
- items.push(...planJsonMcp({
66
+ items.push(...(await planJsonMcp({
67
67
  configPath,
68
68
  parsed,
69
69
  mcp: globalLayerMcp(canonical),
@@ -72,7 +72,7 @@ export class KimiCodeAdapter {
72
72
  policy: canonical.secretsPolicy,
73
73
  ownership,
74
74
  render: renderKimiServerEntry,
75
- }));
75
+ })));
76
76
  return items;
77
77
  }
78
78
  async apply(plan, backup) {
@@ -18,6 +18,7 @@
18
18
  import { existsSync, mkdirSync, readFileSync } from "node:fs";
19
19
  import { dirname, join } from "node:path";
20
20
  import { homedir } from "node:os";
21
+ import { deliveredName } from "../lib/secretEnv.js";
21
22
  import { isInScope } from "../core/adapter.js";
22
23
  import { usesNativeCapabilityDelivery } from "../core/types.js";
23
24
  import * as kiroProbe from "../probes/kiro.js";
@@ -64,11 +65,11 @@ export class KiroAdapter {
64
65
  canonicalRoot,
65
66
  kind: "instructions",
66
67
  });
67
- const mcpItems = this.planMcp(canonical);
68
- const approvedEnvVarsItems = this.planApprovedEnvVars(canonical);
68
+ const mcpItems = await this.planMcp(canonical);
69
+ const approvedEnvVarsItems = await this.planApprovedEnvVars(canonical);
69
70
  return [...skillItems, ...instructionsItems, ...mcpItems, ...approvedEnvVarsItems];
70
71
  }
71
- planMcp(canonical) {
72
+ async planMcp(canonical) {
72
73
  const configPath = join(this.homeDir, ".kiro", "settings", "mcp.json");
73
74
  const parsed = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : undefined;
74
75
  const ownership = ownedByAgent(loadMcpOwnership(this.homeDir), this.id);
@@ -77,17 +78,18 @@ export class KiroAdapter {
77
78
  /** Names come from `resolveMcpPlan`, not a raw scan of `canonical.mcp.servers`
78
79
  * (design.md D2) — a server scoped away from Kiro, or refused for a
79
80
  * known_host_injected collision, never contributes a name here either. */
80
- desiredApprovedEnvVars(canonical) {
81
- const { desired } = resolveMcpPlan(this.id, globalLayerMcp(canonical), canonical.managedAgents, canonical.secretsPolicy);
81
+ async desiredApprovedEnvVars(canonical) {
82
+ const { desired } = await resolveMcpPlan(this.id, globalLayerMcp(canonical), canonical.managedAgents, canonical.secretsPolicy);
82
83
  const names = new Set();
83
84
  for (const { def } of desired) {
85
+ // Approved list holds DELIVERED names (what renderers write), not raw entries.
84
86
  for (const name of declaredEnvNames(def))
85
- names.add(name);
87
+ names.add(deliveredName(name));
86
88
  }
87
89
  return [...names];
88
90
  }
89
- planApprovedEnvVars(canonical) {
90
- const desired = this.desiredApprovedEnvVars(canonical);
91
+ async planApprovedEnvVars(canonical) {
92
+ const desired = await this.desiredApprovedEnvVars(canonical);
91
93
  if (desired.length === 0) {
92
94
  return [];
93
95
  }
@@ -188,7 +190,7 @@ export class KiroAdapter {
188
190
  return [];
189
191
  }
190
192
  })();
191
- for (const name of this.desiredApprovedEnvVars(canonical)) {
193
+ for (const name of await this.desiredApprovedEnvVars(canonical)) {
192
194
  if (!approved.includes(name)) {
193
195
  mismatches.push(`env var "${name}" is needed by a canonical MCP server for kiro but missing from ${APPROVED_ENV_VARS_KEY}`);
194
196
  }
@@ -91,4 +91,6 @@ export interface LiteralSecretMatch {
91
91
  * as before this function reported a field at all.
92
92
  */
93
93
  export declare function findLiteralSecret(def: McpServerDef): LiteralSecretMatch | undefined;
94
- export declare function resolveMcpPlan(agentId: AgentId, mcp: McpConfig, managedAgents: readonly AgentId[], policy: SecretsPolicy): McpPlanResult;
94
+ export declare function resolveMcpPlan(agentId: AgentId, mcp: McpConfig, managedAgents: readonly AgentId[], policy: SecretsPolicy, options?: {
95
+ hosted?: boolean;
96
+ }): Promise<McpPlanResult>;
@@ -20,7 +20,8 @@ import { isInScope } from "../core/adapter.js";
20
20
  import { capabilityDeliveryForAgent } from "../core/types.js";
21
21
  import { isOAuthAuth, oauthOwner } from "../core/types.js";
22
22
  import { codexBearerTokenEnvVar } from "../lib/tomlSection.js";
23
- import { resolveSecretEnv } from "../lib/secretEnv.js";
23
+ import { deliveredName, isValidSecretVarName, parseEnvEntry, resolveSecretEntries, resolveSecretEnv, SecretReferenceError } from "../lib/secretEnv.js";
24
+ import { extractTemplateVarNames, invalidHeaderTemplate } from "../lib/envVarNames.js";
24
25
  export const HUB_ENTRY_NAME = "trellis-hub";
25
26
  export const GATEWAY_ENTRY_NAME = "trellis";
26
27
  export const LEGACY_GATEWAY_ENTRY_NAME = "trellis-gateway";
@@ -138,14 +139,38 @@ export function findLiteralSecret(def) {
138
139
  * the target key it would have been written under
139
140
  * (trellis-migrate-env-var-alias).
140
141
  */
141
- function findUnresolvedEnvName(def, policy) {
142
- const names = [...(def.env ?? []), ...Object.values(def.envAliases ?? {})];
142
+ async function findUnresolvedEnvName(def, policy) {
143
+ const headerRefs = Object.values(def.headers ?? {}).flatMap(extractTemplateVarNames)
144
+ .filter((entry) => !isValidSecretVarName(entry));
145
+ const envNames = [...(def.env ?? []), ...Object.values(def.envAliases ?? {})];
146
+ const names = [...envNames, ...headerRefs];
143
147
  if (names.length === 0)
144
148
  return undefined;
145
- const resolved = resolveSecretEnv(names, policy);
146
- return names.find((name) => !resolved[name]);
149
+ // A malformed `mxenv://` entry conflicts here too — it is rejected input,
150
+ // never a silently-resolved bare name (mx-env-secret-source D6). The
151
+ // returned text is the DECLARED entry; resolution checks use delivered
152
+ // names (the `KEY` segment for a reference).
153
+ const parsed = names.map(parseEnvEntry);
154
+ const invalid = names.find((entry, i) => parsed[i].kind === "invalid" || (headerRefs.includes(entry) && parsed[i].kind === "name"));
155
+ if (invalid !== undefined)
156
+ return invalid;
157
+ try {
158
+ // Retain env/envAliases' existing delivered-name collision behavior.
159
+ // Header references instead remain source-bound, with no bare-name fallback.
160
+ const env = await resolveSecretEnv(envNames, policy);
161
+ const missingEnv = envNames.find((entry) => !env[deliveredName(entry)]);
162
+ if (missingEnv !== undefined)
163
+ return missingEnv;
164
+ const headers = await resolveSecretEntries(headerRefs, policy);
165
+ return headerRefs.find((entry) => !headers[entry]);
166
+ }
167
+ catch (error) {
168
+ if (error instanceof SecretReferenceError)
169
+ return error.entry;
170
+ throw error;
171
+ }
147
172
  }
148
- export function resolveMcpPlan(agentId, mcp, managedAgents, policy) {
173
+ export async function resolveMcpPlan(agentId, mcp, managedAgents, policy, options = {}) {
149
174
  const route = routeForAgent(agentId, mcp, managedAgents);
150
175
  const runtimeEnabled = capabilityDeliveryForAgent(agentId, mcp) !== "native";
151
176
  // Checked before `hub`: only one entry can be "the" MCP entry for an
@@ -161,7 +186,7 @@ export function resolveMcpPlan(agentId, mcp, managedAgents, policy) {
161
186
  conflicts: [{ name: entryName, message: collisionMessage(entryName, agentId), remediation: collisionRemediation(entryName) }],
162
187
  };
163
188
  }
164
- const oauth = planDirectServers(agentId, mcp, managedAgents, policy, route, isAgentOwnedOAuth);
189
+ const oauth = await planDirectServers(agentId, mcp, managedAgents, policy, route, isAgentOwnedOAuth);
165
190
  // Every ordinary per-server check (enabled, scope, literal secrets,
166
191
  // unresolved env names, Codex's header shape) still runs at gateway
167
192
  // startup. OAuth servers are the deliberate exception: they are written
@@ -237,7 +262,7 @@ export function resolveMcpPlan(agentId, mcp, managedAgents, policy) {
237
262
  // Runtime mode owns the agent-facing MCP edge. It resolves the same
238
263
  // direct/gateway-compatible upstream route at startup, so native
239
264
  // per-server entries are not duplicated here.
240
- const oauth = planDirectServers(agentId, mcp, managedAgents, policy, route, isAgentOwnedOAuth);
265
+ const oauth = await planDirectServers(agentId, mcp, managedAgents, policy, route, isAgentOwnedOAuth);
241
266
  return {
242
267
  desired: [{
243
268
  name: RUNTIME_ENTRY_NAME,
@@ -246,14 +271,14 @@ export function resolveMcpPlan(agentId, mcp, managedAgents, policy) {
246
271
  conflicts: oauth.conflicts,
247
272
  };
248
273
  }
249
- const native = planDirectServers(agentId, mcp, managedAgents, policy, route);
274
+ const native = await planDirectServers(agentId, mcp, managedAgents, policy, route, undefined, options.hosted || agentId === "pi");
250
275
  const hosted = native.desired.filter(({ def }) => isOAuthMcpServer(def) && oauthOwner(def.auth) === "trellis");
251
276
  return {
252
277
  desired: native.desired.filter(({ def }) => !isOAuthMcpServer(def) || oauthOwner(def.auth) === "agent"),
253
278
  conflicts: [...native.conflicts, ...hosted.map(({ name }) => ({ name, message: `Trellis-owned OAuth server "${name}" requires gateway or runtime delivery for ${agentId}`, remediation: "enable gateway/runtime delivery or set auth.owner to agent" }))],
254
279
  };
255
280
  }
256
- function planDirectServers(agentId, mcp, managedAgents, policy, route, filter) {
281
+ async function planDirectServers(agentId, mcp, managedAgents, policy, route, filter, hosted = false) {
257
282
  const desired = [];
258
283
  const conflicts = [];
259
284
  for (const [name, def] of Object.entries(mcp.servers)) {
@@ -293,12 +318,17 @@ function planDirectServers(agentId, mcp, managedAgents, policy, route, filter) {
293
318
  });
294
319
  continue;
295
320
  }
321
+ const invalidHeader = invalidHeaderTemplate(def.headers);
322
+ if (invalidHeader !== undefined) {
323
+ conflicts.push({ name, message: `refusing to write MCP server "${name}": invalid reference template in header ${invalidHeader}`, remediation: "use ${VAR} or ${mxenv://<profile>/<KEY>} in headers" });
324
+ continue;
325
+ }
296
326
  // Codex has no generic headers concept — only the single
297
327
  // Authorization-bearer-token shape is expressible there
298
328
  // (trellis-mcp-transport-auth design.md D4). Any other shape is
299
329
  // refused for Codex specifically; every other in-scope agent for
300
330
  // the same server is unaffected (this loop runs once per agentId).
301
- if (agentId === "codex" && def.headers && Object.keys(def.headers).length > 0 && !codexBearerTokenEnvVar(def)) {
331
+ if (!hosted && agentId === "codex" && def.headers && Object.keys(def.headers).length > 0 && !codexBearerTokenEnvVar(def)) {
302
332
  conflicts.push({
303
333
  name,
304
334
  message: `refusing to write MCP server "${name}" for codex: its "headers" field isn't the single { Authorization: "Bearer \${VAR}" } shape Codex's own config format can express — Codex has no generic headers concept, only \`bearer_token_env_var\`. Still written normally for every other in-scope agent.`,
@@ -306,13 +336,33 @@ function planDirectServers(agentId, mcp, managedAgents, policy, route, filter) {
306
336
  });
307
337
  continue;
308
338
  }
309
- const unresolvedName = findUnresolvedEnvName(def, policy);
310
- if (unresolvedName) {
311
- const source = policy.envFile ?? "process environment";
339
+ const unresolvedName = await findUnresolvedEnvName(def, policy);
340
+ if (unresolvedName !== undefined) {
341
+ const entry = parseEnvEntry(unresolvedName);
342
+ if (entry.kind === "invalid" || !isValidSecretVarName(unresolvedName) && entry.kind === "name") {
343
+ conflicts.push({
344
+ name,
345
+ message: `refusing to write MCP server "${name}": its declared env entry "${unresolvedName}" is not a valid mxenv://<profile>/<KEY> reference (mx-env-secret-source)`,
346
+ remediation: `fix the entry in servers.yaml to the mxenv://<profile>/<KEY> form or a plain variable name, then re-run mcp sync`,
347
+ });
348
+ continue;
349
+ }
350
+ const source = entry.kind === "ref"
351
+ ? `the mxenv composition (profile "${entry.profile}")`
352
+ : (policy.envFile ?? "process environment");
353
+ const remediationTarget = entry.kind === "ref" ? entry.key : unresolvedName;
312
354
  conflicts.push({
313
355
  name,
314
356
  message: `refusing to write MCP server "${name}": its declared env var "${unresolvedName}" has no resolvable value in ${source} — writing it now would silently break this server's connection once the agent starts it (trellis-mcp-static-env-and-disabled-servers)`,
315
- remediation: `set "${unresolvedName}" in ${source}, or fix the name in servers.yaml if it was a typo, then re-run mcp sync`,
357
+ remediation: `set "${remediationTarget}" in ${source}, or fix the name in servers.yaml if it was a typo, then re-run mcp sync`,
358
+ });
359
+ continue;
360
+ }
361
+ if (!hosted && Object.values(def.headers ?? {}).flatMap(extractTemplateVarNames).some((entry) => parseEnvEntry(entry).kind === "ref")) {
362
+ conflicts.push({
363
+ name,
364
+ message: `refusing to write MCP server "${name}": mxenv header references require Trellis gateway/runtime delivery; native clients cannot resolve them`,
365
+ remediation: `enable gateway/runtime delivery for ${agentId}, keeping transport and headers in servers.yaml`,
316
366
  });
317
367
  continue;
318
368
  }
@@ -114,7 +114,7 @@ export class PiAdapter {
114
114
  kind: "instructions",
115
115
  });
116
116
  if (supportsNativeMcp(this.versionProbe())) {
117
- return [...skillItems, ...instructionsItems, ...this.planNativeMcp(canonical)];
117
+ return [...skillItems, ...instructionsItems, ...await this.planNativeMcp(canonical)];
118
118
  }
119
119
  const { file: bridgeFile, symlinkName } = resolveBridgeFile();
120
120
  const extensionItems = planSymlinks({
@@ -139,12 +139,12 @@ export class PiAdapter {
139
139
  * apply, and a bare skills/instructions sync can neither remove the
140
140
  * bridge early nor resurrect it afterwards.
141
141
  */
142
- planNativeMcp(canonical) {
142
+ async planNativeMcp(canonical) {
143
143
  const agentDir = join(this.homeDir, ".pi", "agent");
144
144
  const configPath = join(agentDir, "mcp.json");
145
145
  const parsed = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : undefined;
146
146
  const ownership = ownedByAgent(loadMcpOwnership(this.homeDir), this.id);
147
- const items = planJsonMcp({
147
+ const items = (await planJsonMcp({
148
148
  configPath,
149
149
  parsed,
150
150
  mcp: globalLayerMcp(canonical),
@@ -153,7 +153,7 @@ export class PiAdapter {
153
153
  policy: canonical.secretsPolicy,
154
154
  ownership,
155
155
  render: piRenderer,
156
- }).map((item) => {
156
+ })).map((item) => {
157
157
  // pi speaks stdio and streamable HTTP only — an sse server gets an
158
158
  // honest conflict instead of an entry pi could never connect.
159
159
  const write = item.mcpWrite;
@@ -251,7 +251,7 @@ export class PiAdapter {
251
251
  // definition drift. The plan only ever names Trellis-owned entries
252
252
  // (the ownership ledger gates removals), so user-authored servers
253
253
  // can never surface here.
254
- for (const item of this.planNativeMcp(canonical)) {
254
+ for (const item of await this.planNativeMcp(canonical)) {
255
255
  if (item.action === "create" && item.mcpWrite) {
256
256
  mismatches.push(`mcp.json entry "${item.mcpWrite.name}" is missing or differs from canonical — run trellis mcp sync`);
257
257
  }
@@ -16,7 +16,7 @@ export declare function planZcodeMcp(opts: {
16
16
  managedAgents: readonly import("../core/types.js").AgentId[];
17
17
  policy: SecretsPolicy;
18
18
  ownership: JsonRecord;
19
- }): AdapterPlanItem[];
19
+ }): Promise<AdapterPlanItem[]>;
20
20
  export declare function applyZcodeMcp(parsed: JsonRecord | undefined, items: readonly AdapterPlanItem[]): JsonRecord;
21
21
  export declare class ZcodeAdapter implements TrellisAdapter {
22
22
  private readonly homeDir;
@@ -29,8 +29,8 @@ function record(value) {
29
29
  function zcodeServers(parsed) {
30
30
  return record(record(parsed?.mcp).servers);
31
31
  }
32
- export function planZcodeMcp(opts) {
33
- const { desired, conflicts } = resolveMcpPlan("zcode", opts.mcp, opts.managedAgents, opts.policy);
32
+ export async function planZcodeMcp(opts) {
33
+ const { desired, conflicts } = await resolveMcpPlan("zcode", opts.mcp, opts.managedAgents, opts.policy);
34
34
  const existing = zcodeServers(opts.parsed);
35
35
  const items = [];
36
36
  for (const { name, def } of desired) {
@@ -136,14 +136,14 @@ export class ZcodeAdapter {
136
136
  canonicalRoot,
137
137
  kind: "instructions",
138
138
  }));
139
- items.push(...planZcodeMcp({
139
+ items.push(...(await planZcodeMcp({
140
140
  configPath: profile.configPath,
141
141
  parsed,
142
142
  mcp: globalLayerMcp(canonical),
143
143
  managedAgents: canonical.managedAgents,
144
144
  policy: canonical.secretsPolicy,
145
145
  ownership: ownedByAgent(loadMcpOwnership(this.homeDir), this.id),
146
- }));
146
+ })));
147
147
  const current = currentSkillControls(parsed);
148
148
  const ownership = loadZcodeSettingsOwnership(this.homeDir);
149
149
  if (runtimeOnly && (current.featuresSkill !== false || current.skillsEnabled !== false)) {
@@ -214,14 +214,14 @@ export class ZcodeAdapter {
214
214
  for (const name of desiredNativeSkills)
215
215
  if (!actualNativeSkills.has(name))
216
216
  mismatches.push(`skill "${name}" is missing on ZCode`);
217
- const expectedMcp = planZcodeMcp({
217
+ const expectedMcp = (await planZcodeMcp({
218
218
  configPath: profile.configPath,
219
219
  parsed: readConfig(profile.configPath),
220
220
  mcp: globalLayerMcp(canonical),
221
221
  managedAgents: canonical.managedAgents,
222
222
  policy: canonical.secretsPolicy,
223
223
  ownership: ownedByAgent(loadMcpOwnership(this.homeDir), this.id),
224
- }).filter((item) => item.action === "create" || item.action === "conflict");
224
+ })).filter((item) => item.action === "create" || item.action === "conflict");
225
225
  mismatches.push(...expectedMcp.map((item) => item.description));
226
226
  if (!snapshot.instructionsFile?.isSymlink || snapshot.instructionsFile.target !== canonical.instructionsFile)
227
227
  mismatches.push("shared instructions are not linked into ~/.zcode/AGENTS.md");
package/dist/cli.js CHANGED
@@ -118,7 +118,7 @@ Commands:
118
118
  --skill, -s <name> required unless --list
119
119
  --branch <ref> Git branch, tag, or ref; default is
120
120
  the source repository's default branch
121
- --agent, -a <ids|*> restrict delivery to managed Agent ids
121
+ --agent, -a <ids|*|none> choose applicable managed Agents
122
122
  --list show discovered Skills without import
123
123
  --global, --yes accepted skills-CLI compatibility flags
124
124
  --dry-run fetch and preview; write nothing
@@ -205,19 +205,22 @@ Commands:
205
205
  --dry-run preview the plan, write nothing
206
206
  --json machine-readable output, no report text
207
207
  skill list
208
- List canonical skills with resolved scope
208
+ List canonical skills; unregistered skills are paused
209
+ --agent <id> filter by agent and show actual delivery status
209
210
  --json machine-readable output, no report text
210
211
  skill add <name> --from <path>
211
212
  Import a real skill directory into canonical (refuses on
212
213
  an existing name with different content, no overwrite)
213
214
  --workspace copy into .agents/skills/ instead of canonical
215
+ --agents <ids> | --all | --none
216
+ required without an interactive terminal
214
217
  --dry-run preview the plan, write nothing
215
218
  --json machine-readable output, no report text
216
219
  skill remove <name>
217
220
  skill scope <name> --agents <ids> | --all | --none
218
221
  Choose which managed agents receive a canonical skill, then
219
- sync it. Same selector rules as mcp scope; a built-in skill's
220
- scope is not editable
222
+ sync it. --all records today's managed set; newly managed
223
+ agents need explicit registration. Built-in scope is not editable
221
224
  --dry-run preview the plan, write nothing
222
225
  --json machine-readable output, no report text
223
226
  skill update-builtin
@@ -527,7 +530,7 @@ async function main(argv) {
527
530
  const json = skillRest.includes("--json");
528
531
  const dryRun = skillRest.includes("--dry-run");
529
532
  if (subcommand === "list") {
530
- process.exitCode = runSkillList({ json }).exitCode;
533
+ process.exitCode = runSkillList({ json, agent: flagValue(skillRest, "--agent") }).exitCode;
531
534
  return;
532
535
  }
533
536
  if (subcommand === "add") {
@@ -538,7 +541,7 @@ async function main(argv) {
538
541
  process.exitCode = 1;
539
542
  return;
540
543
  }
541
- process.exitCode = (await runSkillAdd(name, from, { json, dryRun, workspace: skillRest.includes("--workspace") })).exitCode;
544
+ process.exitCode = (await runSkillAdd(name, from, { json, dryRun, workspace: skillRest.includes("--workspace"), selection: parseScopeArgs(skillRest) })).exitCode;
542
545
  return;
543
546
  }
544
547
  if (subcommand === "remove") {
@@ -137,7 +137,7 @@ export declare function detectMcpUnreachable(snapshots: AgentSnapshot[]): Findin
137
137
  /** Compares runtime entries against the same canonical MCP plan that sync
138
138
  * uses. Only missing Trellis-owned runtime edges are reported; extra
139
139
  * user-authored MCP entries remain outside doctor ownership. */
140
- export declare function detectRuntimeDrift(snapshots: AgentSnapshot[], canonical: Pick<ReturnType<typeof loadCanonicalSource>, "mcp" | "managedAgents" | "secretsPolicy">): Finding[];
140
+ export declare function detectRuntimeDrift(snapshots: AgentSnapshot[], canonical: Pick<ReturnType<typeof loadCanonicalSource>, "mcp" | "managedAgents" | "secretsPolicy">): Promise<Finding[]>;
141
141
  /** Exported so `trellis onboard` can reuse it verbatim for its own final
142
142
  * stage (trellis-onboard-closed-loop) rather than a second copy of this
143
143
  * formatting. */
@@ -178,7 +178,7 @@ export async function collectDoctorReport(homeDir = homedir(), knownHostInjected
178
178
  // `collectWorkspaceDoctorSection`'s per-file inspection is what we want.
179
179
  let workspace;
180
180
  if (canonical?.workspace) {
181
- workspace = collectWorkspaceDoctorSection(canonical);
181
+ workspace = await collectWorkspaceDoctorSection(canonical);
182
182
  }
183
183
  else {
184
184
  const workspaceRoot = workspaceDir != null ? findWorkspaceRoot(workspaceDir) : null;
@@ -198,7 +198,7 @@ export async function collectDoctorReport(homeDir = homedir(), knownHostInjected
198
198
  diagnostics: [],
199
199
  workspace: { root: workspaceRoot, mcpProvenance: {}, mcpGlobalServers: {}, skillProvenance: {}, globalSkills: [], shadowed: [] },
200
200
  };
201
- workspace = collectWorkspaceDoctorSection(minimal);
201
+ workspace = await collectWorkspaceDoctorSection(minimal);
202
202
  }
203
203
  }
204
204
  // Workspace TOML findings become top-level findings so the exit code
@@ -215,7 +215,7 @@ export async function collectDoctorReport(homeDir = homedir(), knownHostInjected
215
215
  }
216
216
  }
217
217
  const oauth = canonical ? collectOAuthHealth(homeDir, canonical) : [];
218
- 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)])));
218
+ findings.push(...detectDuplication(snapshots), ...detectCollisions(snapshots, knownHostInjected), ...detectCrossAgentDrift(snapshots), ...detectCaseMismatches(snapshots), ...detectParseDiagnostics(snapshots), ...(canonical ? await detectRuntimeDrift(snapshots, canonical) : []), ...detectMcpUnreachable(snapshots), ...oauth.flatMap((entry) => (entry.status === "valid" ? [] : [oauthFinding(entry)])));
219
219
  // 分级开启(trellis-evolve 10.2):级别策略是显式用户选择,读取零副作用。
220
220
  // 未配置(或全空)时整段缺席 —— 从未开启自我进化的机器输出与未安装时
221
221
  // 一致;损坏的策略文件不拖垮扫描,降级为段内诊断呈现。
@@ -427,12 +427,12 @@ export function detectMcpUnreachable(snapshots) {
427
427
  /** Compares runtime entries against the same canonical MCP plan that sync
428
428
  * uses. Only missing Trellis-owned runtime edges are reported; extra
429
429
  * user-authored MCP entries remain outside doctor ownership. */
430
- export function detectRuntimeDrift(snapshots, canonical) {
430
+ export async function detectRuntimeDrift(snapshots, canonical) {
431
431
  const findings = [];
432
432
  for (const snap of snapshots) {
433
433
  if (!snap.present || !canonical.managedAgents.includes(snap.agent) || snap.agent === "pi")
434
434
  continue;
435
- const expected = resolveMcpPlan(snap.agent, canonical.mcp, canonical.managedAgents, canonical.secretsPolicy).desired
435
+ const expected = (await resolveMcpPlan(snap.agent, canonical.mcp, canonical.managedAgents, canonical.secretsPolicy)).desired
436
436
  .map((entry) => entry.name)
437
437
  .filter((name) => name === RUNTIME_ENTRY_NAME || name === GATEWAY_ENTRY_NAME);
438
438
  const actual = new Set(snap.mcpServers.map((server) => server.name));
@@ -65,7 +65,7 @@ export async function collectMcpSyncReport(opts = {}) {
65
65
  continue;
66
66
  }
67
67
  const globalItems = (await adapter.plan(canonical)).filter((item) => item.kind === "mcp");
68
- const wsItems = planWorkspaceMcp(adapter.id, canonical);
68
+ const wsItems = await planWorkspaceMcp(adapter.id, canonical);
69
69
  if (writeMode) {
70
70
  await adapter.apply(globalItems, backup);
71
71
  applyWorkspaceMcp(adapter.id, canonical, wsItems, backup);
@@ -114,7 +114,7 @@ export async function authorizeMcpServer(opts) {
114
114
  const client = oauthClientMetadata(def.auth);
115
115
  let clientSecret;
116
116
  if (client.clientSecretEnv) {
117
- const { [client.clientSecretEnv]: value } = resolveSecretEnv([client.clientSecretEnv], canonical.secretsPolicy);
117
+ const { [client.clientSecretEnv]: value } = await resolveSecretEnv([client.clientSecretEnv], canonical.secretsPolicy);
118
118
  if (value === undefined) {
119
119
  throw new Error(`auth.client_secret_env is "${client.clientSecretEnv}", but no value for that variable was found in ${secretSourceLabel(canonical.secretsPolicy)} — set it there, or drop the field if this provider issues a public client`);
120
120
  }
@@ -46,11 +46,11 @@ export declare function parseMcpGatewayArgs(argv: readonly string[]): {
46
46
  * connect to itself. Resolving against a gateway-less view of the same
47
47
  * canonical gives the set gateway mode replaced.
48
48
  */
49
- export declare function resolveGatewayUpstreams(agentId: AgentId, canonical: Pick<ReturnType<typeof loadCanonicalSource>, "mcp" | "managedAgents" | "secretsPolicy">): {
49
+ export declare function resolveGatewayUpstreams(agentId: AgentId, canonical: Pick<ReturnType<typeof loadCanonicalSource>, "mcp" | "managedAgents" | "secretsPolicy">): Promise<{
50
50
  upstreams: UpstreamSpec[];
51
51
  oauthServers: string[];
52
52
  conflicts: string[];
53
- };
53
+ }>;
54
54
  export declare function runMcpGateway(opts: RunMcpGatewayOptions): Promise<{
55
55
  exitCode: number;
56
56
  }>;
@@ -59,7 +59,7 @@ export function parseMcpGatewayArgs(argv) {
59
59
  * connect to itself. Resolving against a gateway-less view of the same
60
60
  * canonical gives the set gateway mode replaced.
61
61
  */
62
- export function resolveGatewayUpstreams(agentId, canonical) {
62
+ export async function resolveGatewayUpstreams(agentId, canonical) {
63
63
  const route = canonical.mcp.routes?.[agentId];
64
64
  const hubActive = route ? route.mode === "hub" : Boolean(canonical.mcp.hub) && !isGatewayAgent(agentId, canonical.mcp, canonical.managedAgents);
65
65
  if (hubActive) {
@@ -90,7 +90,7 @@ export function resolveGatewayUpstreams(agentId, canonical) {
90
90
  // ownership-gated removal clears it. Serving that stale entry would hand
91
91
  // every server, and every secret resolved into it, to the one agent the
92
92
  // user just said to stop involving.
93
- const { desired, conflicts } = resolveMcpPlan(agentId, direct, canonical.managedAgents, canonical.secretsPolicy);
93
+ const { desired, conflicts } = await resolveMcpPlan(agentId, direct, canonical.managedAgents, canonical.secretsPolicy, { hosted: true });
94
94
  // Agent-owned OAuth servers bypass the gateway as direct entries, so no
95
95
  // connection state exists for them here — but Trellis owns the token
96
96
  // store, so `trellis.mcp.status` reports their credential health for
@@ -101,7 +101,7 @@ export function resolveGatewayUpstreams(agentId, canonical) {
101
101
  const agentOwnedServers = Object.fromEntries(Object.entries(canonical.mcp.servers).filter(([, def]) => isAgentOwnedOAuth(def)));
102
102
  const oauthServers = Object.keys(agentOwnedServers).length === 0
103
103
  ? []
104
- : resolveMcpPlan(agentId, { ...direct, servers: agentOwnedServers }, canonical.managedAgents, canonical.secretsPolicy).desired.map(({ name }) => name);
104
+ : (await resolveMcpPlan(agentId, { ...direct, servers: agentOwnedServers }, canonical.managedAgents, canonical.secretsPolicy)).desired.map(({ name }) => name);
105
105
  return {
106
106
  upstreams: desired.map(({ name }) => ({ name, def: canonical.mcp.servers[name] })),
107
107
  oauthServers,
@@ -131,7 +131,7 @@ async function runMcpEdge(opts, clientInfo, serverInfo) {
131
131
  console.error(`trellis-mcp-gateway: ${err instanceof Error ? err.message : String(err)}`);
132
132
  return { exitCode: 1 };
133
133
  }
134
- const { upstreams, oauthServers, conflicts } = resolveGatewayUpstreams(opts.agentId, canonical);
134
+ const { upstreams, oauthServers, conflicts } = await resolveGatewayUpstreams(opts.agentId, canonical);
135
135
  for (const message of conflicts) {
136
136
  console.error(`trellis-mcp-gateway: ${message}`);
137
137
  }
@@ -9,6 +9,7 @@ import { existsSync, readFileSync } from "node:fs";
9
9
  import { homedir } from "node:os";
10
10
  import { join } from "node:path";
11
11
  import { loadCanonicalSource, ensureGitignoreEntry, ensureShellEnvSource, upsertServerYaml, writeSecretsPolicyExtraction } from "../core/canonical.js";
12
+ import { deliveredName } from "../lib/secretEnv.js";
12
13
  import { openBackupSession } from "../lib/backup.js";
13
14
  import { parseDotenv, writeLocalSecretValue } from "../lib/secretEnv.js";
14
15
  import { collectMcpSyncReport, printReport as printMcpSyncReport } from "./mcp.js";
@@ -115,7 +116,7 @@ function semanticIdentity(def) {
115
116
  enabled: def.enabled,
116
117
  agents: def.agents ? sorted(def.agents) : undefined,
117
118
  staticEnv: def.staticEnv,
118
- envKeys: sorted([...(def.env ?? []), ...Object.keys(def.envAliases ?? {})]),
119
+ envKeys: sorted([...(def.env ?? []).map(deliveredName), ...Object.keys(def.envAliases ?? {})]),
119
120
  headerKeys: sorted(Object.keys(def.headers ?? {})),
120
121
  });
121
122
  }
@@ -19,7 +19,7 @@ import { deepEqual } from "../lib/deepEqual.js";
19
19
  import { readClaudeCodeMcpDefs, readCodexMcpDefs, readKiroMcpDefs, readKimiCodeMcpDefs, readZcodeMcpDefs, resolvedEnvTextMap } from "../lib/mcpMigrateRead.js";
20
20
  import { parseDotenv, writeLocalSecretValue } from "../lib/secretEnv.js";
21
21
  import { findLiteralSecret } from "../adapters/mcpPlan.js";
22
- import { ensureGitignoreEntry, ensureShellEnvSource, loadCanonicalSource, upsertServerYaml, writeSecretsPolicyExtraction } from "../core/canonical.js";
22
+ import { ensureGitignoreEntry, ensureShellEnvSource, loadCanonicalSource, upsertServerYaml, validateSkillScopeYaml, writeSecretsPolicyExtraction, writeSkillScopeYaml } from "../core/canonical.js";
23
23
  import { ALL_AGENTS } from "../core/types.js";
24
24
  import { discoverNativeMemory, MAX_NATIVE_MEMORY_BYTES, renderNativeMemory } from "../lib/nativeMemory.js";
25
25
  /** Where a `staticEnv` literal secret's real value goes when
@@ -390,6 +390,11 @@ function copyDirectoryThroughBackup(sourceDir, destDir, backup) {
390
390
  }
391
391
  export function applyMigratePlan(plan, homeDir = homedir(), backup) {
392
392
  const canonicalRoot = join(homeDir, ".trellis");
393
+ if (plan.items.some((item) => item.kind === "skill" && item.action === "create")) {
394
+ const validation = validateSkillScopeYaml(join(canonicalRoot, "scope.yaml"));
395
+ if (!validation.ok)
396
+ throw new Error(validation.error);
397
+ }
393
398
  for (const item of plan.items) {
394
399
  if (item.action !== "create" && item.action !== "reclassify" && item.action !== "extract-secret")
395
400
  continue;
@@ -400,6 +405,11 @@ export function applyMigratePlan(plan, homeDir = homedir(), backup) {
400
405
  copyDirectoryThroughBackup(item.sourceDir, dest, backup);
401
406
  else
402
407
  cpSync(item.sourceDir, dest, { recursive: true });
408
+ // Migration proves applicability to its source agent only. Sharing
409
+ // with other agents is a separate, explicit compatibility decision.
410
+ const scope = writeSkillScopeYaml(join(canonicalRoot, "scope.yaml"), item.name, [plan.agent], backup);
411
+ if (!scope.ok)
412
+ throw new Error(scope.error);
403
413
  }
404
414
  else if (item.kind === "instructions" && item.sourceContent !== undefined) {
405
415
  mkdirSync(canonicalRoot, { recursive: true });