feat(skills): agent settings Skills tab with uninstall and archive/chat import (#179)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-08-04 20:20:07 +08:00
committed by GitHub
parent d89bfddc7a
commit 7cc5c30e71
13 changed files with 1141 additions and 12 deletions
+1
View File
@@ -40,6 +40,7 @@
"@prismshadow/penguin-core": "workspace:*",
"@prismshadow/penguin-skills": "workspace:*",
"dotenv": "^17.0.0",
"fflate": "^0.8.3",
"hono": "^4.8.0",
"smol-toml": "^1.3.0",
"tar": "^7.5.21",
+13
View File
@@ -1397,6 +1397,19 @@ export interface SkillInstallRequest {
names: string[];
}
/**
* POST /api/projects/:p/agents/:a/skills/archive: install one Skill from an uploaded zip.
* Layout: SKILL.md at the zip root, or exactly one top-level directory containing SKILL.md
* (the directory name is then the Skill name). 201 returns the refreshed installed list
* (AgentSkillsResponse); an already-installed name without `overwrite` is 409 `skill_exists`.
*/
export interface SkillArchiveInstallRequest {
/** Base64-encoded zip archive (decoded size capped at 14MB, same as the Agent snapshot import). */
dataBase64: string;
/** Replace an already-installed Skill of the same name (deletes its directory first). */
overwrite?: boolean;
}
// ---------------------------------------------------------------------------
// Version and self-update
// ---------------------------------------------------------------------------
+236 -4
View File
@@ -2,13 +2,19 @@
* Skill library & Agent-installed-Skills routes:
* GET /api/skills # library groups & metadata (any logged-in user)
* GET|POST /api/projects/:p/agents/:a/skills # installed list / install from library (any member)
* POST /api/projects/:p/agents/:a/skills/archive # install one skill from an uploaded zip (any member)
* GET /api/projects/:p/agents/:a/skills/:name/archive # export one installed skill as a zip (any member)
* DELETE /api/projects/:p/agents/:a/skills/:name # uninstall (any member)
* Installing writes the library's SKILL.md verbatim to agent_state/skills/<name>/;
* reinstalling overwrites with the library content (i.e. an update). The scope is small
* enough to skip a service layer — routes call core's disk-writing functions directly.
* reinstalling overwrites with the library content (i.e. an update). The archive routes
* are symmetric: POST writes every zip file under skills/<name>/ (replace semantics with
* `overwrite`), GET packs the whole directory back under a single top-level <name>/ so
* the download round-trips through the POST unchanged. The scope is small enough to skip
* a service layer — routes call core's disk-writing functions directly.
*/
import fs from "node:fs/promises";
import path from "node:path";
import { unzipSync, strFromU8, zipSync } from "fflate";
import { Hono } from "hono";
import {
installSkill,
@@ -16,7 +22,12 @@ import {
removeSkill,
skillsDir,
} from "@prismshadow/penguin-core";
import { librarySkill, loadSkillGroups } from "@prismshadow/penguin-skills";
import {
librarySkill,
loadSkillGroups,
parseSkillFrontmatter,
SKILL_NAME_PATTERN,
} from "@prismshadow/penguin-skills";
import type { LibrarySkill, SkillMetadata } from "@prismshadow/penguin-skills";
import type {
AgentSkillsResponse,
@@ -26,7 +37,14 @@ import type {
import type { AppEnv } from "../../auth/middleware.js";
import type { AppDeps } from "../../app.js";
import { HttpError } from "../errors.js";
import { badRequest, readJson, requireValidId } from "../validate.js";
import { badRequest, readJson, requireString, requireValidId } from "../validate.js";
/** Decoded zip cap: aligned with the Agent snapshot import (stays within the 20MB body limit after base64). */
const MAX_ARCHIVE_BYTES = 14 * 1024 * 1024;
/** Uncompressed limits (guard against zip bombs): entry count / per-file / total. */
const MAX_ARCHIVE_FILES = 200;
const MAX_FILE_BYTES = 5 * 1024 * 1024;
const MAX_TOTAL_BYTES = 20 * 1024 * 1024;
/**
* Strips the content off a LibrarySkill: the API only sends metadata; the full body is
@@ -61,6 +79,145 @@ function libraryResponse(): SkillLibraryResponse {
};
}
/**
* Validates one zip entry path (zip-slip guard): rejects absolute paths (leading "/" or a
* drive letter), backslashes and any ".." segment — a malicious archive must never write
* outside the target Skill directory.
*/
function assertSafeEntryPath(name: string): void {
if (name.includes("\\")) throw badRequest(`Invalid zip entry path (backslash): ${name}`);
if (name.startsWith("/") || /^[A-Za-z]:/.test(name)) {
throw badRequest(`Invalid zip entry path (absolute): ${name}`);
}
if (name.split("/").some((segment) => segment === "..")) {
throw badRequest(`Invalid zip entry path (traversal): ${name}`);
}
}
/** A skill decoded from an uploaded zip: name + file bytes keyed by path relative to the skill directory. */
interface ArchiveSkill {
name: string;
files: Map<string, Uint8Array>;
}
/**
* Decodes and validates an uploaded skill zip. Accepted layouts: SKILL.md at the zip root
* (name comes from frontmatter), or exactly one top-level directory containing SKILL.md
* (the directory name is the Skill name, consistent with listInstalledSkills where the
* directory name always wins). Directory entries are ignored (paths recreate them); every
* file path is zip-slip-checked and the count/size limits enforced before anything is
* returned. Frontmatter must parse to a non-null name, and the resolved Skill name must
* match SKILL_NAME_PATTERN.
*/
function parseSkillArchive(archive: Buffer): ArchiveSkill {
let entries: Record<string, Uint8Array>;
try {
entries = unzipSync(new Uint8Array(archive));
} catch {
throw badRequest("dataBase64 is not a valid zip archive.");
}
const files = Object.entries(entries).filter(([name]) => !name.endsWith("/"));
if (files.length === 0) throw badRequest("The zip archive contains no files.");
if (files.length > MAX_ARCHIVE_FILES) {
throw badRequest(`The zip archive exceeds the ${MAX_ARCHIVE_FILES}-file limit.`);
}
let total = 0;
for (const [name, data] of files) {
assertSafeEntryPath(name);
if (data.byteLength > MAX_FILE_BYTES) {
throw badRequest(`Zip entry exceeds the 5MB uncompressed limit: ${name}`);
}
total += data.byteLength;
if (total > MAX_TOTAL_BYTES) {
throw badRequest("The zip archive exceeds the 20MB uncompressed limit.");
}
}
const names = files.map(([name]) => name);
let prefix = "";
let dirName: string | undefined;
if (!names.includes("SKILL.md")) {
const topLevels = new Set(names.map((name) => name.split("/", 1)[0]!));
dirName = topLevels.size === 1 ? [...topLevels][0] : undefined;
if (dirName === undefined || !names.includes(`${dirName}/SKILL.md`)) {
throw badRequest(
"The zip must contain SKILL.md at its root, or exactly one top-level directory containing SKILL.md.",
);
}
prefix = `${dirName}/`;
}
const meta = parseSkillFrontmatter(strFromU8(entries[`${prefix}SKILL.md`]!));
if (meta === null) {
throw badRequest("SKILL.md must start with a frontmatter block that sets `name`.");
}
const name = dirName ?? meta.name;
if (!SKILL_NAME_PATTERN.test(name)) {
throw badRequest(
`Invalid skill name ${JSON.stringify(name)}: only letters, digits, "_" and "-" are allowed.`,
);
}
return { name, files: new Map(files.map(([n, data]) => [n.slice(prefix.length), data])) };
}
/**
* Recursively collects an installed skill directory as zip entries under a single
* top-level `<name>/` directory (subpaths preserved, "/" separators), so the export
* round-trips through the POST archive route unchanged. Symlinks and other non-regular
* entries are skipped (nothing outside the directory can leak). The import caps apply
* on the way out too — a directory exceeding them couldn't be re-imported anyway.
*/
async function collectSkillArchive(dir: string, name: string): Promise<Record<string, Uint8Array>> {
const out: Record<string, Uint8Array> = {};
let count = 0;
let total = 0;
const walk = async (abs: string, rel: string): Promise<void> => {
for (const entry of await fs.readdir(abs, { withFileTypes: true })) {
const absChild = path.join(abs, entry.name);
const relChild = `${rel}/${entry.name}`;
if (entry.isDirectory()) {
await walk(absChild, relChild);
continue;
}
if (!entry.isFile()) continue;
count += 1;
const data = new Uint8Array(await fs.readFile(absChild));
total += data.byteLength;
if (
count > MAX_ARCHIVE_FILES ||
data.byteLength > MAX_FILE_BYTES ||
total > MAX_TOTAL_BYTES
) {
throw new HttpError(
413,
"skill_too_large",
`Skill directory exceeds the archive limits (${MAX_ARCHIVE_FILES} files, 5MB per file, 20MB total).`,
);
}
out[relChild] = data;
}
};
await walk(dir, name);
return out;
}
/**
* Version for the export filename: only an explicit frontmatter `version:` field that
* parses as a natural number yields a `-v<version>` filename suffix — parseSkillFrontmatter
* defaults a missing field to 1, which must not be baked into a filename as if declared.
* Mirrors the parser's frontmatter rules (first `---` block, `key: value` split on the
* first colon, BOM/CRLF tolerated).
*/
function explicitSkillVersion(skillMd: string): number | null {
const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(skillMd.replace(/^\uFEFF/, ""))?.[1];
if (block === undefined) return null;
for (const line of block.split(/\r?\n/)) {
const idx = line.indexOf(":");
if (idx <= 0 || line.slice(0, idx).trim() !== "version") continue;
const version = Number.parseInt(line.slice(idx + 1).trim(), 10);
return Number.isInteger(version) && version >= 1 ? version : null;
}
return null;
}
/** Validate the POST request body: names must be a non-empty array of strings. */
function parseInstallNames(body: Record<string, unknown>): string[] {
if (!Array.isArray(body.names) || body.names.length === 0) {
@@ -119,6 +276,81 @@ export function agentSkillsRoutes(deps: AppDeps): Hono<AppEnv> {
return c.json(await listResponse(projectId, agentId), 201);
});
// Install one skill from an uploaded zip. Like the library POST this touches only the
// files (no runtime invalidation): skills are read from disk on demand, so the next
// prompt assembly already sees the new content.
app.post("/archive", async (c) => {
const projectId = requireValidId(c, "projectId");
const agentId = requireValidId(c, "agentId");
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
await deps.agentConfigService.requireExists(projectId, agentId);
const body = await readJson(c);
const dataBase64 = requireString(body, "dataBase64", { minLen: 1, maxLen: 20 * 1024 * 1024 });
const overwrite = body.overwrite === true;
let archive: Buffer;
try {
archive = Buffer.from(dataBase64, "base64");
} catch {
throw badRequest("dataBase64 is not valid base64.");
}
if (archive.byteLength === 0) throw badRequest("The zip archive is empty.");
if (archive.byteLength > MAX_ARCHIVE_BYTES) {
throw badRequest("The zip archive exceeds the 14MB limit.");
}
const skill = parseSkillArchive(archive);
const dir = path.join(skillsDir(deps.config.root, projectId, agentId), skill.name);
// Installed-check uses the same criterion as listInstalledSkills: skills/<name>/SKILL.md exists.
if (!overwrite) {
const installed = await fs.access(path.join(dir, "SKILL.md")).then(
() => true,
() => false,
);
if (installed) {
throw new HttpError(409, "skill_exists", `Skill is already installed: ${skill.name}`);
}
}
// Replace semantics (same as reinstalling from the library): drop the old directory
// first so no stale file survives, then write every archive file (subdirectories kept).
await fs.rm(dir, { recursive: true, force: true });
for (const [rel, data] of skill.files) {
const file = path.join(dir, rel);
await fs.mkdir(path.dirname(file), { recursive: true });
await fs.writeFile(file, data);
}
return c.json(await listResponse(projectId, agentId), 201);
});
// Export one installed skill as a zip: served verbatim as an attachment (same shape as
// the snapshot export / trace download — a direct binary body, not a JSON envelope), so
// what's downloaded can be re-imported byte-compatibly via the POST archive route.
app.get("/:name/archive", async (c) => {
const projectId = requireValidId(c, "projectId");
const agentId = requireValidId(c, "agentId");
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
const name = requireValidId(c, "name");
const dir = path.join(skillsDir(deps.config.root, projectId, agentId), name);
// Installed-check uses the same criterion as listInstalledSkills: skills/<name>/SKILL.md exists.
try {
await fs.access(path.join(dir, "SKILL.md"));
} catch {
throw new HttpError(404, "not_found", `Skill is not installed: ${name}`);
}
const archiveFiles = await collectSkillArchive(dir, name);
// A -v<version> suffix only when the frontmatter declares one explicitly (the header
// is the authority on the filename — the web tab reads it from Content-Disposition).
const version = explicitSkillVersion(strFromU8(archiveFiles[`${name}/SKILL.md`]!));
const fileName = version === null ? `${name}.zip` : `${name}-v${version}.zip`;
const zip = zipSync(archiveFiles);
return new Response(new Uint8Array(zip), {
headers: {
"Content-Type": "application/zip",
// The name is id-validated ([A-Za-z0-9_-]+), so the encoded filename is itself.
"Content-Disposition": `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`,
"X-Content-Type-Options": "nosniff",
},
});
});
app.delete("/:name", async (c) => {
const projectId = requireValidId(c, "projectId");
const agentId = requireValidId(c, "agentId");
+225 -2
View File
@@ -2,11 +2,14 @@
* Integration tests for the Skill routes: library catalog structure (any logged-in user), member
* install/uninstall with 404 for outsiders, 404 for unknown skills, installed
* files matching the library content, idempotent update on reinstall, the
* directory disappearing after uninstall, and default_agent starting with all
* skills installed while a newly created plain Agent has none.
* directory disappearing after uninstall, default_agent starting with all
* skills installed while a newly created plain Agent has none, and the zip
* archive install/export (layouts, zip-slip and limit rejections, 409
* skill_exists + overwrite replace, byte-identical export round-trip).
*/
import fs from "node:fs/promises";
import path from "node:path";
import { strToU8, unzipSync, zipSync } from "fflate";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { skillsDir } from "@prismshadow/penguin-core";
import { librarySkill, loadLibrarySkills } from "@prismshadow/penguin-skills";
@@ -191,6 +194,8 @@ describe("skills api", () => {
const url = base("default_agent");
expect((await outsider.get(url)).status).toBe(404);
expect((await outsider.post(url, { names: ["penguin-sdk"] })).status).toBe(404);
expect((await outsider.post(`${url}/archive`, { dataBase64: "AAAA" })).status).toBe(404);
expect((await outsider.get(`${url}/penguin-sdk/archive`)).status).toBe(404);
expect((await outsider.delete(`${url}/penguin-sdk`)).status).toBe(404);
// The library catalog isn't scoped under a Project prefix: any logged-in user can read it.
expect((await outsider.get("/api/skills")).status).toBe(200);
@@ -217,4 +222,222 @@ describe("skills api", () => {
const fresh = (await (await member.get(base("fresh_agent"))).json()) as AgentSkillsResponse;
expect(fresh.skills).toEqual([]);
});
// ---- POST .../skills/archive: install one skill from an uploaded zip ----
const ZIP_SKILL_MD =
"---\nname: zip-skill\ndescription: Zip demo skill\nshort_description: Zip demo\nversion: 2\nupdated: 2026-08-01\n---\n\n# Zip skill\nBody.\n";
/** Builds an in-memory zip and returns it base64-encoded (the request wire format). */
const zipB64 = (files: Record<string, Uint8Array>): string =>
Buffer.from(zipSync(files)).toString("base64");
it("archive: nested top-dir layout — all files written (subdirs preserved), directory name wins over frontmatter", async () => {
await createPlainAgent("zip_agent");
const url = `${base("zip_agent")}/archive`;
// Frontmatter says zip-skill, but the top-level directory is dir-skill: the directory
// name is the identity (same rule as listInstalledSkills). The explicit directory
// entry ("dir-skill/") must be ignored, not treated as a file.
const res = await member.post(url, {
dataBase64: zipB64({
"dir-skill/": new Uint8Array(0),
"dir-skill/SKILL.md": strToU8(ZIP_SKILL_MD),
"dir-skill/ref/notes.md": strToU8("notes\n"),
}),
});
expect(res.status).toBe(201);
const body = (await res.json()) as AgentSkillsResponse;
expect(body.skills.map((s) => s.name)).toEqual(["dir-skill"]);
expect(body.skills[0]!.version).toBe(2);
expect(body.skills[0]!.shortDescription).toBe("Zip demo");
const dir = path.join(skillsDir(t.root, projectId, "zip_agent"), "dir-skill");
expect(await fs.readFile(path.join(dir, "SKILL.md"), "utf8")).toBe(ZIP_SKILL_MD);
expect(await fs.readFile(path.join(dir, "ref", "notes.md"), "utf8")).toBe("notes\n");
});
it("archive: root layout takes the name from frontmatter; uninstall works on the archive-installed skill", async () => {
await createPlainAgent("zip_root_agent");
const url = base("zip_root_agent");
const res = await member.post(`${url}/archive`, {
dataBase64: zipB64({ "SKILL.md": strToU8(ZIP_SKILL_MD) }),
});
expect(res.status).toBe(201);
const body = (await res.json()) as AgentSkillsResponse;
expect(body.skills.map((s) => s.name)).toEqual(["zip-skill"]);
// Uninstall goes through the same DELETE route as library skills: 204, directory gone.
expect((await member.delete(`${url}/zip-skill`)).status).toBe(204);
await expect(
fs.access(path.join(skillsDir(t.root, projectId, "zip_root_agent"), "zip-skill")),
).rejects.toThrow();
const after = (await (await member.get(url)).json()) as AgentSkillsResponse;
expect(after.skills).toEqual([]);
});
it("archive: zip-slip and unsafe entry paths are rejected with 400, nothing written", async () => {
await createPlainAgent("zip_slip_agent");
const url = base("zip_slip_agent");
const unsafe = ["../evil.md", "/abs.md", "C:/win.md", "a\\b.md"];
for (const entry of unsafe) {
const res = await owner.post(`${url}/archive`, {
dataBase64: zipB64({
"zip-skill/SKILL.md": strToU8(ZIP_SKILL_MD),
[entry]: strToU8("x"),
}),
});
expect(res.status, entry).toBe(400);
}
const list = (await (await owner.get(url)).json()) as AgentSkillsResponse;
expect(list.skills).toEqual([]);
});
it("archive: invalid skill names are rejected (top-level dir and frontmatter name)", async () => {
await createPlainAgent("zip_name_agent");
const url = `${base("zip_name_agent")}/archive`;
// Top-level directory name with a space fails SKILL_NAME_PATTERN.
const badDir = await owner.post(url, {
dataBase64: zipB64({ "bad name/SKILL.md": strToU8(ZIP_SKILL_MD) }),
});
expect(badDir.status).toBe(400);
// Root layout: the frontmatter name is the skill name and must pass the same rule.
const badMeta = await owner.post(url, {
dataBase64: zipB64({
"SKILL.md": strToU8("---\nname: bad/name\ndescription: d\n---\nbody\n"),
}),
});
expect(badMeta.status).toBe(400);
});
it("archive: malformed bodies and layouts are rejected with 400", async () => {
await createPlainAgent("zip_shape_agent");
const url = `${base("zip_shape_agent")}/archive`;
const cases: Array<[string, Record<string, unknown>]> = [
["dataBase64 missing", {}],
["not a zip", { dataBase64: Buffer.from("not a zip").toString("base64") }],
[
"two top-level directories",
{
dataBase64: zipB64({
"one/SKILL.md": strToU8(ZIP_SKILL_MD),
"two/readme.md": strToU8("x"),
}),
},
],
["no SKILL.md anywhere", { dataBase64: zipB64({ "sub/readme.md": strToU8("x") }) }],
[
"frontmatter without name",
{ dataBase64: zipB64({ "SKILL.md": strToU8("no frontmatter here\n") }) },
],
];
for (const [label, body] of cases) {
expect((await owner.post(url, body)).status, label).toBe(400);
}
});
it("archive: uncompressed limits — file count, per-file size, total size", async () => {
await createPlainAgent("zip_limit_agent");
const url = `${base("zip_limit_agent")}/archive`;
// > 200 files.
const many: Record<string, Uint8Array> = { "zip-skill/SKILL.md": strToU8(ZIP_SKILL_MD) };
for (let i = 0; i < 201; i++) many[`zip-skill/f${i}.txt`] = strToU8("x");
expect((await owner.post(url, { dataBase64: zipB64(many) })).status).toBe(400);
// Per-file > 5MB uncompressed (zeros compress tiny, so the wire stays small).
const big: Record<string, Uint8Array> = {
"zip-skill/SKILL.md": strToU8(ZIP_SKILL_MD),
"zip-skill/big.bin": new Uint8Array(5 * 1024 * 1024 + 1),
};
expect((await owner.post(url, { dataBase64: zipB64(big) })).status).toBe(400);
// Total > 20MB uncompressed across files that each stay under the per-file cap.
const total: Record<string, Uint8Array> = { "zip-skill/SKILL.md": strToU8(ZIP_SKILL_MD) };
for (let i = 0; i < 5; i++) {
total[`zip-skill/part${i}.bin`] = new Uint8Array(4200 * 1024);
}
expect((await owner.post(url, { dataBase64: zipB64(total) })).status).toBe(400);
});
it("archive: already installed is 409 skill_exists; overwrite replaces the directory (stale files removed)", async () => {
await createPlainAgent("zip_over_agent");
const url = `${base("zip_over_agent")}/archive`;
const first = await member.post(url, {
dataBase64: zipB64({
"zip-skill/SKILL.md": strToU8(ZIP_SKILL_MD),
"zip-skill/old.txt": strToU8("old\n"),
}),
});
expect(first.status).toBe(201);
// Same name again without overwrite: 409 with the name in the message (the web tab
// reads it from there for the overwrite confirmation copy).
const again = await member.post(url, {
dataBase64: zipB64({ "zip-skill/SKILL.md": strToU8(ZIP_SKILL_MD) }),
});
expect(again.status).toBe(409);
const err = (await again.json()) as { error: { code: string; message: string } };
expect(err.error.code).toBe("skill_exists");
expect(err.error.message).toMatch(/: zip-skill$/);
// overwrite: true replaces the whole directory: old.txt is gone, new.txt appears.
const updatedMd = ZIP_SKILL_MD.replace("version: 2", "version: 3");
const res = await member.post(url, {
dataBase64: zipB64({
"zip-skill/SKILL.md": strToU8(updatedMd),
"zip-skill/new.txt": strToU8("new\n"),
}),
overwrite: true,
});
expect(res.status).toBe(201);
const body = (await res.json()) as AgentSkillsResponse;
expect(body.skills.find((s) => s.name === "zip-skill")!.version).toBe(3);
const dir = path.join(skillsDir(t.root, projectId, "zip_over_agent"), "zip-skill");
expect(await fs.readFile(path.join(dir, "SKILL.md"), "utf8")).toBe(updatedMd);
expect(await fs.readFile(path.join(dir, "new.txt"), "utf8")).toBe("new\n");
await expect(fs.access(path.join(dir, "old.txt"))).rejects.toThrow();
});
it("archive export: single-top-dir zip round-trips byte-identically; a non-installed name is 404", async () => {
await createPlainAgent("zip_export_agent");
const url = base("zip_export_agent");
// Install a multi-file skill through the archive route (nested subdir + icon.svg).
const files: Record<string, Uint8Array> = {
"zip-skill/SKILL.md": strToU8(ZIP_SKILL_MD),
"zip-skill/icon.svg": strToU8('<svg viewBox="0 0 24 24"><path d="M2 2h20"/></svg>\n'),
"zip-skill/ref/notes.md": strToU8("notes\n"),
};
expect((await member.post(`${url}/archive`, { dataBase64: zipB64(files) })).status).toBe(201);
// Export it: a direct binary attachment (application/zip), like the snapshot export.
// The frontmatter declares version: 2 explicitly, so the filename carries -v2.
const res = await member.get(`${url}/zip-skill/archive`);
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toBe("application/zip");
expect(res.headers.get("content-disposition")).toBe(
"attachment; filename*=UTF-8''zip-skill-v2.zip",
);
const entries = unzipSync(new Uint8Array(await res.arrayBuffer()));
// Single-top-dir layout with every installed file, byte-identical to the upload — the
// export feeds back into the POST archive route unchanged.
const fileNames = Object.keys(entries).filter((n) => !n.endsWith("/"));
expect(fileNames.sort()).toEqual(Object.keys(files).sort());
for (const [name, data] of Object.entries(files)) {
expect(Buffer.from(entries[name]!)).toEqual(Buffer.from(data));
}
// Exporting a skill that isn't installed → 404 (same criterion as uninstall).
expect((await member.get(`${url}/no-such-skill/archive`)).status).toBe(404);
// Without an explicit frontmatter version: field the filename stays <name>.zip —
// parseSkillFrontmatter's defaulted 1 must not be presented as a declared version.
const noVersion = "---\nname: nover-skill\ndescription: No version field\n---\nbody\n";
expect(
(
await member.post(`${url}/archive`, {
dataBase64: zipB64({ "SKILL.md": strToU8(noVersion) }),
})
).status,
).toBe(201);
const plain = await member.get(`${url}/nover-skill/archive`);
expect(plain.status).toBe(200);
expect(plain.headers.get("content-disposition")).toBe(
"attachment; filename*=UTF-8''nover-skill.zip",
);
});
});