Skip to content
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
"@biomejs/biome": "2.3.10",
"@modelcontextprotocol/sdk": "^1.25.3",
"@ngrok/ngrok": "^1.5.1",
"@smithery/api": "0.53.0",
"@smithery/api": "0.56.0",
"@smithery/sdk": "^4.1.0",
"@types/inquirer": "^8.2.4",
"@types/inquirer-autocomplete-prompt": "^3.0.3",
Expand Down Expand Up @@ -93,5 +93,8 @@
},
"engines": {
"node": ">=20.0.0"
},
"dependencies": {
"fflate": "^0.8.2"
}
}
14 changes: 9 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

145 changes: 145 additions & 0 deletions src/commands/__tests__/publish.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { strFromU8, unzipSync } from "fflate"
import { afterEach, beforeEach, describe, expect, test } from "vitest"
import {
collectFiles,
createArchiveFromDirectory,
parseSkillName,
} from "../skill/publish-utils"

describe("parseSkillName", () => {
test("extracts name from valid frontmatter", () => {
const content = `---\nname: my-skill\ndescription: A test\n---\n\nHello`
expect(parseSkillName(content)).toBe("my-skill")
})

test("returns null when no frontmatter", () => {
expect(parseSkillName("Just some markdown")).toBe(null)
})

test("returns null when frontmatter has no name field", () => {
const content = `---\ndescription: A test\n---\n\nHello`
expect(parseSkillName(content)).toBe(null)
})

test("trims whitespace from name", () => {
const content = `---\nname: spaced-name \n---\n`
expect(parseSkillName(content)).toBe("spaced-name")
})

test("handles CRLF line endings", () => {
const content = `---\r\nname: crlf-skill\r\ndescription: test\r\n---\r\n\r\nContent`
expect(parseSkillName(content)).toBe("crlf-skill")
})
})

describe("collectFiles", () => {
let tempDir: string

beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "skill-test-"))
})

afterEach(() => {
rmSync(tempDir, { recursive: true, force: true })
})

test("collects files from a flat directory", () => {
writeFileSync(join(tempDir, "SKILL.md"), "# Hello")
writeFileSync(join(tempDir, "helper.txt"), "data")

const files = collectFiles(tempDir)
expect(files.size).toBe(2)
expect(files.has("SKILL.md")).toBe(true)
expect(files.has("helper.txt")).toBe(true)
})

test("collects files from nested directories", () => {
writeFileSync(join(tempDir, "SKILL.md"), "# Hello")
mkdirSync(join(tempDir, "scripts"))
writeFileSync(join(tempDir, "scripts", "run.sh"), "#!/bin/bash")

const files = collectFiles(tempDir)
expect(files.size).toBe(2)
expect(files.has("scripts/run.sh")).toBe(true)
})

test("skips hidden directories", () => {
writeFileSync(join(tempDir, "SKILL.md"), "# Hello")
mkdirSync(join(tempDir, ".git"))
writeFileSync(join(tempDir, ".git", "config"), "secret")

const files = collectFiles(tempDir)
expect(files.size).toBe(1)
expect(files.has(".git/config")).toBe(false)
})

test("skips node_modules", () => {
writeFileSync(join(tempDir, "SKILL.md"), "# Hello")
mkdirSync(join(tempDir, "node_modules"))
writeFileSync(join(tempDir, "node_modules", "pkg.json"), "{}")

const files = collectFiles(tempDir)
expect(files.size).toBe(1)
expect(files.has("node_modules/pkg.json")).toBe(false)
})

test("returns empty map for empty directory", () => {
const files = collectFiles(tempDir)
expect(files.size).toBe(0)
})
})

describe("createArchiveFromDirectory", () => {
let tempDir: string

beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "skill-zip-"))
})

afterEach(() => {
rmSync(tempDir, { recursive: true, force: true })
})

test("creates a valid ZIP containing directory files", () => {
const skillContent = "---\nname: test\ndescription: test\n---\n\nHello"
writeFileSync(join(tempDir, "SKILL.md"), skillContent)
writeFileSync(join(tempDir, "data.txt"), "some data")

const zipData = createArchiveFromDirectory(tempDir)
expect(zipData).toBeInstanceOf(Uint8Array)
expect(zipData.length).toBeGreaterThan(0)

// Verify ZIP contents
const entries = unzipSync(zipData)
const paths = Object.keys(entries)
expect(paths).toContain("SKILL.md")
expect(paths).toContain("data.txt")
expect(strFromU8(entries["SKILL.md"])).toBe(skillContent)
})

test("preserves nested directory structure in ZIP", () => {
writeFileSync(join(tempDir, "SKILL.md"), "# Skill")
mkdirSync(join(tempDir, "lib"))
writeFileSync(join(tempDir, "lib", "util.ts"), "export const x = 1")

const zipData = createArchiveFromDirectory(tempDir)
const entries = unzipSync(zipData)
expect(Object.keys(entries)).toContain("lib/util.ts")
})

test("excludes hidden dirs and node_modules from ZIP", () => {
writeFileSync(join(tempDir, "SKILL.md"), "# Skill")
mkdirSync(join(tempDir, ".hidden"))
writeFileSync(join(tempDir, ".hidden", "secret"), "x")
mkdirSync(join(tempDir, "node_modules"))
writeFileSync(join(tempDir, "node_modules", "pkg"), "y")

const zipData = createArchiveFromDirectory(tempDir)
const entries = unzipSync(zipData)
const paths = Object.keys(entries)
expect(paths).toEqual(["SKILL.md"])
})
})
1 change: 1 addition & 0 deletions src/commands/skill/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { installSkill } from "./install"
export { publishSkill } from "./publish"
export { deleteReview, listReviews, submitReview, voteReview } from "./review"
export { searchSkills } from "./search"
export { viewSkill } from "./view"
Expand Down
43 changes: 43 additions & 0 deletions src/commands/skill/publish-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { readdirSync, readFileSync } from "node:fs"
import { join, relative } from "node:path"
import { zipSync } from "fflate"

/** Recursively collect all file paths in a directory. */
export function collectFiles(
dir: string,
base: string = dir,
): Map<string, Uint8Array> {
const files = new Map<string, Uint8Array>()
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const fullPath = join(dir, entry.name)
if (entry.isDirectory()) {
// Skip hidden dirs and common non-skill dirs
if (entry.name.startsWith(".") || entry.name === "node_modules") continue
for (const [k, v] of collectFiles(fullPath, base)) {
files.set(k, v)
}
} else if (entry.isFile()) {
const relPath = relative(base, fullPath)
files.set(relPath, new Uint8Array(readFileSync(fullPath)))
}
}
return files
}

/** Parse the `name` field from SKILL.md frontmatter. */
export function parseSkillName(skillMdContent: string): string | null {
const match = skillMdContent.match(/^---\r?\n([\s\S]*?)\r?\n---/)
if (!match) return null
const nameMatch = match[1].match(/^name:\s*(.+)$/m)
return nameMatch ? nameMatch[1].trim() : null
}

/** Create a ZIP archive from a directory's contents. */
export function createArchiveFromDirectory(dir: string): Uint8Array {
const files = collectFiles(dir)
const zipInput: Record<string, Uint8Array> = {}
for (const [path, content] of files) {
zipInput[path] = content
}
return zipSync(zipInput)
}
Loading
Loading