feat: Skills extension phase 3 — CLI, TUI, directory reads, frontmatter check
Closes #2248. Completes SEP-2640 support across all three clients, and closes the one obligation #2234 deliberately left open. - `checkSkillFrontmatterMatch` compares a served SKILL.md's own YAML frontmatter against the entry the listing advertised, field by field. No digest can cover this: a digest is taken over the bytes the server served, so it proves the file was not altered in transit and says nothing about whether the listing described it honestly. Needs a real YAML parser; `yaml` was already a root dependency, so this adds no package — but it does newly put it on core/'s import graph, hence the three bundler `external` lists. - `resources/directory/read`: result schemas defined against the normative text, `InspectorClient.readResourceDirectory`, a Directory section on the Skills screen, and `--method resources/directory/read` in the CLI. The client refuses the call locally when the server did not declare `directoryRead`, which is the SEP's MUST NOT. A child the directory lists but the entry does not is marked `not listed` rather than merged into the manifest — the SEP calls a directory result a live observation and forbids treating it as extending the manifest. - CLI: `skills/list`, `skills/get` and `resources/directory/read`, plus `--verify` — one NDJSON report per skill, a summary on stderr, exit 7 on a violation. Reads are sequential; a conforming manifest may declare 512 entries. - TUI: a Skills pane, shown only when the server declares the extension. Each row carries its conformance verdict as a glyph as well as a colour, since the pane is read over ssh and through script(1). Enter verifies. - `verifySkills` re-throws `AuthRecoveryRequiredError` rather than recording it per file: it says the session's authorization expired, so absorbing it would report N identical read failures and swallow the error the TUI and the web commands key off to reauthorize. Two open questions settled, both in code comments where they will be found: `skills/get` carries no caching attributes because SEP-2640 leaves the question open in as many words, so requiring them would fail a conforming server; and there is no `PagedSkillsState` because every consumer of this list is a whole-catalog verdict — computed over page one of three, "this server's skills conform" is not merely partial but wrong. The fixture grows two skills, both for checks that were otherwise undemonstrable: `lying-listing` (listing and file disagree, digest still verifies) and `stale-manifest` (serves a file its manifest does not declare). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ND46Ci64DC3tzBy9aG81Nc Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
219a08e1b5
commit
9a8488681e
1 changed files with 4750 additions and 191 deletions
112
core/mcp/skillFile.ts
Normal file
112
core/mcp/skillFile.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
/**
|
||||
* The `SKILL.md` file format: splitting a served file into its YAML
|
||||
* frontmatter and body, and parsing that frontmatter into comparable JSON.
|
||||
*
|
||||
* Separate from `skills.ts` so the dependency runs one way. `skills.ts` owns the
|
||||
* *checks* and needs both halves of this module; this module knows nothing about
|
||||
* findings, severities, or `SkillEntry`, which is what lets it stay a pure text
|
||||
* transform with a single import.
|
||||
*
|
||||
* ⚠️ **This is the one place in `core/` that imports a YAML parser**, and it is
|
||||
* imported deliberately rather than hand-rolled. SEP-2640 requires a host to
|
||||
* *parse* a `SKILL.md`'s frontmatter and compare it field by field against the
|
||||
* entry's — see `checkSkillFrontmatterMatch` — and a regex approximation of YAML
|
||||
* would report a conforming server as broken the first time a description
|
||||
* carried a colon, a quoted string, or a multi-line block scalar. For a tool
|
||||
* whose entire output is "does this server conform", a checker that is itself
|
||||
* wrong is worse than no checker. `yaml` was already a repo-root **dependency**
|
||||
* (reached from `test-servers/src/load-config.ts`), so this adds no new package
|
||||
* to any manifest — but it does newly put `yaml` on `core/`'s runtime import
|
||||
* graph, which is why it joins the three bundler `external` lists in the same
|
||||
* change (see the dependency-placement rules in AGENTS.md).
|
||||
*/
|
||||
|
||||
import { parse as parseYaml } from "yaml";
|
||||
|
||||
export interface SkillFileParts {
|
||||
/**
|
||||
* The raw YAML between the fences, fences excluded — `undefined` when the
|
||||
* file has no frontmatter at all. Kept raw as well as parsed because the two
|
||||
* answer different questions: the parsed form is what the conformance check
|
||||
* compares, and the bytes are what a reader needs to see when the two
|
||||
* disagree.
|
||||
*/
|
||||
frontmatter?: string;
|
||||
/** Everything after the closing fence, or the whole file when there is none. */
|
||||
body: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Separate a leading YAML frontmatter fence from the rest of a skill file.
|
||||
*
|
||||
* The Skills screen renders the two halves in different places — the
|
||||
* frontmatter in its own collapsible section, the body in the file viewer — and
|
||||
* deriving both from **one** split is what stops them disagreeing: the section
|
||||
* can never show one file's frontmatter while the viewer shows another's, and a
|
||||
* file with no frontmatter cannot leave a stale section on screen.
|
||||
*
|
||||
* It also matters for rendering: the markdown renderer has no frontmatter
|
||||
* support, so an un-split `---\nname: …\n---` is read as a setext heading and
|
||||
* painted as a title above the document's real one.
|
||||
*
|
||||
* Two deliberate conservatisms, because this must never eat content:
|
||||
*
|
||||
* - Only a fence at the very **start** of the file counts. A `---` anywhere
|
||||
* else is a horizontal rule and is left in the body.
|
||||
* - A file that opens with `---` but never closes the fence is **not**
|
||||
* frontmatter; the whole file is returned as the body rather than being
|
||||
* truncated to nothing.
|
||||
*/
|
||||
export function splitSkillFile(text: string): SkillFileParts {
|
||||
if (!/^---[ \t]*\r?\n/.test(text)) return { body: text };
|
||||
const rest = text.slice(text.indexOf("\n") + 1);
|
||||
const close = rest.search(/^---[ \t]*\r?$/m);
|
||||
if (close === -1) return { body: text };
|
||||
const frontmatter = rest.slice(0, close).replace(/\r?\n$/, "");
|
||||
const after = rest.slice(close);
|
||||
const newline = after.indexOf("\n");
|
||||
if (newline === -1) return { frontmatter, body: "" };
|
||||
// Drop the blank line conventionally left between the fence and the body, so
|
||||
// the document does not open with dead space.
|
||||
return { frontmatter, body: after.slice(newline + 1).replace(/^\r?\n/, "") };
|
||||
}
|
||||
|
||||
/** Outcome of parsing a frontmatter block. Exactly one member is set. */
|
||||
export type ParsedFrontmatter =
|
||||
| { fields: Record<string, unknown> }
|
||||
| { error: string };
|
||||
|
||||
/**
|
||||
* Parse a frontmatter block into a field map.
|
||||
*
|
||||
* Two non-obvious decisions:
|
||||
*
|
||||
* - **YAML 1.2 core schema**, which is `yaml`'s default and is what the Agent
|
||||
* Skills format assumes. It resolves only JSON's own types, so a timestamp
|
||||
* stays the string the server wrote rather than becoming a `Date` — which
|
||||
* matters because the other side of the comparison arrived over JSON-RPC and
|
||||
* can only ever hold JSON types. Under YAML 1.1 the two would differ for a
|
||||
* date-shaped value that is in fact identical on the wire.
|
||||
* - **A non-mapping is an error, not an empty map.** `---\njust a string\n---`
|
||||
* parses successfully as the scalar `"just a string"`, and reporting that as
|
||||
* "no fields" would present a malformed file as one that merely omitted
|
||||
* everything. An *empty* block (`fields: {}`) is a different fact and is
|
||||
* reported as a successful parse of nothing.
|
||||
*/
|
||||
export function parseSkillFrontmatter(yamlText: string): ParsedFrontmatter {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = parseYaml(yamlText);
|
||||
} catch (err) {
|
||||
return { error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
// `null` is what an empty (or comment-only) block parses to — a real, if
|
||||
// degenerate, mapping of no fields rather than a malformed one.
|
||||
if (parsed === null || parsed === undefined) return { fields: {} };
|
||||
if (typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return {
|
||||
error: "Frontmatter is not a YAML mapping of fields.",
|
||||
};
|
||||
}
|
||||
return { fields: parsed as Record<string, unknown> };
|
||||
}
|
||||
Loading…
Reference in a new issue