import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import path from "node:path"
import {
appendSystemMessageBlock,
buildSkillSystemMessage,
collectAncestorDirsToRepoRoot,
dedupeSkillsByName,
findNestedSkillRoots,
findSkillByName,
listGlobalSkills,
normalizeSkillDescription,
parseFrontmatter,
parseSkillInvocation,
scanClaudeSkills,
scanCodexSkills,
scanCommandsRoot,
scanCursorSkills,
scanSkillsRoot,
} from "./harness-skills"
describe("parseSkillInvocation", () => {
test("parses a bare invocation", () => {
expect(parseSkillInvocation("/code-review")).toEqual({ name: "code-review", args: "" })
})
test("parses namespaced names and arguments", () => {
expect(parseSkillInvocation("/skill:brave-search find kanna")).toEqual({
name: "skill:brave-search",
args: "find kanna",
})
expect(parseSkillInvocation("/plugin:cmd --flag")).toEqual({ name: "plugin:cmd", args: "--flag" })
})
test("tolerates leading whitespace and multi-line args", () => {
expect(parseSkillInvocation(" /fix-tests\nfocus on auth")).toEqual({
name: "fix-tests",
args: "focus on auth",
})
})
test("returns null for non-invocations", () => {
expect(parseSkillInvocation("hello world")).toBeNull()
expect(parseSkillInvocation("see /etc/hosts")).toBeNull()
expect(parseSkillInvocation("/")).toBeNull()
expect(parseSkillInvocation("")).toBeNull()
})
})
describe("system message failsafe", () => {
test("wraps the skill path exactly", () => {
expect(buildSkillSystemMessage("/tmp/skills/foo/SKILL.md")).toBe(
"the user would like to use the skill available at /tmp/skills/foo/SKILL.md"
)
})
test("appends after content with a blank line, never prepends", () => {
const block = buildSkillSystemMessage("/p/SKILL.md")
expect(appendSystemMessageBlock("/foo run it", block)).toBe(`/foo run it\n\n${block}`)
expect(appendSystemMessageBlock(" ", block)).toBe(block)
expect(appendSystemMessageBlock("/foo", block).startsWith("/foo")).toBe(true)
})
})
describe("parseFrontmatter", () => {
test("reads simple key/value pairs and strips quotes", () => {
const fields = parseFrontmatter(
"---\nname: my-skill\ndescription: \"Does things\"\nargument-hint: ''\n---\n# Body\n"
)
expect(fields.name).toBe("my-skill")
expect(fields.description).toBe("Does things")
expect(fields["argument-hint"]).toBe("")
})
test("returns empty for missing or malformed frontmatter", () => {
expect(parseFrontmatter("# Just markdown")).toEqual({})
expect(parseFrontmatter("---\nunterminated")).toEqual({})
})
})
describe("filesystem scanners", () => {
let base: string
beforeEach(() => {
base = mkdtempSync(path.join(tmpdir(), "kanna-skills-"))
})
afterEach(() => {
rmSync(base, { recursive: true, force: true })
})
function writeSkill(root: string, name: string, frontmatter?: Record) {
const dir = path.join(root, name)
mkdirSync(dir, { recursive: true })
const fields = { name, description: `${name} description`, ...frontmatter }
const header = Object.entries(fields).map(([key, value]) => `${key}: ${value}`).join("\n")
writeFileSync(path.join(dir, "SKILL.md"), `---\n${header}\n---\n# ${name}\n`)
return path.join(dir, "SKILL.md")
}
test("replaces codex chronicle's block-scalar '|' description, and only that", () => {
const root = path.join(base, "codex-skills")
// YAML block scalar: our single-line frontmatter reader captures the "|".
const blockScalar = "---\nname: chronicle\ndescription: |\n Multi-line text the naive parser misses.\n---\n"
mkdirSync(path.join(root, "chronicle"), { recursive: true })
writeFileSync(path.join(root, "chronicle", "SKILL.md"), blockScalar)
mkdirSync(path.join(root, "other-skill"), { recursive: true })
writeFileSync(path.join(root, "other-skill", "SKILL.md"), "---\nname: other-skill\ndescription: |\n Also block scalar.\n---\n")
const byName = new Map(scanSkillsRoot(root).map((skill) => [skill.name, skill]))
expect(byName.get("chronicle")?.description).toContain("Chronicle is Codex’s local screen-context feature")
// Only chronicle gets the hardcoded subtitle; other skills keep the raw artifact.
expect(byName.get("other-skill")?.description).toBe("|")
// A real single-line description is never overridden.
expect(normalizeSkillDescription("chronicle", "Real description")).toBe("Real description")
})
test("scanSkillsRoot reads SKILL.md dirs and skips non-skills", () => {
const root = path.join(base, "skills")
const skillPath = writeSkill(root, "alpha")
mkdirSync(path.join(root, "not-a-skill"), { recursive: true })
const skills = scanSkillsRoot(root)
expect(skills).toEqual([
{ name: "alpha", description: "alpha description", source: "skill", path: skillPath },
])
})
test("scanCommandsRoot reads *.md files with frontmatter metadata", () => {
const root = path.join(base, "commands")
mkdirSync(root, { recursive: true })
writeFileSync(path.join(root, "deploy.md"), "---\ndescription: Ship it\nargument-hint: \n---\nDeploy $1\n")
writeFileSync(path.join(root, "notes.txt"), "not a command")
const commands = scanCommandsRoot(root)
expect(commands).toEqual([
{
name: "deploy",
description: "Ship it",
argumentHint: "",
source: "command",
path: path.join(root, "deploy.md"),
},
])
})
test("dedupeSkillsByName keeps the first occurrence (precedence order)", () => {
const deduped = dedupeSkillsByName([
{ name: "a", description: "project", source: "skill" },
{ name: "a", description: "user", source: "skill" },
{ name: "b", description: "", source: "command" },
])
expect(deduped.map((skill) => skill.description)).toEqual(["project", ""])
})
test("collectAncestorDirsToRepoRoot walks cwd up to the git root only", () => {
const repo = path.join(base, "repo")
mkdirSync(path.join(repo, ".git"), { recursive: true })
const nested = path.join(repo, "packages", "app")
mkdirSync(nested, { recursive: true })
expect(collectAncestorDirsToRepoRoot(nested)).toEqual([
nested,
path.join(repo, "packages"),
repo,
])
// Outside a repo: just the cwd, never the whole filesystem.
const loose = path.join(base, "loose")
mkdirSync(loose, { recursive: true })
expect(collectAncestorDirsToRepoRoot(loose)).toEqual([loose])
})
test("findNestedSkillRoots finds .cursor/.agents skill dirs and skips node_modules", () => {
mkdirSync(path.join(base, ".cursor", "skills"), { recursive: true })
mkdirSync(path.join(base, "packages", "web", ".agents", "skills"), { recursive: true })
mkdirSync(path.join(base, "node_modules", "dep", ".cursor", "skills"), { recursive: true })
const roots = findNestedSkillRoots(base, [".cursor", ".agents"])
expect(roots.sort()).toEqual([
path.join(base, ".cursor", "skills"),
path.join(base, "packages", "web", ".agents", "skills"),
].sort())
})
test("scanClaudeSkills merges project + user skills and commands, project first", () => {
const cwd = path.join(base, "project")
const home = path.join(base, "home")
writeSkill(path.join(cwd, ".claude", "skills"), "shared", { description: "project copy" })
writeSkill(path.join(home, ".claude", "skills"), "shared", { description: "user copy" })
mkdirSync(path.join(home, ".claude", "commands"), { recursive: true })
writeFileSync(path.join(home, ".claude", "commands", "release.md"), "---\ndescription: Cut a release\n---\nRelease\n")
const skills = scanClaudeSkills({ cwd, home })
expect(skills.find((skill) => skill.name === "shared")?.description).toBe("project copy")
expect(skills.find((skill) => skill.name === "release")?.source).toBe("command")
})
test("scanCodexSkills reads repo .agents/skills up to the git root plus user dirs", () => {
const home = path.join(base, "home")
const repo = path.join(base, "repo")
mkdirSync(path.join(repo, ".git"), { recursive: true })
const nested = path.join(repo, "packages", "app")
writeSkill(path.join(repo, ".agents", "skills"), "repo-skill")
writeSkill(path.join(home, ".agents", "skills"), "user-agents-skill")
writeSkill(path.join(home, ".codex", "skills"), "legacy-codex-skill")
const names = scanCodexSkills({ cwd: nested, home }).map((skill) => skill.name)
expect(names).toContain("repo-skill")
expect(names).toContain("user-agents-skill")
expect(names).toContain("legacy-codex-skill")
})
test("listGlobalSkills attributes each root to its harnesses and merges duplicates", () => {
const home = path.join(base, "home")
writeSkill(path.join(home, ".agents", "skills"), "universal-skill")
writeSkill(path.join(home, ".claude", "skills"), "claude-only")
writeSkill(path.join(home, ".cursor", "skills"), "cursor-only")
writeSkill(path.join(home, ".codex", "skills"), "codex-legacy")
// Marketplace-style install: same skill in both the universal + claude dirs.
writeSkill(path.join(home, ".agents", "skills"), "everywhere")
writeSkill(path.join(home, ".claude", "skills"), "everywhere")
const skills = listGlobalSkills({ home })
const byName = new Map(skills.map((skill) => [skill.name, skill]))
expect(byName.get("universal-skill")?.providers).toEqual(["codex", "cursor", "pi"])
expect(byName.get("claude-only")?.providers).toEqual(["claude"])
expect(byName.get("cursor-only")?.providers).toEqual(["cursor"])
expect(byName.get("codex-legacy")?.providers).toEqual(["codex"])
// Duplicate name merges to one entry with the provider union + both paths.
expect(byName.get("everywhere")?.providers).toEqual(["claude", "codex", "cursor", "pi"])
expect(byName.get("everywhere")?.paths).toHaveLength(2)
// Sorted by name for a stable settings list.
expect(skills.map((skill) => skill.name)).toEqual([...skills.map((skill) => skill.name)].sort())
})
test("scanCursorSkills reads nested .cursor/.agents roots and user dirs", () => {
const home = path.join(base, "home")
const cwd = path.join(base, "workspace")
writeSkill(path.join(cwd, "apps", "ios", ".cursor", "skills"), "nested-cursor-skill")
writeSkill(path.join(cwd, ".agents", "skills"), "repo-agents-skill")
writeSkill(path.join(home, ".cursor", "skills"), "user-cursor-skill")
const skills = scanCursorSkills({ cwd, home })
const names = skills.map((skill) => skill.name)
expect(names).toContain("nested-cursor-skill")
expect(names).toContain("repo-agents-skill")
expect(names).toContain("user-cursor-skill")
expect(findSkillByName(skills, "user-cursor-skill")?.path).toBe(
path.join(home, ".cursor", "skills", "user-cursor-skill", "SKILL.md")
)
expect(findSkillByName(skills, "nope")).toBeNull()
})
})