) => void;
+}) {
+ const { t } = useT("skills");
+ return (
+
+ {candidates.map((candidate) => (
+
+
+
= MAX_LOCAL_SKILL_IMPORT_BATCH)}
+ onCheckedChange={(checked) => onChange(candidate.id, { selected: checked === true })}
+ className="mt-1"
+ />
+
+
+
+ ))}
+
+ );
+}
+
+function PreviewMeta({ candidate }: { candidate: LocalSkillCandidate }) {
+ const { t } = useT("skills");
+ const reasonLabels = useResultReasonLabels();
+ return (
+
+
{t(($) => $.upload_import.primary_file_badge)}
+
+ {t(($) => $.upload_import.files_count, { count: candidate.fileCount })}
+
+ {candidate.skipped.length > 0 && (
+
+ {t(($) => $.upload_import.skipped_count, { count: candidate.skipped.length })}
+
+ )}
+ {candidate.skipped.length > 0 && (
+
+ {candidate.skipped.map((item) => (
+
+ {item.path}: {resultReasonLabel(item.reason, reasonLabels)}
+
+ ))}
+
+ )}
+
+ );
+}
+
+function ResultSummary({ summary }: { summary: ImportSummary }) {
+ const reasonLabels = useResultReasonLabels();
+ return (
+
+ {summary.created.map((item) => (
+
+ ))}
+ {summary.skipped.map((item) => (
+
+ ))}
+ {summary.failed.map((item) => (
+
+ ))}
+
+ );
+}
+
+function ResultLine({ name, reason, ok = false }: { name: string; reason: string; ok?: boolean }) {
+ return (
+
+ {ok ? (
+
+ ) : (
+
+ )}
+
{name} {reason}
+
+ );
+}
+
+function AlertMessage({ children }: { children: React.ReactNode; tone: "destructive" }) {
+ return (
+
+ );
+}
+
+function rootLabel(path: string): string {
+ return path.replace(/\\/g, "/").split("/").filter(Boolean)[0] || "upload";
+}
+
+function limitSelectedCandidates(candidates: LocalSkillCandidate[]): LocalSkillCandidate[] {
+ let selectedValidCount = 0;
+ return candidates.map((candidate) => {
+ if (!candidate.valid) return candidate;
+ selectedValidCount += 1;
+ if (selectedValidCount <= MAX_LOCAL_SKILL_IMPORT_BATCH) return candidate;
+ return { ...candidate, selected: false };
+ });
+}
+
+function isZipFile(file: File): boolean {
+ return file.name.toLowerCase().endsWith(".zip") || file.type === "application/zip";
+}
+
+function useResultReasonLabels(): ResultReasonLabels {
+ const { t } = useT("skills");
+ return {
+ already_exists: t(($) => $.upload_import.result_reasons.already_exists),
+ missing_skill_md: t(($) => $.upload_import.result_reasons.missing_skill_md),
+ invalid_file_path: t(($) => $.upload_import.result_reasons.invalid_file_path),
+ hidden_file: t(($) => $.upload_import.result_reasons.hidden_file),
+ metadata_file: t(($) => $.upload_import.result_reasons.metadata_file),
+ absolute_path: t(($) => $.upload_import.result_reasons.absolute_path),
+ path_traversal: t(($) => $.upload_import.result_reasons.path_traversal),
+ file_too_large: t(($) => $.upload_import.result_reasons.file_too_large),
+ binary_file: t(($) => $.upload_import.result_reasons.binary_file),
+ too_many_files: t(($) => $.upload_import.result_reasons.too_many_files),
+ bundle_too_large: t(($) => $.upload_import.result_reasons.bundle_too_large),
+ imported: t(($) => $.upload_import.result_reasons.imported),
+ };
+}
+
+function resultReasonLabel(reason: string, labels: ResultReasonLabels): string {
+ return reason in labels ? labels[reason as keyof ResultReasonLabels] : reason.replaceAll("_", " ");
+}
diff --git a/packages/views/skills/components/skill-columns.tsx b/packages/views/skills/components/skill-columns.tsx
index 2b0cbdae44..e36d662d65 100644
--- a/packages/views/skills/components/skill-columns.tsx
+++ b/packages/views/skills/components/skill-columns.tsx
@@ -209,6 +209,11 @@ function SourceCell({
} else if (origin.type === "github") {
icon = ;
label = t(($) => $.table.source_github);
+ } else if (origin.type === "uploaded_bundle") {
+ icon = ;
+ label = origin.label
+ ? t(($) => $.table.source_uploaded_bundle_named, { label: origin.label })
+ : t(($) => $.table.source_uploaded_bundle);
}
return (
diff --git a/packages/views/skills/components/skill-detail-page.tsx b/packages/views/skills/components/skill-detail-page.tsx
index afd585a501..5c2cd28d7d 100644
--- a/packages/views/skills/components/skill-detail-page.tsx
+++ b/packages/views/skills/components/skill-detail-page.tsx
@@ -201,7 +201,9 @@ function OriginSidebarCard({
? t(($) => $.detail.origin_card.imported_clawhub)
: origin.type === "github"
? t(($) => $.detail.origin_card.imported_github)
- : t(($) => $.detail.origin_card.imported_skills_sh);
+ : origin.type === "uploaded_bundle"
+ ? t(($) => $.detail.origin_card.imported_uploaded_bundle)
+ : t(($) => $.detail.origin_card.imported_skills_sh);
return (
@@ -228,6 +230,11 @@ function OriginSidebarCard({
{origin.source_url}
)}
+ {origin.label && (
+
+ {origin.label}
+
+ )}
{origin.provider && (
{t(($) => $.detail.origin_card.provider, { provider: origin.provider })}
@@ -547,6 +554,11 @@ export function SkillDetailPage({ skillId }: { skillId: string }) {
if (origin.type === "clawhub") return t(($) => $.detail.subline.origin_clawhub);
if (origin.type === "skills_sh") return t(($) => $.detail.subline.origin_skills_sh);
if (origin.type === "github") return t(($) => $.detail.subline.origin_github);
+ if (origin.type === "uploaded_bundle") {
+ return origin.label
+ ? t(($) => $.detail.subline.origin_uploaded_bundle_named, { label: origin.label })
+ : t(($) => $.detail.subline.origin_uploaded_bundle);
+ }
return t(($) => $.detail.subline.origin_workspace);
})();
diff --git a/packages/views/skills/lib/origin.test.ts b/packages/views/skills/lib/origin.test.ts
new file mode 100644
index 0000000000..6c8ecdc8f1
--- /dev/null
+++ b/packages/views/skills/lib/origin.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from "vitest";
+
+import { readOrigin } from "./origin";
+
+describe("readOrigin", () => {
+ it("preserves uploaded bundle origins", () => {
+ expect(
+ readOrigin({
+ config: {
+ origin: {
+ type: "uploaded_bundle",
+ label: "team.zip/review-helper",
+ },
+ },
+ } as never),
+ ).toEqual({
+ type: "uploaded_bundle",
+ label: "team.zip/review-helper",
+ });
+ });
+});
diff --git a/packages/views/skills/lib/origin.ts b/packages/views/skills/lib/origin.ts
index 46ca49b2bf..02e02e4c83 100644
--- a/packages/views/skills/lib/origin.ts
+++ b/packages/views/skills/lib/origin.ts
@@ -7,7 +7,14 @@ import type { Skill, SkillSummary } from "@multica/core/types";
* `{ type: "manual" }` for them to keep the consumer code uniform.
*/
export type OriginInfo = {
- type: "runtime_local" | "clawhub" | "skills_sh" | "github" | "manual";
+ type:
+ | "runtime_local"
+ | "clawhub"
+ | "skills_sh"
+ | "github"
+ | "uploaded_bundle"
+ | "manual";
+ label?: string;
provider?: string;
runtime_id?: string;
source_path?: string;
@@ -22,6 +29,7 @@ export function readOrigin(skill: SkillSummary): OriginInfo {
if (raw?.type === "clawhub") return raw;
if (raw?.type === "skills_sh") return raw;
if (raw?.type === "github") return raw;
+ if (raw?.type === "uploaded_bundle") return raw;
return { type: "manual" };
}
diff --git a/packages/views/skills/utils/local-skill-upload.test.ts b/packages/views/skills/utils/local-skill-upload.test.ts
new file mode 100644
index 0000000000..225d23ae62
--- /dev/null
+++ b/packages/views/skills/utils/local-skill-upload.test.ts
@@ -0,0 +1,525 @@
+import { describe, expect, it } from "vitest";
+import JSZip from "jszip";
+import {
+ buildLocalSkillCandidates,
+ normalizeUploadPath,
+ parseSkillFrontmatter,
+ readZipFile,
+} from "./local-skill-upload";
+
+describe("local skill upload parser", () => {
+ it("parses yaml frontmatter defaults", () => {
+ expect(parseSkillFrontmatter("---\nname: Code Review\ndescription: Reviews PRs\n---\nBody")).toEqual({
+ name: "Code Review",
+ description: "Reviews PRs",
+ });
+ });
+
+ it("groups a selected root folder that contains SKILL.md", async () => {
+ const candidates = await buildLocalSkillCandidates(
+ [
+ file("review-helper/SKILL.md", "---\nname: Review Helper\n---\nBody"),
+ file("review-helper/templates/check.md", "check"),
+ ],
+ "review-helper",
+ );
+
+ expect(candidates).toHaveLength(1);
+ expect(candidates[0]).toMatchObject({
+ root: "review-helper",
+ label: "review-helper",
+ valid: true,
+ name: "Review Helper",
+ fileCount: 2,
+ });
+ expect(candidates[0]?.files).toEqual([{ path: "templates/check.md", content: "check" }]);
+ });
+
+ it("keeps supporting files for root-level skill bundles", async () => {
+ const candidates = await buildLocalSkillCandidates(
+ [
+ file("SKILL.md", "---\nname: Root Skill\n---\nBody"),
+ file("templates/check.md", "check"),
+ ],
+ "root-skill.zip",
+ );
+
+ expect(candidates).toHaveLength(1);
+ expect(candidates[0]).toMatchObject({
+ root: "",
+ label: "root-skill.zip",
+ valid: true,
+ name: "Root Skill",
+ fileCount: 2,
+ });
+ expect(candidates[0]?.files).toEqual([{ path: "templates/check.md", content: "check" }]);
+ });
+
+ it("uses the source label for unnamed root-level skill bundles", async () => {
+ const candidates = await buildLocalSkillCandidates(
+ [file("SKILL.md", "# Review Helper")],
+ "review-helper.zip",
+ );
+
+ expect(candidates[0]).toMatchObject({
+ root: "",
+ label: "review-helper.zip",
+ valid: true,
+ name: "review-helper.zip",
+ });
+ });
+
+ it("counts SKILL.md in the displayed file count", async () => {
+ const candidates = await buildLocalSkillCandidates(
+ [file("review-helper/SKILL.md", "# Review Helper")],
+ "review-helper",
+ );
+
+ expect(candidates[0]).toMatchObject({
+ valid: true,
+ fileCount: 1,
+ });
+ expect(candidates[0]?.files).toEqual([]);
+ });
+
+ it("groups multiple child skill roots", async () => {
+ const candidates = await buildLocalSkillCandidates(
+ [
+ file("team/review/SKILL.md", "# Review"),
+ file("team/docs/SKILL.md", "# Docs"),
+ file("team/notes.txt", "ignored"),
+ ],
+ "team",
+ );
+
+ expect(candidates.map((c) => c.root)).toEqual(["team/docs", "team/review"]);
+ expect(candidates.map((c) => c.label)).toEqual(["team/docs", "team/review"]);
+ });
+
+ it("keeps zip source labels before skill roots", async () => {
+ const candidates = await buildLocalSkillCandidates(
+ [file("team/review/SKILL.md", "# Review")],
+ "team.zip",
+ );
+
+ expect(candidates[0]?.label).toBe("team.zip/team/review");
+ });
+
+ it("keeps nested SKILL.md files inside an already detected parent skill", async () => {
+ const candidates = await buildLocalSkillCandidates(
+ [
+ file("team/top/SKILL.md", "---\nname: Top\n---\n# Top"),
+ file("team/top/templates/SKILL.md", "# Template"),
+ file("team/release/reporter/SKILL.md", "---\nname: Release Reporter\n---\n# Reporter"),
+ ],
+ "team",
+ );
+
+ expect(candidates.map((c) => c.root)).toEqual(["team/release/reporter", "team/top"]);
+ expect(candidates.find((c) => c.root === "team/top")?.files).toEqual([
+ { path: "templates/SKILL.md", content: "# Template" },
+ ]);
+ });
+
+ it("skips hidden, binary, oversized, and traversal files", async () => {
+ const candidates = await buildLocalSkillCandidates(
+ [
+ file("review/SKILL.md", "# Review"),
+ file("review/.DS_Store", "ignored"),
+ file("review/../secret.md", "bad"),
+ file("review/binary.dat", "hello\u0000world"),
+ file("review/large.txt", "x".repeat(1024 * 1024 + 1)),
+ ],
+ "review",
+ );
+
+ expect(candidates[0]?.files).toEqual([]);
+ expect(candidates[0]?.skipped.map((s) => s.reason)).toEqual([
+ "hidden_file",
+ "path_traversal",
+ "binary_file",
+ "file_too_large",
+ ]);
+ });
+
+ it("skips files with invalid UTF-8 bytes", async () => {
+ const candidates = await buildLocalSkillCandidates(
+ [
+ file("review/SKILL.md", "# Review"),
+ bytesFile("review/binary.dat", [0xff, 0xfe, 0xfd]),
+ ],
+ "review",
+ );
+
+ expect(candidates[0]?.files).toEqual([]);
+ expect(candidates[0]?.skipped).toEqual([
+ { path: "review/binary.dat", reason: "binary_file" },
+ ]);
+ });
+
+ it("surfaces an oversized primary SKILL.md selection", async () => {
+ const candidates = await buildLocalSkillCandidates(
+ [file("review/SKILL.md", "x".repeat(1024 * 1024 + 1))],
+ "review",
+ );
+
+ expect(candidates).toHaveLength(1);
+ expect(candidates[0]).toMatchObject({
+ valid: false,
+ reason: "unreadable_skill_md",
+ fileCount: 0,
+ files: [],
+ skipped: [{ path: "review/SKILL.md", reason: "file_too_large" }],
+ });
+ });
+
+ it("surfaces an unreadable primary SKILL.md selection", async () => {
+ const candidates = await buildLocalSkillCandidates(
+ [bytesFile("review/SKILL.md", [0xff, 0xfe, 0xfd])],
+ "review",
+ );
+
+ expect(candidates).toHaveLength(1);
+ expect(candidates[0]).toMatchObject({
+ valid: false,
+ reason: "unreadable_skill_md",
+ fileCount: 0,
+ files: [],
+ skipped: [{ path: "review/SKILL.md", reason: "binary_file" }],
+ });
+ });
+
+ it("counts aggregate bundle size in UTF-8 bytes", async () => {
+ const candidates = await buildLocalSkillCandidates(
+ [
+ file("review/SKILL.md", "# Review"),
+ ...Array.from({ length: 9 }, (_, index) =>
+ file(`review/templates/${index}.md`, "你".repeat(340_000)),
+ ),
+ ],
+ "review",
+ );
+
+ expect(candidates[0]?.files).toHaveLength(8);
+ expect(candidates[0]?.skipped).toEqual([
+ { path: "review/templates/8.md", reason: "bundle_too_large" },
+ ]);
+ });
+
+ it("does not read folder files past the per-skill file cap", async () => {
+ const counter = { reads: 0 };
+ const candidates = await buildLocalSkillCandidates(
+ [
+ countedFile("review/SKILL.md", "# Review", counter),
+ ...Array.from({ length: 130 }, (_, index) =>
+ countedFile(`review/templates/${index}.md`, `template-${index}`, counter),
+ ),
+ ],
+ "review",
+ );
+
+ expect(counter.reads).toBe(129);
+ expect(candidates[0]?.files).toHaveLength(128);
+ expect(candidates[0]?.skipped).toEqual([
+ { path: "review/templates/128.md", reason: "too_many_files" },
+ { path: "review/templates/129.md", reason: "too_many_files" },
+ ]);
+ });
+
+ it("does not read folder files past the per-skill byte budget", async () => {
+ const counter = { reads: 0 };
+ const candidates = await buildLocalSkillCandidates(
+ [
+ countedFile("review/SKILL.md", "# Review", counter),
+ ...Array.from({ length: 10 }, (_, index) =>
+ countedFile(`review/templates/${index}.md`, "你".repeat(340_000), counter),
+ ),
+ ],
+ "review",
+ );
+
+ expect(counter.reads).toBeLessThan(11);
+ expect(candidates[0]?.files.length).toBeLessThanOrEqual(8);
+ const skippedBundleLarge = candidates[0]?.skipped.filter(
+ (s) => s.reason === "bundle_too_large",
+ );
+ expect(skippedBundleLarge?.length).toBeGreaterThan(0);
+ });
+
+ it("reports unreadable primary SKILL.md with a specific reason", async () => {
+ const candidates = await buildLocalSkillCandidates(
+ [bytesFile("review/SKILL.md", [0xff, 0xfe, 0xfd])],
+ "review",
+ );
+
+ expect(candidates).toHaveLength(1);
+ expect(candidates[0]).toMatchObject({
+ valid: false,
+ reason: "unreadable_skill_md",
+ });
+ });
+
+ it("reports oversized primary SKILL.md with a specific reason", async () => {
+ const candidates = await buildLocalSkillCandidates(
+ [file("review/SKILL.md", "x".repeat(1024 * 1024 + 1))],
+ "review",
+ );
+
+ expect(candidates).toHaveLength(1);
+ expect(candidates[0]).toMatchObject({
+ valid: false,
+ reason: "unreadable_skill_md",
+ });
+ });
+
+ it("reports oversized SKILL.md from zip with a specific reason", async () => {
+ const zip = new JSZip();
+ zip.file("SKILL.md", "x".repeat(1024 * 1024 + 1));
+ const blob = await zip.generateAsync({ type: "blob", compression: "DEFLATE" });
+
+ const entries = await readZipFile(new File([blob], "skill.zip"));
+ const candidates = await buildLocalSkillCandidates(entries, "skill.zip");
+
+ expect(candidates).toHaveLength(1);
+ expect(candidates[0]).toMatchObject({
+ valid: false,
+ reason: "unreadable_skill_md",
+ });
+ });
+
+ it("rejects absolute and traversal paths during normalization", () => {
+ expect(normalizeUploadPath("/tmp/SKILL.md")).toEqual({ ok: false, reason: "absolute_path" });
+ expect(normalizeUploadPath("skill/../../secret.md")).toEqual({
+ ok: false,
+ reason: "path_traversal",
+ });
+ });
+
+ it("rejects zip entries whose original name contains traversal", async () => {
+ const zip = new JSZip();
+ zip.file("skill/../SKILL.md", "# Traversal");
+ const blob = await zip.generateAsync({ type: "blob" });
+
+ await expect(readZipFile(new File([blob], "skill.zip"))).rejects.toThrow("path_traversal");
+ });
+
+ it("marks oversized zip entries without inflating them", async () => {
+ const zip = new JSZip();
+ zip.file("SKILL.md", "# Review");
+ zip.file("templates/large.md", "x".repeat(1024 * 1024 + 1));
+ const blob = await zip.generateAsync({ type: "blob", compression: "DEFLATE" });
+
+ const entries = await readZipFile(new File([blob], "skill.zip"));
+ const candidates = await buildLocalSkillCandidates(entries, "skill.zip");
+
+ expect(entries.find((entry) => entry.path === "templates/large.md")?.file).toBeUndefined();
+ expect(candidates).toHaveLength(1);
+ expect(candidates[0]).toMatchObject({
+ root: "",
+ valid: true,
+ fileCount: 1,
+ });
+ expect(candidates[0]?.files).toEqual([]);
+ expect(candidates[0]?.skipped).toEqual([
+ { path: "templates/large.md", reason: "file_too_large" },
+ ]);
+ });
+
+ it("surfaces an oversized primary SKILL.md from a zip", async () => {
+ const zip = new JSZip();
+ zip.file("SKILL.md", "x".repeat(1024 * 1024 + 1));
+ const blob = await zip.generateAsync({ type: "blob", compression: "DEFLATE" });
+
+ const entries = await readZipFile(new File([blob], "skill.zip"));
+ const candidates = await buildLocalSkillCandidates(entries, "skill.zip");
+
+ expect(entries).toEqual([{ path: "SKILL.md", skippedReason: "file_too_large" }]);
+ expect(candidates).toHaveLength(1);
+ expect(candidates[0]).toMatchObject({
+ root: "",
+ label: "skill.zip",
+ valid: false,
+ reason: "unreadable_skill_md",
+ fileCount: 0,
+ files: [],
+ skipped: [{ path: "SKILL.md", reason: "file_too_large" }],
+ });
+ });
+
+ it("keeps oversized zip SKILL.md markers alongside valid skills", async () => {
+ const zip = new JSZip();
+ zip.file("review/SKILL.md", "# Review");
+ zip.file("broken/SKILL.md", "x".repeat(1024 * 1024 + 1));
+ const blob = await zip.generateAsync({ type: "blob", compression: "DEFLATE" });
+
+ const entries = await readZipFile(new File([blob], "skills.zip"));
+ const candidates = await buildLocalSkillCandidates(entries, "skills.zip");
+
+ expect(entries.find((entry) => entry.path === "broken/SKILL.md")).toEqual({
+ path: "broken/SKILL.md",
+ skippedReason: "file_too_large",
+ });
+ expect(candidates.map((candidate) => candidate.root)).toEqual(["broken", "review"]);
+ expect(candidates.find((candidate) => candidate.root === "broken")).toMatchObject({
+ valid: false,
+ skipped: [{ path: "broken/SKILL.md", reason: "file_too_large" }],
+ });
+ expect(candidates.find((candidate) => candidate.root === "review")).toMatchObject({
+ valid: true,
+ fileCount: 1,
+ });
+ });
+
+ it("allows zip imports with multiple skills under the per-skill file cap", async () => {
+ const zip = new JSZip();
+ zip.file("review/SKILL.md", "# Review");
+ zip.file("docs/SKILL.md", "# Docs");
+ for (let index = 0; index < 70; index += 1) {
+ zip.file(`review/templates/${index}.md`, "review");
+ zip.file(`docs/templates/${index}.md`, "docs");
+ }
+ const blob = await zip.generateAsync({ type: "blob" });
+ const buffer = await blob.arrayBuffer();
+
+ await expect(readZipFile(new File([buffer], "skills.zip"))).resolves.toHaveLength(142);
+ });
+
+ it("marks excess zip entries without rejecting the skill", async () => {
+ const zip = new JSZip();
+ zip.file("SKILL.md", "# Review");
+ for (let index = 0; index < 130; index += 1) {
+ zip.file(`templates/${index}.md`, "template");
+ }
+ const blob = await zip.generateAsync({ type: "blob" });
+
+ const entries = await readZipFile(new File([blob], "skill.zip"));
+ const candidates = await buildLocalSkillCandidates(entries, "skill.zip");
+
+ expect(candidates).toHaveLength(1);
+ expect(candidates[0]).toMatchObject({
+ root: "",
+ valid: true,
+ fileCount: 129,
+ });
+ expect(candidates[0]?.files).toHaveLength(128);
+ expect(candidates[0]?.skipped).toEqual([
+ { path: "templates/128.md", reason: "too_many_files" },
+ { path: "templates/129.md", reason: "too_many_files" },
+ ]);
+ });
+
+ it("does not let skipped zip entries consume the supporting file budget", async () => {
+ const zip = new JSZip();
+ zip.file("SKILL.md", "# Review");
+ for (let index = 0; index < 128; index += 1) {
+ zip.file(`.hidden/${index}.md`, "ignored");
+ }
+ zip.file("templates/real.md", "real content");
+ const blob = await zip.generateAsync({ type: "blob" });
+
+ const entries = await readZipFile(new File([blob], "skill.zip"));
+ const candidates = await buildLocalSkillCandidates(entries, "skill.zip");
+
+ expect(candidates).toHaveLength(1);
+ expect(candidates[0]?.files).toEqual([{ path: "templates/real.md", content: "real content" }]);
+ expect(candidates[0]?.skipped).toHaveLength(128);
+ expect(candidates[0]?.skipped.every((item) => item.reason === "hidden_file")).toBe(true);
+ });
+
+ it("does not let binary zip files consume the supporting file budget", async () => {
+ const zip = new JSZip();
+ zip.file("SKILL.md", "# Review");
+ for (let index = 0; index < 128; index += 1) {
+ zip.file(`templates/${index}.bin`, new Uint8Array([0xff, 0xfe, 0xfd]));
+ }
+ zip.file("templates/real.md", "real content");
+ const blob = await zip.generateAsync({ type: "blob" });
+
+ const entries = await readZipFile(new File([blob], "skill.zip"));
+ const candidates = await buildLocalSkillCandidates(entries, "skill.zip");
+
+ expect(candidates).toHaveLength(1);
+ expect(candidates[0]?.files).toEqual([{ path: "templates/real.md", content: "real content" }]);
+ const binarySkipped = candidates[0]?.skipped.filter((s) => s.reason === "binary_file");
+ expect(binarySkipped).toHaveLength(128);
+ });
+
+ it("enforces per-root byte budget before inflating zip entries", async () => {
+ const zip = new JSZip();
+ zip.file("SKILL.md", "# Review");
+ for (let index = 0; index < 10; index += 1) {
+ zip.file(`templates/${index}.md`, "x".repeat(1_000_000));
+ }
+ const blob = await zip.generateAsync({ type: "blob", compression: "DEFLATE" });
+
+ const entries = await readZipFile(new File([blob], "skill.zip"));
+
+ const inflatedCount = entries.filter((e) => e.file).length;
+ const skippedBundleLarge = entries.filter((e) => e.skippedReason === "bundle_too_large");
+ expect(inflatedCount).toBeLessThan(12);
+ expect(skippedBundleLarge.length).toBeGreaterThan(0);
+ });
+
+ it("skips hidden zip entries without inflating them", async () => {
+ const zip = new JSZip();
+ zip.file("SKILL.md", "# Review");
+ zip.file(".hidden/secret.md", "x".repeat(500_000));
+ zip.file("templates/real.md", "real content");
+ const blob = await zip.generateAsync({ type: "blob" });
+
+ const entries = await readZipFile(new File([blob], "skill.zip"));
+
+ const hiddenEntry = entries.find((e) => e.path === ".hidden/secret.md");
+ expect(hiddenEntry?.file).toBeUndefined();
+ expect(hiddenEntry?.skippedReason).toBe("hidden_file");
+ const realEntry = entries.find((e) => e.path === "templates/real.md");
+ expect(realEntry?.file).toBeDefined();
+ });
+
+ it("ignores zip entries outside detected skill roots", async () => {
+ const zip = new JSZip();
+ zip.file("skill/SKILL.md", "# Review");
+ zip.file("skill/templates/real.md", "real content");
+ zip.file("unrelated/large.txt", "x".repeat(1024 * 1024));
+ const blob = await zip.generateAsync({ type: "blob" });
+
+ const entries = await readZipFile(new File([blob], "skill.zip"));
+
+ expect(entries.map((entry) => entry.path).sort()).toEqual([
+ "skill/SKILL.md",
+ "skill/templates/real.md",
+ ]);
+ });
+});
+
+function file(path: string, content: string): File & { webkitRelativePath: string } {
+ const value = new File([content], path.split("/").at(-1) ?? "file.txt", { type: "text/plain" });
+ Object.defineProperty(value, "webkitRelativePath", { value: path });
+ return value as File & { webkitRelativePath: string };
+}
+
+function bytesFile(path: string, bytes: number[]): File & { webkitRelativePath: string } {
+ const buffer = new ArrayBuffer(bytes.length);
+ new Uint8Array(buffer).set(bytes);
+ const value = new File([buffer], path.split("/").at(-1) ?? "file.bin", {
+ type: "application/octet-stream",
+ });
+ Object.defineProperty(value, "webkitRelativePath", { value: path });
+ return value as File & { webkitRelativePath: string };
+}
+
+function countedFile(
+ path: string,
+ content: string,
+ counter: { reads: number },
+): File & { webkitRelativePath: string } {
+ const value = file(path, content);
+ const originalArrayBuffer = value.arrayBuffer.bind(value);
+ Object.defineProperty(value, "arrayBuffer", {
+ value: async () => {
+ counter.reads += 1;
+ return originalArrayBuffer();
+ },
+ });
+ return value;
+}
diff --git a/packages/views/skills/utils/local-skill-upload.ts b/packages/views/skills/utils/local-skill-upload.ts
new file mode 100644
index 0000000000..2f7ea902d6
--- /dev/null
+++ b/packages/views/skills/utils/local-skill-upload.ts
@@ -0,0 +1,506 @@
+import JSZip from "jszip";
+import type { ImportLocalSkillRequest } from "@multica/core/types";
+
+export const MAX_SKILL_FILE_BYTES = 1024 * 1024;
+export const MAX_SKILL_FILES = 128;
+export const MAX_SKILL_ACCEPTED_BYTES = 8 * 1024 * 1024;
+export const MAX_LOCAL_SKILL_IMPORT_BATCH = 16;
+
+export interface LocalSkillInputFile {
+ path: string;
+ file?: File;
+ skippedReason?: SkippedLocalSkillFileReason;
+}
+
+export type SkippedLocalSkillFileReason =
+ | "hidden_file"
+ | "metadata_file"
+ | "absolute_path"
+ | "path_traversal"
+ | "file_too_large"
+ | "binary_file"
+ | "too_many_files"
+ | "bundle_too_large";
+
+export interface SkippedLocalSkillFile {
+ path: string;
+ reason: SkippedLocalSkillFileReason;
+}
+
+export interface LocalSkillCandidate {
+ id: string;
+ root: string;
+ label: string;
+ valid: boolean;
+ reason?: "missing_skill_md" | "unreadable_skill_md" | "all_files_skipped";
+ name: string;
+ description: string;
+ content: string;
+ fileCount: number;
+ files: { path: string; content: string }[];
+ skipped: SkippedLocalSkillFile[];
+ selected: boolean;
+}
+
+interface InvalidSkillGroup {
+ root: string;
+ skipped: SkippedLocalSkillFile[];
+}
+
+type NormalizedUploadPath =
+ | { ok: true; path: string }
+ | { ok: false; reason: "absolute_path" | "path_traversal" };
+
+interface ReadUploadFile {
+ path: string;
+ content: string;
+}
+
+interface SkillGroup {
+ root: string;
+ skillFile: ReadUploadFile;
+ files: ReadUploadFile[];
+ skipped: SkippedLocalSkillFile[];
+}
+
+const metadataFiles = new Set(["thumbs.db", "desktop.ini"]);
+const textEncoder = new TextEncoder();
+const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
+
+export function parseSkillFrontmatter(content: string): { name?: string; description?: string } {
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
+ if (!match) return {};
+
+ const result: { name?: string; description?: string } = {};
+ const frontmatter = match[1] ?? "";
+ for (const line of frontmatter.split(/\r?\n/)) {
+ const field = line.match(/^(name|description):\s*(.+?)\s*$/);
+ if (!field) continue;
+ const key = field[1] as "name" | "description";
+ result[key] = (field[2] ?? "").replace(/^["']|["']$/g, "").trim();
+ }
+ return result;
+}
+
+export function normalizeUploadPath(path: string): NormalizedUploadPath {
+ const normalized = path.replace(/\\/g, "/").replace(/^\.\/+/, "");
+ if (normalized.startsWith("/") || /^[a-zA-Z]:\//.test(normalized)) {
+ return { ok: false, reason: "absolute_path" };
+ }
+ const parts = normalized.split("/").filter(Boolean);
+ if (parts.includes("..")) {
+ return { ok: false, reason: "path_traversal" };
+ }
+ return { ok: true, path: parts.join("/") };
+}
+
+export async function buildLocalSkillCandidates(
+ files: File[] | LocalSkillInputFile[],
+ sourceLabel: string,
+): Promise {
+ const skipped: SkippedLocalSkillFile[] = [];
+ const normalizedInputs: LocalSkillInputFile[] = [];
+
+ for (const item of files) {
+ const input = toInputFile(item);
+ const normalized = normalizeUploadPath(input.path);
+ if (!normalized.ok) {
+ skipped.push({ path: input.path, reason: normalized.reason });
+ continue;
+ }
+ if (isHiddenPath(normalized.path)) {
+ skipped.push({ path: normalized.path, reason: "hidden_file" });
+ continue;
+ }
+ if (isMetadataFile(normalized.path)) {
+ skipped.push({ path: normalized.path, reason: "metadata_file" });
+ continue;
+ }
+ normalizedInputs.push({ ...input, path: normalized.path });
+ }
+
+ const skillRoots = normalizedInputs
+ .filter((input) =>
+ basename(input.path) === "SKILL.md" &&
+ (input.skippedReason !== undefined ||
+ (!!input.file && input.file.size <= MAX_SKILL_FILE_BYTES)),
+ )
+ .map((input) => dirname(input.path))
+ .sort()
+ .filter((root, index, roots) => !hasAncestorSkillRoot(root, roots.slice(0, index)));
+
+ const readFiles: ReadUploadFile[] = [];
+ const supportingFileCountsByRoot = new Map();
+ const bytesByRoot = new Map();
+
+ for (const input of normalizedInputs) {
+ if (input.skippedReason) {
+ skipped.push({ path: input.path, reason: input.skippedReason });
+ continue;
+ }
+ if (!input.file) continue;
+ const root = nearestSkillRoot(input.path, skillRoots);
+ if (root === null) {
+ if (basename(input.path) === "SKILL.md" && input.file.size > MAX_SKILL_FILE_BYTES) {
+ skipped.push({ path: input.path, reason: "file_too_large" });
+ }
+ continue;
+ }
+ if (input.file.size > MAX_SKILL_FILE_BYTES) {
+ skipped.push({ path: input.path, reason: "file_too_large" });
+ continue;
+ }
+
+ const isSupportingFile = input.path !== joinPath(root, "SKILL.md");
+ if (isSupportingFile && (supportingFileCountsByRoot.get(root) ?? 0) >= MAX_SKILL_FILES) {
+ skipped.push({ path: input.path, reason: "too_many_files" });
+ continue;
+ }
+ if (isSupportingFile && (bytesByRoot.get(root) ?? 0) >= MAX_SKILL_ACCEPTED_BYTES) {
+ skipped.push({ path: input.path, reason: "bundle_too_large" });
+ continue;
+ }
+
+ const content = await readUtf8Text(input.file);
+ if (content === null) {
+ skipped.push({ path: input.path, reason: "binary_file" });
+ continue;
+ }
+ if (content.includes("\u0000")) {
+ skipped.push({ path: input.path, reason: "binary_file" });
+ continue;
+ }
+ if (isSupportingFile) {
+ supportingFileCountsByRoot.set(root, (supportingFileCountsByRoot.get(root) ?? 0) + 1);
+ }
+ const fileBytes = utf8ByteLength(content);
+ bytesByRoot.set(root, (bytesByRoot.get(root) ?? 0) + fileBytes);
+ readFiles.push({ path: input.path, content: content.replaceAll("\u0000", "") });
+ }
+
+ if (skillRoots.length === 0) {
+ const hasSkippedSkillMd = skipped.some(
+ (s) => basename(s.path) === "SKILL.md" && (s.reason === "binary_file" || s.reason === "file_too_large"),
+ );
+ return [
+ {
+ id: "missing-skill-md",
+ root: sourceLabel,
+ label: sourceLabel,
+ valid: false,
+ reason: hasSkippedSkillMd ? "unreadable_skill_md" : "missing_skill_md",
+ name: folderName(sourceLabel),
+ description: "",
+ content: "",
+ fileCount: 0,
+ files: [],
+ skipped,
+ selected: false,
+ },
+ ];
+ }
+
+ const groups = new Map();
+ const invalidGroups = new Map();
+ for (const root of skillRoots) {
+ const skillFile = readFiles.find((file) => file.path === joinPath(root, "SKILL.md"));
+ if (skillFile) {
+ groups.set(root, { root, skillFile, files: [], skipped: [] });
+ } else {
+ invalidGroups.set(root, { root, skipped: [] });
+ }
+ }
+
+ for (const file of readFiles) {
+ const root = nearestSkillRoot(file.path, skillRoots);
+ if (root === null) continue;
+ if (file.path === joinPath(root, "SKILL.md")) continue;
+ groups.get(root)?.files.push({
+ path: relativePath(root, file.path),
+ content: file.content,
+ });
+ }
+
+ for (const item of skipped) {
+ const root = nearestSkillRoot(item.path, skillRoots);
+ if (root !== null) {
+ groups.get(root)?.skipped.push(item);
+ invalidGroups.get(root)?.skipped.push(item);
+ }
+ }
+
+ return [
+ ...Array.from(groups.values()).map((group) => buildCandidate(group, sourceLabel)),
+ ...Array.from(invalidGroups.values()).map((group) => buildInvalidCandidate(group, sourceLabel)),
+ ].sort((a, b) => a.root.localeCompare(b.root));
+}
+
+export function candidateToImportRequest(candidate: LocalSkillCandidate): ImportLocalSkillRequest {
+ return {
+ name: candidate.name,
+ description: candidate.description,
+ content: candidate.content,
+ files: candidate.files,
+ source: { type: "uploaded_bundle", label: candidate.label },
+ };
+}
+
+export async function readZipFile(file: File): Promise {
+ const zip = await JSZip.loadAsync(await file.arrayBuffer());
+ const entries: LocalSkillInputFile[] = [];
+ const fileEntries = Object.values(zip.files).filter((entry) => !entry.dir);
+ const normalizedEntries: {
+ entry?: JSZip.JSZipObject;
+ path: string;
+ skippedReason?: SkippedLocalSkillFileReason;
+ }[] = [];
+
+ for (const entry of fileEntries) {
+ const unsafeName = (entry as typeof entry & { unsafeOriginalName?: string }).unsafeOriginalName ?? entry.name;
+ const original = normalizeUploadPath(unsafeName);
+ if (!original.ok) {
+ throw new Error(original.reason);
+ }
+ const normalized = normalizeUploadPath(entry.name);
+ if (!normalized.ok) {
+ throw new Error(normalized.reason);
+ }
+ if (zipEntryUncompressedSize(entry) > MAX_SKILL_FILE_BYTES) {
+ normalizedEntries.push({
+ path: normalized.path,
+ skippedReason: "file_too_large",
+ });
+ continue;
+ }
+ normalizedEntries.push({ entry, path: normalized.path });
+ }
+
+ const skillRoots = normalizedEntries
+ .filter((item) => basename(item.path) === "SKILL.md" && (item.entry || item.skippedReason))
+ .map((item) => dirname(item.path))
+ .sort()
+ .filter((root, index, roots) => !hasAncestorSkillRoot(root, roots.slice(0, index)));
+
+ const bytesByRoot = new Map();
+ const fileCountsByRoot = new Map();
+ for (const { entry, path, skippedReason } of normalizedEntries) {
+ const root = nearestSkillRoot(path, skillRoots);
+ if (root === null) continue;
+ if (skippedReason) {
+ entries.push({ path, skippedReason });
+ continue;
+ }
+ if (!entry) continue;
+ if (isHiddenPath(path) || isMetadataFile(path)) {
+ entries.push({
+ path,
+ skippedReason: isHiddenPath(path) ? "hidden_file" : "metadata_file",
+ });
+ continue;
+ }
+ const isSupportingFile = path !== joinPath(root, "SKILL.md");
+ if (isSupportingFile && (bytesByRoot.get(root) ?? 0) >= MAX_SKILL_ACCEPTED_BYTES) {
+ entries.push({ path, skippedReason: "bundle_too_large" });
+ continue;
+ }
+ if (isSupportingFile && (fileCountsByRoot.get(root) ?? 0) >= MAX_SKILL_FILES * 2) {
+ entries.push({ path, skippedReason: "too_many_files" });
+ continue;
+ }
+ const blob = await entry.async("blob");
+ bytesByRoot.set(root, (bytesByRoot.get(root) ?? 0) + zipEntryUncompressedSize(entry));
+ if (isSupportingFile) {
+ fileCountsByRoot.set(root, (fileCountsByRoot.get(root) ?? 0) + 1);
+ }
+ entries.push({
+ path,
+ file: new File([blob], basename(path)),
+ });
+ }
+ return entries;
+}
+
+function zipEntryUncompressedSize(entry: JSZip.JSZipObject): number {
+ const withData = entry as JSZip.JSZipObject & {
+ _data?: { uncompressedSize?: number };
+ };
+ return withData._data?.uncompressedSize ?? 0;
+}
+
+export async function filesFromDataTransfer(items: DataTransferItemList): Promise {
+ const files: LocalSkillInputFile[] = [];
+ for (const item of Array.from(items)) {
+ const entry = getDroppedEntry(item);
+ if (entry) {
+ files.push(...(await readEntry(entry, "")));
+ continue;
+ }
+ const file = item.getAsFile();
+ if (file) files.push({ path: file.name, file });
+ }
+ return files;
+}
+
+function buildCandidate(group: SkillGroup, sourceLabel: string): LocalSkillCandidate {
+ const defaults = parseSkillFrontmatter(group.skillFile.content);
+ const acceptedFiles = group.files.slice(0, MAX_SKILL_FILES);
+ const skipped = [...group.skipped];
+
+ if (group.files.length > MAX_SKILL_FILES) {
+ for (const file of group.files.slice(MAX_SKILL_FILES)) {
+ skipped.push({ path: joinPath(group.root, file.path), reason: "too_many_files" });
+ }
+ }
+
+ let total = utf8ByteLength(group.skillFile.content);
+ const sizeLimitedFiles: { path: string; content: string }[] = [];
+ for (const file of acceptedFiles) {
+ const fileBytes = utf8ByteLength(file.content);
+ if (total + fileBytes > MAX_SKILL_ACCEPTED_BYTES) {
+ skipped.push({ path: joinPath(group.root, file.path), reason: "bundle_too_large" });
+ continue;
+ }
+ total += fileBytes;
+ sizeLimitedFiles.push(file);
+ }
+
+ const name = defaults.name || folderName(group.root || sourceLabel);
+ return {
+ id: group.root || sourceLabel,
+ root: group.root,
+ label: sourceLabelForGroup(group.root, sourceLabel),
+ valid: true,
+ name,
+ description: defaults.description || "",
+ content: group.skillFile.content,
+ fileCount: 1 + sizeLimitedFiles.length,
+ files: sizeLimitedFiles,
+ skipped,
+ selected: true,
+ };
+}
+
+function buildInvalidCandidate(group: InvalidSkillGroup, sourceLabel: string): LocalSkillCandidate {
+ const skillMdPath = joinPath(group.root, "SKILL.md");
+ const skillMdSkipped = group.skipped.some(
+ (s) => s.path === skillMdPath && (s.reason === "binary_file" || s.reason === "file_too_large"),
+ );
+ return {
+ id: group.root || sourceLabel,
+ root: group.root,
+ label: sourceLabelForGroup(group.root, sourceLabel),
+ valid: false,
+ reason: skillMdSkipped ? "unreadable_skill_md" : "missing_skill_md",
+ name: folderName(group.root || sourceLabel),
+ description: "",
+ content: "",
+ fileCount: 0,
+ files: [],
+ skipped: group.skipped,
+ selected: false,
+ };
+}
+
+function sourceLabelForGroup(root: string, sourceLabel: string): string {
+ if (!root) return sourceLabel;
+ if (!sourceLabel) return root;
+ if (root === sourceLabel || root.startsWith(`${sourceLabel}/`)) return root;
+ return `${sourceLabel}/${root}`;
+}
+
+function utf8ByteLength(value: string): number {
+ return textEncoder.encode(value).byteLength;
+}
+
+async function readUtf8Text(file: File): Promise {
+ try {
+ return utf8Decoder.decode(await file.arrayBuffer());
+ } catch {
+ return null;
+ }
+}
+
+function toInputFile(item: File | LocalSkillInputFile): LocalSkillInputFile {
+ if ("path" in item && ("file" in item || "skippedReason" in item)) return item;
+ const file = item as File & { webkitRelativePath?: string };
+ return { path: file.webkitRelativePath || file.name, file };
+}
+
+function isHiddenPath(path: string): boolean {
+ return path.split("/").some((part) => part.startsWith("."));
+}
+
+function isMetadataFile(path: string): boolean {
+ return metadataFiles.has(basename(path).toLowerCase());
+}
+
+function basename(path: string): string {
+ return path.split("/").filter(Boolean).at(-1) || "";
+}
+
+function dirname(path: string): string {
+ const parts = path.split("/").filter(Boolean);
+ return parts.slice(0, -1).join("/");
+}
+
+function folderName(path: string): string {
+ return basename(path) || "Untitled Skill";
+}
+
+function joinPath(root: string, path: string): string {
+ return root ? `${root}/${path}` : path;
+}
+
+function relativePath(root: string, path: string): string {
+ if (!root) return path;
+ return path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path;
+}
+
+function nearestSkillRoot(path: string, roots: string[]): string | null {
+ const sorted = [...roots].sort((a, b) => b.length - a.length);
+ return sorted.find((root) => path === root || path.startsWith(`${root}/`) || (!root && path)) ?? null;
+}
+
+function hasAncestorSkillRoot(root: string, previousRoots: string[]): boolean {
+ return previousRoots.some((candidate) => candidate === "" || root.startsWith(`${candidate}/`));
+}
+
+type DroppedEntry = FileSystemFileEntry | FileSystemDirectoryEntry;
+
+function getDroppedEntry(item: DataTransferItem): DroppedEntry | null {
+ const withEntry = item as DataTransferItem & {
+ webkitGetAsEntry?: () => FileSystemEntry | null;
+ };
+ const entry = withEntry.webkitGetAsEntry?.() ?? null;
+ if (!entry) return null;
+ return entry as DroppedEntry;
+}
+
+async function readEntry(entry: DroppedEntry, prefix: string): Promise {
+ if (entry.isFile) {
+ const file = await fileFromEntry(entry as FileSystemFileEntry);
+ return [{ path: joinPath(prefix, file.name), file }];
+ }
+
+ const directory = entry as FileSystemDirectoryEntry;
+ const children = await readAllDirectoryEntries(directory);
+ const childPrefix = joinPath(prefix, directory.name);
+ const nested = await Promise.all(children.map((child) => readEntry(child, childPrefix)));
+ return nested.flat();
+}
+
+function fileFromEntry(entry: FileSystemFileEntry): Promise {
+ return new Promise((resolve, reject) => entry.file(resolve, reject));
+}
+
+async function readAllDirectoryEntries(entry: FileSystemDirectoryEntry): Promise {
+ const reader = entry.createReader();
+ const entries: DroppedEntry[] = [];
+ while (true) {
+ const batch = await new Promise((resolve, reject) => {
+ reader.readEntries((items) => resolve(items as DroppedEntry[]), reject);
+ });
+ if (batch.length === 0) return entries;
+ entries.push(...batch);
+ }
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4c83bc9a3b..e860e45db5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -48,6 +48,9 @@ catalogs:
jsdom:
specifier: ^29.0.1
version: 29.0.1
+ jszip:
+ specifier: ^3.10.1
+ version: 3.10.1
katex:
specifier: ^0.16.45
version: 0.16.45
@@ -512,7 +515,7 @@ importers:
version: 1.369.3
react-i18next:
specifier: 'catalog:'
- version: 17.0.6(i18next@26.0.8(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)
+ version: 17.0.6(i18next@26.0.8(typescript@5.9.3))(react@19.2.3)(typescript@5.9.3)
zod:
specifier: 'catalog:'
version: 4.3.6
@@ -784,6 +787,9 @@ importers:
i18next:
specifier: 'catalog:'
version: 26.0.8(typescript@5.9.3)
+ jszip:
+ specifier: 'catalog:'
+ version: 3.10.1
katex:
specifier: 'catalog:'
version: 0.16.45
@@ -4971,6 +4977,9 @@ packages:
engines: {node: '>=16.x'}
hasBin: true
+ immediate@3.0.6:
+ resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
+
immer@10.2.0:
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
@@ -5210,6 +5219,9 @@ packages:
resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==}
engines: {node: '>=16'}
+ isarray@1.0.0:
+ resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
+
isarray@2.0.5:
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
@@ -5304,6 +5316,9 @@ packages:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'}
+ jszip@3.10.1:
+ resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
+
katex@0.16.45:
resolution: {integrity: sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA==}
hasBin: true
@@ -5339,6 +5354,9 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
+ lie@3.3.0:
+ resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
+
lightningcss-android-arm64@1.32.0:
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
engines: {node: '>= 12.0.0'}
@@ -6067,6 +6085,9 @@ packages:
package-manager-detector@1.6.0:
resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
+ pako@1.0.11:
+ resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
+
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@@ -6275,6 +6296,9 @@ packages:
resolution: {integrity: sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==}
engines: {node: ^18.17.0 || >=20.5.0}
+ process-nextick-args@2.0.1:
+ resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
+
progress@2.0.3:
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
engines: {node: '>=0.4.0'}
@@ -6541,6 +6565,9 @@ packages:
resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==}
hasBin: true
+ readable-stream@2.3.8:
+ resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
+
readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
@@ -6734,6 +6761,9 @@ packages:
resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==}
engines: {node: '>=0.4'}
+ safe-buffer@5.1.2:
+ resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
+
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
@@ -6808,6 +6838,9 @@ packages:
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
engines: {node: '>= 0.4'}
+ setimmediate@1.0.5:
+ resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
+
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
@@ -6978,6 +7011,9 @@ packages:
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
engines: {node: '>= 0.4'}
+ string_decoder@1.1.1:
+ resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
+
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
@@ -10570,8 +10606,7 @@ snapshots:
core-js@3.49.0: {}
- core-util-is@1.0.2:
- optional: true
+ core-util-is@1.0.2: {}
cors@2.8.6:
dependencies:
@@ -12155,6 +12190,8 @@ snapshots:
image-size@2.0.2: {}
+ immediate@3.0.6: {}
+
immer@10.2.0: {}
immer@11.1.4: {}
@@ -12357,6 +12394,8 @@ snapshots:
dependencies:
is-inside-container: 1.0.0
+ isarray@1.0.0: {}
+
isarray@2.0.5: {}
isbinaryfile@4.0.10: {}
@@ -12460,6 +12499,13 @@ snapshots:
object.assign: 4.1.7
object.values: 1.2.1
+ jszip@3.10.1:
+ dependencies:
+ lie: 3.3.0
+ pako: 1.0.11
+ readable-stream: 2.3.8
+ setimmediate: 1.0.5
+
katex@0.16.45:
dependencies:
commander: 8.3.0
@@ -12494,6 +12540,10 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
+ lie@3.3.0:
+ dependencies:
+ immediate: 3.0.6
+
lightningcss-android-arm64@1.32.0:
optional: true
@@ -13532,6 +13582,8 @@ snapshots:
package-manager-detector@1.6.0: {}
+ pako@1.0.11: {}
+
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@@ -13734,6 +13786,8 @@ snapshots:
proc-log@5.0.0: {}
+ process-nextick-args@2.0.1: {}
+
progress@2.0.3: {}
promise-retry@2.0.1:
@@ -13947,6 +14001,16 @@ snapshots:
react-dom: 19.2.3(react@19.2.3)
typescript: 5.9.3
+ react-i18next@17.0.6(i18next@26.0.8(typescript@5.9.3))(react@19.2.3)(typescript@5.9.3):
+ dependencies:
+ '@babel/runtime': 7.29.2
+ html-parse-stringify: 3.0.1
+ i18next: 26.0.8(typescript@5.9.3)
+ react: 19.2.3
+ use-sync-external-store: 1.6.0(react@19.2.3)
+ optionalDependencies:
+ typescript: 5.9.3
+
react-is@16.13.1: {}
react-is@17.0.2: {}
@@ -14052,6 +14116,16 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ readable-stream@2.3.8:
+ dependencies:
+ core-util-is: 1.0.2
+ inherits: 2.0.4
+ isarray: 1.0.0
+ process-nextick-args: 2.0.1
+ safe-buffer: 5.1.2
+ string_decoder: 1.1.1
+ util-deprecate: 1.0.2
+
readable-stream@3.6.2:
dependencies:
inherits: 2.0.4
@@ -14369,6 +14443,8 @@ snapshots:
has-symbols: 1.1.0
isarray: 2.0.5
+ safe-buffer@5.1.2: {}
+
safe-buffer@5.2.1: {}
safe-push-apply@1.0.0:
@@ -14463,6 +14539,8 @@ snapshots:
es-errors: 1.3.0
es-object-atoms: 1.1.1
+ setimmediate@1.0.5: {}
+
setprototypeof@1.2.0: {}
shadcn@4.1.0(@types/node@25.5.0)(typescript@5.9.3):
@@ -14735,6 +14813,10 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.1.1
+ string_decoder@1.1.1:
+ dependencies:
+ safe-buffer: 5.1.2
+
string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 31e0c248e2..4c5bd02224 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -33,6 +33,7 @@ catalog:
rehype-katex: "^7.0.1"
remark-math: "^6.0.0"
mermaid: "^11.14.0"
+ jszip: "^3.10.1"
# Icons
lucide-react: "^1.0.1"
diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go
index 0f996878f6..1a340405a1 100644
--- a/server/cmd/server/router.go
+++ b/server/cmd/server/router.go
@@ -553,6 +553,7 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
r.Get("/", h.ListSkills)
r.Post("/", h.CreateSkill)
r.Post("/import", h.ImportSkill)
+ r.Post("/import-local", h.ImportLocalSkills)
r.Route("/{id}", func(r chi.Router) {
r.Get("/", h.GetSkill)
r.Put("/", h.UpdateSkill)
diff --git a/server/internal/handler/skill_import_local.go b/server/internal/handler/skill_import_local.go
new file mode 100644
index 0000000000..b67df7c5e1
--- /dev/null
+++ b/server/internal/handler/skill_import_local.go
@@ -0,0 +1,207 @@
+package handler
+
+import (
+ "encoding/json"
+ "net/http"
+ "path/filepath"
+ "strings"
+
+ "github.com/multica-ai/multica/server/pkg/protocol"
+)
+
+const (
+ maxLocalUploadedSkills = 16
+ maxLocalUploadedSkillFiles = 128
+ maxLocalUploadedSkillFileBytes = 1024 * 1024
+ maxLocalUploadedSkillTotalBytes = 8 * 1024 * 1024
+ maxLocalUploadedSkillRequestBytes = 32 * 1024 * 1024
+)
+
+type ImportLocalSkillSourceRequest struct {
+ Type string `json:"type"`
+ Label string `json:"label"`
+}
+
+type ImportLocalSkillRequest struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Content string `json:"content"`
+ Files []CreateSkillFileRequest `json:"files,omitempty"`
+ Source ImportLocalSkillSourceRequest `json:"source"`
+}
+
+type ImportLocalSkillsRequest struct {
+ Skills []ImportLocalSkillRequest `json:"skills"`
+}
+
+type ImportLocalSkillCreated struct {
+ Skill SkillWithFilesResponse `json:"skill"`
+ SourceLabel string `json:"source_label"`
+}
+
+type ImportLocalSkillResult struct {
+ Name string `json:"name"`
+ Reason string `json:"reason"`
+}
+
+type ImportLocalSkillsResponse struct {
+ Created []ImportLocalSkillCreated `json:"created"`
+ Skipped []ImportLocalSkillResult `json:"skipped"`
+ Failed []ImportLocalSkillResult `json:"failed"`
+}
+
+var metadataFileNames = map[string]bool{
+ "thumbs.db": true,
+ "desktop.ini": true,
+}
+
+func normalizeLocalUploadedSkillFilePath(path string) (string, bool) {
+ path = strings.ReplaceAll(filepath.ToSlash(path), "\\", "/")
+ if path == "" || strings.HasPrefix(path, "/") || strings.Contains(path, "\x00") || isWindowsAbsPath(path) {
+ return "", false
+ }
+ parts := strings.Split(path, "/")
+ for _, part := range parts {
+ if part == "" || part == "." || part == ".." {
+ return "", false
+ }
+ if strings.HasPrefix(part, ".") {
+ return "", false
+ }
+ }
+ cleaned := strings.Join(parts, "/")
+ if cleaned == "SKILL.md" {
+ return "", false
+ }
+ if metadataFileNames[strings.ToLower(parts[len(parts)-1])] {
+ return "", false
+ }
+ return cleaned, true
+}
+
+func isWindowsAbsPath(path string) bool {
+ return len(path) >= 3 && path[1] == ':' && path[2] == '/' &&
+ ((path[0] >= 'a' && path[0] <= 'z') || (path[0] >= 'A' && path[0] <= 'Z'))
+}
+
+func validateLocalImportSkill(skill ImportLocalSkillRequest) string {
+ if strings.TrimSpace(skill.Name) == "" {
+ return "missing_name"
+ }
+ if strings.TrimSpace(skill.Content) == "" {
+ return "missing_skill_md"
+ }
+ if len(skill.Content) > maxLocalUploadedSkillFileBytes {
+ return "file_too_large"
+ }
+ if len(skill.Files) > maxLocalUploadedSkillFiles {
+ return "too_many_files"
+ }
+
+ total := len(skill.Content)
+ for _, f := range skill.Files {
+ if _, ok := normalizeLocalUploadedSkillFilePath(f.Path); !ok {
+ return "invalid_file_path"
+ }
+ if len(f.Content) > maxLocalUploadedSkillFileBytes {
+ return "file_too_large"
+ }
+ total += len(f.Content)
+ }
+ if total > maxLocalUploadedSkillTotalBytes {
+ return "bundle_too_large"
+ }
+ return ""
+}
+
+func decodeImportLocalSkillsRequest(w http.ResponseWriter, r *http.Request, maxBytes int64) (ImportLocalSkillsRequest, bool) {
+ var req ImportLocalSkillsRequest
+ r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeError(w, http.StatusBadRequest, "invalid request body")
+ return req, false
+ }
+ return req, true
+}
+
+func (h *Handler) ImportLocalSkills(w http.ResponseWriter, r *http.Request) {
+ workspaceID := h.resolveWorkspaceID(r)
+ creatorID, ok := requireUserID(w, r)
+ if !ok {
+ return
+ }
+ workspaceUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
+ if !ok {
+ return
+ }
+ creatorUUID := parseUUID(creatorID)
+
+ req, ok := decodeImportLocalSkillsRequest(w, r, int64(maxLocalUploadedSkillRequestBytes))
+ if !ok {
+ return
+ }
+ if len(req.Skills) == 0 {
+ writeError(w, http.StatusBadRequest, "skills is required")
+ return
+ }
+ if len(req.Skills) > maxLocalUploadedSkills {
+ writeError(w, http.StatusBadRequest, "too many skills")
+ return
+ }
+
+ resp := ImportLocalSkillsResponse{
+ Created: []ImportLocalSkillCreated{},
+ Skipped: []ImportLocalSkillResult{},
+ Failed: []ImportLocalSkillResult{},
+ }
+ for _, item := range req.Skills {
+ item.Name = sanitizeNullBytes(strings.TrimSpace(item.Name))
+ item.Description = sanitizeNullBytes(item.Description)
+ item.Content = sanitizeNullBytes(item.Content)
+ item.Source.Label = sanitizeNullBytes(item.Source.Label)
+ for i := range item.Files {
+ item.Files[i].Path = sanitizeNullBytes(item.Files[i].Path)
+ item.Files[i].Content = sanitizeNullBytes(item.Files[i].Content)
+ }
+
+ if reason := validateLocalImportSkill(item); reason != "" {
+ resp.Failed = append(resp.Failed, ImportLocalSkillResult{Name: item.Name, Reason: reason})
+ continue
+ }
+ for i := range item.Files {
+ item.Files[i].Path, _ = normalizeLocalUploadedSkillFilePath(item.Files[i].Path)
+ }
+
+ created, err := h.createSkillWithFiles(r.Context(), skillCreateInput{
+ WorkspaceID: workspaceUUID,
+ CreatorID: creatorUUID,
+ Name: item.Name,
+ Description: item.Description,
+ Content: item.Content,
+ Config: map[string]any{
+ "origin": map[string]any{
+ "type": "uploaded_bundle",
+ "label": item.Source.Label,
+ },
+ },
+ Files: item.Files,
+ })
+ if err != nil {
+ if isUniqueViolation(err) {
+ resp.Skipped = append(resp.Skipped, ImportLocalSkillResult{Name: item.Name, Reason: "already_exists"})
+ continue
+ }
+ resp.Failed = append(resp.Failed, ImportLocalSkillResult{Name: item.Name, Reason: "create_failed"})
+ continue
+ }
+
+ actorType, actorID := h.resolveActor(r, creatorID, workspaceID)
+ h.publish(protocol.EventSkillCreated, workspaceID, actorType, actorID, map[string]any{"skill": created})
+ resp.Created = append(resp.Created, ImportLocalSkillCreated{
+ Skill: created,
+ SourceLabel: item.Source.Label,
+ })
+ }
+
+ writeJSON(w, http.StatusOK, resp)
+}
diff --git a/server/internal/handler/skill_import_local_test.go b/server/internal/handler/skill_import_local_test.go
new file mode 100644
index 0000000000..0e46356052
--- /dev/null
+++ b/server/internal/handler/skill_import_local_test.go
@@ -0,0 +1,439 @@
+package handler
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestImportLocalSkillsCreatesValidSkillAndFiles(t *testing.T) {
+ if testHandler == nil {
+ t.Skip("database not available")
+ }
+
+ name := "Uploaded Review " + time.Now().Format("150405.000000000")
+ w := httptest.NewRecorder()
+ req := newRequestAsUser(testUserID, http.MethodPost, "/api/skills/import-local", map[string]any{
+ "skills": []map[string]any{{
+ "name": name,
+ "description": "Reviews pull requests",
+ "content": "# Uploaded Review",
+ "files": []map[string]any{{
+ "path": "templates/review.md",
+ "content": "review body",
+ }},
+ "source": map[string]any{
+ "type": "uploaded_bundle",
+ "label": "team.zip/uploaded-review",
+ },
+ }},
+ })
+
+ testHandler.ImportLocalSkills(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+ var resp ImportLocalSkillsResponse
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if len(resp.Created) != 1 || resp.Created[0].Skill.Name != name {
+ t.Fatalf("created = %#v", resp.Created)
+ }
+ if len(resp.Created[0].Skill.Files) != 1 || resp.Created[0].Skill.Files[0].Path != "templates/review.md" {
+ t.Fatalf("files = %#v", resp.Created[0].Skill.Files)
+ }
+}
+
+func TestImportLocalSkillsSkipsDuplicateAndContinues(t *testing.T) {
+ if testHandler == nil {
+ t.Skip("database not available")
+ }
+
+ name := "Duplicate Upload " + time.Now().Format("150405.000000000")
+ createSkillForLocalImportTest(t, name)
+ w := httptest.NewRecorder()
+ req := newRequestAsUser(testUserID, http.MethodPost, "/api/skills/import-local", map[string]any{
+ "skills": []map[string]any{
+ {"name": name, "content": "# Duplicate"},
+ {"name": name + " Fresh", "content": "# Fresh"},
+ },
+ })
+
+ testHandler.ImportLocalSkills(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+ var resp ImportLocalSkillsResponse
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if len(resp.Created) != 1 || resp.Created[0].Skill.Name != name+" Fresh" {
+ t.Fatalf("created = %#v", resp.Created)
+ }
+ if len(resp.Skipped) != 1 || resp.Skipped[0].Reason != "already_exists" {
+ t.Fatalf("skipped = %#v", resp.Skipped)
+ }
+}
+
+func TestImportLocalSkillsRejectsInvalidPathAndLimits(t *testing.T) {
+ if testHandler == nil {
+ t.Skip("database not available")
+ }
+
+ w := httptest.NewRecorder()
+ req := newRequestAsUser(testUserID, http.MethodPost, "/api/skills/import-local", map[string]any{
+ "skills": []map[string]any{{
+ "name": "Bad Path " + time.Now().Format("150405.000000000"),
+ "content": "# Bad",
+ "files": []map[string]any{{
+ "path": "../secret.md",
+ "content": "no",
+ }},
+ }},
+ })
+
+ testHandler.ImportLocalSkills(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected structured 200, got %d: %s", w.Code, w.Body.String())
+ }
+ var resp ImportLocalSkillsResponse
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if len(resp.Failed) != 1 || resp.Failed[0].Reason != "invalid_file_path" {
+ t.Fatalf("failed = %#v", resp.Failed)
+ }
+}
+
+func TestImportLocalSkillsRejectsSingleOversizedSupportingFile(t *testing.T) {
+ if testHandler == nil {
+ t.Skip("database not available")
+ }
+
+ w := httptest.NewRecorder()
+ req := newRequestAsUser(testUserID, http.MethodPost, "/api/skills/import-local", map[string]any{
+ "skills": []map[string]any{{
+ "name": "Large File " + time.Now().Format("150405.000000000"),
+ "content": "# Large File",
+ "files": []map[string]any{{
+ "path": "templates/large.md",
+ "content": strings.Repeat("a", 2*1024*1024),
+ }},
+ }},
+ })
+
+ testHandler.ImportLocalSkills(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected structured 200, got %d: %s", w.Code, w.Body.String())
+ }
+ var resp ImportLocalSkillsResponse
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if len(resp.Failed) != 1 || resp.Failed[0].Reason != "file_too_large" {
+ t.Fatalf("failed = %#v", resp.Failed)
+ }
+}
+
+func TestImportLocalSkillsRejectsOversizedPrimarySkillFile(t *testing.T) {
+ if testHandler == nil {
+ t.Skip("database not available")
+ }
+
+ w := httptest.NewRecorder()
+ req := newRequestAsUser(testUserID, http.MethodPost, "/api/skills/import-local", map[string]any{
+ "skills": []map[string]any{{
+ "name": "Large Skill " + time.Now().Format("150405.000000000"),
+ "content": strings.Repeat("a", 2*1024*1024),
+ }},
+ })
+
+ testHandler.ImportLocalSkills(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected structured 200, got %d: %s", w.Code, w.Body.String())
+ }
+ var resp ImportLocalSkillsResponse
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if len(resp.Failed) != 1 || resp.Failed[0].Reason != "file_too_large" {
+ t.Fatalf("failed = %#v", resp.Failed)
+ }
+}
+
+func TestImportLocalSkillsRejectsCleanedSkillMDPath(t *testing.T) {
+ if testHandler == nil {
+ t.Skip("database not available")
+ }
+
+ w := httptest.NewRecorder()
+ req := newRequestAsUser(testUserID, http.MethodPost, "/api/skills/import-local", map[string]any{
+ "skills": []map[string]any{{
+ "name": "Bad Clean Path " + time.Now().Format("150405.000000000"),
+ "content": "# Bad",
+ "files": []map[string]any{{
+ "path": "templates/../SKILL.md",
+ "content": "would overwrite the primary skill file",
+ }},
+ }},
+ })
+
+ testHandler.ImportLocalSkills(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected structured 200, got %d: %s", w.Code, w.Body.String())
+ }
+ var resp ImportLocalSkillsResponse
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if len(resp.Created) != 0 {
+ t.Fatalf("created = %#v", resp.Created)
+ }
+ if len(resp.Failed) != 1 || resp.Failed[0].Reason != "invalid_file_path" {
+ t.Fatalf("failed = %#v", resp.Failed)
+ }
+}
+
+func TestImportLocalSkillsRejectsOversizedRequestBodyBeforeDecode(t *testing.T) {
+ w := httptest.NewRecorder()
+ body := io.MultiReader(
+ strings.NewReader(`{"skills":[{"name":"Too Large","content":"`),
+ io.LimitReader(repeatedByteReader('a'), 65),
+ strings.NewReader(`"}]}`),
+ )
+ req := httptest.NewRequest(http.MethodPost, "/api/skills/import-local", body)
+ req.Header.Set("Content-Type", "application/json")
+
+ if _, ok := decodeImportLocalSkillsRequest(w, req, 64); ok {
+ t.Fatal("expected oversized request body to fail before decode")
+ }
+
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400 for oversized request body, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
+func TestImportLocalSkillRequestCapStaysBelowHundredsOfMiB(t *testing.T) {
+ maxCap := 32 * 1024 * 1024
+ if maxLocalUploadedSkillRequestBytes > maxCap {
+ t.Fatalf("request cap = %d, want at most %d", maxLocalUploadedSkillRequestBytes, maxCap)
+ }
+}
+
+func TestImportLocalSkillsRejectsHugeDecodedPayloadBeforeDecode(t *testing.T) {
+ w := httptest.NewRecorder()
+ body := io.MultiReader(
+ strings.NewReader(`{"skills":[{"name":"Too Large","content":"`),
+ io.LimitReader(repeatedByteReader('a'), int64(maxLocalUploadedSkillRequestBytes+1)),
+ strings.NewReader(`"}]}`),
+ )
+ req := httptest.NewRequest(http.MethodPost, "/api/skills/import-local", body)
+ req.Header.Set("Content-Type", "application/json")
+
+ if _, ok := decodeImportLocalSkillsRequest(w, req, int64(maxLocalUploadedSkillRequestBytes)); ok {
+ t.Fatal("expected huge decoded payload to fail before decode")
+ }
+
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400 for huge decoded payload, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
+func TestImportLocalSkillsRejectsTooManySkills(t *testing.T) {
+ if testHandler == nil {
+ t.Skip("database not available")
+ }
+
+ skills := []map[string]any{}
+ for i := 0; i < maxLocalUploadedSkills+1; i++ {
+ skills = append(skills, map[string]any{
+ "name": "Batch Item " + time.Now().Format("150405.000000000") + "-" + string(rune('A'+i)),
+ "content": "# Batch Item",
+ })
+ }
+
+ w := httptest.NewRecorder()
+ req := newRequestAsUser(testUserID, http.MethodPost, "/api/skills/import-local", map[string]any{
+ "skills": skills,
+ })
+
+ testHandler.ImportLocalSkills(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400 for too many skills, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
+func TestImportLocalSkillsAllowsEscapedJSONWithinDecodedBundleLimit(t *testing.T) {
+ if testHandler == nil {
+ t.Skip("database not available")
+ }
+
+ w := httptest.NewRecorder()
+ req := newRequestAsUser(testUserID, http.MethodPost, "/api/skills/import-local", map[string]any{
+ "skills": []map[string]any{{
+ "name": "Escaped Bundle " + time.Now().Format("150405.000000000"),
+ "content": "# Escaped",
+ "files": []map[string]any{
+ {"path": "templates/quotes-1.md", "content": strings.Repeat(`"`, 900*1024)},
+ {"path": "templates/quotes-2.md", "content": strings.Repeat(`"`, 900*1024)},
+ {"path": "templates/quotes-3.md", "content": strings.Repeat(`"`, 900*1024)},
+ {"path": "templates/quotes-4.md", "content": strings.Repeat(`"`, 900*1024)},
+ {"path": "templates/quotes-5.md", "content": strings.Repeat(`"`, 900*1024)},
+ },
+ }},
+ })
+
+ testHandler.ImportLocalSkills(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200 for escaped JSON under decoded limit, got %d: %s", w.Code, w.Body.String())
+ }
+ var resp ImportLocalSkillsResponse
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if len(resp.Created) != 1 {
+ t.Fatalf("created = %#v failed = %#v", resp.Created, resp.Failed)
+ }
+}
+
+func TestImportLocalSkillsAllowsEscapedJSONForMultipleDecodedBundles(t *testing.T) {
+ if testHandler == nil {
+ t.Skip("database not available")
+ }
+
+ skills := []map[string]any{}
+ for i := 0; i < 3; i++ {
+ skills = append(skills, map[string]any{
+ "name": "Escaped Batch " + time.Now().Format("150405.000000000") + "-" + string(rune('A'+i)),
+ "content": "# Escaped Batch",
+ "files": []map[string]any{
+ {"path": "templates/quotes-1.md", "content": strings.Repeat(`"`, 900*1024)},
+ {"path": "templates/quotes-2.md", "content": strings.Repeat(`"`, 900*1024)},
+ {"path": "templates/quotes-3.md", "content": strings.Repeat(`"`, 900*1024)},
+ {"path": "templates/quotes-4.md", "content": strings.Repeat(`"`, 900*1024)},
+ {"path": "templates/quotes-5.md", "content": strings.Repeat(`"`, 900*1024)},
+ },
+ })
+ }
+
+ w := httptest.NewRecorder()
+ req := newRequestAsUser(testUserID, http.MethodPost, "/api/skills/import-local", map[string]any{
+ "skills": skills,
+ })
+
+ testHandler.ImportLocalSkills(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200 for escaped JSON batch under decoded limit, got %d: %s", w.Code, w.Body.String())
+ }
+ var resp ImportLocalSkillsResponse
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if len(resp.Created) != 3 {
+ t.Fatalf("created = %#v failed = %#v", resp.Created, resp.Failed)
+ }
+}
+
+func TestImportLocalSkillsRejectsHiddenFilePaths(t *testing.T) {
+ if testHandler == nil {
+ t.Skip("database not available")
+ }
+
+ w := httptest.NewRecorder()
+ req := newRequestAsUser(testUserID, http.MethodPost, "/api/skills/import-local", map[string]any{
+ "skills": []map[string]any{{
+ "name": "Hidden File " + time.Now().Format("150405.000000000"),
+ "content": "# Hidden File",
+ "files": []map[string]any{
+ {"path": ".env", "content": "SECRET=foo"},
+ {"path": "templates/.DS_Store", "content": "binary"},
+ },
+ }},
+ })
+
+ testHandler.ImportLocalSkills(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected structured 200, got %d: %s", w.Code, w.Body.String())
+ }
+ var resp ImportLocalSkillsResponse
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if len(resp.Failed) != 1 || resp.Failed[0].Reason != "invalid_file_path" {
+ t.Fatalf("failed = %#v", resp.Failed)
+ }
+}
+
+func TestImportLocalSkillsRejectsMetadataFilePaths(t *testing.T) {
+ if testHandler == nil {
+ t.Skip("database not available")
+ }
+
+ w := httptest.NewRecorder()
+ req := newRequestAsUser(testUserID, http.MethodPost, "/api/skills/import-local", map[string]any{
+ "skills": []map[string]any{{
+ "name": "Metadata File " + time.Now().Format("150405.000000000"),
+ "content": "# Metadata File",
+ "files": []map[string]any{
+ {"path": "Thumbs.db", "content": "binary"},
+ },
+ }},
+ })
+
+ testHandler.ImportLocalSkills(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected structured 200, got %d: %s", w.Code, w.Body.String())
+ }
+ var resp ImportLocalSkillsResponse
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if len(resp.Failed) != 1 || resp.Failed[0].Reason != "invalid_file_path" {
+ t.Fatalf("failed = %#v", resp.Failed)
+ }
+}
+
+func TestNormalizeLocalUploadedSkillFilePathRejectsBackslashTraversal(t *testing.T) {
+ if _, ok := normalizeLocalUploadedSkillFilePath(`templates\..\SKILL.md`); ok {
+ t.Fatal("expected Windows-style traversal to be rejected")
+ }
+}
+
+type repeatedByteReader byte
+
+func (r repeatedByteReader) Read(p []byte) (int, error) {
+ for i := range p {
+ p[i] = byte(r)
+ }
+ return len(p), nil
+}
+
+func createSkillForLocalImportTest(t *testing.T, name string) {
+ t.Helper()
+
+ w := httptest.NewRecorder()
+ req := newRequest(http.MethodPost, "/api/skills", map[string]any{
+ "name": name,
+ "content": "# Existing",
+ })
+ testHandler.CreateSkill(w, req)
+ if w.Code != http.StatusCreated {
+ t.Fatalf("create existing skill: expected 201, got %d: %s", w.Code, w.Body.String())
+ }
+}