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
2 changed files with 4750 additions and 191 deletions
|
|
@ -28,21 +28,57 @@ const NOTES_TEXT = "different\n";
|
|||
const REF_DIGEST = await sha256Digest(textToBytes(REF_TEXT));
|
||||
const SELF_DIGEST = await sha256Digest(textToBytes(SELF_TEXT));
|
||||
|
||||
type Frontmatter = { name: string; description: string };
|
||||
|
||||
/**
|
||||
* The `SKILL.md` a given frontmatter implies.
|
||||
*
|
||||
* Every fixture's served file is built from the very frontmatter its entry
|
||||
* advertises, so the two agree by construction. SEP-2640 requires that match
|
||||
* field for field and #2248 added the check that enforces it — hand-writing
|
||||
* the file instead would report a frontmatter discrepancy in every fixture
|
||||
* here, drowning the tests that are actually about one. Same discipline, and
|
||||
* the same reason, as `skillMd` in `test-servers/src/skills.ts`.
|
||||
*/
|
||||
function skillMdFor(fm: Frontmatter): string {
|
||||
return `---\nname: ${fm.name}\ndescription: ${fm.description}\n---\n\n# ${fm.name}\n`;
|
||||
}
|
||||
|
||||
/** The entry's own SKILL.md manifest row: derived text, and its real digest. */
|
||||
async function selfEntry(uri: string, fm: Frontmatter) {
|
||||
const text = skillMdFor(fm);
|
||||
return {
|
||||
uri,
|
||||
digest: await sha256Digest(textToBytes(text)),
|
||||
size: textToBytes(text).byteLength,
|
||||
};
|
||||
}
|
||||
|
||||
const CLEAN_FM: Frontmatter = {
|
||||
name: "data-analysis",
|
||||
description: "Analyze a CSV and summarize its columns",
|
||||
};
|
||||
const TAMPERED_FM: Frontmatter = {
|
||||
name: "tampered",
|
||||
description: "Bad digest",
|
||||
};
|
||||
const DYNAMIC_FM: Frontmatter = {
|
||||
name: "dynamic-report",
|
||||
description: "Generated files",
|
||||
};
|
||||
const MISMATCHED_FM: Frontmatter = {
|
||||
name: "right-name",
|
||||
description: "Name disagreement",
|
||||
};
|
||||
|
||||
// Every manifest lists the skill's own SKILL.md: a manifest is the complete
|
||||
// file set, so one that omits it is a `manifest-missing-self` error and no
|
||||
// fixture here would be "clean".
|
||||
const CLEAN_SKILL: SkillEntry = {
|
||||
uri: "skill://data-analysis/SKILL.md",
|
||||
frontmatter: {
|
||||
name: "data-analysis",
|
||||
description: "Analyze a CSV and summarize its columns",
|
||||
},
|
||||
frontmatter: CLEAN_FM,
|
||||
resources: [
|
||||
{
|
||||
uri: "skill://data-analysis/SKILL.md",
|
||||
digest: SELF_DIGEST,
|
||||
size: textToBytes(SELF_TEXT).byteLength,
|
||||
},
|
||||
await selfEntry("skill://data-analysis/SKILL.md", CLEAN_FM),
|
||||
{
|
||||
uri: "skill://data-analysis/reference.md",
|
||||
digest: REF_DIGEST,
|
||||
|
|
@ -53,13 +89,9 @@ const CLEAN_SKILL: SkillEntry = {
|
|||
|
||||
const TAMPERED_SKILL: SkillEntry = {
|
||||
uri: "skill://tampered/SKILL.md",
|
||||
frontmatter: { name: "tampered", description: "Bad digest" },
|
||||
frontmatter: TAMPERED_FM,
|
||||
resources: [
|
||||
{
|
||||
uri: "skill://tampered/SKILL.md",
|
||||
digest: SELF_DIGEST,
|
||||
size: textToBytes(SELF_TEXT).byteLength,
|
||||
},
|
||||
await selfEntry("skill://tampered/SKILL.md", TAMPERED_FM),
|
||||
{
|
||||
// A well-formed digest of bytes the fake read does not return, and a
|
||||
// size that agrees — so the failure reported is a *digest* mismatch and
|
||||
|
|
@ -73,20 +105,14 @@ const TAMPERED_SKILL: SkillEntry = {
|
|||
|
||||
const DYNAMIC_SKILL: SkillEntry = {
|
||||
uri: "skill://dynamic-report/SKILL.md",
|
||||
frontmatter: { name: "dynamic-report", description: "Generated files" },
|
||||
frontmatter: DYNAMIC_FM,
|
||||
resources: "dynamic",
|
||||
};
|
||||
|
||||
const MISMATCHED_SKILL: SkillEntry = {
|
||||
uri: "skill://wrong-folder/SKILL.md",
|
||||
frontmatter: { name: "right-name", description: "Name disagreement" },
|
||||
resources: [
|
||||
{
|
||||
uri: "skill://wrong-folder/SKILL.md",
|
||||
digest: SELF_DIGEST,
|
||||
size: textToBytes(SELF_TEXT).byteLength,
|
||||
},
|
||||
],
|
||||
frontmatter: MISMATCHED_FM,
|
||||
resources: [await selfEntry("skill://wrong-folder/SKILL.md", MISMATCHED_FM)],
|
||||
};
|
||||
|
||||
const ALL_SKILLS = [
|
||||
|
|
@ -96,11 +122,20 @@ const ALL_SKILLS = [
|
|||
MISMATCHED_SKILL,
|
||||
];
|
||||
|
||||
/** A `resources/read` that serves the fixture bytes for any known URI. */
|
||||
/**
|
||||
* A `resources/read` that serves the fixture bytes for any known URI. A skill's
|
||||
* own `SKILL.md` comes from {@link skillMdFor}, so it agrees with the entry's
|
||||
* advertised frontmatter by construction; a test that needs a disagreement
|
||||
* supplies its own entry.
|
||||
*/
|
||||
const readFixtureFile = vi.fn(async (uri: string) => {
|
||||
if (uri === "skill://data-analysis/reference.md") return { text: REF_TEXT };
|
||||
if (uri === "skill://tampered/notes.md") return { text: NOTES_TEXT };
|
||||
return { text: SELF_TEXT, mimeType: "text/markdown" };
|
||||
const owner = ALL_SKILLS.find((skill) => skill.uri === uri);
|
||||
return {
|
||||
text: owner ? skillMdFor(owner.frontmatter as Frontmatter) : SELF_TEXT,
|
||||
mimeType: "text/markdown",
|
||||
};
|
||||
});
|
||||
|
||||
const baseProps: SkillsScreenProps = {
|
||||
|
|
@ -576,15 +611,11 @@ describe("SkillsScreen", () => {
|
|||
{
|
||||
...CLEAN_SKILL,
|
||||
resources: [
|
||||
{
|
||||
uri: "skill://data-analysis/SKILL.md",
|
||||
digest: SELF_DIGEST,
|
||||
size: textToBytes(SELF_TEXT).byteLength,
|
||||
},
|
||||
await selfEntry("skill://data-analysis/SKILL.md", CLEAN_FM),
|
||||
{
|
||||
uri: "skill://data-analysis/SKILL.md",
|
||||
digest: `sha256:${"d".repeat(64)}`,
|
||||
size: textToBytes(SELF_TEXT).byteLength,
|
||||
size: textToBytes(skillMdFor(CLEAN_FM)).byteLength,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -1717,3 +1748,416 @@ describe("SkillsScreen", () => {
|
|||
expect(screen.queryByText("too late")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The Directory section (`resources/directory/read`, SEP-2640, #2248).
|
||||
*
|
||||
* Gated on the CALLBACK's presence, not on a boolean beside it: the SEP makes
|
||||
* calling the method against a server that has not declared `directoryRead` a
|
||||
* MUST NOT, and an absent callback is that rule expressed in the type.
|
||||
*/
|
||||
describe("SkillsScreen directory browsing (#2248)", () => {
|
||||
const ROOT = "skill://data-analysis";
|
||||
const CHILD_FILE = {
|
||||
uri: "skill://data-analysis/reference.md",
|
||||
name: "reference.md",
|
||||
mimeType: "text/markdown",
|
||||
};
|
||||
const CHILD_DIR = {
|
||||
uri: "skill://data-analysis/templates",
|
||||
name: "templates",
|
||||
mimeType: "inode/directory",
|
||||
};
|
||||
const NESTED = {
|
||||
uri: "skill://data-analysis/templates/invoice.md",
|
||||
name: "invoice.md",
|
||||
mimeType: "text/markdown",
|
||||
};
|
||||
|
||||
function directoryReader(
|
||||
pages: Record<string, { resources: unknown[]; nextCursor?: string }>,
|
||||
) {
|
||||
return vi.fn(async (uri: string, cursor?: string) => {
|
||||
const page = pages[cursor === undefined ? uri : `${uri}#${cursor}`];
|
||||
if (!page) throw new Error(`no page for ${uri} ${cursor ?? ""}`);
|
||||
return page as never;
|
||||
});
|
||||
}
|
||||
|
||||
it("renders no Directory section when the server did not declare directoryRead", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<ControlledSkillsScreen />);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /Directory/ }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reads on a click, never on selection", async () => {
|
||||
// Every round trip on this screen is asked for — the same posture "Fetch
|
||||
// entry" takes.
|
||||
const user = userEvent.setup();
|
||||
const onReadResourceDirectory = directoryReader({
|
||||
[ROOT]: { resources: [CHILD_FILE] },
|
||||
});
|
||||
renderWithMantine(
|
||||
<ControlledSkillsScreen
|
||||
onReadResourceDirectory={onReadResourceDirectory}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
expect(onReadResourceDirectory).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Read directory" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("skill-directory")).toBeInTheDocument(),
|
||||
);
|
||||
expect(onReadResourceDirectory).toHaveBeenCalledWith(ROOT, undefined);
|
||||
expect(
|
||||
within(screen.getByTestId("skill-directory")).getByText(
|
||||
"skill://data-analysis/reference.md",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
/**
|
||||
* Open the Directory section on the clean skill and read its root.
|
||||
*
|
||||
* Shared because the descend/ascend assertions below would otherwise each
|
||||
* repeat four sequential `userEvent` clicks inside one 5s budget — enough to
|
||||
* make them the first thing to time out when the suite runs under load,
|
||||
* which is a property of the test rather than of the screen.
|
||||
*/
|
||||
async function openRoot(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
reader: ReturnType<typeof directoryReader>,
|
||||
) {
|
||||
renderWithMantine(
|
||||
<ControlledSkillsScreen onReadResourceDirectory={reader} />,
|
||||
);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
await user.click(screen.getByRole("button", { name: "Read directory" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("skill-directory")).toBeInTheDocument(),
|
||||
);
|
||||
}
|
||||
|
||||
it("descends into a child directory", async () => {
|
||||
const user = userEvent.setup();
|
||||
await openRoot(
|
||||
user,
|
||||
directoryReader({
|
||||
[ROOT]: { resources: [CHILD_FILE, CHILD_DIR] },
|
||||
[CHILD_DIR.uri]: { resources: [NESTED] },
|
||||
}),
|
||||
);
|
||||
// A directory child is labelled as one and descends rather than opening in
|
||||
// the viewer; the listing is not recursive, so this is a second call.
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: `Open directory ${CHILD_DIR.uri}` }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
within(screen.getByTestId("skill-directory")).getByText(NESTED.uri),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("offers Up only below the skill root, and returns to it", async () => {
|
||||
// Ascent is bounded by the root: this section browses the selected skill's
|
||||
// tree, and walking above it would leave every other section's subject
|
||||
// behind.
|
||||
const user = userEvent.setup();
|
||||
await openRoot(
|
||||
user,
|
||||
directoryReader({
|
||||
[ROOT]: { resources: [CHILD_FILE, CHILD_DIR] },
|
||||
[CHILD_DIR.uri]: { resources: [NESTED] },
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Up" }),
|
||||
).not.toBeInTheDocument();
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: `Open directory ${CHILD_DIR.uri}` }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Up" })).toBeInTheDocument(),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Up" }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
within(screen.getByTestId("skill-directory")).getByText(CHILD_FILE.uri),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Up" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens a file child in the viewer rather than descending", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onReadResourceDirectory = directoryReader({
|
||||
[ROOT]: { resources: [CHILD_FILE] },
|
||||
});
|
||||
renderWithMantine(
|
||||
<ControlledSkillsScreen
|
||||
onReadResourceDirectory={onReadResourceDirectory}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
await user.click(screen.getByRole("button", { name: "Read directory" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("skill-directory")).toBeInTheDocument(),
|
||||
);
|
||||
readFixtureFile.mockClear();
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: `View ${CHILD_FILE.uri}` }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(readFixtureFile).toHaveBeenCalledWith(CHILD_FILE.uri),
|
||||
);
|
||||
});
|
||||
|
||||
it("pages manually, accumulating children rather than replacing them", async () => {
|
||||
// The cursor belongs to the client per the SEP, and this screen is what a
|
||||
// server author uses to see their own pagination work — auto-walking it
|
||||
// would hide the behaviour under test.
|
||||
const user = userEvent.setup();
|
||||
const onReadResourceDirectory = directoryReader({
|
||||
[ROOT]: { resources: [CHILD_FILE], nextCursor: "1" },
|
||||
[`${ROOT}#1`]: { resources: [CHILD_DIR] },
|
||||
});
|
||||
renderWithMantine(
|
||||
<ControlledSkillsScreen
|
||||
onReadResourceDirectory={onReadResourceDirectory}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
await user.click(screen.getByRole("button", { name: "Read directory" }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Load more" }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Load more" }));
|
||||
await waitFor(() => {
|
||||
const table = within(screen.getByTestId("skill-directory"));
|
||||
expect(table.getByText(CHILD_FILE.uri)).toBeInTheDocument();
|
||||
expect(table.getByText(CHILD_DIR.uri)).toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Load more" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks a child the manifest declares as listed", async () => {
|
||||
const user = userEvent.setup();
|
||||
await openRoot(
|
||||
user,
|
||||
directoryReader({ [ROOT]: { resources: [CHILD_FILE] } }),
|
||||
);
|
||||
const table = within(screen.getByTestId("skill-directory"));
|
||||
expect(table.getByText("listed")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("skill-directory-unlisted"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("flags a child the entry does not declare, without merging the two views", async () => {
|
||||
// SEP-2640: a directory read is "a live observation" and hosts "MUST NOT
|
||||
// treat the directory result as extending the manifest". The Inspector is
|
||||
// not a host and does not refuse the read — what it must not do is present
|
||||
// the child as one of the skill's files without saying where it came from.
|
||||
const user = userEvent.setup();
|
||||
const STRAY = {
|
||||
uri: "skill://data-analysis/added-later.md",
|
||||
name: "added-later.md",
|
||||
mimeType: "text/markdown",
|
||||
};
|
||||
await openRoot(
|
||||
user,
|
||||
directoryReader({ [ROOT]: { resources: [CHILD_FILE, STRAY] } }),
|
||||
);
|
||||
const table = within(screen.getByTestId("skill-directory"));
|
||||
expect(table.getByText("listed")).toBeInTheDocument();
|
||||
expect(table.getByText("not listed")).toBeInTheDocument();
|
||||
const banner = screen.getByTestId("skill-directory-unlisted");
|
||||
expect(banner).toHaveTextContent(/1 file here that the held/);
|
||||
// The recovery path the SEP names, rather than "read error".
|
||||
expect(banner).toHaveTextContent(/skills\/get/);
|
||||
});
|
||||
|
||||
it("gives a subdirectory no listed/unlisted verdict", async () => {
|
||||
// A manifest lists files, so a directory is not a missing entry — a "not
|
||||
// listed" chip on one would report a defect that is not there.
|
||||
const user = userEvent.setup();
|
||||
await openRoot(
|
||||
user,
|
||||
directoryReader({ [ROOT]: { resources: [CHILD_DIR] } }),
|
||||
);
|
||||
expect(
|
||||
within(screen.getByTestId("skill-directory")).queryByText("not listed"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("skill-directory-unlisted"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("gives a dynamic skill's children no verdict either", async () => {
|
||||
// `"dynamic"` advertises no manifest, so there is nothing for a child to be
|
||||
// missing from — and a directory read is the only way its files are
|
||||
// discoverable at all, which is the case the method exists for.
|
||||
const user = userEvent.setup();
|
||||
const reader = directoryReader({
|
||||
"skill://dynamic-report": {
|
||||
resources: [
|
||||
{
|
||||
uri: "skill://dynamic-report/generated.md",
|
||||
name: "generated.md",
|
||||
mimeType: "text/markdown",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
renderWithMantine(
|
||||
<ControlledSkillsScreen onReadResourceDirectory={reader} />,
|
||||
);
|
||||
await user.click(screen.getByText("dynamic-report"));
|
||||
await user.click(screen.getByRole("button", { name: "Read directory" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("skill-directory")).toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
within(screen.getByTestId("skill-directory")).queryByText("not listed"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("skill-directory-unlisted"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says an empty directory is empty", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(
|
||||
<ControlledSkillsScreen
|
||||
onReadResourceDirectory={directoryReader({ [ROOT]: { resources: [] } })}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
await user.click(screen.getByRole("button", { name: "Read directory" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("This directory is empty.")).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders a read failure without losing the section", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onReadResourceDirectory = vi.fn(async () => {
|
||||
throw new Error("-32602 Not a directory resource");
|
||||
});
|
||||
renderWithMantine(
|
||||
<ControlledSkillsScreen
|
||||
onReadResourceDirectory={
|
||||
onReadResourceDirectory as unknown as SkillsScreenProps["onReadResourceDirectory"]
|
||||
}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
await user.click(screen.getByRole("button", { name: "Read directory" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/Not a directory resource/)).toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Directory/ }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("drops a listing when the selection changes mid-read", async () => {
|
||||
// A read still in flight when the user switches skills must not land
|
||||
// afterwards and paint one skill's tree under another's name.
|
||||
const user = userEvent.setup();
|
||||
let release: ((value: unknown) => void) | undefined;
|
||||
const onReadResourceDirectory = vi.fn(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
}) as never,
|
||||
);
|
||||
renderWithMantine(
|
||||
<ControlledSkillsScreen
|
||||
onReadResourceDirectory={onReadResourceDirectory}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
await user.click(screen.getByRole("button", { name: "Read directory" }));
|
||||
await user.click(screen.getByText("right-name"));
|
||||
release?.({ resources: [CHILD_FILE] });
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("skill-directory")).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders no Directory section for a skill whose URI is malformed", async () => {
|
||||
// There is no root to browse, and `malformed-uri` already reports it in
|
||||
// Conformance.
|
||||
const user = userEvent.setup();
|
||||
const odd: SkillEntry = {
|
||||
uri: "not-a-uri",
|
||||
frontmatter: { name: "odd", description: "d" },
|
||||
resources: [],
|
||||
};
|
||||
renderWithMantine(
|
||||
<ControlledSkillsScreen
|
||||
skills={[odd]}
|
||||
onReadResourceDirectory={directoryReader({})}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByText("odd"));
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /Directory/ }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SkillsScreen frontmatter cross-check (#2248)", () => {
|
||||
it("reports a listing whose frontmatter disagrees with the served SKILL.md", async () => {
|
||||
// The violation no digest can catch — the digest is over the bytes served
|
||||
// and says nothing about whether the listing described them honestly.
|
||||
const user = userEvent.setup();
|
||||
const lying: SkillEntry = {
|
||||
...CLEAN_SKILL,
|
||||
frontmatter: {
|
||||
name: "data-analysis",
|
||||
description: "Not what the file says",
|
||||
},
|
||||
};
|
||||
renderWithMantine(<ControlledSkillsScreen skills={[lying]} />);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
await user.click(screen.getByRole("button", { name: /Conformance/ }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByTestId("skill-frontmatter-issues"),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
// Scoped to the findings block: the skill's own description is rendered in
|
||||
// the header too, so an unscoped match would pass on the wrong element.
|
||||
expect(
|
||||
within(screen.getByTestId("skill-frontmatter-issues")).getByText(
|
||||
/Not what the file says/,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reports nothing when the served frontmatter agrees", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<ControlledSkillsScreen />);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
await user.click(screen.getByRole("button", { name: /Conformance/ }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("No structural issues")).toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
screen.queryByTestId("skill-frontmatter-issues"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,17 +19,25 @@ import {
|
|||
import { MdSearch, MdVerifiedUser } from "react-icons/md";
|
||||
import { RiArrowRightSLine } from "react-icons/ri";
|
||||
import type {
|
||||
DirectoryReadResult,
|
||||
SkillEntry,
|
||||
SkillResource,
|
||||
} from "@inspector/core/mcp/skillsSchemas.js";
|
||||
import { DYNAMIC_RESOURCES } from "@inspector/core/mcp/skillsSchemas.js";
|
||||
import {
|
||||
DIRECTORY_MIME_TYPE,
|
||||
DYNAMIC_RESOURCES,
|
||||
} from "@inspector/core/mcp/skillsSchemas.js";
|
||||
import {
|
||||
checkSkillConformance,
|
||||
checkSkillFrontmatterMatch,
|
||||
skillDisplayName,
|
||||
skillFileBytes,
|
||||
skillEntriesMatch,
|
||||
skillUriIdentity,
|
||||
SKILL_FILE_SUFFIX,
|
||||
totalSkillBytes,
|
||||
verifySkillResource,
|
||||
type SkillFileContents,
|
||||
type SkillIssue,
|
||||
type SkillVerification,
|
||||
} from "@inspector/core/mcp/skills.js";
|
||||
|
|
@ -37,11 +45,7 @@ import { CodeHighlight } from "../../elements/CodeHighlight/CodeHighlight";
|
|||
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
|
||||
import { ListToggle } from "../../elements/ListToggle/ListToggle";
|
||||
import { useValueChange } from "../../../hooks/useValueChange";
|
||||
import {
|
||||
skillFileBytes,
|
||||
type SkillFileContents,
|
||||
} from "../../../utils/skillFileBytes";
|
||||
import { splitSkillFile } from "../../../utils/splitSkillFile";
|
||||
import { splitSkillFile } from "@inspector/core/mcp/skillFile.js";
|
||||
import {
|
||||
inferMimeFromUri,
|
||||
isGenericMime,
|
||||
|
|
@ -126,6 +130,38 @@ interface FetchedEntryState {
|
|||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One child of a directory resource, as `resources/directory/read` returned it.
|
||||
* Structurally the base protocol's `Resource`; only the three members this
|
||||
* section renders are named.
|
||||
*/
|
||||
interface DirectoryChild {
|
||||
uri: string;
|
||||
name: string;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The directory browser's state: which directory is on screen, the children
|
||||
* gathered so far, and the cursor for the next page.
|
||||
*
|
||||
* Pages **accumulate** rather than replacing, unlike a paged list elsewhere in
|
||||
* the app, because a directory listing is one thing split across responses — a
|
||||
* reader descending a tree wants the directory's contents, not page 2 of them.
|
||||
* `key` invalidates it exactly as it does every other async slot here.
|
||||
*/
|
||||
interface DirectoryState {
|
||||
key: string | null;
|
||||
attempt?: number;
|
||||
/** The directory being shown (or read). */
|
||||
uri?: string;
|
||||
children?: DirectoryChild[];
|
||||
/** Cursor for the page after `children`, when the server sent one. */
|
||||
nextCursor?: string;
|
||||
loading?: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface SkillsScreenProps {
|
||||
/**
|
||||
* Identity of the connected session. Part of the invalidation key below, so
|
||||
|
|
@ -157,6 +193,20 @@ export interface SkillsScreenProps {
|
|||
* fresh read excuses.
|
||||
*/
|
||||
onGetSkill: (uri: string) => Promise<SkillEntry>;
|
||||
/**
|
||||
* One page of `resources/directory/read` (SEP-2640), or **`undefined` when
|
||||
* the server did not declare `directoryRead`** — which is how the Directory
|
||||
* section is gated.
|
||||
*
|
||||
* Gated by the prop's presence rather than by a boolean beside it, so the
|
||||
* section cannot be rendered without a way to populate it: the SEP makes
|
||||
* calling this method against a server that has not declared the sub-flag a
|
||||
* MUST NOT, and an absent callback is that rule expressed in the type.
|
||||
*/
|
||||
onReadResourceDirectory?: (
|
||||
uri: string,
|
||||
cursor?: string,
|
||||
) => Promise<DirectoryReadResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -437,7 +487,13 @@ const SkillTitle = Text.withProps({
|
|||
const SECTION_FLEX = "0 1 auto";
|
||||
|
||||
/** Every section this screen can render, in display order. */
|
||||
const ALL_SECTIONS = ["conformance", "resources", "frontmatter", "resource"];
|
||||
const ALL_SECTIONS = [
|
||||
"conformance",
|
||||
"resources",
|
||||
"directory",
|
||||
"frontmatter",
|
||||
"resource",
|
||||
];
|
||||
|
||||
/**
|
||||
* The open set for the FIRST render.
|
||||
|
|
@ -597,6 +653,7 @@ export function SkillsScreen({
|
|||
onRefreshList,
|
||||
onReadSkillFile,
|
||||
onGetSkill,
|
||||
onReadResourceDirectory,
|
||||
}: SkillsScreenProps) {
|
||||
const { selectedSkillUri, search } = ui;
|
||||
// Both slices carry the manifest key they belong to, and every async
|
||||
|
|
@ -615,6 +672,7 @@ export function SkillsScreen({
|
|||
const [fetchedEntry, setFetchedEntry] = useState<FetchedEntryState>({
|
||||
key: null,
|
||||
});
|
||||
const [directory, setDirectory] = useState<DirectoryState>({ key: null });
|
||||
// Every "Verify all" batch in flight, keyed by the manifest it belongs to.
|
||||
//
|
||||
// A **map**, not one slot, and the reason is a bug a single slot really had:
|
||||
|
|
@ -716,6 +774,11 @@ export function SkillsScreen({
|
|||
setVerification({ key: next, files: {} });
|
||||
setPreviewState({ key: next });
|
||||
setFetchedEntry({ key: next });
|
||||
// A directory listing is a live observation of a path under the *selected*
|
||||
// skill, so it is invalidated with everything else — carrying one across a
|
||||
// selection change would show the previous skill's tree under the new
|
||||
// skill's name.
|
||||
setDirectory({ key: next });
|
||||
// Conformance tracks whether it has anything to say: an entry with no
|
||||
// errors and no warnings opens collapsed, because "0 error(s), 0
|
||||
// warning(s)" on the header already carries the whole message and an
|
||||
|
|
@ -940,6 +1003,86 @@ export function SkillsScreen({
|
|||
showResource(selectedUri, manifestKey);
|
||||
}, [manifestKey, selectedUri, showResource]);
|
||||
|
||||
/**
|
||||
* The skill's root directory: its entry URI with `/SKILL.md` removed.
|
||||
*
|
||||
* Computed from the NORMALIZED URI, like every containment decision in
|
||||
* `core/mcp/skills.ts`, so a `..` segment cannot produce a root the resolved
|
||||
* path does not carry. `undefined` for a malformed entry URI — there is no
|
||||
* root to browse, and `malformed-uri` already reports that in Conformance.
|
||||
*/
|
||||
const skillRoot = useMemo(() => {
|
||||
if (!selected) return undefined;
|
||||
const normalized = skillUriIdentity(selected.uri);
|
||||
return normalized.endsWith(SKILL_FILE_SUFFIX)
|
||||
? normalized.slice(0, -SKILL_FILE_SUFFIX.length)
|
||||
: undefined;
|
||||
}, [selected]);
|
||||
|
||||
/**
|
||||
* Read one page of a directory, replacing the listing (`cursor` omitted) or
|
||||
* appending to it (`cursor` given).
|
||||
*
|
||||
* Keyed and attempt-stamped exactly as the other async slots here, so a read
|
||||
* still in flight when the user descends into a different directory — or
|
||||
* switches skills — cannot land afterwards and paint one directory's children
|
||||
* under another's path.
|
||||
*/
|
||||
const readDirectory = useCallback(
|
||||
(uri: string, key: string, cursor?: string) => {
|
||||
if (!onReadResourceDirectory) return;
|
||||
const attempt = (nextAttempt.current += 1);
|
||||
const write = (next: Omit<DirectoryState, "key" | "attempt">) =>
|
||||
setDirectory((prev) => {
|
||||
if (prev.key !== null && prev.key !== key) return prev;
|
||||
if (prev.attempt !== undefined && prev.attempt > attempt) return prev;
|
||||
return { key, attempt, ...next };
|
||||
});
|
||||
// The path is claimed before the request goes out, so the header names
|
||||
// the directory being read rather than continuing to announce the
|
||||
// previous one for as long as the read takes.
|
||||
setDirectory((prev) =>
|
||||
prev.key !== null && prev.key !== key
|
||||
? prev
|
||||
: {
|
||||
key,
|
||||
attempt,
|
||||
uri,
|
||||
// Pages accumulate, so a "load more" keeps what is on screen;
|
||||
// a fresh read of a different directory starts empty.
|
||||
children: cursor === undefined ? undefined : prev.children,
|
||||
loading: true,
|
||||
},
|
||||
);
|
||||
// A click handler cannot await, and this chain terminates in its own
|
||||
// `catch`, which surfaces the message in the section.
|
||||
void onReadResourceDirectory(uri, cursor)
|
||||
.then((page) => {
|
||||
setDirectory((prev) => {
|
||||
if (prev.key !== null && prev.key !== key) return prev;
|
||||
if (prev.attempt !== undefined && prev.attempt > attempt) {
|
||||
return prev;
|
||||
}
|
||||
const held = cursor === undefined ? [] : (prev.children ?? []);
|
||||
return {
|
||||
key,
|
||||
attempt,
|
||||
uri,
|
||||
children: [...held, ...page.resources],
|
||||
nextCursor: page.nextCursor,
|
||||
};
|
||||
});
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
write({
|
||||
uri,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
});
|
||||
},
|
||||
[onReadResourceDirectory],
|
||||
);
|
||||
|
||||
const fetchEntry = useCallback(() => {
|
||||
if (!selected) return;
|
||||
const key = manifestKey;
|
||||
|
|
@ -1004,6 +1147,50 @@ export function SkillsScreen({
|
|||
const batchRunning = batches.has(manifestKey);
|
||||
|
||||
const previewCurrent = previewState.key === manifestKey;
|
||||
/**
|
||||
* The identities of every file the held entry's manifest lists.
|
||||
*
|
||||
* SEP-2640 is explicit that a directory read is *"a live observation that may
|
||||
* run ahead of or behind"* the entry, and that **"Hosts MUST NOT treat the
|
||||
* directory result as extending the manifest"** — a child the server lists
|
||||
* but the entry does not is, to a host acting on the skill, a verification
|
||||
* failure exactly as a digest mismatch is. The Inspector is not a host and
|
||||
* does not refuse the read; what it must not do is present such a child as
|
||||
* one of the skill's files without saying which view it came from. So the two
|
||||
* views are labelled rather than merged.
|
||||
*
|
||||
* Compared on the normalized identity, like every other URI comparison here.
|
||||
*/
|
||||
const manifestIdentities = useMemo(
|
||||
() => new Set(manifest.map((resource) => skillUriIdentity(resource.uri))),
|
||||
[manifest],
|
||||
);
|
||||
|
||||
// The directory slot, but only when it belongs to the current manifest —
|
||||
// same guard every other async slot on this screen uses.
|
||||
const directoryCurrent = directory.key === manifestKey;
|
||||
const directoryUri = directoryCurrent ? directory.uri : undefined;
|
||||
const directoryChildren = directoryCurrent ? directory.children : undefined;
|
||||
const directoryError = directoryCurrent ? directory.message : undefined;
|
||||
const directoryLoading = directoryCurrent && directory.loading === true;
|
||||
const directoryNextCursor = directoryCurrent
|
||||
? directory.nextCursor
|
||||
: undefined;
|
||||
/**
|
||||
* Children the directory listed that the held entry's manifest does not.
|
||||
* Directories are excluded: a manifest lists files, so a directory is not a
|
||||
* missing entry. So is a `"dynamic"` skill, which advertises no manifest for
|
||||
* anything to be missing from.
|
||||
*/
|
||||
const unlistedChildren = useMemo(() => {
|
||||
if (directoryChildren === undefined || isDynamic) return [];
|
||||
return directoryChildren.filter(
|
||||
(child) =>
|
||||
child.mimeType !== DIRECTORY_MIME_TYPE &&
|
||||
!manifestIdentities.has(skillUriIdentity(child.uri)),
|
||||
);
|
||||
}, [directoryChildren, isDynamic, manifestIdentities]);
|
||||
|
||||
const preview = previewCurrent ? previewState.contents : undefined;
|
||||
const previewError = previewCurrent ? previewState.message : undefined;
|
||||
// The file the viewer is showing (or fetching). Falls back to the skill's own
|
||||
|
|
@ -1089,10 +1276,13 @@ export function SkillsScreen({
|
|||
() => [
|
||||
"conformance",
|
||||
...(isDynamic ? [] : ["resources"]),
|
||||
...(onReadResourceDirectory && skillRoot !== undefined
|
||||
? ["directory"]
|
||||
: []),
|
||||
...(previewParts?.frontmatter !== undefined ? ["frontmatter"] : []),
|
||||
"resource",
|
||||
],
|
||||
[isDynamic, previewParts],
|
||||
[isDynamic, onReadResourceDirectory, previewParts, skillRoot],
|
||||
);
|
||||
const allSectionsOpen = sectionIds.every((id) => openSections.includes(id));
|
||||
|
||||
|
|
@ -1113,6 +1303,35 @@ export function SkillsScreen({
|
|||
state.status === "done" && state.verification.status === "mismatch",
|
||||
).length;
|
||||
|
||||
/**
|
||||
* The SEP-2640 frontmatter cross-check, run against the file on screen — but
|
||||
* **only when that file is the skill's own `SKILL.md`**.
|
||||
*
|
||||
* The obligation is that the entry's `frontmatter` match the frontmatter of
|
||||
* the file the entry names; running it against a supporting file would report
|
||||
* `frontmatter-absent` for every one of them, which is the tool inventing a
|
||||
* defect. `showingSkillMd` is the same identity comparison the rest of this
|
||||
* screen uses.
|
||||
*
|
||||
* It is deliberately **not** part of the static `issues` above: those are
|
||||
* derived from the listing alone and are available the moment the list
|
||||
* arrives, while this one needs a `resources/read` the user asked for. Folding
|
||||
* them together would make the header badge's count change on its own the
|
||||
* first time a file happened to be fetched.
|
||||
*/
|
||||
const frontmatterIssues = useMemo(() => {
|
||||
if (!selected || !showingSkillMd || previewParts === undefined) return [];
|
||||
// Reconstructed from the split rather than re-derived from the payload, so
|
||||
// the check reads exactly the bytes the Frontmatter section displays.
|
||||
if (previewParts.frontmatter === undefined) {
|
||||
return checkSkillFrontmatterMatch(selected, previewParts.body);
|
||||
}
|
||||
return checkSkillFrontmatterMatch(
|
||||
selected,
|
||||
`---\n${previewParts.frontmatter}\n---\n\n${previewParts.body}`,
|
||||
);
|
||||
}, [selected, showingSkillMd, previewParts]);
|
||||
|
||||
const errorCount = issues.filter((i) => i.severity === "error").length;
|
||||
const warningCount = issues.length - errorCount;
|
||||
|
||||
|
|
@ -1348,6 +1567,25 @@ export function SkillsScreen({
|
|||
))}
|
||||
</IssueStack>
|
||||
)}
|
||||
{/* The frontmatter cross-check renders in Conformance
|
||||
rather than beside the Frontmatter section, because it
|
||||
is a *finding about the entry* and every other finding
|
||||
about the entry is here — a reader checking "does this
|
||||
skill conform" must not have to know that one class of
|
||||
violation is filed somewhere else. */}
|
||||
{frontmatterIssues.length > 0 && (
|
||||
<IssueStack data-testid="skill-frontmatter-issues">
|
||||
{frontmatterIssues.map((issue, index) => (
|
||||
<Alert
|
||||
key={`frontmatter:${index}`}
|
||||
color={issueColor(issue)}
|
||||
title={issue.code}
|
||||
>
|
||||
<Text size="sm">{issue.message}</Text>
|
||||
</Alert>
|
||||
))}
|
||||
</IssueStack>
|
||||
)}
|
||||
{manifest.map((resource, index) => {
|
||||
const state = fileStates[index];
|
||||
if (state?.status === "done") {
|
||||
|
|
@ -1597,6 +1835,197 @@ export function SkillsScreen({
|
|||
</Accordion.Item>
|
||||
)}
|
||||
|
||||
{/* Gated on the CALLBACK, which the parent supplies only when
|
||||
the server declared `directoryRead`. SEP-2640 makes calling
|
||||
`resources/directory/read` against a server that has not
|
||||
declared it a MUST NOT, so an absent section is that rule
|
||||
rather than a UI preference. */}
|
||||
{onReadResourceDirectory && skillRoot !== undefined && (
|
||||
<Accordion.Item
|
||||
value="directory"
|
||||
flex={SECTION_FLEX}
|
||||
mih={
|
||||
openSections.includes("directory")
|
||||
? OPEN_SECTION_MIN_HEIGHT
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Accordion.Control>
|
||||
<SectionHeading>Directory</SectionHeading>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel tabIndex={0}>
|
||||
<Stack gap="xs">
|
||||
{/* Read on a click, never on selection. A directory read
|
||||
is a live round trip, and this screen's posture is
|
||||
that every one of them is asked for — the same reason
|
||||
"Fetch entry" is a button and not an effect. */}
|
||||
<SectionControlsRow>
|
||||
<MonoCaption>{directoryUri ?? skillRoot}</MonoCaption>
|
||||
<Group gap="xs">
|
||||
{/* Ascending is bounded by the skill root: this
|
||||
section browses the selected skill's tree, and
|
||||
walking above it would leave the subject of every
|
||||
other section on screen. */}
|
||||
{directoryUri !== undefined &&
|
||||
directoryUri !== skillRoot && (
|
||||
<FetchButton
|
||||
onClick={() =>
|
||||
readDirectory(
|
||||
directoryUri.slice(
|
||||
0,
|
||||
directoryUri.lastIndexOf("/"),
|
||||
),
|
||||
manifestKey,
|
||||
)
|
||||
}
|
||||
>
|
||||
Up
|
||||
</FetchButton>
|
||||
)}
|
||||
<FetchButton
|
||||
loading={directoryLoading}
|
||||
onClick={() =>
|
||||
readDirectory(skillRoot, manifestKey)
|
||||
}
|
||||
>
|
||||
{directoryChildren === undefined
|
||||
? "Read directory"
|
||||
: "Reload root"}
|
||||
</FetchButton>
|
||||
</Group>
|
||||
</SectionControlsRow>
|
||||
{directoryError !== undefined && (
|
||||
<Alert color="red" variant="light" title="Read failed">
|
||||
{directoryError}
|
||||
</Alert>
|
||||
)}
|
||||
{/* Stated in prose the first time the two views
|
||||
disagree, because the per-row chip alone does not say
|
||||
why it matters — and "the skill changed and needs
|
||||
re-approval" is what SEP-2640 asks a host to present
|
||||
here, rather than a read error. */}
|
||||
{unlistedChildren.length > 0 && (
|
||||
<Alert
|
||||
color="yellow"
|
||||
variant="light"
|
||||
title="This directory lists files the entry does not"
|
||||
data-testid="skill-directory-unlisted"
|
||||
>
|
||||
The server is serving {unlistedChildren.length} file
|
||||
{unlistedChildren.length === 1 ? "" : "s"} here that
|
||||
the held <Code>skills/list</Code> entry does not
|
||||
declare. A directory read is a live observation and
|
||||
does <strong>not</strong> extend the manifest: to a
|
||||
host acting on this skill, reading one of these is a
|
||||
verification failure equivalent to a digest mismatch.
|
||||
Re-fetch the entry with <Code>skills/get</Code> to see
|
||||
whether the skill has changed.
|
||||
</Alert>
|
||||
)}
|
||||
{directoryChildren !== undefined &&
|
||||
(directoryChildren.length === 0 ? (
|
||||
<EmptyState>This directory is empty.</EmptyState>
|
||||
) : (
|
||||
<ManifestTable data-testid="skill-directory">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>URI</Table.Th>
|
||||
<Table.Th>MIME type</Table.Th>
|
||||
<Table.Th>In manifest</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{directoryChildren.map((child, index) => {
|
||||
const isDir =
|
||||
child.mimeType === DIRECTORY_MIME_TYPE;
|
||||
// A directory is not a manifest entry in the
|
||||
// first place — a manifest lists files — so it
|
||||
// is neither listed nor unlisted and gets no
|
||||
// verdict rather than a misleading "no".
|
||||
const listed = manifestIdentities.has(
|
||||
skillUriIdentity(child.uri),
|
||||
);
|
||||
return (
|
||||
// Index-keyed for the same reason the
|
||||
// manifest rows are: a server repeating a URI
|
||||
// is a defect to display, not two rows to
|
||||
// collapse into one.
|
||||
<Table.Tr key={index}>
|
||||
<Table.Td>
|
||||
<ResourceUriButton
|
||||
// A directory descends; a file opens in
|
||||
// the viewer below. One column, two
|
||||
// destinations, so the label says which
|
||||
// for a reader who cannot see the MIME
|
||||
// column at a glance.
|
||||
aria-label={
|
||||
isDir
|
||||
? `Open directory ${child.uri}`
|
||||
: `View ${child.uri}`
|
||||
}
|
||||
onClick={() =>
|
||||
isDir
|
||||
? readDirectory(
|
||||
child.uri,
|
||||
manifestKey,
|
||||
)
|
||||
: showResource(
|
||||
child.uri,
|
||||
manifestKey,
|
||||
)
|
||||
}
|
||||
>
|
||||
{isDir ? `${child.name}/` : child.name}
|
||||
</ResourceUriButton>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<MonoCaption>{child.uri}</MonoCaption>
|
||||
</Table.Td>
|
||||
<Table.Td>{child.mimeType ?? "—"}</Table.Td>
|
||||
<Table.Td>
|
||||
{isDir || isDynamic ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
) : (
|
||||
<CountBadge
|
||||
color={listed ? "green" : "yellow"}
|
||||
>
|
||||
{listed ? "listed" : "not listed"}
|
||||
</CountBadge>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</ManifestTable>
|
||||
))}
|
||||
{/* Paging is manual because the SEP gives the cursor to
|
||||
the client and this screen is what a server author
|
||||
uses to see their own pagination work. Auto-walking it
|
||||
would hide exactly the behaviour under test. */}
|
||||
{directoryNextCursor !== undefined &&
|
||||
directoryUri !== undefined && (
|
||||
<FetchButton
|
||||
loading={directoryLoading}
|
||||
onClick={() =>
|
||||
readDirectory(
|
||||
directoryUri,
|
||||
manifestKey,
|
||||
directoryNextCursor,
|
||||
)
|
||||
}
|
||||
>
|
||||
Load more
|
||||
</FetchButton>
|
||||
)}
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
)}
|
||||
|
||||
{/* Rendered ONLY when the file on display actually carries
|
||||
frontmatter. A skill's manifest files generally do not, and a
|
||||
section that lingered would be showing the previously selected
|
||||
|
|
|
|||
Loading…
Reference in a new issue