agent-trellis 0.9.1 → 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.
@@ -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
+ }
@@ -10,13 +10,23 @@ import type { BackupSession } from "../lib/backup.js";
10
10
  * The on-disk shape for one server entry — `static_env`/`env_aliases`
11
11
  * (snake_case, like every other multi-word key across `.trellis/*.yaml`)
12
12
  * are translated to `McpServerDef.staticEnv`/`envAliases` (camelCase)
13
- * below; every other field happens to already be a single word, so no
14
- * server-def field needed this treatment before
15
- * (trellis-mcp-static-env-and-disabled-servers, trellis-migrate-env-var-alias).
13
+ * below, and `auth`'s object form carries `client_id`/`client_secret_env`
14
+ * the same way (trellis-mcp-static-env-and-disabled-servers,
15
+ * trellis-migrate-env-var-alias, trellis-mcp-oauth-static-client).
16
16
  */
17
- type McpServerDefYaml = Omit<McpServerDef, "staticEnv" | "envAliases"> & {
17
+ type McpServerDefYaml = Omit<McpServerDef, "staticEnv" | "envAliases" | "auth"> & {
18
18
  static_env?: Record<string, string>;
19
19
  env_aliases?: Record<string, string>;
20
+ auth?: McpAuthYaml;
21
+ };
22
+ /** The on-disk `auth`: the `oauth` scalar exactly as before, or the object
23
+ * form whose keys are snake_case like every other multi-word key in
24
+ * `.trellis/*.yaml`. Values are `unknown` until `fromAuthYaml` validates
25
+ * them — a YAML cast is optimistic, validation is this module's job. */
26
+ type McpAuthYaml = string | {
27
+ kind?: unknown;
28
+ client_id?: unknown;
29
+ client_secret_env?: unknown;
20
30
  };
21
31
  /** Inverse of `fromServerDefYaml` (trellis-canonical-cli-crud) — strips
22
32
  * `undefined` fields so the written YAML never gets a literal `null`
@@ -127,13 +137,6 @@ export declare function ensureGitignoreEntry(gitignorePath: string, line: string
127
137
  * `export` prefix of its own.
128
138
  */
129
139
  export declare function ensureShellEnvSource(rcPath: string, envFilePath: string, backup?: BackupSession): void;
130
- /**
131
- * `homeDir` defaults to the real `~` and is only ever overridden for tests
132
- * and `scripts/sandbox.sh` — the same seam P0's probes use
133
- * (src/probes/*.ts) and for the same reason: never touch a developer's
134
- * real dotfiles from a test. It is not a workspace/project root — see
135
- * specs/canonical-source-loading's global-only requirement, which this
136
- * parameter does not weaken.
137
- */
140
+ /** Load the global canonical source from the supplied home directory. */
138
141
  export declare function loadCanonicalSource(homeDir?: string): CanonicalSource;
139
142
  export {};
@@ -8,26 +8,75 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSy
8
8
  import { homedir } from "node:os";
9
9
  import { basename, dirname, join } from "node:path";
10
10
  import { isMap, isSeq, parse as parseYaml, parseDocument } from "yaml";
11
- import { ALL_AGENTS } from "./types.js";
12
- function fromServerDefYaml(def) {
13
- const { static_env, env_aliases, ...rest } = def;
11
+ import { ALL_AGENTS, oauthClientMetadata } from "./types.js";
12
+ import { isValidSecretVarName } from "../lib/secretEnv.js";
13
+ function fromServerDefYaml(serverName, def) {
14
+ const { static_env, env_aliases, auth, ...rest } = def;
14
15
  const out = { ...rest };
15
16
  if (static_env)
16
17
  out.staticEnv = static_env;
17
18
  if (env_aliases)
18
19
  out.envAliases = env_aliases;
20
+ if (auth !== undefined)
21
+ out.auth = fromAuthYaml(serverName, auth);
22
+ return out;
23
+ }
24
+ /**
25
+ * `auth` translation and shape validation
26
+ * (trellis-mcp-oauth-static-client tasks 1.1/1.3): the scalar and the
27
+ * object form share one discriminator — `kind: oauth` — and the object
28
+ * accepts exactly `kind`, `client_id`, `client_secret_env`. Everything
29
+ * else is refused here at load, naming the field but never echoing the
30
+ * value: an invalid `client_secret_env` is at best a typo, at worst a
31
+ * pasted secret.
32
+ */
33
+ function fromAuthYaml(serverName, auth) {
34
+ if (auth === "oauth")
35
+ return "oauth";
36
+ if (typeof auth !== "object" || auth === null) {
37
+ throw new Error(`mcp/servers.yaml: "${serverName}" auth must be the scalar "oauth" or { kind: oauth, client_id?, client_secret_env? }`);
38
+ }
39
+ const { kind, client_id, client_secret_env, ...extra } = auth;
40
+ const unknownKey = Object.keys(extra)[0];
41
+ if (unknownKey !== undefined) {
42
+ throw new Error(`mcp/servers.yaml: "${serverName}" auth has unrecognized key "${unknownKey}" — expected kind, client_id, client_secret_env`);
43
+ }
44
+ if (kind !== "oauth") {
45
+ throw new Error(`mcp/servers.yaml: "${serverName}" auth.kind must be "oauth" — kind is the only recognized discriminator`);
46
+ }
47
+ if (client_id !== undefined && typeof client_id !== "string") {
48
+ throw new Error(`mcp/servers.yaml: "${serverName}" auth.client_id must be a string`);
49
+ }
50
+ if (client_secret_env !== undefined && (typeof client_secret_env !== "string" || !isValidSecretVarName(client_secret_env))) {
51
+ throw new Error(`mcp/servers.yaml: "${serverName}" auth.client_secret_env must be a variable NAME (e.g. FIGMA_CLIENT_SECRET), never a secret value`);
52
+ }
53
+ const out = { kind: "oauth" };
54
+ if (typeof client_id === "string")
55
+ out.clientId = client_id;
56
+ if (typeof client_secret_env === "string")
57
+ out.clientSecretEnv = client_secret_env;
19
58
  return out;
20
59
  }
21
60
  /** Inverse of `fromServerDefYaml` (trellis-canonical-cli-crud) — strips
22
61
  * `undefined` fields so the written YAML never gets a literal `null`
23
62
  * for an omitted optional. */
24
63
  export function toServerDefYaml(def) {
25
- const { staticEnv, envAliases, ...rest } = def;
64
+ const { staticEnv, envAliases, auth, ...rest } = def;
26
65
  const out = { ...rest };
27
66
  if (staticEnv)
28
67
  out.static_env = staticEnv;
29
68
  if (envAliases)
30
69
  out.env_aliases = envAliases;
70
+ if (auth !== undefined) {
71
+ out.auth =
72
+ typeof auth === "string"
73
+ ? auth
74
+ : {
75
+ kind: auth.kind,
76
+ ...(auth.clientId !== undefined ? { client_id: auth.clientId } : {}),
77
+ ...(auth.clientSecretEnv !== undefined ? { client_secret_env: auth.clientSecretEnv } : {}),
78
+ };
79
+ }
31
80
  for (const key of Object.keys(out)) {
32
81
  if (out[key] === undefined)
33
82
  delete out[key];
@@ -83,7 +132,7 @@ function loadServersYaml(path) {
83
132
  return { servers: {}, knownHostInjected: [] };
84
133
  }
85
134
  const parsed = (parseYaml(readFileSync(path, "utf-8")) ?? {});
86
- const servers = Object.fromEntries(Object.entries(parsed.servers ?? {}).map(([name, def]) => [name, fromServerDefYaml(def)]));
135
+ const servers = Object.fromEntries(Object.entries(parsed.servers ?? {}).map(([name, def]) => [name, fromServerDefYaml(name, def)]));
87
136
  return {
88
137
  servers,
89
138
  knownHostInjected: parsed.known_host_injected ?? [],
@@ -452,7 +501,7 @@ export function ensureShellEnvSource(rcPath, envFilePath, backup) {
452
501
  * all managed agents by default. A recognized but empty list is left as
453
502
  * authored (an explicitly agent-less scope), not coerced to "all". */
454
503
  /** Missing file and `agents: []` both resolve to `[]` — zero managed
455
- * agents (D1), never "everyone." An unrecognized id is dropped with a
504
+ * agents, never "everyone." An unrecognized id is dropped with a
456
505
  * diagnostic, same posture as an unrecognized scope.yaml agent id. */
457
506
  function loadManagedYaml(path, diagnostics) {
458
507
  if (!existsSync(path)) {
@@ -480,17 +529,36 @@ function validAgentIds(scope) {
480
529
  return scope.every((id) => ALL_AGENTS.includes(id));
481
530
  }
482
531
  /**
483
- * `homeDir` defaults to the real `~` and is only ever overridden for tests
484
- * and `scripts/sandbox.sh` — the same seam P0's probes use
485
- * (src/probes/*.ts) and for the same reason: never touch a developer's
486
- * real dotfiles from a test. It is not a workspace/project root — see
487
- * specs/canonical-source-loading's global-only requirement, which this
488
- * parameter does not weaken.
532
+ * Load-time literal-value scan for OAuth client metadata
533
+ * (trellis-mcp-oauth-static-client task 1.2). `secrets audit` reports
534
+ * whole-file pattern hits as findings; this check goes further for the
535
+ * two fields a pasted secret is most likely to reach and refuses the
536
+ * load outright — the offending server and field are named, the value
537
+ * never printed.
489
538
  */
539
+ function assertAuthMetadataClean(mcp, policy) {
540
+ for (const [name, def] of Object.entries(mcp.servers)) {
541
+ const meta = oauthClientMetadata(def.auth);
542
+ const fields = [
543
+ ["client_id", meta.clientId],
544
+ ["client_secret_env", meta.clientSecretEnv],
545
+ ];
546
+ for (const [field, value] of fields) {
547
+ if (value === undefined)
548
+ continue;
549
+ for (const pattern of policy.rejectPatterns) {
550
+ if (pattern.test(value)) {
551
+ throw new Error(`mcp/servers.yaml: "${name}" auth.${field} matches a secrets.policy.yaml reject pattern — client_id must be the provider-published id, client_secret_env a variable name; the offending value is intentionally not printed`);
552
+ }
553
+ }
554
+ }
555
+ }
556
+ }
557
+ /** Load the global canonical source from the supplied home directory. */
490
558
  export function loadCanonicalSource(homeDir = homedir()) {
491
559
  const root = trellisRoot(homeDir);
492
560
  if (!existsSync(root)) {
493
- throw new Error(`No canonical source at ${root}. Create it before running trellis sync — see docs/architecture.md's canonical schema.`);
561
+ throw new Error(`No canonical source at ${root}. Run trellis init before trellis sync.`);
494
562
  }
495
563
  const diagnostics = [];
496
564
  const managedAgents = loadManagedYaml(join(root, "managed.yaml"), diagnostics);
@@ -527,17 +595,17 @@ export function loadCanonicalSource(homeDir = homedir()) {
527
595
  }
528
596
  }
529
597
  }
598
+ const mcp = loadServersYaml(join(root, "mcp", "servers.yaml"));
599
+ const secretsPolicy = loadSecretsPolicyYaml(join(root, "secrets.policy.yaml"), homeDir);
600
+ assertAuthMetadataClean(mcp, secretsPolicy);
530
601
  return {
531
602
  instructionsFile: join(root, "agents.md"),
532
603
  managedAgents,
533
604
  skills,
534
605
  agents,
535
606
  memories,
536
- mcp: loadServersYaml(join(root, "mcp", "servers.yaml")),
537
- // P2's pre-write guard (src/adapters/mcpPlan.ts) uses its own narrow,
538
- // hardcoded floor instead of this field — see design.md D1 in
539
- // trellis-secrets-audit-p3.
540
- secretsPolicy: loadSecretsPolicyYaml(join(root, "secrets.policy.yaml"), homeDir),
607
+ mcp,
608
+ secretsPolicy,
541
609
  diagnostics,
542
610
  };
543
611
  }
@@ -6,6 +6,29 @@
6
6
  */
7
7
  export type Transport = "stdio" | "http" | "sse";
8
8
  export type McpAuthMode = "oauth";
9
+ /**
10
+ * Object form of the OAuth classification (trellis-mcp-oauth-static-client
11
+ * design.md D1). `kind` is the only discriminator; `client_id` is a public
12
+ * literal by design; `client_secret_env` is a variable NAME resolved
13
+ * through the secrets policy at authorization time. There is deliberately
14
+ * no field for a secret value in any form.
15
+ */
16
+ export interface McpAuthConfig {
17
+ kind: McpAuthMode;
18
+ clientId?: string;
19
+ clientSecretEnv?: string;
20
+ }
21
+ /** True when `auth` classifies the server as OAuth, in either form. Every
22
+ * former `def.auth === "oauth"` check must go through this now that the
23
+ * object form exists — a strict-equality check would silently strip the
24
+ * OAuth routing from metadata-bearing servers. */
25
+ export declare function isOAuthAuth(auth: McpAuthMode | McpAuthConfig | undefined): boolean;
26
+ /** The pre-registered client metadata a classification carries. A scalar
27
+ * carries none — both forms route identically (design.md D1). */
28
+ export declare function oauthClientMetadata(auth: McpAuthMode | McpAuthConfig | undefined): {
29
+ clientId?: string;
30
+ clientSecretEnv?: string;
31
+ };
9
32
  export type AgentId = "claude-code" | "codex" | "kiro" | "pi" | "kimi-code" | "zcode";
10
33
  export declare const ALL_AGENTS: readonly AgentId[];
11
34
  /**
@@ -28,8 +51,11 @@ export declare function resolveScope(scope: Scope, managedAgents: readonly Agent
28
51
  export interface McpServerDef {
29
52
  transport: Transport;
30
53
  /** Explicit authorization classification. Absence means ordinary MCP;
31
- * Trellis never infers OAuth from a URL or a transient 401. */
32
- auth?: McpAuthMode;
54
+ * Trellis never infers OAuth from a URL or a transient 401. The scalar
55
+ * form and a metadata-less object are equivalent — metadata extends the
56
+ * authorization flow, it never changes routing
57
+ * (trellis-mcp-oauth-static-client design.md D1). */
58
+ auth?: McpAuthMode | McpAuthConfig;
33
59
  /** stdio only */
34
60
  command?: string;
35
61
  args?: string[];
@@ -4,6 +4,25 @@
4
4
  * for what a "canonical source" object looks like in memory; the YAML/MD
5
5
  * files on disk are its serialization, not the other way around.
6
6
  */
7
+ /** True when `auth` classifies the server as OAuth, in either form. Every
8
+ * former `def.auth === "oauth"` check must go through this now that the
9
+ * object form exists — a strict-equality check would silently strip the
10
+ * OAuth routing from metadata-bearing servers. */
11
+ export function isOAuthAuth(auth) {
12
+ return auth === "oauth" || (typeof auth === "object" && auth !== null && auth.kind === "oauth");
13
+ }
14
+ /** The pre-registered client metadata a classification carries. A scalar
15
+ * carries none — both forms route identically (design.md D1). */
16
+ export function oauthClientMetadata(auth) {
17
+ if (typeof auth !== "object" || auth === null)
18
+ return {};
19
+ const meta = {};
20
+ if (auth.clientId !== undefined)
21
+ meta.clientId = auth.clientId;
22
+ if (auth.clientSecretEnv !== undefined)
23
+ meta.clientSecretEnv = auth.clientSecretEnv;
24
+ return meta;
25
+ }
7
26
  export const ALL_AGENTS = [
8
27
  "claude-code",
9
28
  "codex",
@@ -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
  }
@@ -2,6 +2,12 @@
2
2
  * The interactive half: dynamic client registration (RFC 7591) plus the
3
3
  * authorization-code grant with PKCE (RFC 6749 §4.1, RFC 7636).
4
4
  *
5
+ * Registration is a fallback, not a requirement: a caller that already
6
+ * holds a pre-registered `client_id` passes it in and the RFC 7591 step
7
+ * is skipped entirely (trellis-mcp-oauth-static-client design.md D3) —
8
+ * some providers refuse registration outright, and those are exactly the
9
+ * ones that publish a client for third-party use instead.
10
+ *
5
11
  * Only ever reached from `trellis mcp auth`, run by a human. The gateway
6
12
  * must never call into this file: it is spawned silently by an agent,
7
13
  * typically with no terminal and no display, so it can neither show a
@@ -31,6 +37,13 @@ export interface AuthorizeOptions {
31
37
  * endpoint — that is a legitimate configuration (a pre-registered
32
38
  * client), not an error, so the caller falls back to whatever client id
33
39
  * it was given.
40
+ *
41
+ * A refused registration is the one failure a human can actually fix, so
42
+ * the AS's own OAuth error code is read out of the response body rather
43
+ * than collapsed into a bare status: `unauthorized_client` is the
44
+ * provider saying "this server is not open to registration", which is
45
+ * precisely when a pre-registered `client_id` is the answer
46
+ * (trellis-mcp-oauth-static-client design.md D3).
34
47
  */
35
48
  export declare function registerClient(metadata: AuthorizationServerMetadata, redirectUri: string, opts?: {
36
49
  fetchImpl?: FetchLike;
@@ -2,6 +2,12 @@
2
2
  * The interactive half: dynamic client registration (RFC 7591) plus the
3
3
  * authorization-code grant with PKCE (RFC 6749 §4.1, RFC 7636).
4
4
  *
5
+ * Registration is a fallback, not a requirement: a caller that already
6
+ * holds a pre-registered `client_id` passes it in and the RFC 7591 step
7
+ * is skipped entirely (trellis-mcp-oauth-static-client design.md D3) —
8
+ * some providers refuse registration outright, and those are exactly the
9
+ * ones that publish a client for third-party use instead.
10
+ *
5
11
  * Only ever reached from `trellis mcp auth`, run by a human. The gateway
6
12
  * must never call into this file: it is spawned silently by an agent,
7
13
  * typically with no terminal and no display, so it can neither show a
@@ -17,6 +23,13 @@ export class AuthorizationError extends Error {
17
23
  * endpoint — that is a legitimate configuration (a pre-registered
18
24
  * client), not an error, so the caller falls back to whatever client id
19
25
  * it was given.
26
+ *
27
+ * A refused registration is the one failure a human can actually fix, so
28
+ * the AS's own OAuth error code is read out of the response body rather
29
+ * than collapsed into a bare status: `unauthorized_client` is the
30
+ * provider saying "this server is not open to registration", which is
31
+ * precisely when a pre-registered `client_id` is the answer
32
+ * (trellis-mcp-oauth-static-client design.md D3).
20
33
  */
21
34
  export async function registerClient(metadata, redirectUri, opts = {}) {
22
35
  if (!metadata.registrationEndpoint)
@@ -35,7 +48,10 @@ export async function registerClient(metadata, redirectUri, opts = {}) {
35
48
  }),
36
49
  });
37
50
  if (!response.ok) {
38
- throw new AuthorizationError(`dynamic client registration failed with HTTP ${response.status}`);
51
+ const code = await readOAuthErrorCode(response);
52
+ throw new AuthorizationError(code
53
+ ? `dynamic client registration was refused (${code})`
54
+ : `dynamic client registration failed with HTTP ${response.status}`);
39
55
  }
40
56
  const payload = (await response.json());
41
57
  if (!payload.client_id) {
@@ -46,6 +62,22 @@ export async function registerClient(metadata, redirectUri, opts = {}) {
46
62
  out.clientSecret = payload.client_secret;
47
63
  return out;
48
64
  }
65
+ /** RFC 6749 §5.2 error body. Best-effort by design: a registration
66
+ * endpoint that answers with plain text or an empty body still produces
67
+ * a usable error, just a less specific one. */
68
+ async function readOAuthErrorCode(response) {
69
+ try {
70
+ const payload = (await response.json());
71
+ return typeof payload.error === "string" ? payload.error : undefined;
72
+ }
73
+ catch {
74
+ return undefined;
75
+ }
76
+ }
77
+ /** Provider-neutral remediation, appended wherever registration is the
78
+ * thing that failed. Names the exact file and key so the fix needs no
79
+ * further search. */
80
+ const STATIC_CLIENT_REMEDIATION = "register a client with the provider and set auth.client_id (plus auth.client_secret_env if the provider requires a secret) under this server in ~/.trellis/mcp/servers.yaml";
49
81
  /** A one-shot loopback listener for the redirect. Bound to 127.0.0.1
50
82
  * explicitly, never 0.0.0.0: the authorization code arrives in this URL,
51
83
  * and a listener on all interfaces would accept it from the network. */
@@ -101,9 +133,22 @@ export async function authorize(serverName, metadata, opts = {}) {
101
133
  let clientId = opts.clientId;
102
134
  let clientSecret = opts.clientSecret;
103
135
  if (!clientId) {
104
- const registered = await registerClient(metadata, redirectUri, { fetchImpl, scope: opts.scope });
136
+ // The AS cannot register us AND canonical carries no client_id — the
137
+ // one dead end a person has to resolve by hand, so it is reported as
138
+ // such rather than as a generic registration failure
139
+ // (trellis-mcp-oauth-static-client design.md D3, task 2.3).
140
+ let registered;
141
+ try {
142
+ registered = await registerClient(metadata, redirectUri, { fetchImpl, scope: opts.scope });
143
+ }
144
+ catch (err) {
145
+ if (err instanceof AuthorizationError) {
146
+ throw new AuthorizationError(`"${serverName}": ${err.message} — ${STATIC_CLIENT_REMEDIATION}`);
147
+ }
148
+ throw err;
149
+ }
105
150
  if (!registered) {
106
- throw new AuthorizationError(`"${serverName}"'s authorization server offers no dynamic client registration, and no client_id is configured for it`);
151
+ throw new AuthorizationError(`"${serverName}"'s authorization server offers no dynamic client registration — ${STATIC_CLIENT_REMEDIATION}`);
107
152
  }
108
153
  clientId = registered.clientId;
109
154
  clientSecret = registered.clientSecret;