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.
@@ -0,0 +1,65 @@
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 { ALL_AGENTS } from "../core/types.js";
9
+ export const SCOPE_SELECTOR_USAGE = "exactly one of --agents <ids>, --all, --none is required";
10
+ /** `argv.indexOf`-style reader, same idiom as every other command's flags. */
11
+ export function parseScopeArgs(argv) {
12
+ const i = argv.indexOf("--agents");
13
+ return {
14
+ ...(i >= 0 ? { agents: argv[i + 1] ?? "" } : {}),
15
+ all: argv.includes("--all"),
16
+ none: argv.includes("--none"),
17
+ };
18
+ }
19
+ function sameSet(a, b) {
20
+ return a.length === b.length && a.every((id) => b.includes(id));
21
+ }
22
+ export function resolveScopeSelection(raw, managed) {
23
+ const chosen = [raw.agents !== undefined, raw.all === true, raw.none === true].filter(Boolean).length;
24
+ if (chosen !== 1)
25
+ return { ok: false, detail: SCOPE_SELECTOR_USAGE };
26
+ if (raw.all)
27
+ return { ok: true, scope: undefined, mode: "all", normalizedFromFull: false };
28
+ if (raw.none)
29
+ return { ok: true, scope: [], mode: "none", normalizedFromFull: false };
30
+ // An empty list is refused rather than read as "nobody": "nobody" is a real
31
+ // choice and has its own spelling, so a blank value is almost always a slip.
32
+ const ids = [...new Set((raw.agents ?? "").split(",").map((s) => s.trim()).filter(Boolean))];
33
+ if (ids.length === 0) {
34
+ return { ok: false, detail: "--agents must list at least one agent id (use --none for no agent at all)" };
35
+ }
36
+ for (const id of ids) {
37
+ if (!ALL_AGENTS.includes(id)) {
38
+ return { ok: false, detail: `--agents "${id}" is not a recognized agent id (${ALL_AGENTS.join(", ")})` };
39
+ }
40
+ // An unmanaged id would be accepted by the data model but never take
41
+ // effect — scope is always intersected with the managed set — so writing
42
+ // it would only leave config that looks like it does something.
43
+ if (!managed.includes(id)) {
44
+ const known = managed.length > 0 ? `managed agents: ${managed.join(", ")}` : "no agents are managed yet — run `trellis onboard` first";
45
+ return { ok: false, detail: `--agents "${id}" is not a managed agent (${known})` };
46
+ }
47
+ }
48
+ // Stable on-disk order regardless of how the user typed the list.
49
+ const scope = ALL_AGENTS.filter((id) => ids.includes(id));
50
+ if (sameSet(scope, managed))
51
+ return { ok: true, scope: undefined, mode: "all", normalizedFromFull: true };
52
+ return { ok: true, scope, mode: "explicit", normalizedFromFull: false };
53
+ }
54
+ /** Order-insensitive equality between the scope on disk and the one about to
55
+ * be written, with `undefined` (no explicit scope) distinct from `[]`. */
56
+ export function sameScope(current, next) {
57
+ if (current === undefined || next === undefined)
58
+ return current === next;
59
+ return sameSet(current, next);
60
+ }
61
+ export function describeScope(scope, managed) {
62
+ if (scope === undefined)
63
+ return `every managed agent (${managed.join(", ") || "none managed yet"})`;
64
+ return scope.length > 0 ? scope.join(", ") : "no agent";
65
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Manifest for serving a canonical skill in the shape the Skills-over-MCP
3
+ * extension defines (trellis-skills-over-mcp design.md D2/D3): every file's
4
+ * URI, `sha256:<hex>` digest and byte size, computed from the exact bytes
5
+ * that `resources/read` returns. One reader serves both, so the manifest and
6
+ * the content can never disagree.
7
+ *
8
+ * A digest here means "these bytes match this manifest" — it is not a claim
9
+ * about the content's safety (design.md D5).
10
+ */
11
+ /** The extension's SHOULD-NOT-exceed limits per skill. */
12
+ export declare const MAX_SKILL_FILES = 512;
13
+ export declare const MAX_SKILL_BYTES: number;
14
+ /** Same per-file bound the existing provider tools enforce. */
15
+ export declare const MAX_SKILL_FILE_BYTES: number;
16
+ export interface SkillManifestFile {
17
+ uri: string;
18
+ digest: string;
19
+ size: number;
20
+ }
21
+ export interface SkillEntry {
22
+ uri: string;
23
+ frontmatter: Record<string, unknown>;
24
+ resources: SkillManifestFile[];
25
+ }
26
+ export type SkillBuild = {
27
+ ok: true;
28
+ entry: SkillEntry;
29
+ } | {
30
+ ok: false;
31
+ reason: string;
32
+ };
33
+ export declare function skillRootUri(name: string): string;
34
+ export declare function skillFileUri(name: string, relativePath: string): string;
35
+ /** `skill://<name>/<path>` → parts, or undefined for any other shape. Parsed
36
+ * by hand: `URL` would lowercase the authority and mangle odd names. */
37
+ export declare function parseSkillUri(uri: string): {
38
+ name: string;
39
+ path: string;
40
+ } | undefined;
41
+ export declare function digestOf(bytes: Buffer): string;
42
+ /**
43
+ * The one reader. Refuses secret-shaped paths, anything resolving outside the
44
+ * skill root (symlink escape included), non-files and oversized files; returns
45
+ * the raw bytes otherwise.
46
+ */
47
+ export declare function readSkillFileBytes(skillDir: string, relativePath: string): Buffer;
48
+ export declare function mimeTypeFor(path: string): string;
49
+ /** Valid UTF-8 round-trips exactly as `text`; anything else must go out as a
50
+ * base64 blob, or the bytes a host verifies would not be the bytes we hashed. */
51
+ export declare function toResourceContent(uri: string, path: string, bytes: Buffer): {
52
+ uri: string;
53
+ mimeType: string;
54
+ text: string;
55
+ } | {
56
+ uri: string;
57
+ mimeType: string;
58
+ blob: string;
59
+ };
60
+ /**
61
+ * Builds the entry for one skill, or says why it cannot be served. A skill
62
+ * that breaks a limit is omitted whole rather than published with a partial
63
+ * manifest — a partial manifest is a false claim of completeness (D2).
64
+ */
65
+ export declare function buildSkillEntry(name: string, skillDir: string): SkillBuild;
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Manifest for serving a canonical skill in the shape the Skills-over-MCP
3
+ * extension defines (trellis-skills-over-mcp design.md D2/D3): every file's
4
+ * URI, `sha256:<hex>` digest and byte size, computed from the exact bytes
5
+ * that `resources/read` returns. One reader serves both, so the manifest and
6
+ * the content can never disagree.
7
+ *
8
+ * A digest here means "these bytes match this manifest" — it is not a claim
9
+ * about the content's safety (design.md D5).
10
+ */
11
+ import { createHash } from "node:crypto";
12
+ import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
13
+ import { relative, resolve, sep } from "node:path";
14
+ import { parse as parseYaml } from "yaml";
15
+ /** The extension's SHOULD-NOT-exceed limits per skill. */
16
+ export const MAX_SKILL_FILES = 512;
17
+ export const MAX_SKILL_BYTES = 16 * 1024 * 1024;
18
+ /** Same per-file bound the existing provider tools enforce. */
19
+ export const MAX_SKILL_FILE_BYTES = 256 * 1024;
20
+ const SKILL_SCHEME = "skill://";
21
+ /** Agent Skills naming rule: lowercase alphanumerics and single hyphens. It
22
+ * also keeps the name safe as a URI authority, which lowercases. */
23
+ const NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
24
+ const SKIPPED_DIRS = new Set(["node_modules"]);
25
+ export function skillRootUri(name) {
26
+ return `${SKILL_SCHEME}${name}/SKILL.md`;
27
+ }
28
+ export function skillFileUri(name, relativePath) {
29
+ return `${SKILL_SCHEME}${name}/${relativePath.split(sep).map(encodeURIComponent).join("/")}`;
30
+ }
31
+ /** `skill://<name>/<path>` → parts, or undefined for any other shape. Parsed
32
+ * by hand: `URL` would lowercase the authority and mangle odd names. */
33
+ export function parseSkillUri(uri) {
34
+ if (!uri.startsWith(SKILL_SCHEME))
35
+ return undefined;
36
+ const rest = uri.slice(SKILL_SCHEME.length);
37
+ const slash = rest.indexOf("/");
38
+ if (slash <= 0)
39
+ return undefined;
40
+ const name = rest.slice(0, slash);
41
+ try {
42
+ const segments = rest.slice(slash + 1).split("/").map(decodeURIComponent);
43
+ if (segments.length === 0 || segments.some((s) => s === "" || s === "." || s === ".."))
44
+ return undefined;
45
+ return { name, path: segments.join("/") };
46
+ }
47
+ catch {
48
+ return undefined;
49
+ }
50
+ }
51
+ function isSecretLikePath(path) {
52
+ const normalized = path.toLowerCase();
53
+ return normalized.includes(".env") || normalized.includes("secret") || normalized.endsWith("servers.local.env");
54
+ }
55
+ export function digestOf(bytes) {
56
+ return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
57
+ }
58
+ /**
59
+ * The one reader. Refuses secret-shaped paths, anything resolving outside the
60
+ * skill root (symlink escape included), non-files and oversized files; returns
61
+ * the raw bytes otherwise.
62
+ */
63
+ export function readSkillFileBytes(skillDir, relativePath) {
64
+ if (relativePath.length === 0 || relativePath.includes("\0") || isSecretLikePath(relativePath)) {
65
+ throw new Error("supporting file path is not allowed");
66
+ }
67
+ const root = realpathSync(skillDir);
68
+ const real = realpathSync(resolve(skillDir, relativePath));
69
+ const rel = relative(root, real);
70
+ if (rel.startsWith(`..${sep}`) || rel === ".." || rel === "")
71
+ throw new Error("file is outside the skill root");
72
+ if (isSecretLikePath(rel))
73
+ throw new Error("secret-like files are not readable through the skill provider");
74
+ const stat = statSync(real);
75
+ if (!stat.isFile())
76
+ throw new Error("not a regular file");
77
+ if (stat.size > MAX_SKILL_FILE_BYTES)
78
+ throw new Error(`file exceeds the ${MAX_SKILL_FILE_BYTES}-byte provider limit`);
79
+ return readFileSync(real);
80
+ }
81
+ export function mimeTypeFor(path) {
82
+ const lower = path.toLowerCase();
83
+ if (lower.endsWith(".md"))
84
+ return "text/markdown";
85
+ if (lower.endsWith(".json"))
86
+ return "application/json";
87
+ if (lower.endsWith(".yaml") || lower.endsWith(".yml"))
88
+ return "application/yaml";
89
+ if (lower.endsWith(".html"))
90
+ return "text/html";
91
+ return "text/plain";
92
+ }
93
+ /** Valid UTF-8 round-trips exactly as `text`; anything else must go out as a
94
+ * base64 blob, or the bytes a host verifies would not be the bytes we hashed. */
95
+ export function toResourceContent(uri, path, bytes) {
96
+ try {
97
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
98
+ return { uri, mimeType: mimeTypeFor(path), text };
99
+ }
100
+ catch {
101
+ return { uri, mimeType: "application/octet-stream", blob: bytes.toString("base64") };
102
+ }
103
+ }
104
+ function parseFrontmatter(content) {
105
+ if (!content.startsWith("---"))
106
+ return undefined;
107
+ const end = content.indexOf("\n---", 3);
108
+ if (end < 0)
109
+ return undefined;
110
+ try {
111
+ const parsed = parseYaml(content.slice(3, end));
112
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : undefined;
113
+ }
114
+ catch {
115
+ return undefined;
116
+ }
117
+ }
118
+ /** Walks the skill directory without following links out of it. Stops as soon
119
+ * as a limit is exceeded so a huge tree is never fully traversed. */
120
+ function collectFiles(skillDir) {
121
+ const files = [];
122
+ const stack = [skillDir];
123
+ while (stack.length > 0) {
124
+ const dir = stack.pop();
125
+ for (const name of readdirSync(dir).sort()) {
126
+ if (name.startsWith(".") || SKIPPED_DIRS.has(name))
127
+ continue;
128
+ const full = resolve(dir, name);
129
+ const link = lstatSync(full);
130
+ if (link.isSymbolicLink()) {
131
+ // A link is followed only if it stays inside the root and is a file.
132
+ try {
133
+ const target = realpathSync(full);
134
+ const rel = relative(realpathSync(skillDir), target);
135
+ if (rel.startsWith(`..${sep}`) || rel === ".." || !statSync(target).isFile())
136
+ continue;
137
+ }
138
+ catch {
139
+ continue;
140
+ }
141
+ }
142
+ else if (link.isDirectory()) {
143
+ stack.push(full);
144
+ continue;
145
+ }
146
+ else if (!link.isFile()) {
147
+ continue;
148
+ }
149
+ files.push(relative(skillDir, full));
150
+ if (files.length > MAX_SKILL_FILES)
151
+ return { tooLarge: `more than ${MAX_SKILL_FILES} files` };
152
+ }
153
+ }
154
+ return { files };
155
+ }
156
+ /**
157
+ * Builds the entry for one skill, or says why it cannot be served. A skill
158
+ * that breaks a limit is omitted whole rather than published with a partial
159
+ * manifest — a partial manifest is a false claim of completeness (D2).
160
+ */
161
+ export function buildSkillEntry(name, skillDir) {
162
+ if (!NAME_RE.test(name) || name.length > 64) {
163
+ return { ok: false, reason: `"${name}" is not a valid Agent Skills name (lowercase letters, digits and single hyphens, at most 64 characters)` };
164
+ }
165
+ if (!existsSync(resolve(skillDir, "SKILL.md")))
166
+ return { ok: false, reason: `"${name}" has no SKILL.md` };
167
+ let skillMd;
168
+ try {
169
+ skillMd = readSkillFileBytes(skillDir, "SKILL.md");
170
+ }
171
+ catch (err) {
172
+ return { ok: false, reason: `"${name}" SKILL.md cannot be served: ${err instanceof Error ? err.message : String(err)}` };
173
+ }
174
+ const frontmatter = parseFrontmatter(skillMd.toString("utf8"));
175
+ if (!frontmatter || typeof frontmatter.name !== "string" || typeof frontmatter.description !== "string" || frontmatter.description.length === 0) {
176
+ return { ok: false, reason: `"${name}" SKILL.md frontmatter needs a string name and a non-empty description` };
177
+ }
178
+ if (frontmatter.name !== name) {
179
+ return { ok: false, reason: `"${name}" directory name does not match its frontmatter name "${frontmatter.name}"` };
180
+ }
181
+ const collected = collectFiles(skillDir);
182
+ if ("tooLarge" in collected)
183
+ return { ok: false, reason: `"${name}" exceeds the extension's size limit (${collected.tooLarge})` };
184
+ const resources = [];
185
+ let total = 0;
186
+ for (const rel of collected.files) {
187
+ let bytes;
188
+ try {
189
+ bytes = readSkillFileBytes(skillDir, rel);
190
+ }
191
+ catch {
192
+ continue; // refused by the reader: absent from the manifest and from reads alike
193
+ }
194
+ total += bytes.length;
195
+ if (total > MAX_SKILL_BYTES)
196
+ return { ok: false, reason: `"${name}" exceeds the extension's size limit (more than ${MAX_SKILL_BYTES} bytes)` };
197
+ resources.push({ uri: skillFileUri(name, rel), digest: digestOf(bytes), size: bytes.length });
198
+ }
199
+ if (!resources.some((file) => file.uri === skillRootUri(name))) {
200
+ return { ok: false, reason: `"${name}" SKILL.md is missing from its own manifest` };
201
+ }
202
+ return { ok: true, entry: { uri: skillRootUri(name), frontmatter, resources } };
203
+ }
@@ -1,11 +1,31 @@
1
1
  import type { Resource, ReadResourceResult, Tool, CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2
2
  import type { RuntimeContext, TrellisProvider } from "./mcpRuntime.js";
3
+ import { type SkillEntry } from "./skillManifest.js";
3
4
  export declare class SkillProvider implements TrellisProvider {
5
+ private readonly onDiagnostic;
4
6
  readonly id = "skills";
7
+ /** Each omitted skill is reported once per process, not on every request. */
8
+ private readonly reported;
9
+ constructor(onDiagnostic?: (message: string) => void);
10
+ /**
11
+ * The skills this agent may be served under `skill://`, each with a manifest
12
+ * computed from the bytes it would read (trellis-skills-over-mcp D2/D3). A
13
+ * skill that cannot be served validly is omitted and reported, never served
14
+ * under a URI the specification calls invalid.
15
+ */
16
+ private catalog;
17
+ /** `skills/list` — one entry per servable, in-scope skill. */
18
+ listSkillEntries(context: RuntimeContext): SkillEntry[];
19
+ /** `skills/get` — lookup by URI, independent of listing. Out-of-scope and
20
+ * unknown URIs are indistinguishable by design. */
21
+ getSkillEntry(uri: string, context: RuntimeContext): SkillEntry | undefined;
5
22
  listTools(): Tool[];
6
23
  callTool(name: string, args: unknown, context: RuntimeContext): Promise<CallToolResult>;
7
24
  listResources(context: RuntimeContext): Resource[];
8
25
  readResource(uri: URL, context: RuntimeContext): ReadResourceResult;
26
+ /** Serves only what the manifest advertises, from the same reader that
27
+ * computed its digests — the two cannot disagree. */
28
+ private readConventionalResource;
9
29
  private search;
10
30
  private readSkill;
11
31
  private readFile;
@@ -5,6 +5,7 @@ import { parse as parseYaml } from "yaml";
5
5
  import { isInScope } from "../core/adapter.js";
6
6
  import { loadCanonicalSource } from "../core/canonical.js";
7
7
  import { resolveScope } from "../core/types.js";
8
+ import { buildSkillEntry, parseSkillUri, readSkillFileBytes, toResourceContent } from "./skillManifest.js";
8
9
  const MAX_FILE_BYTES = 256 * 1024;
9
10
  const SKILL_RESOURCE_PREFIX = "trellis://skills/";
10
11
  const SEARCH_TOOL = {
@@ -99,7 +100,47 @@ function skillMetadata(skill, canonical, agentId, content) {
99
100
  };
100
101
  }
101
102
  export class SkillProvider {
103
+ onDiagnostic;
102
104
  id = "skills";
105
+ /** Each omitted skill is reported once per process, not on every request. */
106
+ reported = new Set();
107
+ constructor(onDiagnostic = (message) => console.error(message)) {
108
+ this.onDiagnostic = onDiagnostic;
109
+ }
110
+ /**
111
+ * The skills this agent may be served under `skill://`, each with a manifest
112
+ * computed from the bytes it would read (trellis-skills-over-mcp D2/D3). A
113
+ * skill that cannot be served validly is omitted and reported, never served
114
+ * under a URI the specification calls invalid.
115
+ */
116
+ catalog(canonical, agentId) {
117
+ const out = [];
118
+ for (const skill of canonical.skills) {
119
+ if (!hasSkillDocument(skill) || !isInScope(agentId, skill.scope, canonical.managedAgents))
120
+ continue;
121
+ const built = buildSkillEntry(skill.name, skill.dir);
122
+ if (built.ok) {
123
+ out.push({ skill, entry: built.entry });
124
+ }
125
+ else if (!this.reported.has(built.reason)) {
126
+ this.reported.add(built.reason);
127
+ this.onDiagnostic(`trellis-mcp-runtime: skill not served under skill://: ${built.reason}`);
128
+ }
129
+ }
130
+ return out;
131
+ }
132
+ /** `skills/list` — one entry per servable, in-scope skill. */
133
+ listSkillEntries(context) {
134
+ return this.catalog(loadCanonicalSource(context.homeDir), context.agentId).map(({ entry }) => entry);
135
+ }
136
+ /** `skills/get` — lookup by URI, independent of listing. Out-of-scope and
137
+ * unknown URIs are indistinguishable by design. */
138
+ getSkillEntry(uri, context) {
139
+ const parsed = parseSkillUri(uri);
140
+ if (!parsed || parsed.path !== "SKILL.md")
141
+ return undefined;
142
+ return this.listSkillEntries(context).find((entry) => entry.uri === uri);
143
+ }
103
144
  listTools() {
104
145
  return [SEARCH_TOOL, READ_TOOL, READ_FILE_TOOL];
105
146
  }
@@ -116,7 +157,7 @@ export class SkillProvider {
116
157
  }
117
158
  listResources(context) {
118
159
  const canonical = loadCanonicalSource(context.homeDir);
119
- return canonical.skills
160
+ const legacy = canonical.skills
120
161
  .filter((skill) => hasSkillDocument(skill) && isInScope(context.agentId, skill.scope, canonical.managedAgents))
121
162
  .map((skill) => ({
122
163
  uri: skillUri(skill.name),
@@ -124,8 +165,15 @@ export class SkillProvider {
124
165
  description: `Instructions for the ${skill.name} skill`,
125
166
  mimeType: "text/markdown",
126
167
  }));
168
+ // The convention's view of the same data, additive to the above
169
+ // (design.md D1): every manifest file, so a host that lists resources
170
+ // sees exactly what skills/list advertises.
171
+ const conventional = this.catalog(canonical, context.agentId).flatMap(({ entry }) => entry.resources.map((file) => ({ uri: file.uri, name: file.uri.slice("skill://".length), mimeType: file.uri.endsWith(".md") ? "text/markdown" : "text/plain", size: file.size })));
172
+ return [...legacy, ...conventional];
127
173
  }
128
174
  readResource(uri, context) {
175
+ if (uri.protocol === "skill:")
176
+ return this.readConventionalResource(uri.toString(), context);
129
177
  if (uri.protocol !== "trellis:" || uri.hostname !== "skills") {
130
178
  throw new Error(`unsupported skill resource URI "${uri.toString()}"`);
131
179
  }
@@ -140,6 +188,18 @@ export class SkillProvider {
140
188
  const content = this.readBoundedFile(resolve(skill.dir, "SKILL.md"), skill.dir);
141
189
  return { contents: [{ uri: uri.toString(), mimeType: "text/markdown", text: content }] };
142
190
  }
191
+ /** Serves only what the manifest advertises, from the same reader that
192
+ * computed its digests — the two cannot disagree. */
193
+ readConventionalResource(uri, context) {
194
+ const parsed = parseSkillUri(uri);
195
+ const canonical = loadCanonicalSource(context.homeDir);
196
+ const skill = parsed ? skillFor(canonical, context.agentId, parsed.name) : undefined;
197
+ const served = skill && parsed ? this.catalog(canonical, context.agentId).find(({ entry }) => entry.uri === `skill://${parsed.name}/SKILL.md`) : undefined;
198
+ if (!parsed || !skill || !served || !served.entry.resources.some((file) => file.uri === uri)) {
199
+ throw new Error(`skill resource "${uri}" is not available to ${context.agentId}`);
200
+ }
201
+ return { contents: [toResourceContent(uri, parsed.path, readSkillFileBytes(skill.dir, parsed.path))] };
202
+ }
143
203
  search(canonical, agentId, input) {
144
204
  const query = typeof input.query === "string" ? input.query.trim().toLowerCase() : "";
145
205
  const limit = typeof input.limit === "number" ? Math.min(50, Math.max(1, Math.floor(input.limit))) : 20;