Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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