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",
);
});
});
+2 -2
View File
@@ -89,8 +89,8 @@ export function parseSkillFrontmatter(content: string): SkillMetadata | null {
/** Root directory of library files: the package's `skills/` (both dist/ and src/ sit one level below the package root, so one level up reaches it). */
const SKILLS_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "skills");
/** Character rule for Skill names (directory names): prevents path traversal. */
const SKILL_NAME_PATTERN = /^[A-Za-z0-9_-]+$/;
/** Character rule for Skill names (directory names): prevents path traversal (exported for the server's archive-install validation). */
export const SKILL_NAME_PATTERN = /^[A-Za-z0-9_-]+$/;
/**
* Reads a single library directory to construct a LibrarySkill; returns undefined if SKILL.md
+18
View File
@@ -54,6 +54,7 @@ import type {
SessionResponse,
SessionsResponse,
SessionTracesResponse,
SkillArchiveInstallRequest,
SkillInstallRequest,
SkillLibraryResponse,
RetryNowResponse,
@@ -527,6 +528,23 @@ export const installAgentSkills = (projectId: string, agentId: string, names: st
{ method: "POST", body: { names } satisfies SkillInstallRequest },
);
/** Installs one skill from an uploaded zip (base64); 409 skill_exists unless overwrite; 201 returns the latest installed list. */
export const installAgentSkillArchive = (
projectId: string,
agentId: string,
body: SkillArchiveInstallRequest,
) =>
apiFetch<AgentSkillsResponse>(
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}` +
`/skills/archive`,
{ method: "POST", body },
);
/** Zip download URL for one installed skill (server sets Content-Disposition attachment); the export round-trips through installAgentSkillArchive. */
export const agentSkillArchiveUrl = (projectId: string, agentId: string, name: string): string =>
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}` +
`/skills/${encodeURIComponent(name)}/archive`;
export const removeAgentSkill = (projectId: string, agentId: string, name: string) =>
apiFetch<void>(
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}` +
@@ -1,10 +1,10 @@
/**
* Agent settings page: six tabs —
* Agent settings page: seven tabs —
* Overview (name/description/State path/active count/State version + snapshot
* export-import + restore default configuration), Prompt (AGENTS.md and system_prompt editors + placeholder
* reference), Runtime (max_turns, model.*, compaction.*), Tools (editable built-in
* tools table, MCP Server read-only JSON), Vault (vault-tab.tsx), Schedule
* (schedules-tab.tsx).
* tools table, MCP Server read-only JSON), Skills (skills-tab.tsx), Vault
* (vault-tab.tsx), Schedule (schedules-tab.tsx).
* Save = PUT config (sends only the changed keys; YAML comments are preserved
* server-side).
*/
@@ -33,11 +33,12 @@ import { OptionMenu, type OptionMenuChoice } from "../../components/ui/option-me
import { Switch } from "../../components/ui/switch";
import { ConfirmModal, useSaveConfirm } from "../../components/ui/confirm-modal";
import { Skeleton } from "../../components/ui/skeleton";
import { SkillsTab } from "./skills-tab";
import { VaultTab } from "./vault-tab";
import { SchedulesTab } from "./schedules-tab";
import { thinkingLevelOptionsFor } from "../chat/thinking-level";
type TabKey = "overview" | "prompt" | "runtime" | "tools" | "vault" | "schedules";
type TabKey = "overview" | "prompt" | "runtime" | "tools" | "skills" | "vault" | "schedules";
/**
* Dropdown rows from a dictionary's [value, description] pairs (exported for unit tests).
@@ -92,6 +93,7 @@ export function AgentSettingsPage() {
{ key: "prompt", label: S.agent.tabPrompt },
{ key: "runtime", label: S.agent.tabRuntime },
{ key: "tools", label: S.agent.tabTools },
{ key: "skills", label: S.agent.tabSkills },
{ key: "vault", label: S.agent.tabVault },
{ key: "schedules", label: S.agent.tabSchedules },
] as const;
@@ -232,6 +234,7 @@ export function AgentSettingsPage() {
{tab === "prompt" && <PromptTab data={data} onSave={save} />}
{tab === "runtime" && <RuntimeTab data={data} onSave={save} />}
{tab === "tools" && <ToolsTab data={data} onSave={save} />}
{tab === "skills" && <SkillsTab agentId={agentId} />}
{tab === "vault" && <VaultTab agentId={agentId} />}
{tab === "schedules" && <SchedulesTab agentId={agentId} />}
</div>
@@ -0,0 +1,51 @@
/**
* Import-source classification for the Skills tab's import dialog (pure logic, unit
* tested): the source field accepts more than a webpage URL — a GitHub repo or directory
* URL, a local filesystem path, an install command from another ecosystem (whose plugins
* are essentially skill files; PenguinHarness has no plugin mechanism of its own), or a
* bare plugin/marketplace reference. The generated chat prompt tailors its lead sentence
* per kind; the security tail (read fully, review for malicious instructions, then
* install) is shared and always appended.
*/
import { S } from "../../lib/strings";
/** How the pasted source should be approached by the agent (one lead sentence per kind in S.skills.importPromptLead). */
export type ImportSourceKind = "webUrl" | "repoUrl" | "localPath" | "command" | "reference";
/** Hosts whose URLs point at a repository or a directory within one (clone / fetch and locate SKILL.md dirs). */
const REPO_HOSTS = /^(www\.)?(github|gitlab|gitee|bitbucket)\.(com|org)$/i;
/**
* Lightly classifies a pasted source. Heuristic on purpose — the result only picks the
* prompt's lead sentence, and a miss still yields a safe, workable instruction:
* - repoUrl: scp-style git remote (git@host:…), any *.git reference, or an http(s) URL
* on a known forge host (repo or directory link);
* - webUrl: any other http(s) URL;
* - localPath: absolute / home / dot-relative / Windows-drive / UNC paths;
* - command: a leading executable word followed by arguments (npx skills add …);
* - reference: anything else (marketplace reference, bare skill name).
*/
export function classifyImportSource(input: string): ImportSourceKind {
const s = input.trim();
if (/^git@[\w.-]+:/.test(s) || /\.git$/i.test(s)) return "repoUrl";
if (/^https?:\/\//i.test(s)) {
try {
if (REPO_HOSTS.test(new URL(s).hostname)) return "repoUrl";
} catch {
// Malformed URL-ish input: keep the generic web page treatment.
}
return "webUrl";
}
if (/^(\/|~\/|\.{1,2}\/|[A-Za-z]:[\\/]|\\\\)/.test(s)) return "localPath";
if (/^[A-Za-z@][\w@./-]*\s+\S/.test(s)) return "command";
return "reference";
}
/**
* Localized install prompt for one source: the per-kind lead sentence plus the shared
* security/skill-porting tail on a second line. Reads S at call time (live binding), so
* it follows language switches like every other string consumer.
*/
export function buildImportPrompt(input: string): string {
return `${S.skills.importPromptLead[classifyImportSource(input)](input)}\n${S.skills.importPromptTail}`;
}
@@ -0,0 +1,428 @@
/**
* Agent settings page "Skills" tab: the skills installed on this Agent
* (agent_state/skills/<name>/ — the files are the single source of truth, so the list is
* re-fetched from the API after every mutation instead of trusting client state). Rows show
* the skill icon, name, localized short description and version/updated metadata; uninstall
* confirms first (deletes the whole directory, local edits included). The "Import skill"
* modal offers two paths: the recommended chat install (a source field accepting a web
* page / repo URL / local path / foreign install command — see skill-import-source.ts —
* whose generated review-then-install prompt can be copied or prefilled into a new chat
* with this Agent) and a zip upload posted base64 to the archive endpoint (409
* skill_exists asks before overwriting). Each row can also export the installed directory
* as a zip that round-trips through that same endpoint. Read and mutate are both
* member-level, matching the skills routes — no owner gating here.
*/
import { useCallback, useEffect, useState } from "react";
import type { ChangeEvent } from "react";
import { useNavigate } from "react-router";
import type { SkillMetadataItem } from "@prismshadow/penguin-server/api";
import * as api from "../../api/endpoints";
import { ApiError } from "../../api/client";
import { S } from "../../lib/strings";
import { apiErrorText } from "../../lib/api-error";
import { formatRelativeDate } from "../../lib/format";
import { useAuth } from "../../state/auth";
import { useLocale } from "../../state/locale";
import { agentDisplayName, useProject } from "../../state/project";
import { Button } from "../../components/ui/button";
import { GlyphIcon } from "../../components/ui/glyph-icon";
import { Input, Textarea } from "../../components/ui/input";
import { Modal } from "../../components/ui/modal";
import { ConfirmModal } from "../../components/ui/confirm-modal";
import { DownloadIcon } from "../../components/ui/icons";
import { HiddenFileInput } from "../../components/ui/hidden-file-input";
import { SkeletonList } from "../../components/ui/skeleton";
import { toastError, toastSuccess } from "../../components/ui/toast";
import { SkillIcon, skillTileColor } from "../skills/skill-icon-view";
import { localizedShortText } from "../chat/skill-use";
import { DRAFT_SESSION_ID } from "../chat/chat-page";
import { draftKey, loadDraft, saveDraft } from "../chat/draft-cache";
import { buildImportPrompt } from "./skill-import-source";
/** <label> version of the button look (matches Button secondary sm; the Button component only renders <button>) — same as the Overview tab's snapshot-import label. */
const UPLOAD_LABEL_CLASS =
"inline-flex cursor-pointer items-center justify-center gap-1 rounded-md border border-gray-300 " +
"bg-white px-2.5 py-1 text-xs font-medium text-gray-800 transition-colors duration-150 " +
"hover:bg-gray-50 focus-within:ring-2 focus-within:ring-gray-400/30 " +
"dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800";
/** Delete (trash can) icon path — the same glyph as the agents page card delete. */
const TRASH_ICON =
"M4 7h16M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2m3 0l-1 13a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L6 7m4 4v6m4-6v6";
/** Zip pending an overwrite confirmation: the payload to resend with overwrite: true plus the skill name for the confirm copy. */
interface PendingOverwrite {
dataBase64: string;
name: string;
}
export function SkillsTab({ agentId }: { agentId: string }) {
const navigate = useNavigate();
const { locale } = useLocale();
const userId = useAuth().user?.userId ?? null;
const { currentProject, agents, setCurrentAgentId } = useProject();
const projectId = currentProject?.projectId ?? null;
const [skills, setSkills] = useState<SkillMetadataItem[] | null>(null);
// Tab-level error is only the initial list load failure; row/import actions report via toast or inside the modal.
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
// Skill name pending uninstall confirmation (non-null shows the confirm modal).
const [removing, setRemoving] = useState<string | null>(null);
// Import modal: the source for the chat-install prompt + upload state travel with the modal.
const [importOpen, setImportOpen] = useState(false);
const [source, setSource] = useState("");
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
// Non-null shows the overwrite confirm (the archive POST answered 409 skill_exists).
const [overwriting, setOverwriting] = useState<PendingOverwrite | null>(null);
const load = useCallback(async () => {
if (!projectId || !agentId) return;
setSkills(null);
setError(null);
try {
const res = await api.getAgentSkills(projectId, agentId);
setSkills(res.skills);
} catch (e) {
setError(apiErrorText(e));
}
}, [projectId, agentId]);
useEffect(() => {
void load();
}, [load]);
/** Display name of this Agent for toasts / confirm copy (falls back to the raw id). */
const agent = agents.find((a) => a.agentId === agentId);
const agentName = agent ? agentDisplayName(agent) : agentId;
/**
* "Export as zip": fetch the archive and save it via a temporary object-URL anchor.
* The codebase's other downloads are bare `<a href download>` anchors (snapshot export,
* trace download), but a bare anchor would save the error JSON as a file when the
* request fails — fetching first lets failures surface as a toast instead. The JSON-only
* api client can't carry binary, hence the raw fetch (errors re-wrapped as ApiError so
* apiErrorText localizes by code as usual).
*/
const exportSkill = async (name: string) => {
if (!projectId) return;
try {
const res = await fetch(api.agentSkillArchiveUrl(projectId, agentId, name));
if (!res.ok) {
const body = (await res.json().catch(() => null)) as {
error?: { code?: string; message?: string };
} | null;
throw new ApiError(
res.status,
body?.error?.code ?? "unknown",
body?.error?.message ?? S.common.unknownError,
);
}
// The server's Content-Disposition is the authority on the filename (it appends
// -v<version> when the frontmatter declares one explicitly); <name>.zip is only
// the fallback for a missing/unparseable header.
const encoded = /filename\*=UTF-8''([^;]+)/i.exec(
res.headers.get("content-disposition") ?? "",
)?.[1];
const url = URL.createObjectURL(await res.blob());
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = encoded ? decodeURIComponent(encoded) : `${name}.zip`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
} catch (e) {
toastError(apiErrorText(e));
}
};
/** Confirm modal's "Confirm": uninstall, then always re-fetch the list from disk. */
const confirmRemove = async () => {
if (!projectId || removing === null) return;
setBusy(true);
try {
await api.removeAgentSkill(projectId, agentId, removing);
toastSuccess(S.skills.uninstalledToast(removing, agentName));
await load();
} catch (e) {
toastError(apiErrorText(e));
} finally {
setBusy(false);
setRemoving(null);
}
};
/** Open the import modal (reset the form and upload state). */
const openImport = () => {
setSource("");
setUploadError(null);
setOverwriting(null);
setImportOpen(true);
};
// The prompt tailors its lead sentence to the source kind (URL / repo / path / command /
// reference — see skill-import-source.ts); the preview substitutes a placeholder token
// until something is entered.
const trimmedSource = source.trim();
const chatPrompt = buildImportPrompt(trimmedSource || S.skills.importSourceToken);
const copyPrompt = () => {
void navigator.clipboard
.writeText(buildImportPrompt(trimmedSource))
.then(() => toastSuccess(S.skills.importCopied))
.catch(() => toastError(S.common.unknownError));
};
/**
* "Open a new chat" with this Agent: the same draft-state entry as the agents page
* "New Chat" button. A non-empty source also prefills the composer with the generated
* install prompt through the draft cache — the mechanism the skill library's quick
* invoke already uses, so draft-view itself needs no changes. handoffAgentId is
* cleared (a leftover handoff target would forward the install prompt to a different
* Agent) and the skill pre-selection reset alongside the overwritten text.
*/
const openChat = () => {
if (userId && projectId && trimmedSource) {
const key = draftKey(userId, projectId);
saveDraft(key, {
...loadDraft(key),
agentId,
text: buildImportPrompt(trimmedSource),
skills: [],
handoffAgentId: undefined,
});
}
setCurrentAgentId(agentId);
navigate(`/chat/${DRAFT_SESSION_ID}`, { state: { agentId } });
};
/**
* POST the zip to the archive endpoint. A 409 skill_exists pops the overwrite confirm
* (the skill name is read from the server's fixed message tail, pinned by the route
* tests; `fallbackName` — the picked file's stem — covers a parse miss). Success closes
* the modal and re-fetches the list.
*/
const upload = async (dataBase64: string, fallbackName: string, overwrite: boolean) => {
if (!projectId) return;
setUploading(true);
setUploadError(null);
try {
await api.installAgentSkillArchive(projectId, agentId, {
dataBase64,
...(overwrite ? { overwrite: true } : {}),
});
setOverwriting(null);
setImportOpen(false);
toastSuccess(S.skills.importDoneToast);
await load();
} catch (e) {
if (e instanceof ApiError && e.status === 409 && e.code === "skill_exists") {
const name = /:\s*([A-Za-z0-9_-]+)$/.exec(e.message)?.[1] ?? fallbackName;
setOverwriting({ dataBase64, name });
} else {
setOverwriting(null);
setUploadError(apiErrorText(e));
}
} finally {
setUploading(false);
}
};
const onPickFile = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
setUploadError(null);
const fallbackName = file.name.replace(/\.zip$/i, "");
const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result as string;
void upload(dataUrl.slice(dataUrl.indexOf(",") + 1), fallbackName, false); // strip the data:...;base64, prefix
};
reader.onerror = () => setUploadError(S.common.unknownError);
reader.readAsDataURL(file);
};
/** Metadata line: version · semantic update time (omitted when there's no date), matching the library card. */
const metaLine = (skill: SkillMetadataItem): string =>
[`v${skill.version}`, skill.updated ? formatRelativeDate(skill.updated, locale) : null]
.filter((v): v is string => v !== null)
.join(" · ");
if (!projectId) return null;
return (
<div className="space-y-4">
<div className="flex items-start justify-between gap-3">
<p className="text-xs text-gray-500 dark:text-gray-400">{S.skills.agentTabDesc}</p>
<Button
size="sm"
variant="primary"
className="shrink-0"
disabled={skills === null}
onClick={openImport}
>
{S.skills.importSkill}
</Button>
</div>
{skills === null ? (
<SkeletonList rows={4} />
) : skills.length === 0 ? (
// Plain-text empty state (settings area doesn't use the penguin-icon EmptyState, keeps the same gray level as the table area).
<p className="py-2 text-xs text-gray-400 dark:text-gray-500">{S.skills.agentTabEmpty}</p>
) : (
<div className="overflow-hidden rounded-md border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
{skills.map((skill) => (
<div
key={skill.name}
className="flex items-center gap-3 border-b border-gray-100 px-3 py-2.5 transition-colors duration-150 last:border-b-0 hover:bg-gray-50 dark:border-gray-800/60 dark:hover:bg-gray-800/40"
>
<span
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${skillTileColor(skill.name)}`}
>
<SkillIcon icon={skill.icon} size={20} />
</span>
<div className="min-w-0 flex-1">
<span
className="block truncate font-mono text-[13px] font-semibold"
title={skill.name}
>
{skill.name}
</span>
{/* Short description truncates to one line (full description goes into title for hover reading). */}
<p
className="mt-0.5 truncate text-xs text-gray-500 dark:text-gray-400"
title={skill.description}
>
{localizedShortText(locale, skill)}
</p>
</div>
<span
className="hidden shrink-0 text-[11px] text-gray-400 sm:block dark:text-gray-500"
title={metaLine(skill)}
>
{metaLine(skill)}
</span>
{/* Icon-only row actions (same affordance as the agents page cards: neutral
bordered icon for export, danger variant with red text/hover for delete);
the tooltip + aria-label carry the wording. */}
<Button
size="icon"
title={S.skills.exportSkill}
aria-label={`${S.skills.exportSkill} ${skill.name}`}
disabled={busy}
onClick={() => void exportSkill(skill.name)}
>
<DownloadIcon size={14} className="text-gray-600 dark:text-gray-300" />
</Button>
<Button
size="icon"
variant="danger"
title={S.skills.uninstall}
aria-label={`${S.skills.uninstall} ${skill.name}`}
disabled={busy}
onClick={() => setRemoving(skill.name)}
>
<GlyphIcon d={TRASH_ICON} size={14} />
</Button>
</div>
))}
</div>
)}
{/* Import modal: recommended chat install on top, zip upload below. */}
<Modal
open={importOpen}
title={S.skills.importSkill}
onClose={() => setImportOpen(false)}
widthClass="sm:max-w-lg"
>
<div className="space-y-4">
<section>
<p className="text-sm font-medium">{S.skills.importChatTitle}</p>
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{S.skills.importChatWhy}
</p>
<div className="mt-2.5 space-y-2.5">
<Input
size="sm"
label={S.skills.importSourceLabel}
hint={S.skills.importSourceHint}
value={source}
onChange={(e) => setSource(e.target.value)}
placeholder={S.skills.importSourcePlaceholder}
autoComplete="off"
/>
<Textarea
label={S.skills.importPromptLabel}
size="sm"
rows={5}
readOnly
value={chatPrompt}
className="text-gray-600 dark:text-gray-300"
/>
<div className="flex gap-2">
<Button size="sm" disabled={trimmedSource === ""} onClick={copyPrompt}>
{S.skills.importCopyPrompt}
</Button>
<Button size="sm" variant="primary" onClick={openChat}>
{S.skills.importOpenChat}
</Button>
</div>
</div>
</section>
<section className="border-t border-gray-200 pt-4 dark:border-gray-800">
<p className="text-sm font-medium">{S.skills.importUploadTitle}</p>
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{S.skills.importUploadDesc}
</p>
<label
className={`${UPLOAD_LABEL_CLASS} mt-2.5 ${uploading ? "pointer-events-none opacity-60" : ""}`}
>
<HiddenFileInput accept=".zip" disabled={uploading} onChange={onPickFile} />
{uploading ? S.skills.importUploading : S.skills.importUploadAction}
</label>
{uploadError && (
<p className="mt-1.5 text-xs text-red-600 dark:text-red-400">{uploadError}</p>
)}
</section>
</div>
</Modal>
{/* Overwrite confirmation: the import modal stays underneath, so cancel returns to it; confirm resends the same zip with overwrite: true. */}
<ConfirmModal
open={overwriting !== null}
title={S.skills.importOverwriteTitle}
confirmLabel={S.skills.importOverwriteAction}
busy={uploading}
onClose={() => setOverwriting(null)}
onConfirm={() => {
if (overwriting !== null) void upload(overwriting.dataBase64, overwriting.name, true);
}}
>
<p className="text-sm text-gray-600 dark:text-gray-300">
{overwriting !== null ? S.skills.importOverwriteBody(overwriting.name) : ""}
</p>
</ConfirmModal>
{/* Uninstall confirmation (shared ConfirmModal, same copy as the skill library page). */}
<ConfirmModal
open={removing !== null}
title={removing !== null ? S.skills.uninstallConfirmTitle(removing) : ""}
busy={busy}
onClose={() => setRemoving(null)}
onConfirm={() => void confirmRemove()}
>
<p className="text-sm text-gray-600 dark:text-gray-300">
{removing !== null ? S.skills.uninstallConfirmBody(removing, agentName) : ""}
</p>
</ConfirmModal>
{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}
</div>
);
}
+46
View File
@@ -215,6 +215,7 @@ export const en: Strings = {
tabPrompt: "Prompt",
tabRuntime: "Runtime",
tabTools: "Tools",
tabSkills: "Skills",
tabVault: "Vault",
tabSchedules: "Schedules",
stateDir: "State path",
@@ -534,6 +535,51 @@ export const en: Strings = {
uninstallConfirmTitle: (name: string): string => `Uninstall ${name}`,
uninstallConfirmBody: (skill: string, agent: string): string =>
`Uninstall ${skill} from ${agent}? Its installed files (local edits included) will be deleted.`,
/** Agent settings "Skills" tab (installed list + import modal). */
agentTabDesc:
"Skills installed on this agent (agent_state/skills/ — the files are the source of truth): metadata is injected into the system prompt and the body is read by the model on demand; uninstalling deletes the whole skill directory.",
agentTabEmpty: "No skills installed yet",
exportSkill: "Export",
importSkill: "Import skill",
importChatTitle: "Recommended: install by chatting with the agent",
importChatWhy:
"The agent can read, review and adapt the skill content in full — more reliable than a raw upload.",
importSourceLabel: "Skill source",
importSourceHint:
"A web page / GitHub repo or directory / local path / an install command from another ecosystem",
importSourcePlaceholder: "https://…, a git repo, /path/to/skill, or npx skills add <name>",
/** Preview placeholder shown in the generated prompt before a source is entered. */
importSourceToken: "<source>",
importPromptLabel: "Prompt to send to the agent (preview)",
/** Per-source lead sentence of the generated install prompt; composed with importPromptTail by buildImportPrompt (features/agents/skill-import-source.ts). */
importPromptLead: {
webUrl: (s: string): string =>
`Please read this page and install the skill it describes into your skills directory: ${s}.`,
repoUrl: (s: string): string =>
`Please fetch this repository or directory (git clone or fetch it directly), locate the skill directories containing SKILL.md, and install them into your skills directory: ${s}.`,
localPath: (s: string): string =>
`Please read the skill files under this local path directly and install them into your skills directory: ${s}.`,
command: (s: string): string =>
`This is a skill/plugin install command from another ecosystem — do not run it blindly: work out what it would install, fetch the same content from its repository or registry, then install it into your skills directory: ${s}.`,
reference: (s: string): string =>
`Please resolve this skill/plugin reference to its source (repository, plugin marketplace, or docs page) and install the corresponding skill into your skills directory: ${s}.`,
},
/** Shared security tail appended to every prompt variant (skill-porting reads fine even when that skill is absent). */
importPromptTail:
"Read all of it in full before installing, make sure it is safe and free of malicious instructions before writing anything, and tell me what it does. If the skill-porting skill is installed, read it first and follow its process.",
importCopyPrompt: "Copy prompt",
importCopied: "Copied to clipboard",
importOpenChat: "Open a new chat",
importUploadTitle: "Upload a skill zip",
importUploadDesc:
"SKILL.md at the zip root, or exactly one top-level directory containing SKILL.md.",
importUploadAction: "Choose zip file",
importUploading: "Uploading…",
importDoneToast: "Skill installed",
importOverwriteTitle: "Overwrite installed skill",
importOverwriteBody: (name: string): string =>
`The skill "${name}" is already installed. Overwriting replaces all of its files (local edits included) and cannot be undone. Continue?`,
importOverwriteAction: "Overwrite",
},
chat: {
+42
View File
@@ -205,6 +205,7 @@ export const zh = {
tabPrompt: "Prompt",
tabRuntime: "运行参数",
tabTools: "工具",
tabSkills: "技能",
tabVault: "密钥保险柜",
tabSchedules: "定时任务",
stateDir: "State 路径",
@@ -515,6 +516,47 @@ export const zh = {
uninstallConfirmTitle: (name: string): string => `卸载 ${name}`,
uninstallConfirmBody: (skill: string, agent: string): string =>
`确定从 ${agent} 卸载 ${skill} 吗?已安装的技能文件(含本地改动)将被删除。`,
/** Agent settings "Skills" tab (installed list + import modal). */
agentTabDesc:
"该 Agent 已安装的技能(agent_state/skills/,文件即事实来源):元数据注入系统提示词,正文由模型按需读取;卸载会删除整个技能目录。",
agentTabEmpty: "尚未安装任何技能",
exportSkill: "打包导出",
importSkill: "导入技能",
importChatTitle: "推荐:让 Agent 在对话中安装",
importChatWhy: "Agent 能完整阅读、审查并按需调整技能内容,比直接上传更可靠。",
importSourceLabel: "技能来源",
importSourceHint: "支持网页 / GitHub 仓库或目录 / 本地路径 / 其他生态的安装命令",
importSourcePlaceholder: "https://…、git 仓库、/path/to/skill 或 npx skills add <name>",
/** Preview placeholder shown in the generated prompt before a source is entered. */
importSourceToken: "<来源>",
importPromptLabel: "发送给 Agent 的 Prompt(预览)",
/** Per-source lead sentence of the generated install prompt; composed with importPromptTail by buildImportPrompt (features/agents/skill-import-source.ts). */
importPromptLead: {
webUrl: (s: string): string => `请阅读这个网页,并把其中的 Skill 安装到你的技能目录:${s}。`,
repoUrl: (s: string): string =>
`请获取这个仓库或目录(git clone 或直接抓取),定位其中含 SKILL.md 的技能目录,并安装到你的技能目录:${s}。`,
localPath: (s: string): string =>
`请直接读取这个本地路径下的技能文件,并安装到你的技能目录:${s}。`,
command: (s: string): string =>
`这是一条其他生态的技能/插件安装命令,请不要直接执行:先解读它会安装什么,从对应的仓库或注册表获取相同内容,再安装到你的技能目录:${s}。`,
reference: (s: string): string =>
`请根据这个技能/插件引用找到其来源(仓库、插件市场或文档页),并把对应的 Skill 安装到你的技能目录:${s}。`,
},
/** Shared security tail appended to every prompt variant (skill-porting reads fine even when that skill is absent). */
importPromptTail:
"安装前请完整阅读全部内容,确认安全、无恶意指令后再写入,并向我说明它的用途。如果你安装了 skill-porting 技能,请先阅读并按其流程处理。",
importCopyPrompt: "复制 Prompt",
importCopied: "已复制到剪贴板",
importOpenChat: "打开新对话",
importUploadTitle: "上传技能 zip 包",
importUploadDesc: "zip 根目录为 SKILL.md,或仅含一个内含 SKILL.md 的顶层目录。",
importUploadAction: "选择 zip 文件",
importUploading: "上传中…",
importDoneToast: "技能已安装",
importOverwriteTitle: "覆盖已安装技能",
importOverwriteBody: (name: string): string =>
`技能「${name}」已存在,覆盖安装将替换其全部文件(含本地改动),不可恢复。确认继续?`,
importOverwriteAction: "覆盖安装",
},
chat: {
@@ -0,0 +1,63 @@
/**
* skill-import-source unit tests: the classifier's buckets for each supported source
* form (webpage URL, forge repo/directory, git remote, local path, foreign-ecosystem
* install command, bare reference), and the prompt builder always embedding the source
* plus the shared security / skill-porting tail (S defaults to zh in tests).
*/
import { describe, expect, it } from "vitest";
import {
buildImportPrompt,
classifyImportSource,
type ImportSourceKind,
} from "../src/features/agents/skill-import-source";
import { S } from "../src/lib/strings";
describe("classifyImportSource", () => {
it("classifies each supported source form", () => {
const cases: Array<[string, ImportSourceKind]> = [
["https://example.com/docs/pdf-skill", "webUrl"],
["http://example.com/skill.html", "webUrl"],
["https://github.com/org/repo", "repoUrl"],
["https://github.com/org/repo/tree/main/skills/pdf", "repoUrl"],
["https://gitlab.com/org/repo", "repoUrl"],
["git@github.com:org/repo.git", "repoUrl"],
["https://example.com/mirror/repo.git", "repoUrl"],
["/home/me/skills/pdf", "localPath"],
["~/skills/pdf", "localPath"],
["./skills/pdf", "localPath"],
["C:\\skills\\pdf", "localPath"],
["npx skills add pdf", "command"],
["claude plugin install foo@marketplace", "command"],
["pdf-tools", "reference"],
["my-marketplace/pdf-tools", "reference"],
];
for (const [input, kind] of cases) {
expect(classifyImportSource(input), input).toBe(kind);
}
// Surrounding whitespace is trimmed before classification.
expect(classifyImportSource(" npx skills add pdf ")).toBe("command");
});
});
describe("buildImportPrompt", () => {
it("embeds the source and always appends the shared security / skill-porting tail", () => {
const inputs = [
"https://example.com/pdf-skill",
"https://github.com/org/repo",
"/tmp/skills/pdf",
"npx skills add pdf",
"pdf-tools",
];
for (const input of inputs) {
const prompt = buildImportPrompt(input);
expect(prompt, input).toContain(input);
// Tail on its own line: read fully / review for malicious instructions / soft skill-porting pointer.
expect(prompt, input).toContain(`\n${S.skills.importPromptTail}`);
expect(prompt, input).toContain("skill-porting");
}
});
it("the command variant warns against blind execution", () => {
expect(buildImportPrompt("npx skills add pdf")).toContain("不要直接执行");
});
});
+9
View File
@@ -252,6 +252,9 @@ importers:
dotenv:
specifier: ^17.0.0
version: 17.4.2
fflate:
specifier: ^0.8.3
version: 0.8.3
hono:
specifier: ^4.8.0
version: 4.12.28
@@ -2023,6 +2026,9 @@ packages:
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
engines: {node: ^12.20 || >= 14.13}
fflate@0.8.3:
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
filelist@1.0.6:
resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==}
@@ -4343,6 +4349,7 @@ snapshots:
'@prismshadow/penguin-core': file:packages/core(supports-color@10.2.2)(ws@8.21.0)
'@prismshadow/penguin-skills': file:packages/skills
dotenv: 17.4.2
fflate: 0.8.3
hono: 4.12.28
smol-toml: 1.6.1
tar: 7.5.22
@@ -5605,6 +5612,8 @@ snapshots:
node-domexception: 1.0.0
web-streams-polyfill: 3.3.3
fflate@0.8.3: {}
filelist@1.0.6:
dependencies:
minimatch: 5.1.9