agent-trellis 0.21.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.
@@ -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): Promise<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 { parseEnvEntry, 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";
@@ -139,7 +140,10 @@ export function findLiteralSecret(def) {
139
140
  * (trellis-migrate-env-var-alias).
140
141
  */
141
142
  async function findUnresolvedEnvName(def, policy) {
142
- const names = [...(def.env ?? []), ...Object.values(def.envAliases ?? {})];
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
149
  // A malformed `mxenv://` entry conflicts here too — it is rejected input,
@@ -147,16 +151,26 @@ async function findUnresolvedEnvName(def, policy) {
147
151
  // returned text is the DECLARED entry; resolution checks use delivered
148
152
  // names (the `KEY` segment for a reference).
149
153
  const parsed = names.map(parseEnvEntry);
150
- const invalid = parsed.find((p) => p.kind === "invalid");
151
- if (invalid)
152
- return invalid.entry;
153
- const resolved = await resolveSecretEnv(names, policy);
154
- return names.find((name) => {
155
- const p = parseEnvEntry(name);
156
- return !resolved[p.kind === "invalid" ? name : p.delivered];
157
- });
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
+ }
158
172
  }
159
- export async function resolveMcpPlan(agentId, mcp, managedAgents, policy) {
173
+ export async function resolveMcpPlan(agentId, mcp, managedAgents, policy, options = {}) {
160
174
  const route = routeForAgent(agentId, mcp, managedAgents);
161
175
  const runtimeEnabled = capabilityDeliveryForAgent(agentId, mcp) !== "native";
162
176
  // Checked before `hub`: only one entry can be "the" MCP entry for an
@@ -257,14 +271,14 @@ export async function resolveMcpPlan(agentId, mcp, managedAgents, policy) {
257
271
  conflicts: oauth.conflicts,
258
272
  };
259
273
  }
260
- const native = await planDirectServers(agentId, mcp, managedAgents, policy, route);
274
+ const native = await planDirectServers(agentId, mcp, managedAgents, policy, route, undefined, options.hosted || agentId === "pi");
261
275
  const hosted = native.desired.filter(({ def }) => isOAuthMcpServer(def) && oauthOwner(def.auth) === "trellis");
262
276
  return {
263
277
  desired: native.desired.filter(({ def }) => !isOAuthMcpServer(def) || oauthOwner(def.auth) === "agent"),
264
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" }))],
265
279
  };
266
280
  }
267
- async function planDirectServers(agentId, mcp, managedAgents, policy, route, filter) {
281
+ async function planDirectServers(agentId, mcp, managedAgents, policy, route, filter, hosted = false) {
268
282
  const desired = [];
269
283
  const conflicts = [];
270
284
  for (const [name, def] of Object.entries(mcp.servers)) {
@@ -304,12 +318,17 @@ async function planDirectServers(agentId, mcp, managedAgents, policy, route, fil
304
318
  });
305
319
  continue;
306
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
+ }
307
326
  // Codex has no generic headers concept — only the single
308
327
  // Authorization-bearer-token shape is expressible there
309
328
  // (trellis-mcp-transport-auth design.md D4). Any other shape is
310
329
  // refused for Codex specifically; every other in-scope agent for
311
330
  // the same server is unaffected (this loop runs once per agentId).
312
- 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)) {
313
332
  conflicts.push({
314
333
  name,
315
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.`,
@@ -318,9 +337,9 @@ async function planDirectServers(agentId, mcp, managedAgents, policy, route, fil
318
337
  continue;
319
338
  }
320
339
  const unresolvedName = await findUnresolvedEnvName(def, policy);
321
- if (unresolvedName) {
340
+ if (unresolvedName !== undefined) {
322
341
  const entry = parseEnvEntry(unresolvedName);
323
- if (entry.kind === "invalid") {
342
+ if (entry.kind === "invalid" || !isValidSecretVarName(unresolvedName) && entry.kind === "name") {
324
343
  conflicts.push({
325
344
  name,
326
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)`,
@@ -339,6 +358,14 @@ async function planDirectServers(agentId, mcp, managedAgents, policy, route, fil
339
358
  });
340
359
  continue;
341
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`,
366
+ });
367
+ continue;
368
+ }
342
369
  desired.push({ name, def });
343
370
  }
344
371
  return { desired, conflicts };
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") {
@@ -90,7 +90,7 @@ export async 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 } = await 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
@@ -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 });
@@ -15,6 +15,7 @@ import { isBuiltinSkillName } from "../lib/builtinSkills.js";
15
15
  import { emptyRemoteSkillLock, readRemoteSkillLock, writeRemoteSkillLock } from "../lib/remoteSkillLock.js";
16
16
  import { fetchRemoteRepository, normalizeGitHubRepository } from "../lib/remoteSkillSource.js";
17
17
  import { collectSyncReport, printReport as printSyncReport } from "./sync.js";
18
+ import { requestSkillScope } from "../lib/skillScope.js";
18
19
  function safeSkillName(name) {
19
20
  return typeof name === "string" && /^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(name) && name !== "." && name !== "..";
20
21
  }
@@ -41,7 +42,9 @@ function scopeForRequest(homeDir, agents) {
41
42
  return { error: error instanceof Error ? error.message : String(error) };
42
43
  }
43
44
  if (!agents || agents.length === 0)
44
- return { scope: undefined };
45
+ return { error: 'choose applicable agents with --agent <ids> or --agent "*"' };
46
+ if (agents.length === 1 && agents[0] === "none")
47
+ return { scope: [] };
45
48
  const values = agents.flatMap((value) => value.split(",").map((id) => id.trim()).filter(Boolean));
46
49
  if (values.length === 0)
47
50
  return { error: "--agent needs at least one Agent id" };
@@ -164,6 +167,20 @@ export async function runRemoteSkillAdd(sourceInput, name, opts = {}) {
164
167
  console.error(plan.detail);
165
168
  return { exitCode: 1 };
166
169
  }
170
+ if (!opts.agents) {
171
+ const home = opts.homeDir ?? homedir();
172
+ const managed = existsSync(join(home, ".trellis")) ? loadCanonicalSource(home).managedAgents : [];
173
+ const selected = await requestSkillScope({}, managed, !opts.json);
174
+ if ("error" in selected) {
175
+ const detail = `${selected.error}; remote import uses --agent <ids>, --agent "*" or --agent none`;
176
+ if (opts.json)
177
+ console.log(JSON.stringify({ action: "invalid-options", detail }));
178
+ else
179
+ console.error(detail);
180
+ return { exitCode: 1 };
181
+ }
182
+ opts = { ...opts, agents: selected.scope.length ? selected.scope : ["none"] };
183
+ }
167
184
  let fetched;
168
185
  try {
169
186
  fetched = fetchRemoteRepository(repository, { branch: opts.branch });
@@ -29,7 +29,7 @@ import { KiroAdapter } from "../adapters/kiro.js";
29
29
  import { ZcodeAdapter } from "../adapters/zcode.js";
30
30
  import { declaredEnvNames, extractJsonEnvVarNames, extractTomlEnvVarNames, extractZcodeEnvVarNames } from "../lib/envVarNames.js";
31
31
  import { resolveZcodeProfile } from "../probes/zcode.js";
32
- import { parseEnvEntry, resolveSecretEnv } from "../lib/secretEnv.js";
32
+ import { parseEnvEntry, resolveSecretEntries, SecretReferenceError } from "../lib/secretEnv.js";
33
33
  /** Only agents in `managedAgents` — a present-but-unmanaged agent's config
34
34
  * file is never even read (trellis-managed-agents), same restriction as
35
35
  * `sync`/`mcp sync`. pi has no static, generated MCP config file to audit
@@ -76,16 +76,29 @@ async function findMissingEnvValues(servers, policy) {
76
76
  if (resolvable.length === 0)
77
77
  return findings;
78
78
  // Findings name the DELIVERED name (the KEY segment for a reference).
79
- const resolved = await resolveSecretEnv(resolvable, policy);
79
+ const pending = [...resolvable];
80
+ let resolved = {};
81
+ // One broken profile must be a finding, not abort the entire audit.
82
+ while (pending.length) {
83
+ try {
84
+ resolved = await resolveSecretEntries(pending, policy);
85
+ break;
86
+ }
87
+ catch (error) {
88
+ if (!(error instanceof SecretReferenceError))
89
+ throw error;
90
+ pending.splice(pending.indexOf(error.entry), 1);
91
+ }
92
+ }
80
93
  for (const entry of resolvable) {
81
94
  const p = parseEnvEntry(entry);
82
95
  const delivered = p.kind === "invalid" ? entry : p.delivered;
83
- if (!resolved[delivered]) {
96
+ if (!resolved[entry]) {
84
97
  findings.push({
85
98
  agent: "environment",
86
99
  file: p.kind === "ref" ? `the mxenv composition (profile "${p.profile}")` : (policy.envFile ?? "process environment"),
87
100
  kind: "missing-env-value",
88
- detail: `"${delivered}" is declared by a canonical MCP server's env but has no resolvable value`,
101
+ detail: `"${delivered}" is declared by a canonical MCP server's env or headers but has no resolvable value`,
89
102
  });
90
103
  }
