agent-trellis 0.10.0 → 0.11.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.
package/dist/cli.js CHANGED
@@ -9,13 +9,14 @@ import { runInit } from "./commands/init.js";
9
9
  import { runMigrate } from "./commands/migrate.js";
10
10
  import { runOnboard } from "./commands/onboard.js";
11
11
  import { runSync } from "./commands/sync.js";
12
- import { runMcpSync, runMcpList, runMcpAdd, runMcpRemove, runMcpSetAuth, parseMcpAddArgs, parseMcpSetArgs } from "./commands/mcp.js";
12
+ import { runMcpSync, runMcpList, runMcpAdd, runMcpRemove, runMcpSetAuth, runMcpScope, parseMcpAddArgs, parseMcpSetArgs } from "./commands/mcp.js";
13
+ import { parseScopeArgs } from "./lib/scopeSelection.js";
13
14
  import { runMcpImport } from "./commands/mcpImport.js";
14
15
  import { parseMcpGatewayArgs, runMcpGateway, runMcpRuntime } from "./commands/mcpGateway.js";
15
16
  import { runMcpAuth } from "./commands/mcpAuth.js";
16
17
  import { runSecretsAudit } from "./commands/secretsAudit.js";
17
18
  import { runRollback } from "./commands/rollback.js";
18
- import { runSkillList, runSkillAdd, runSkillRemove, runSkillUpdateBuiltin } from "./commands/skill.js";
19
+ import { runSkillList, runSkillAdd, runSkillRemove, runSkillScope, runSkillUpdateBuiltin } from "./commands/skill.js";
19
20
  import { runRemoteSkillAdd, runRemoteSkillList, runRemoteSkillUpdate } from "./commands/remoteSkill.js";
20
21
  import { collectMemoryList, runMemoryExtraction, runMemorySync } from "./commands/memory.js";
21
22
  import { parseManageArgs, runManage } from "./commands/manage.js";
@@ -142,6 +143,14 @@ Commands:
142
143
  --auth none removes it along with the classification.
143
144
  --dry-run preview the plan, write nothing
144
145
  --json machine-readable output, no report text
146
+ mcp scope <name> --agents <ids> | --all | --none
147
+ Choose which managed agents reach a canonical MCP server, then
148
+ run the same sync as mcp sync. Exactly one selector is
149
+ required; ids must be managed agents. Selecting every managed
150
+ agent records no explicit scope, so a newly managed agent is
151
+ included. An entry Trellis did not write is never removed.
152
+ --dry-run preview the plan, write nothing
153
+ --json machine-readable output, no report text
145
154
  mcp auth <server-name>
146
155
  Authorize a remote (http/sse) MCP server that uses OAuth —
147
156
  opens a browser once, stores the result 0600 under
@@ -169,6 +178,12 @@ Commands:
169
178
  --dry-run preview the plan, write nothing
170
179
  --json machine-readable output, no report text
171
180
  skill remove <name>
181
+ skill scope <name> --agents <ids> | --all | --none
182
+ Choose which managed agents receive a canonical skill, then
183
+ sync it. Same selector rules as mcp scope; a built-in skill's
184
+ scope is not editable
185
+ --dry-run preview the plan, write nothing
186
+ --json machine-readable output, no report text
172
187
  skill update-builtin
173
188
  Refresh the package-owned trellis-runtime Skill in canonical
174
189
  source; existing native symlinks see the update
@@ -440,6 +455,11 @@ async function main(argv) {
440
455
  process.exitCode = runMcpSetAuth(name, parseMcpSetArgs(mcpRest), { json, dryRun }).exitCode;
441
456
  return;
442
457
  }
