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
@@ -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 });
@@ -35,7 +35,7 @@ export interface SecretsFinding {
35
35
  * `~/.trellis/mcp/servers.yaml` itself (see module doc comment). */
36
36
  agent: AgentId | "environment" | "canonical";
37
37
  file: string;
38
- kind: "literal-secret" | "unexpected-var-name" | "missing-env-value";
38
+ kind: "literal-secret" | "unexpected-var-name" | "missing-env-value" | "malformed-reference";
39
39
  detail: string;
40
40
  }
41
41
  export interface SecretsAuditReport {
@@ -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 { 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
@@ -49,7 +49,7 @@ function auditedAgents(homeDir, managedAgents) {
49
49
  * a problem regardless of which agents' `servers.yaml` `agents:` field
50
50
  * would route it to (trellis-secrets-env-management design.md D3).
51
51
  */
52
- function findMissingEnvValues(servers, policy) {
52
+ async function findMissingEnvValues(servers, policy) {
53
53
  const names = new Set();
54
54
  for (const def of Object.values(servers)) {
55
55
  for (const name of declaredEnvNames(def))
@@ -57,16 +57,52 @@ function findMissingEnvValues(servers, policy) {
57
57
  }
58
58
  if (names.size === 0)
59
59
  return [];
60
- const resolved = resolveSecretEnv([...names], policy);
61
- const source = policy.envFile ?? "process environment";
62
- return [...names]
63
- .filter((name) => !resolved[name])
64
- .map((name) => ({
65
- agent: "environment",
66
- file: source,
67
- kind: "missing-env-value",
68
- detail: `"${name}" is declared by a canonical MCP server's env but has no resolvable value`,
69
- }));
60
+ // Malformed `mxenv://` entries are findings in their own right — never
61
+ // skipped, never treated as bare names (mx-env-secret-source spec).
62
+ const entries = [...names];
63
+ const parsed = entries.map(parseEnvEntry);
64
+ const findings = [];
65
+ for (const p of parsed) {
66
+ if (p.kind === "invalid") {
67
+ findings.push({
68
+ agent: "environment",
69
+ file: policy.envFile ?? "process environment",
70
+ kind: "malformed-reference",
71
+ detail: `"${p.entry}" is not a valid mxenv://<profile>/<KEY> reference`,
72
+ });
73
+ }
74
+ }
75
+ const resolvable = entries.filter((_, i) => parsed[i].kind !== "invalid");
76
+ if (resolvable.length === 0)
77
+ return findings;
78
+ // Findings name the DELIVERED name (the KEY segment for a reference).
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
+ }
93
+ for (const entry of resolvable) {
94
+ const p = parseEnvEntry(entry);
95
+ const delivered = p.kind === "invalid" ? entry : p.delivered;
96
+ if (!resolved[entry]) {
97
+ findings.push({
98
+ agent: "environment",
99
+ file: p.kind === "ref" ? `the mxenv composition (profile "${p.profile}")` : (policy.envFile ?? "process environment"),
100
+ kind: "missing-env-value",
101
+ detail: `"${delivered}" is declared by a canonical MCP server's env or headers but has no resolvable value`,
102
+ });
103
+ }
104
+ }
105
+ return findings;
70
106
  }
71
107
  /**
72
108
  * Reject-pattern-only scan of canonical's own `mcp/servers.yaml` (design.md
@@ -118,7 +154,7 @@ export async function collectSecretsAuditReport(opts = {}) {
118
154
  const content = readFileSync(file, "utf-8");
119
155
  findings.push(...auditFile(agent.id, file, content, canonical.secretsPolicy, agent.extractNames));
120
156
  }
121
- findings.push(...findMissingEnvValues(canonical.mcp.servers, canonical.secretsPolicy));
157
+ findings.push(...await findMissingEnvValues(canonical.mcp.servers, canonical.secretsPolicy));
122
158
  findings.push(...auditCanonicalServersYaml(homeDir, canonical.secretsPolicy));
123
159
  return { findings };
124
160
  }
@@ -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;
@@ -13,6 +13,8 @@ import { ALL_AGENTS, oauthClientMetadata } from "./types.js";
13
13
  import { fromServerDefYaml, toServerDefYaml } from "./mcpServerDef.js";
14
14
  import { loadWorkspaceLayer } from "../lib/workspace.js";
15
15
  import { mergeWorkspaceLayer } from "../lib/workspaceMerge.js";
16
+ import { registeredSkillScope } from "../lib/skillScope.js";
17
+ import { isBuiltinSkillName } from "../lib/builtinSkills.js";
16
18
  export { toServerDefYaml } from "./mcpServerDef.js";
17
19
  function trellisRoot(homeDir) {
18
20
  return join(homeDir, ".trellis");
@@ -166,6 +168,9 @@ export function validateSkillScopeYaml(path) {
166
168
  const doc = parseDocument(readFileSync(path, "utf-8"));
167
169
  if (doc.errors.length > 0)
168
170
  throw new Error(doc.errors.map((error) => error.message).join("; "));
171
+ const skills = doc.get("skills", true);
172
+ if (skills !== undefined && !isMap(skills))
173
+ throw new Error("skills must be a map of names to agent lists");
169
174
  return { ok: true };
170
175
  }
171
176
  catch (err) {
@@ -174,8 +179,7 @@ export function validateSkillScopeYaml(path) {
174
179
  }
175
180
  /**
176
181
  * Sets one Skill's canonical scope without rebuilding unrelated hand-authored
177
- * scope entries. `undefined` removes this Skill's explicit entry and
178
- * therefore restores the normal all-managed default.
182
+ * scope entries. `undefined` removes registration, pausing distribution.
179
183
  */
180
184
  export function writeSkillScopeYaml(path, name, scope, backup) {
181
185
  if (!existsSync(path) && scope === undefined)
@@ -428,9 +432,6 @@ export function ensureShellEnvSource(rcPath, envFilePath, backup) {
428
432
  mkdirSync(dirname(rcPath), { recursive: true });
429
433
  persistText(rcPath, `${existing}${separator}${block}`, backup);
430
434
  }
431
- /** `undefined` if the name has no entry in scope.yaml's map — "shared with
432
- * all managed agents by default. A recognized but empty list is left as
433
- * authored (an explicitly agent-less scope), not coerced to "all". */
434
435
  /** Missing file and `agents: []` both resolve to `[]` — zero managed
435
436
  * agents, never "everyone." An unrecognized id is dropped with a
436
437
  * diagnostic, same posture as an unrecognized scope.yaml agent id. */
@@ -455,9 +456,9 @@ function scopeFor(map, name) {
455
456
  return map?.[name];
456
457
  }
457
458
  function validAgentIds(scope) {
458
- if (!scope)
459
+ if (scope === undefined)
459
460
  return true;
460
- return scope.every((id) => ALL_AGENTS.includes(id));
461
+ return Array.isArray(scope) && scope.every((id) => ALL_AGENTS.includes(id));
461
462
  }
462
463
  /**
463
464
  * Load-time literal-value scan for OAuth client metadata
@@ -497,7 +498,13 @@ export function loadCanonicalSource(arg = {}) {
497
498
  const scopeYaml = loadScopeYaml(join(root, "scope.yaml"));
498
499
  const skillDirs = listSkillDirs(join(root, "skills"));
499
500
  const knownSkillNames = new Set(skillDirs.map((s) => s.name));
500
- const skills = skillDirs.map(({ name, dir }) => ({ name, dir, scope: scopeFor(scopeYaml.skills, name) }));
501
+ const skills = skillDirs.map(({ name, dir }) => {
502
+ const declared = scopeFor(scopeYaml.skills, name);
503
+ if (declared === undefined && !isBuiltinSkillName(name)) {
504
+ diagnostics.push(`scope.yaml: skill "${name}" has no registered agents — distribution paused; run trellis skill scope ${name} --agents <ids>`);
505
+ }
506
+ return { name, dir, scope: isBuiltinSkillName(name) && declared === undefined ? undefined : registeredSkillScope(declared) };
507
+ });
501
508
  const agentFiles = listMarkdownFiles(join(root, "agents"));
502
509
  const knownAgentProfileNames = new Set(agentFiles.map((a) => a.name));
503
510
  const agents = agentFiles.map(({ name, file }) => ({
@@ -523,7 +530,7 @@ export function loadCanonicalSource(arg = {}) {
523
530
  diagnostics.push(`scope.yaml: "${section}.${name}" does not match any known ${section.slice(0, -1)} — ignored`);
524
531
  }
525
532
  else if (!validAgentIds(map?.[name])) {
526
- diagnostics.push(`scope.yaml: "${section}.${name}" lists an unrecognized agent id — ignored`);
533
+ diagnostics.push(`scope.yaml: "${section}.${name}" lists an unrecognized agent id${section === "skills" ? " — distribution paused" : " — ignored"}`);
527
534
  }
528
535
  }
529
536
  }
@@ -39,11 +39,9 @@ export declare function oauthClientMetadata(auth: McpAuthMode | McpAuthConfig |
39
39
  export type AgentId = "claude-code" | "codex" | "kiro" | "pi" | "kimi-code" | "zcode";
40
40
  export declare const ALL_AGENTS: readonly AgentId[];
41
41
  /**
42
- * Every scopable item (skill, subagent, memory entry, MCP server) defaults
43
- * to all managed agents when `scope` is omitted — sharing everywhere is the
44
- * common case Trellis exists for; restricting to specific agents is the
45
- * exception and must be declared explicitly. See docs/architecture.md
46
- * "Private / agent-specific capabilities".
42
+ * Omitted scope resolves to the managed set. Custom skills are registered
43
+ * explicitly by the canonical/workspace loaders: missing or invalid skill
44
+ * registration becomes `[]` (paused). Other capability defaults are unchanged.
47
45
  */
48
46
  export type Scope = AgentId[] | undefined;
49
47
  /**
@@ -69,7 +67,9 @@ export interface McpServerDef {
69
67
  /** http/sse only */
70
68
  url?: string;
71
69
  /**
72
- * http/sse only. Values are `${VAR}` references, same discipline as
70
+ * http/sse only. Values are `${VAR}` or `${mxenv://profile/KEY}` templates,
71
+ * the latter requiring Trellis-hosted resolution (gateway/runtime/pi).
72
+ * Same discipline as
73
73
  * `env` — never a real value. Codex has no generic headers concept;
74
74
  * only the single shape `{ Authorization: "Bearer ${VAR}" }` is
75
75
  * expressible there (trellis-mcp-transport-auth design.md D4) — any
@@ -190,7 +190,8 @@ export interface SkillRef {
190
190
  * filename is case-sensitive on at least one target agent (Codex). */
191
191
  dir: string;
192
192
  /**
193
- * Omit for "all agents" (the default). Deliberately NOT read from
193
+ * Explicit applicable agents; unregistered custom skills load as `[]`.
194
+ * Package-owned built-ins may supply their own default. NOT read from
194
195
  * SKILL.md's own frontmatter — Codex validates SKILL.md frontmatter
195
196
  * against an allow-list of known keys and rejects unknown ones (see
196
197
  * docs/research.md), so a Trellis-only field embedded there would break
@@ -7,7 +7,11 @@
7
7
  * still more machinery than this one narrow extraction needs.
8
8
  */
9
9
  import type { McpServerDef } from "../core/types.js";
10
- /** Every `${NAME}` occurrence in a string — a header value can in
10
+ export declare function replaceTemplateVars(value: string, replace: (entry: string) => string): string;
11
+ /** Report only the header's name, never a potentially sensitive template. */
12
+ export declare function invalidHeaderTemplate(headers: Record<string, string> | undefined): string | undefined;
13
+ /** Every `${NAME}` or `${mxenv://profile/KEY}` occurrence in a string,
14
+ * including malformed placeholders for validation — a header value can in
11
15
  * principle reference more than one name (trellis-mcp-transport-auth
12
16
  * design.md D6), same extraction `env`'s single-name-per-value case
13
17
  * already needed, just generalized. */
@@ -6,8 +6,21 @@
6
6
  * there is no round-trip-fidelity concern here, but a full parser is
7
7
  * still more machinery than this one narrow extraction needs.
8
8
  */
9
- const TEMPLATE_VAR_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
10
- /** Every `${NAME}` occurrence in a string — a header value can in
9
+ // Keep malformed placeholders visible too: a misspelled reference scheme
10
+ // must reach validation rather than silently survive as literal header text.
11
+ const TEMPLATE_VAR_RE = /\$\{([^{}]*)\}/g;
12
+ export function replaceTemplateVars(value, replace) {
13
+ return value.replace(TEMPLATE_VAR_RE, (_match, entry) => replace(entry));
14
+ }
15
+ /** Report only the header's name, never a potentially sensitive template. */
16
+ export function invalidHeaderTemplate(headers) {
17
+ return Object.entries(headers ?? {}).find(([, value]) => {
18
+ const literal = replaceTemplateVars(value, () => "");
19
+ return literal.includes("${") || /mx-?env:\/\//.test(literal);
20
+ })?.[0];
21
+ }
22
+ /** Every `${NAME}` or `${mxenv://profile/KEY}` occurrence in a string,
23
+ * including malformed placeholders for validation — a header value can in
11
24
  * principle reference more than one name (trellis-mcp-transport-auth
12
25
  * design.md D6), same extraction `env`'s single-name-per-value case
13
26
  * already needed, just generalized. */
@@ -22,7 +35,14 @@ export function extractTemplateVarNames(value) {
22
35
  * same answer to "what names does this def reference," not two
23
36
  * separately-maintained collections. */
24
37
  export function declaredEnvNames(def) {
38
+ // RAW entries, including `envAliases` sources (mx-env-secret-source
39
+ // secrets-audit delta): consumers that RESOLVE must see the original
40
+ // `mxenv://` text so references go through composition, then look the
41
+ // result up by delivered name; consumers that want the delivered NAMES
42
+ // map through `deliveredName` at their own use site (e.g. Kiro).
25
43
  const names = new Set(def.env ?? []);
44
+ for (const source of Object.values(def.envAliases ?? {}))
45
+ names.add(source);
26
46
  for (const value of Object.values(def.headers ?? {})) {
27
47
  for (const name of extractTemplateVarNames(value))
28
48
  names.add(name);
@@ -45,7 +45,7 @@ export declare function connectWithCleanup<T, TTransport extends {
45
45
  connect(transport: TTransport): Promise<unknown>;
46
46
  }, transport: TTransport, timeoutMs: number, message: string): Promise<T>;
47
47
  export declare function connectStdio(def: McpServerDef, secretsPolicy: SecretsPolicy, timeoutMs: number, clientInfo: McpClientInfo): Promise<Client>;
48
- export declare function resolveHeaders(def: McpServerDef, secretsPolicy: SecretsPolicy): Record<string, string> | undefined;
48
+ export declare function resolveHeaders(def: McpServerDef, secretsPolicy: SecretsPolicy): Promise<Record<string, string> | undefined>;
49
49
  export declare function connectHttp(def: McpServerDef, secretsPolicy: SecretsPolicy, timeoutMs: number, clientInfo: McpClientInfo, authProvider?: AuthProvider): Promise<Client>;
50
50
  export declare function connectSse(def: McpServerDef, secretsPolicy: SecretsPolicy, timeoutMs: number, clientInfo: McpClientInfo, authProvider?: AuthProvider): Promise<Client>;
51
51
  /**