91
104
  }
@@ -31,12 +31,18 @@ export declare function runSkillUpdateBuiltin(opts?: {
31
31
  export interface SkillListEntry {
32
32
  name: string;
33
33
  scope: readonly AgentId[];
34
+ registered?: boolean;
35
+ delivery?: {
36
+ state: "linked" | "missing" | "conflict" | "runtime";
37
+ path?: string;
38
+ };
34
39
  remote?: Pick<RemoteSkillLockEntry, "source" | "requestedRef" | "commit" | "subdirectory" | "digest">;
35
40
  }
36
- export declare function collectSkillList(homeDir?: string): SkillListEntry[];
41
+ export declare function collectSkillList(homeDir?: string, agent?: AgentId): SkillListEntry[];
37
42
  export declare function runSkillList(opts?: {
38
43
  homeDir?: string;
39
44
  json?: boolean;
45
+ agent?: string;
40
46
  }): {
41
47
  exitCode: number;
42
48
  };
@@ -46,6 +52,7 @@ export interface SkillAddPlan {
46
52
  action: SkillAddAction;
47
53
  detail: string;
48
54
  sourceDir?: string;
55
+ scope?: AgentId[];
49
56
  }
50
57
  export declare function collectSkillAddPlan(name: string, fromPath: string, homeDir?: string): SkillAddPlan;
51
58
  export declare function applySkillAddPlan(plan: SkillAddPlan, homeDir?: string, backup?: BackupSession): void;
@@ -84,6 +91,7 @@ export declare function runSkillAdd(name: string, fromPath: string, opts?: {
84
91
  dryRun?: boolean;
85
92
  workspace?: boolean;
86
93
  workspaceDir?: string;
94
+ selection?: ScopeSelectorRaw;
87
95
  }): Promise<{
88
96
  exitCode: number;
89
97
  }>;
@@ -117,13 +125,10 @@ export interface SkillScopePlan {
117
125
  name: string;
118
126
  action: SkillScopeAction;
119
127
  detail: string;
120
- /** What will be recorded in `scope.yaml`; `undefined` clears the explicit
121
- * scope so the skill reaches every managed agent. Set when the selection was
122
- * valid, whatever the action. */
128
+ /** Applicable agents recorded in scope.yaml. Even --all is a fixed list. */
123
129
  scope?: AgentId[];
124
130
  mode?: "all" | "none" | "explicit";
125
- /** True when a full selection was dropped instead of recorded
126
- * (trellis-scope-editing-and-auth-status design.md D3). */
131
+ /** Retained for API compatibility; skill lists are never normalized away. */
127
132
  normalizedFromFull?: boolean;
128
133
  /** The agents that will actually receive the skill afterwards. */
129
134
  effective?: readonly AgentId[];
@@ -14,14 +14,17 @@ import { dirname } from "node:path";
14
14
  import { fileURLToPath } from "node:url";
15
15
  import { decideDirImport } from "../lib/dirEquals.js";
16
16
  import { findSkillFile } from "../lib/skillFile.js";
17
- import { loadCanonicalSource, writeSkillScopeYaml } from "../core/canonical.js";
17
+ import { loadCanonicalSource, validateSkillScopeYaml, writeSkillScopeYaml } from "../core/canonical.js";
18
18
  import { resolveScope } from "../core/types.js";
19
19
  import { isBuiltinSkillName } from "../lib/builtinSkills.js";
20
20
  import { openBackupSession } from "../lib/backup.js";
21
21
  import { collectSyncReport, printReport as printSyncReport } from "./sync.js";
22
22
  import { remoteSkillProvenance } from "./remoteSkill.js";
23
- import { describeScope, resolveScopeSelection, sameScope } from "../lib/scopeSelection.js";
23
+ import { sameScope } from "../lib/scopeSelection.js";
24
24
  import { findWorkspaceRoot } from "../lib/workspace.js";
25
+ import { readSkillScopes, requestSkillScope, selectSkillScope } from "../lib/skillScope.js";
26
+ import { isSymlinkTo } from "../lib/fsIdentity.js";
27
+ import { usesNativeCapabilityDelivery, ALL_AGENTS } from "../core/types.js";
25
28
  function builtinSkillTemplate() {
26
29
  const here = dirname(fileURLToPath(import.meta.url));
27
30
  return readFileSync(join(here, "..", "..", "schema", "builtin-skills", "trellis-runtime", "SKILL.md"), "utf8");
@@ -58,17 +61,34 @@ export function runSkillUpdateBuiltin(opts = {}) {
58
61
  console.log(`${opts.dryRun ? "[dry run] " : ""}skill update-builtin\n [${plan.action}] ${plan.detail}`);
59
62
  return { exitCode: 0 };
60
63
  }
61
- export function collectSkillList(homeDir = homedir()) {
64
+ export function collectSkillList(homeDir = homedir(), agent) {
62
65
  const canonical = loadCanonicalSource(homeDir);
63
66
  const provenance = remoteSkillProvenance(homeDir);
64
- return canonical.skills.filter((skill) => !isBuiltinSkillName(skill.name)).map((skill) => ({
67
+ const registry = readSkillScopes(join(homeDir, ".trellis", "scope.yaml"));
68
+ const roots = {
69
+ "claude-code": join(homeDir, ".claude", "skills"), codex: join(homeDir, ".agents", "skills"),
70
+ kiro: join(homeDir, ".kiro", "skills"), pi: join(homeDir, ".pi", "agent", "skills"),
71
+ "kimi-code": join(homeDir, ".kimi-code", "skills"), zcode: "",
72
+ };
73
+ return canonical.skills.filter((skill) => agent ? resolveScope(skill.scope, canonical.managedAgents).includes(agent) : !isBuiltinSkillName(skill.name)).map((skill) => ({
65
74
  name: skill.name,
66
75
  scope: resolveScope(skill.scope, canonical.managedAgents),
76
+ ...(agent ? {
77
+ registered: Object.hasOwn(registry, skill.name) || isBuiltinSkillName(skill.name),
78
+ delivery: !usesNativeCapabilityDelivery(agent, canonical.mcp) || agent === "zcode"
79
+ ? { state: "runtime" }
80
+ : { state: isSymlinkTo(join(roots[agent], skill.name), skill.dir) ? "linked"
81
+ : existsSync(join(roots[agent], skill.name)) ? "conflict" : "missing", path: join(roots[agent], skill.name) },
82
+ } : {}),
67
83
  ...(provenance[skill.name] ? { remote: provenance[skill.name] } : {}),
68
84
  }));
69
85
  }
70
86
  export function runSkillList(opts = {}) {
71
- const entries = collectSkillList(opts.homeDir ?? homedir());
87
+ if (opts.agent !== undefined && !ALL_AGENTS.includes(opts.agent)) {
88
+ console.error(`unknown agent: ${opts.agent}; choose ${ALL_AGENTS.join(", ")}`);
89
+ return { exitCode: 1 };
90
+ }
91
+ const entries = collectSkillList(opts.homeDir ?? homedir(), opts.agent);
72
92
  if (opts.json) {
73
93
  console.log(JSON.stringify(entries, null, 2));
74
94
  }
@@ -76,9 +96,9 @@ export function runSkillList(opts = {}) {
76
96
  console.log("No skills in canonical source yet.");
77
97
  }
78
98
  else {
79
- for (const { name, scope, remote } of entries) {
99
+ for (const { name, scope, remote, delivery } of entries) {
80
100
  const provenance = remote ? ` — remote: ${remote.source}@${remote.commit.slice(0, 12)}` : "";
81
- console.log(`${name} — ${scope.length > 0 ? scope.join(", ") : "(no managed agent reaches it)"}${provenance}`);
101
+ console.log(`${name} — ${scope.length > 0 ? scope.join(", ") : "(paused: no applicable managed agent)"}${delivery ? ` [${delivery.state}]` : ""}${provenance}`);
82
102
  }
83
103
  }
84
104
  return { exitCode: 0 };
@@ -105,15 +125,26 @@ export function collectSkillAddPlan(name, fromPath, homeDir = homedir()) {
105
125
  }
106
126
  }
107
127
  export function applySkillAddPlan(plan, homeDir = homedir(), backup) {
108
- if (plan.action !== "create" || !plan.sourceDir)
128
+ if (plan.action !== "create" && !(plan.action === "already-present" && plan.scope !== undefined))
109
129
  return;
110
- const dest = join(homeDir, ".trellis", "skills", plan.name);
111
- if (backup) {
112
- backup.createDirFromSource(dest, plan.sourceDir);
130
+ if (plan.action === "create" && !plan.sourceDir)
113
131
  return;
132
+ const scopePath = join(homeDir, ".trellis", "scope.yaml");
133
+ const validation = validateSkillScopeYaml(scopePath);
134
+ if (!validation.ok)
135
+ throw new Error(validation.error);
136
+ const dest = join(homeDir, ".trellis", "skills", plan.name);
137
+ if (plan.action === "create") {
138
+ if (backup)
139
+ backup.createDirFromSource(dest, plan.sourceDir);
140
+ else {
141
+ mkdirSync(dest, { recursive: true });
142
+ cpSync(plan.sourceDir, dest, { recursive: true });
143
+ }
114
144
  }
115
- mkdirSync(dest, { recursive: true });
116
- cpSync(plan.sourceDir, dest, { recursive: true });
145
+ const written = writeSkillScopeYaml(scopePath, plan.name, plan.scope ?? [], backup);
146
+ if (!written.ok)
147
+ throw new Error(written.error);
117
148
  }
118
149
  /** The workspace twin of `collectSkillAddPlan` (trellis-workspace-scope
119
150
  * task 3.4): same source validation, destination is `.agents/skills/`
@@ -137,29 +168,40 @@ export function collectSkillAddWorkspacePlan(name, fromPath, workspaceRoot) {
137
168
  }
138
169
  }
139
170
  export function applySkillAddWorkspacePlan(plan, workspaceRoot, backup) {
140
- if (plan.action !== "create" || !plan.sourceDir)
171
+ if (plan.action !== "create" && !(plan.action === "already-present" && plan.scope !== undefined))
141
172
  return;
142
- const dest = join(workspaceRoot, ".agents", "skills", plan.name);
143
- if (backup) {
144
- backup.createDirFromSource(dest, plan.sourceDir);
173
+ if (plan.action === "create" && !plan.sourceDir)
145
174
  return;
175
+ const scopePath = join(workspaceRoot, ".agents", "scope.yaml");
176
+ const validation = validateSkillScopeYaml(scopePath);
177
+ if (!validation.ok)
178
+ throw new Error(validation.error);
179
+ const dest = join(workspaceRoot, ".agents", "skills", plan.name);
180
+ if (plan.action === "create") {
181
+ if (backup)
182
+ backup.createDirFromSource(dest, plan.sourceDir);
183
+ else {
184
+ mkdirSync(dest, { recursive: true });
185
+ cpSync(plan.sourceDir, dest, { recursive: true });
186
+ }
146
187
  }
147
- mkdirSync(dest, { recursive: true });
148
- cpSync(plan.sourceDir, dest, { recursive: true });
188
+ const written = writeSkillScopeYaml(scopePath, plan.name, plan.scope ?? [], backup);
189
+ if (!written.ok)
190
+ throw new Error(written.error);
149
191
  }
150
192
  /** Same own-backup-session + cascade-sync posture as
151
193
  * `applySkillAddWithSync`, scoped to the workspace layer. */
152
194
  export async function applySkillAddWorkspaceWithSync(plan, workspaceRoot, opts = {}) {
153
195
  const homeDir = opts.homeDir ?? homedir();
154
- const ownSession = !opts.dryRun && !opts.backupSession && plan.action === "create" ? openBackupSession(homeDir, "skill-add") : undefined;
196
+ const ownSession = !opts.dryRun && !opts.backupSession && (plan.action === "create" || plan.action === "already-present" && plan.scope !== undefined) ? openBackupSession(homeDir, "skill-add") : undefined;
155
197
  const session = opts.backupSession ?? ownSession;
156
- if (!opts.dryRun && plan.action === "create") {
198
+ if (!opts.dryRun && (plan.action === "create" || plan.action === "already-present")) {
157
199
  applySkillAddWorkspacePlan(plan, workspaceRoot, session);
158
200
  }
159
201
  ownSession?.finalize();
160
202
  const failed = plan.action === "conflict" || plan.action === "invalid-source";
161
203
  let syncReport;
162
- if (!failed && plan.action === "create" && existsSync(join(homeDir, ".trellis"))) {
204
+ if (!failed && (plan.action === "create" || plan.scope !== undefined) && existsSync(join(homeDir, ".trellis"))) {
163
205
  syncReport = await collectSyncReport({ target: "skills", homeDir, workspaceDir: workspaceRoot, dryRun: opts.dryRun });
164
206
  }
165
207
  return { plan, ...(syncReport ? { sync: syncReport } : {}) };
@@ -174,21 +216,30 @@ export async function applySkillAddWorkspaceWithSync(plan, workspaceRoot, opts =
174
216
  */
175
217
  export async function applySkillAddWithSync(plan, opts = {}) {
176
218
  const homeDir = opts.homeDir ?? homedir();
177
- const ownSession = !opts.dryRun && !opts.backupSession && plan.action === "create" ? openBackupSession(homeDir, "skill-add") : undefined;
219
+ const ownSession = !opts.dryRun && !opts.backupSession && (plan.action === "create" || plan.action === "already-present" && plan.scope !== undefined) ? openBackupSession(homeDir, "skill-add") : undefined;
178
220
  const session = opts.backupSession ?? ownSession;
179
- if (!opts.dryRun && plan.action === "create") {
221
+ if (!opts.dryRun && (plan.action === "create" || plan.action === "already-present")) {
180
222
  applySkillAddPlan(plan, homeDir, session);
181
223
  }
182
224
  ownSession?.finalize();
183
225
  const failed = plan.action === "conflict" || plan.action === "invalid-source";
184
226
  let syncReport;
185
- if (!failed && plan.action === "create" && existsSync(join(homeDir, ".trellis"))) {
227
+ if (!failed && (plan.action === "create" || plan.scope !== undefined) && existsSync(join(homeDir, ".trellis"))) {
186
228
  syncReport = await collectSyncReport({ target: "skills", homeDir, dryRun: opts.dryRun });
187
229
  }
188
230
  return { plan, ...(syncReport ? { sync: syncReport } : {}) };
189
231
  }
190
232
  export async function runSkillAdd(name, fromPath, opts = {}) {
191
233
  const homeDir = opts.homeDir ?? homedir();
234
+ const managed = existsSync(join(homeDir, ".trellis")) ? loadCanonicalSource(homeDir).managedAgents : [];
235
+ const selected = await requestSkillScope(opts.selection ?? {}, managed, !opts.json);
236
+ if ("error" in selected) {
237
+ if (opts.json)
238
+ console.log(JSON.stringify({ name, action: "invalid-source", detail: selected.error }));
239
+ else
240
+ console.error(selected.error);
241
+ return { exitCode: 1 };
242
+ }
192
243
  if (opts.workspace) {
193
244
  const workspaceRoot = opts.workspaceDir ?? findWorkspaceRoot(process.cwd());
194
245
  if (!workspaceRoot) {
@@ -196,6 +247,7 @@ export async function runSkillAdd(name, fromPath, opts = {}) {
196
247
  return { exitCode: 1 };
197
248
  }
198
249
  const plan = collectSkillAddWorkspacePlan(name, fromPath, workspaceRoot);
250
+ plan.scope = selected.scope;
199
251
  const { sync: syncReport } = await applySkillAddWorkspaceWithSync(plan, workspaceRoot, { homeDir, dryRun: opts.dryRun });
200
252
  const failed = plan.action === "conflict" || plan.action === "invalid-source";
201
253
  if (opts.json) {
@@ -211,6 +263,7 @@ export async function runSkillAdd(name, fromPath, opts = {}) {
211
263
  return { exitCode: failed || syncConflict ? 1 : 0 };
212
264
  }
213
265
  const plan = collectSkillAddPlan(name, fromPath, homeDir);
266
+ plan.scope = selected.scope;
214
267
  const { sync: syncReport } = await applySkillAddWithSync(plan, { homeDir, dryRun: opts.dryRun });
215
268
  const failed = plan.action === "conflict" || plan.action === "invalid-source";
216
269
  if (opts.json) {
@@ -289,9 +342,10 @@ export function collectSkillScopePlan(name, raw, homeDir = homedir()) {
289
342
  return { name, action: "invalid-input", detail: `"${name}" is a Trellis package-owned Skill — its scope is not editable` };
290
343
  }
291
344
  const canonical = loadCanonicalSource(homeDir);
292
- const selection = resolveScopeSelection(raw, canonical.managedAgents);
293
- if (!selection.ok)
294
- return { name, action: "invalid-input", detail: selection.detail };
345
+ const chosen = selectSkillScope(raw, canonical.managedAgents);
346
+ if ("error" in chosen)
347
+ return { name, action: "invalid-input", detail: chosen.error };
348
+ const selection = { scope: chosen.scope, mode: raw.all ? "all" : raw.none ? "none" : "explicit", normalizedFromFull: false };
295
349
  const skill = canonical.skills.find((candidate) => candidate.name === name);
296
350
  if (!skill)
297
351
  return { name, action: "not-found", detail: `no canonical skill named "${name}"` };
@@ -302,12 +356,11 @@ export function collectSkillScopePlan(name, raw, homeDir = homedir()) {
302
356
  normalizedFromFull: selection.normalizedFromFull,
303
357
  effective: resolveScope(selection.scope, canonical.managedAgents),
304
358
  };
305
- const summary = describeScope(selection.scope, canonical.managedAgents);
306
- const normalized = selection.normalizedFromFull ? " (every managed agent selected — recorded as the default, so a newly managed agent is included)" : "";
307
- if (sameScope(skill.scope, selection.scope)) {
359
+ const summary = selection.scope.length ? selection.scope.join(", ") : "no agent (paused)";
360
+ if (sameScope(skill.scope, selection.scope) && Object.hasOwn(readSkillScopes(join(homeDir, ".trellis", "scope.yaml")), name)) {
308
361
  return { ...base, action: "already-set", detail: `scope is already ${summary}` };
309
362
  }
310
- return { ...base, action: "updated", detail: `set scope to ${summary}${normalized}` };
363
+ return { ...base, action: "updated", detail: `set scope to ${summary}` };
311
364
  }
312
365
  /** Write inside a backup session so `trellis rollback` covers it, then the
313
366
  * same skills sync `add` / `remove` run — narrowing is converged by
@@ -55,6 +55,7 @@ export interface AgentSyncReport {
55
55
  }
56
56
  export interface SyncReport {
57
57
  reports: AgentSyncReport[];
58
+ diagnostics?: string[];
58
59
  /** Workspace root when a `.agents/` layer participated in this run. */
59
60
  workspaceRoot?: string;
60
61
  }
@@ -83,7 +83,7 @@ export async function collectSyncReport(opts = {}) {
83
83
  reports.push({ agent: adapter.id, present: true, items: allItems, ...(warnings.length > 0 ? { warnings } : {}) });
84
84
  }
85
85
  ownSession?.finalize();
86
- return { reports, ...(canonical.workspace ? { workspaceRoot: canonical.workspace.root } : {}) };
86
+ return { reports, ...(canonical.diagnostics.length ? { diagnostics: canonical.diagnostics } : {}), ...(canonical.workspace ? { workspaceRoot: canonical.workspace.root } : {}) };
87
87
  }
88
88
  export async function runSync(opts = {}) {
89
89
  let report;
@@ -118,6 +118,8 @@ export function printReport(report, dryRun, check = false) {
118
118
  console.log("[dry run]");
119
119
  if (check)
120
120
  console.log("[check]");
121
+ for (const diagnostic of report.diagnostics ?? [])
122
+ console.log(` ${diagnostic}`);
121
123
  if (report.workspaceRoot) {
122
124
  console.log(`Workspace layer: ${report.workspaceRoot} (.agents/)`);
123
125
  }
@@ -27,8 +27,7 @@ export type ScopeYamlWriteResult = {
27
27
  export declare function validateSkillScopeYaml(path: string): ScopeYamlWriteResult;
28
28
  /**
29
29
  * Sets one Skill's canonical scope without rebuilding unrelated hand-authored
30
- * scope entries. `undefined` removes this Skill's explicit entry and
31
- * therefore restores the normal all-managed default.
30
+ * scope entries. `undefined` removes registration, pausing distribution.
32
31
  */
33
32
  export declare function writeSkillScopeYaml(path: string, name: string, scope: Scope, backup?: BackupSession): ScopeYamlWriteResult;
34
33
  export declare function upsertServerYaml(path: string, name: string, def: McpServerDef, backup?: BackupSession): ServersYamlWriteResult;