458
+ if (subcommand === "scope") {
459
+ const [name] = mcpRest;
460
+ process.exitCode = (await runMcpScope(name && !name.startsWith("--") ? name : undefined, parseScopeArgs(mcpRest), { json, dryRun })).exitCode;
461
+ return;
462
+ }
443
463
  if (subcommand === "remove") {
444
464
  const [name] = mcpRest;
445
465
  if (!name) {
@@ -461,7 +481,7 @@ async function main(argv) {
461
481
  process.exitCode = exitCode;
462
482
  return;
463
483
  }
464
- console.error(`Unknown mcp subcommand: ${subcommand ?? "(none)"}\nUsage: trellis mcp sync|list|add <name>|set <name>|remove <name>|auth <name>\n`);
484
+ console.error(`Unknown mcp subcommand: ${subcommand ?? "(none)"}\nUsage: trellis mcp sync|list|add <name>|set <name>|scope <name>|remove <name>|auth <name>\n`);
465
485
  process.exitCode = 1;
466
486
  return;
467
487
  }
@@ -495,11 +515,16 @@ async function main(argv) {
495
515
  process.exitCode = (await runSkillRemove(name, { json, dryRun })).exitCode;
496
516
  return;
497
517
  }
518
+ if (subcommand === "scope") {
519
+ const [name] = skillRest;
520
+ process.exitCode = (await runSkillScope(name && !name.startsWith("--") ? name : undefined, parseScopeArgs(skillRest), { json, dryRun })).exitCode;
521
+ return;
522
+ }
498
523
  if (subcommand === "update-builtin") {
499
524
  process.exitCode = runSkillUpdateBuiltin({ json, dryRun }).exitCode;
500
525
  return;
501
526
  }
502
- console.error(`Unknown skill subcommand: ${subcommand ?? "(none)"}\nUsage: trellis skill list|add <name> --from <path>|remove <name>|update-builtin\n`);
527
+ console.error(`Unknown skill subcommand: ${subcommand ?? "(none)"}\nUsage: trellis skill list|add <name> --from <path>|remove <name>|scope <name> --agents <ids>|--all|--none|update-builtin\n`);
503
528
  process.exitCode = 1;
504
529
  return;
505
530
  }
@@ -15,6 +15,7 @@
15
15
  import { type ServersYamlWriteResult } from "../core/canonical.js";
16
16
  import type { AdapterPlanItem } from "../core/adapter.js";
17
17
  import type { AgentId, McpAuthMode, McpConfig, McpServerDef, Transport } from "../core/types.js";
18
+ import { type ScopeSelectorRaw } from "../lib/scopeSelection.js";
18
19
  import { type BackupSession } from "../lib/backup.js";
19
20
  export interface RunMcpSyncOptions {
20
21
  json?: boolean;
@@ -62,6 +63,12 @@ export interface McpListEntry {
62
63
  * ever changes and there is one less place a future secret-looking field
63
64
  * could leak through. */
64
65
  preRegisteredClient?: true;
66
+ /** State of the stored OAuth credential, for OAuth-classified servers only
67
+ * (trellis-scope-editing-and-auth-status design.md D6). It describes the
68
+ * credential, not the connection: an `authorized` server can still be down. */
69
+ authStatus?: McpAuthStatus;
70
+ /** Epoch ms, when the stored token advertises an expiry. */
71
+ authExpiresAt?: number;
65
72
  enabled: boolean;
66
73
  agents: readonly AgentId[];
67
74
  command?: string;
@@ -77,6 +84,18 @@ export interface McpListEntry {
77
84
  * printed in full. */
78
85
  staticEnv?: Record<string, string>;
79
86
  }
87
+ export type McpAuthStatus = "not-authorized" | "authorized" | "refreshable" | "expired";
88
+ /**
89
+ * Reads only the per-server token store and returns the state, never a
90
+ * token. Classification stays explicit — the caller only asks for servers
91
+ * already declared OAuth, so a stray token file never makes one. A server
92
+ * name that cannot be a token filename, or an unreadable file, simply has no
93
+ * credential: one odd entry must not take down the whole listing.
94
+ */
95
+ export declare function mcpCredentialState(homeDir: string, serverName: string): {
96
+ authStatus: McpAuthStatus;
97
+ authExpiresAt?: number;
98
+ };
80
99
  export declare function collectMcpListPlan(homeDir?: string): McpListEntry[];
81
100
  export declare function runMcpList(opts?: {
82
101
  homeDir?: string;
@@ -169,6 +188,47 @@ export declare function runMcpSetAuth(name: string | undefined, raw: McpSetAuthR
169
188
  }): {
170
189
  exitCode: number;
171
190
  };
191
+ export type McpScopeAction = "updated" | "already-set" | "not-found" | "invalid-input";
192
+ export interface McpScopePlan {
193
+ name: string;
194
+ action: McpScopeAction;
195
+ detail: string;
196
+ /** Recorded as `agents:` on the entry; `undefined` removes the key so the
197
+ * server reaches every managed agent. */
198
+ scope?: AgentId[];
199
+ mode?: "all" | "none" | "explicit";
200
+ normalizedFromFull?: boolean;
201
+ effective?: readonly AgentId[];
202
+ /** Only set when action === "updated"; consumed by applyMcpScopeWithSync. */
203
+ def?: McpServerDef;
204
+ }
205
+ /**
206
+ * `mcp scope <name> --agents a,b | --all | --none`
207
+ * (trellis-scope-editing-and-auth-status tasks.md 1.2). Same selector rules as
208
+ * `skill scope`, which is why both go through `resolveScopeSelection`.
209
+ */
210
+ export declare function collectMcpScopePlan(name: string | undefined, raw: ScopeSelectorRaw, homeDir?: string): McpScopePlan;
211
+ export interface McpScopeOutcome {
212
+ plan: McpScopePlan;
213
+ writeError?: string;
214
+ sync?: McpSyncReport;
215
+ }
216
+ /** Write inside a backup session, then the same `mcp sync` `add` / `remove`
217
+ * run. Removal of an entry from an agent narrowed away stays gated by the
218
+ * ownership ledger, so an entry Trellis did not write is never deleted
219
+ * (design.md D5). */
220
+ export declare function applyMcpScopeWithSync(plan: McpScopePlan, opts?: {
221
+ homeDir?: string;
222
+ dryRun?: boolean;
223
+ backupSession?: BackupSession;
224
+ }): Promise<McpScopeOutcome>;
225
+ export declare function runMcpScope(name: string | undefined, raw: ScopeSelectorRaw, opts?: {
226
+ homeDir?: string;
227
+ json?: boolean;
228
+ dryRun?: boolean;
229
+ }): Promise<{
230
+ exitCode: number;
231
+ }>;
172
232
  export declare function applyMcpAddPlan(plan: McpAddPlan, homeDir?: string, backup?: BackupSession): ServersYamlWriteResult;
173
233
  export interface McpAddOutcome {
174
234
  plan: McpAddPlan;
@@ -18,6 +18,8 @@ import { join } from "node:path";
18
18
  import { loadCanonicalSource, upsertServerYaml, removeServerYaml } from "../core/canonical.js";
19
19
  import { ALL_AGENTS, isOAuthAuth, oauthClientMetadata, resolveScope } from "../core/types.js";
20
20
  import { isValidSecretVarName } from "../lib/secretEnv.js";
21
+ import { isExpired, readToken } from "../lib/oauth/store.js";
22
+ import { describeScope, resolveScopeSelection, sameScope } from "../lib/scopeSelection.js";
21
23
  import { ClaudeCodeAdapter } from "../adapters/claude-code.js";
22
24
  import { CodexAdapter } from "../adapters/codex.js";
23
25
  import { KiroAdapter } from "../adapters/kiro.js";
@@ -114,6 +116,28 @@ export function printReport(report, dryRun) {
114
116
  function serversYamlPath(homeDir) {
115
117
  return join(homeDir, ".trellis", "mcp", "servers.yaml");
116
118
  }
119
+ /**
120
+ * Reads only the per-server token store and returns the state, never a
121
+ * token. Classification stays explicit — the caller only asks for servers
122
+ * already declared OAuth, so a stray token file never makes one. A server
123
+ * name that cannot be a token filename, or an unreadable file, simply has no
124
+ * credential: one odd entry must not take down the whole listing.
125
+ */
126
+ export function mcpCredentialState(homeDir, serverName) {
127
+ let token;
128
+ try {
129
+ token = readToken(homeDir, serverName);
130
+ }
131
+ catch {
132
+ return { authStatus: "not-authorized" };
133
+ }
134
+ if (!token?.accessToken)
135
+ return { authStatus: "not-authorized" };
136
+ const expiry = token.expiresAt !== undefined ? { authExpiresAt: token.expiresAt } : {};
137
+ if (!isExpired(token))
138
+ return { authStatus: "authorized", ...expiry };
139
+ return { authStatus: token.refreshToken ? "refreshable" : "expired", ...expiry };
140
+ }
117
141
  export function collectMcpListPlan(homeDir = homedir()) {
118
142
  const canonical = loadCanonicalSource(homeDir);
119
143
  return Object.entries(canonical.mcp.servers).map(([name, def]) => ({
@@ -121,6 +145,7 @@ export function collectMcpListPlan(homeDir = homedir()) {
121
145
  transport: def.transport,
122
146
  auth: isOAuthAuth(def.auth) ? "oauth" : undefined,
123
147
  preRegisteredClient: oauthClientMetadata(def.auth).clientId !== undefined ? true : undefined,
148
+ ...(isOAuthAuth(def.auth) ? mcpCredentialState(homeDir, name) : {}),
124
149
  enabled: def.enabled ?? true,
125
150
  agents: resolveScope(def.agents, canonical.managedAgents),
126
151
  command: def.command,
@@ -150,7 +175,7 @@ export function runMcpList(opts = {}) {
150
175
  else {
151
176
  for (const e of entries) {
152
177
  const scope = e.agents.length > 0 ? e.agents.join(", ") : "(no managed agent reaches it)";
153
- console.log(`${e.name} (${e.transport}${e.auth === "oauth" ? ", oauth" : ""}${e.preRegisteredClient ? ", pre-registered client" : ""})${e.enabled ? "" : " [disabled]"} — ${scope}`);
178
+ console.log(`${e.name} (${e.transport}${e.auth === "oauth" ? ", oauth" : ""}${e.preRegisteredClient ? ", pre-registered client" : ""}${e.authStatus ? `, credential: ${e.authStatus}` : ""})${e.enabled ? "" : " [disabled]"} — ${scope}`);
154
179
  if (e.env && e.env.length > 0)
155
180
  console.log(` env: ${e.env.join(", ")} (values never read/printed)`);
156
181
  if (e.staticEnv)
@@ -399,6 +424,85 @@ export function runMcpSetAuth(name, raw, opts = {}) {
399
424
  }
400
425
  return { exitCode: ["invalid-input", "not-found"].includes(plan.action) || Boolean(writeError) ? 1 : 0 };
401
426
  }
427
+ /**
428
+ * `mcp scope <name> --agents a,b | --all | --none`
429
+ * (trellis-scope-editing-and-auth-status tasks.md 1.2). Same selector rules as
430
+ * `skill scope`, which is why both go through `resolveScopeSelection`.
431
+ */
432
+ export function collectMcpScopePlan(name, raw, homeDir = homedir()) {
433
+ if (!name)
434
+ return { name: "(none)", action: "invalid-input", detail: "usage: trellis mcp scope <name> --agents <ids> | --all | --none" };
435
+ const canonical = loadCanonicalSource(homeDir);
436
+ const selection = resolveScopeSelection(raw, canonical.managedAgents);
437
+ if (!selection.ok)
438
+ return { name, action: "invalid-input", detail: selection.detail };
439
+ const current = canonical.mcp.servers[name];
440
+ if (!current)
441
+ return { name, action: "not-found", detail: `no canonical MCP server named "${name}"` };
442
+ const base = {
443
+ name,
444
+ scope: selection.scope,
445
+ mode: selection.mode,
446
+ normalizedFromFull: selection.normalizedFromFull,
447
+ effective: resolveScope(selection.scope, canonical.managedAgents),
448
+ };
449
+ const summary = describeScope(selection.scope, canonical.managedAgents);
450
+ if (sameScope(current.agents, selection.scope)) {
451
+ return { ...base, action: "already-set", detail: `scope is already ${summary}` };
452
+ }
453
+ const { agents: _agents, ...withoutAgents } = current;
454
+ const def = selection.scope === undefined ? withoutAgents : { ...withoutAgents, agents: selection.scope };
455
+ const normalized = selection.normalizedFromFull ? " (every managed agent selected — recorded as the default, so a newly managed agent is included)" : "";
456
+ return { ...base, action: "updated", detail: `set scope to ${summary}${normalized}`, def };
457
+ }
458
+ /** Write inside a backup session, then the same `mcp sync` `add` / `remove`
459
+ * run. Removal of an entry from an agent narrowed away stays gated by the
460
+ * ownership ledger, so an entry Trellis did not write is never deleted
461
+ * (design.md D5). */
462
+ export async function applyMcpScopeWithSync(plan, opts = {}) {
463
+ const homeDir = opts.homeDir ?? homedir();
464
+ const ownSession = !opts.dryRun && !opts.backupSession && plan.action === "updated" ? openBackupSession(homeDir, "mcp-scope") : undefined;
465
+ const session = opts.backupSession ?? ownSession;
466
+ let writeError;
467
+ if (!opts.dryRun && plan.action === "updated" && plan.def) {
468
+ const result = upsertServerYaml(serversYamlPath(homeDir), plan.name, plan.def, session);
469
+ if (!result.ok)
470
+ writeError = result.error;
471
+ }
472
+ ownSession?.finalize();
473
+ let syncReport;
474
+ if (!writeError && plan.action === "updated" && existsSync(join(homeDir, ".trellis"))) {
475
+ syncReport = await collectMcpSyncReport({ homeDir, dryRun: opts.dryRun });
476
+ }
477
+ return { plan, ...(writeError ? { writeError } : {}), ...(syncReport ? { sync: syncReport } : {}) };
478
+ }
479
+ export async function runMcpScope(name, raw, opts = {}) {
480
+ const homeDir = opts.homeDir ?? homedir();
481
+ let outcome;
482
+ try {
483
+ outcome = await applyMcpScopeWithSync(collectMcpScopePlan(name, raw, homeDir), { homeDir, dryRun: opts.dryRun });
484
+ }
485
+ catch (err) {
486
+ console.error(err instanceof Error ? err.message : String(err));
487
+ return { exitCode: 1 };
488
+ }
489
+ const { plan, writeError, sync: syncReport } = outcome;
490
+ if (opts.json) {
491
+ const payload = writeError ? { ...plan, writeError } : plan;
492
+ console.log(JSON.stringify(syncReport ? { ...payload, sync: syncReport } : payload, null, 2));
493
+ }
494
+ else {
495
+ console.log(`${opts.dryRun ? "[dry run] " : ""}mcp scope ${plan.name}`);
496
+ console.log(` [${plan.action}] ${plan.detail}`);
497
+ if (writeError)
498
+ console.error(` write failed: ${writeError}`);
499
+ if (syncReport)
500
+ printReport(syncReport, opts.dryRun ?? false);
501
+ }
502
+ const failed = plan.action === "invalid-input" || plan.action === "not-found" || Boolean(writeError);
503
+ const syncConflict = syncReport?.reports.some((r) => r.items.some((i) => i.action === "conflict")) ?? false;
504
+ return { exitCode: failed || syncConflict ? 1 : 0 };
505
+ }
402
506
  export function applyMcpAddPlan(plan, homeDir = homedir(), backup) {
403
507
  if (plan.action !== "create" || !plan.def)
404
508
  return { ok: true };
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `trellis skill list|add|remove` — command-line CRUD for canonical
2
+ * `trellis skill list|add|remove|scope` — command-line CRUD for canonical
3
3
  * skills (trellis-canonical-cli-crud), an alternative to hand-editing
4
4
  * `~/.trellis/skills/<name>/SKILL.md` directly. `add`'s conflict
5
5
  * decision and `remove`'s "sync will un-sync it" behavior both reuse
@@ -10,6 +10,7 @@
10
10
  import type { AgentId } from "../core/types.js";
11
11
  import { type BackupSession } from "../lib/backup.js";
12
12
  import type { SyncReport } from "./sync.js";
13
+ import { type ScopeSelectorRaw } from "../lib/scopeSelection.js";
13
14
  import type { RemoteSkillLockEntry } from "../lib/remoteSkillLock.js";
14
15
  export type BuiltinSkillUpdateAction = "create" | "update" | "already-current";
15
16
  export interface BuiltinSkillUpdatePlan {
@@ -97,3 +98,46 @@ export declare function runSkillRemove(name: string, opts?: {
97
98
  }): Promise<{
98
99
  exitCode: number;
99
100
  }>;
101
+ export type SkillScopeAction = "updated" | "already-set" | "not-found" | "invalid-input";
102
+ export interface SkillScopePlan {
103
+ name: string;
104
+ action: SkillScopeAction;
105
+ detail: string;
106
+ /** What will be recorded in `scope.yaml`; `undefined` clears the explicit
107
+ * scope so the skill reaches every managed agent. Set when the selection was
108
+ * valid, whatever the action. */
109
+ scope?: AgentId[];
110
+ mode?: "all" | "none" | "explicit";
111
+ /** True when a full selection was dropped instead of recorded
112
+ * (trellis-scope-editing-and-auth-status design.md D3). */
113
+ normalizedFromFull?: boolean;
114
+ /** The agents that will actually receive the skill afterwards. */
115
+ effective?: readonly AgentId[];
116
+ }
117
+ /**
118
+ * `skill scope <name> --agents a,b | --all | --none`
119
+ * (trellis-scope-editing-and-auth-status tasks.md 1.1). Plan only — the write
120
+ * and the cascade sync live in `applySkillScopeWithSync`.
121
+ */
122
+ export declare function collectSkillScopePlan(name: string | undefined, raw: ScopeSelectorRaw, homeDir?: string): SkillScopePlan;
123
+ export interface SkillScopeOutcome {
124
+ plan: SkillScopePlan;
125
+ writeError?: string;
126
+ sync?: SyncReport;
127
+ }
128
+ /** Write inside a backup session so `trellis rollback` covers it, then the
129
+ * same skills sync `add` / `remove` run — narrowing is converged by
130
+ * `symlinkPlan`'s existing "canonical entry gone or scoped away" removal
131
+ * (design.md D5). */
132
+ export declare function applySkillScopeWithSync(plan: SkillScopePlan, opts?: {
133
+ homeDir?: string;
134
+ dryRun?: boolean;
135
+ backupSession?: BackupSession;
136
+ }): Promise<SkillScopeOutcome>;
137
+ export declare function runSkillScope(name: string | undefined, raw: ScopeSelectorRaw, opts?: {
138
+ homeDir?: string;
139
+ json?: boolean;
140
+ dryRun?: boolean;
141
+ }): Promise<{
142
+ exitCode: number;
143
+ }>;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `trellis skill list|add|remove` — command-line CRUD for canonical
2
+ * `trellis skill list|add|remove|scope` — command-line CRUD for canonical
3
3
  * skills (trellis-canonical-cli-crud), an alternative to hand-editing
4
4
  * `~/.trellis/skills/<name>/SKILL.md` directly. `add`'s conflict
5
5
  * decision and `remove`'s "sync will un-sync it" behavior both reuse
@@ -14,12 +14,13 @@ 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 } from "../core/canonical.js";
17
+ import { loadCanonicalSource, 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
24
  function builtinSkillTemplate() {
24
25
  const here = dirname(fileURLToPath(import.meta.url));
25
26
  return readFileSync(join(here, "..", "..", "schema", "builtin-skills", "trellis-runtime", "SKILL.md"), "utf8");
@@ -205,3 +206,83 @@ export async function runSkillRemove(name, opts = {}) {
205
206
  const syncConflict = syncReport?.reports.some((r) => r.items.some((i) => i.action === "conflict")) ?? false;
206
207
  return { exitCode: failed || syncConflict ? 1 : 0 };
207
208
  }
209
+ /**
210
+ * `skill scope <name> --agents a,b | --all | --none`
211
+ * (trellis-scope-editing-and-auth-status tasks.md 1.1). Plan only — the write
212
+ * and the cascade sync live in `applySkillScopeWithSync`.
213
+ */
214
+ export function collectSkillScopePlan(name, raw, homeDir = homedir()) {
215
+ if (!name)
216
+ return { name: "(none)", action: "invalid-input", detail: "usage: trellis skill scope <name> --agents <ids> | --all | --none" };
217
+ if (isBuiltinSkillName(name)) {
218
+ return { name, action: "invalid-input", detail: `"${name}" is a Trellis package-owned Skill — its scope is not editable` };
219
+ }
220
+ const canonical = loadCanonicalSource(homeDir);
221
+ const selection = resolveScopeSelection(raw, canonical.managedAgents);
222
+ if (!selection.ok)
223
+ return { name, action: "invalid-input", detail: selection.detail };
224
+ const skill = canonical.skills.find((candidate) => candidate.name === name);
225
+ if (!skill)
226
+ return { name, action: "not-found", detail: `no canonical skill named "${name}"` };
227
+ const base = {
228
+ name,
229
+ scope: selection.scope,
230
+ mode: selection.mode,
231
+ normalizedFromFull: selection.normalizedFromFull,
232
+ effective: resolveScope(selection.scope, canonical.managedAgents),
233
+ };
234
+ const summary = describeScope(selection.scope, canonical.managedAgents);
235
+ const normalized = selection.normalizedFromFull ? " (every managed agent selected — recorded as the default, so a newly managed agent is included)" : "";
236
+ if (sameScope(skill.scope, selection.scope)) {
237
+ return { ...base, action: "already-set", detail: `scope is already ${summary}` };
238
+ }
239
+ return { ...base, action: "updated", detail: `set scope to ${summary}${normalized}` };
240
+ }
241
+ /** Write inside a backup session so `trellis rollback` covers it, then the
242
+ * same skills sync `add` / `remove` run — narrowing is converged by
243
+ * `symlinkPlan`'s existing "canonical entry gone or scoped away" removal
244
+ * (design.md D5). */
245
+ export async function applySkillScopeWithSync(plan, opts = {}) {
246
+ const homeDir = opts.homeDir ?? homedir();
247
+ const ownSession = !opts.dryRun && !opts.backupSession && plan.action === "updated" ? openBackupSession(homeDir, "skill-scope") : undefined;
248
+ const session = opts.backupSession ?? ownSession;
249
+ let writeError;
250
+ if (!opts.dryRun && plan.action === "updated") {
251
+ const result = writeSkillScopeYaml(join(homeDir, ".trellis", "scope.yaml"), plan.name, plan.scope, session);
252
+ if (!result.ok)
253
+ writeError = result.error;
254
+ }
255
+ ownSession?.finalize();
256
+ let syncReport;
257
+ if (!writeError && plan.action === "updated" && existsSync(join(homeDir, ".trellis"))) {
258
+ syncReport = await collectSyncReport({ target: "skills", homeDir, dryRun: opts.dryRun });
259
+ }
260
+ return { plan, ...(writeError ? { writeError } : {}), ...(syncReport ? { sync: syncReport } : {}) };
261
+ }
262
+ export async function runSkillScope(name, raw, opts = {}) {
263
+ const homeDir = opts.homeDir ?? homedir();
264
+ let outcome;
265
+ try {
266
+ outcome = await applySkillScopeWithSync(collectSkillScopePlan(name, raw, homeDir), { homeDir, dryRun: opts.dryRun });
267
+ }
268
+ catch (err) {
269
+ console.error(err instanceof Error ? err.message : String(err));
270
+ return { exitCode: 1 };
271
+ }
272
+ const { plan, writeError, sync: syncReport } = outcome;
273
+ if (opts.json) {
274
+ const payload = writeError ? { ...plan, writeError } : plan;
275
+ console.log(JSON.stringify(syncReport ? { ...payload, sync: syncReport } : payload, null, 2));
276
+ }
277
+ else {
278
+ console.log(`${opts.dryRun ? "[dry run] " : ""}skill scope ${plan.name}`);
279
+ console.log(` [${plan.action}] ${plan.detail}`);
280
+ if (writeError)
281
+ console.error(` write failed: ${writeError}`);
282
+ if (syncReport)
283
+ printSyncReport(syncReport, opts.dryRun ?? false);
284
+ }
285
+ const failed = plan.action === "invalid-input" || plan.action === "not-found" || Boolean(writeError);
286
+ const syncConflict = syncReport?.reports.some((r) => r.items.some((i) => i.action === "conflict")) ?? false;
287
+ return { exitCode: failed || syncConflict ? 1 : 0 };
288
+ }
@@ -2,6 +2,7 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
2
  import type { CallToolResult, GetPromptResult, Implementation, Prompt, ReadResourceResult, Resource, Tool } from "@modelcontextprotocol/sdk/types.js";
3
3
  import type { AgentId } from "../core/types.js";
4
4
  import type { GatewayBackend } from "./gatewayBackend.js";
5
+ import type { SkillEntry } from "./skillManifest.js";
5
6
  export interface RuntimeContext {
6
7
  agentId: AgentId;
7
8
  homeDir: string;
@@ -14,6 +15,9 @@ export interface TrellisProvider {
14
15
  readResource?(uri: URL, context: RuntimeContext): Promise<ReadResourceResult> | ReadResourceResult;
15
16
  listPrompts?(context: RuntimeContext): Promise<Prompt[]> | Prompt[];
16
17
  getPrompt?(name: string, args: Record<string, string> | undefined, context: RuntimeContext): Promise<GetPromptResult> | GetPromptResult;
18
+ /** Skills-over-MCP `skills/list` and `skills/get` (trellis-skills-over-mcp). */
19
+ listSkillEntries?(context: RuntimeContext): Promise<SkillEntry[]> | SkillEntry[];
20
+ getSkillEntry?(uri: string, context: RuntimeContext): Promise<SkillEntry | undefined> | SkillEntry | undefined;
17
21
  close?(): Promise<void>;
18
22
  }
19
23
  export declare class BuiltinRegistry {
@@ -29,6 +33,8 @@ export declare class BuiltinRegistry {
29
33
  callTool(name: string, args: unknown, context: RuntimeContext): Promise<CallToolResult>;
30
34
  listResources(context: RuntimeContext): Promise<Resource[]>;
31
35
  readResource(uri: URL, context: RuntimeContext): Promise<ReadResourceResult>;
36
+ listSkills(context: RuntimeContext): Promise<SkillEntry[]>;
37
+ getSkill(uri: string, context: RuntimeContext): Promise<SkillEntry | undefined>;
32
38
  listPrompts(context: RuntimeContext): Promise<Prompt[]>;
33
39
  getPrompt(name: string, args: Record<string, string> | undefined, context: RuntimeContext): Promise<GetPromptResult>;
34
40
  close(): Promise<void>;
@@ -1,5 +1,5 @@
1
1
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
- import { CallToolRequestSchema, GetPromptRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema } from "@modelcontextprotocol/sdk/types.js";
2
+ import { CallToolRequestSchema, ErrorCode, McpError, GetPromptRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema } from "@modelcontextprotocol/sdk/types.js";
3
3
  import { allocateExposedToolNames } from "./mcpToolRegistry.js";
4
4
  function compactBuiltinToolName(name) {
5
5
  return name.startsWith("trellis.") ? name.slice("trellis.".length).replaceAll(".", "_") : name;
@@ -106,6 +106,35 @@ export class BuiltinRegistry {
106
106
  throw new Error(`unknown Trellis resource "${key}"`);
107
107
  return provider.readResource(uri, context);
108
108
  }
109
+ async listSkills(context) {
110
+ const skills = [];
111
+ for (const provider of this.providers) {
112
+ if (!provider.listSkillEntries)
113
+ continue;
114
+ try {
115
+ skills.push(...(await provider.listSkillEntries(context)));
116
+ }
117
+ catch (err) {
118
+ this.warn(providerError(provider.id, err));
119
+ }
120
+ }
121
+ return skills;
122
+ }
123
+ async getSkill(uri, context) {
124
+ for (const provider of this.providers) {
125
+ if (!provider.getSkillEntry)
126
+ continue;
127
+ try {
128
+ const entry = await provider.getSkillEntry(uri, context);
129
+ if (entry)
130
+ return entry;
131
+ }
132
+ catch (err) {
133
+ this.warn(providerError(provider.id, err));
134
+ }
135
+ }
136
+ return undefined;
137
+ }
109
138
  async listPrompts(context) {
110
139
  const prompts = [];
111
140
  this.promptOwners.clear();
@@ -181,5 +210,27 @@ export function createRuntimeServer(serverInfo, context, registry) {
181
210
  server.setRequestHandler(ReadResourceRequestSchema, async (request) => registry.readResource(new URL(request.params.uri), context));
182
211
  server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: await registry.listPrompts(context) }));
183
212
  server.setRequestHandler(GetPromptRequestSchema, async (request) => registry.getPrompt(request.params.name, request.params.arguments, context));
213
+ // `skills/list` and `skills/get` are not in this SDK's schema set (it speaks
214
+ // at most 2025-11-25), so they are answered through the SDK's fallback for
215
+ // methods with no registered handler, which keeps the extension free of a new
216
+ // direct zod dependency. The extension itself is deliberately NOT declared in
217
+ // the capabilities above: the specification declares it only through
218
+ // `server/discover`, which this revision lacks, and inventing another field
219
+ // would claim a protocol nobody defined (trellis-skills-over-mcp D4). Any
220
+ // other unknown method keeps the SDK's usual "method not found".
221
+ server.fallbackRequestHandler = async (request) => {
222
+ const cache = { resultType: "complete", ttlMs: 0, cacheScope: "private" };
223
+ if (request.method === "skills/list") {
224
+ return { ...cache, skills: await registry.listSkills(context) };
225
+ }
226
+ if (request.method === "skills/get") {
227
+ const uri = request.params?.uri;
228
+ const entry = typeof uri === "string" ? await registry.getSkill(uri, context) : undefined;
229
+ if (!entry)
230
+ throw new McpError(ErrorCode.InvalidParams, "unknown skill");
231
+ return { ...cache, skill: entry };
232
+ }
233
+ throw new McpError(ErrorCode.MethodNotFound, `Method not found: ${request.method}`);
234
+ };
184
235
  return server;
185
236
  }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Shared agent-scope selection for `skill scope` / `mcp scope`
3
+ * (trellis-scope-editing-and-auth-status design.md D1–D3). Both commands
4
+ * take exactly one of `--agents a,b`, `--all`, `--none`; keeping the parsing,
5
+ * validation and normalization here means a skill and an MCP server can never
6
+ * disagree about what a given selection means.
7
+ */
8
+ import type { AgentId } from "../core/types.js";
9
+ /** Raw, unvalidated flag values — the parser stays a dumb reader and every
10
+ * rule below lives in `resolveScopeSelection`. */
11
+ export interface ScopeSelectorRaw {
12
+ /** Comma-separated agent ids. */
13
+ agents?: string;
14
+ all?: boolean;
15
+ none?: boolean;
16
+ }
17
+ export type ScopeSelection = {
18
+ ok: true;
19
+ /** What to record: `undefined` clears the explicit scope. */
20
+ scope: AgentId[] | undefined;
21
+ mode: "all" | "none" | "explicit";
22
+ /** True when the user listed every managed agent and the explicit scope
23
+ * was dropped instead of recorded (design.md D3). */
24
+ normalizedFromFull: boolean;
25
+ } | {
26
+ ok: false;
27
+ detail: string;
28
+ };
29
+ export declare const SCOPE_SELECTOR_USAGE = "exactly one of --agents <ids>, --all, --none is required";
30
+ /** `argv.indexOf`-style reader, same idiom as every other command's flags. */
31
+ export declare function parseScopeArgs(argv: readonly string[]): ScopeSelectorRaw;
32
+ export declare function resolveScopeSelection(raw: ScopeSelectorRaw, managed: readonly AgentId[]): ScopeSelection;
33
+ /** Order-insensitive equality between the scope on disk and the one about to
34
+ * be written, with `undefined` (no explicit scope) distinct from `[]`. */
35
+ export declare function sameScope(current: readonly AgentId[] | undefined, next: readonly AgentId[] | undefined): boolean;
36
+ export declare function describeScope(scope: readonly AgentId[] | undefined, managed: readonly AgentId[]): string;