package skills
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// skillFile is a SKILL.md for a slug, in the shape Claude Code's own ship in.
func skillFile(name, desc string) string {
return "---\nname: " + name + "\ndescription: " + desc + "\n---\n\nbody of " + name + "\n"
}
// claudeHome builds a fake ~/.claude with one synced personal skill, two
// plugins in the manifest — one with two versions cached, one with no skills
// at all — and a broken skill, which is enough of the real layout to catch
// every branch of the scan.
type claudeHome struct {
home, work string
// syncedDir is the personal root the synced skills sit under, three deep.
syncedDir string
// pluginDir is the newer of the two cached versions of the plugin.
pluginDir, stalePluginDir string
}
func newClaudeHome(t *testing.T) claudeHome {
t.Helper()
h := claudeHome{home: t.TempDir(), work: t.TempDir()}
claude := filepath.Join(h.home, ".claude")
h.syncedDir = filepath.Join(claude, "skills", "synced", "6a8af4f2_927ce955")
write(t, h.syncedDir, "pdf", skillFile("pdf", "Use for anything with PDF files"))
if err := os.WriteFile(filepath.Join(h.syncedDir, "manifest.json"), []byte(`{"skills":[]}`), 0o644); err != nil {
t.Fatal(err)
}
cache := filepath.Join(claude, "plugins", "cache", "official")
h.stalePluginDir = filepath.Join(cache, "tools", "1.0.0")
h.pluginDir = filepath.Join(cache, "tools", "2.0.0")
write(t, filepath.Join(h.stalePluginDir, "skills"), "debugging", skillFile("debugging", "the stale one"))
write(t, filepath.Join(h.pluginDir, "skills"), "debugging", skillFile("debugging", "Use when hunting a bug"))
write(t, filepath.Join(h.pluginDir, "skills"), "broken", "no frontmatter at all\n")
empty := filepath.Join(cache, "empty", "1.0.0")
if err := os.MkdirAll(empty, 0o755); err != nil {
t.Fatal(err)
}
manifest := map[string]any{
"version": 2,
"plugins": map[string]any{
"tools@official": []map[string]string{
{"scope": "user", "installPath": h.pluginDir, "version": "2.0.0",
"installedAt": "2026-03-01T10:00:00.000Z", "lastUpdated": "2026-03-02T10:00:00.000Z"},
{"scope": "user", "installPath": h.stalePluginDir, "version": "1.0.0",
"installedAt": "2026-01-01T10:00:00.000Z", "lastUpdated": "2026-01-01T10:00:00.000Z"},
},
"empty@official": []map[string]string{
{"scope": "user", "installPath": empty, "version": "1.0.0",
"installedAt": "2026-01-01T10:00:00.000Z"},
},
},
}
raw, err := json.Marshal(manifest)
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(claude, "plugins"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(claude, "plugins", "installed_plugins.json"), raw, 0o644); err != nil {
t.Fatal(err)
}
return h
}
func names(found []Skill) string {
var out []string
for _, s := range found {
out = append(out, s.Name)
}
return strings.Join(out, ",")
}
func find(found []Skill, name string) (Skill, bool) {
for _, s := range found {
if s.Name == name {
return s, true
}
}
return Skill{}, false
}
func TestDiscoverClaudeReadsEveryRoot(t *testing.T) {
h := newClaudeHome(t)
write(t, filepath.Join(h.work, ".claude", "skills"), "local", skillFile("local", "only in this repo"))
found, problems := DiscoverClaude(h.home, h.work)
if got := names(found); got != "debugging,local,pdf" {
t.Fatalf("names = %q, want debugging,local,pdf", got)
}
// The broken plugin skill is the one problem, and it names its file.
if len(problems) != 1 || !strings.Contains(problems[0].Error(), filepath.Join(h.pluginDir, "skills", "broken", "SKILL.md")) {
t.Errorf("problems = %v, want one naming the broken SKILL.md", problems)
}
pdf, _ := find(found, "pdf")
if pdf.Source != SourceClaude {
t.Errorf("personal Source = %q, want %q", pdf.Source, SourceClaude)
}
if pdf.Dir != filepath.Join(h.syncedDir, "pdf") {
t.Errorf("personal Dir = %q, want the synced directory", pdf.Dir)
}
dbg, _ := find(found, "debugging")
if dbg.Source != "claude:tools" {
t.Errorf("plugin Source = %q, want claude:tools", dbg.Source)
}
if dbg.Description != "Use when hunting a bug" {
t.Errorf("plugin skill came from the stale cache: %+v", dbg)
}
local, _ := find(found, "local")
if local.Source != SourceClaudeProject {
t.Errorf("project Source = %q, want %q", local.Source, SourceClaudeProject)
}
}
// Every cached version of a plugin is in the manifest; only the newest is
// read, whichever order the manifest lists them in.
func TestNewestPluginInstallWins(t *testing.T) {
tests := []struct {
name string
installs []pluginInstall
want string
}{
{
name: "by lastUpdated",
installs: []pluginInstall{
{InstallPath: "new", LastUpdated: "2026-03-02T00:00:00Z"},
{InstallPath: "old", LastUpdated: "2026-01-01T00:00:00Z"},
},
want: "new",
},
{
name: "installedAt when lastUpdated is missing",
installs: []pluginInstall{
{InstallPath: "old", InstalledAt: "2026-01-01T00:00:00Z"},
{InstallPath: "new", InstalledAt: "2026-02-01T00:00:00Z"},
},
want: "new",
},
{
name: "last listed when no time can be read",
installs: []pluginInstall{
{InstallPath: "first", LastUpdated: "yesterday"},
{InstallPath: "last"},
},
want: "last",
},
{
name: "a tie goes to the later entry",
installs: []pluginInstall{
{InstallPath: "first", LastUpdated: "2026-01-01T00:00:00Z"},
{InstallPath: "second", LastUpdated: "2026-01-01T00:00:00Z"},
},
want: "second",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, ok := newest(tc.installs)
if !ok || got.InstallPath != tc.want {
t.Errorf("newest = %q (ok %v), want %q", got.InstallPath, ok, tc.want)
}
})
}
if _, ok := newest(nil); ok {
t.Error("newest of nothing reported something")
}
}
// The reason Claude's skills get their own parser: their frontmatter was
// written to Claude Code's rules, not Codey's, and most of the differences
// are not worth a rejection.
func TestParseClaudeIsTolerant(t *testing.T) {
tests := []struct {
name string
dir string
content string
want string // slug, or "" for a problem
}{
{
name: "a valid frontmatter name wins over the directory",
dir: "Some Dir",
content: skillFile("commit-helper", "d"),
want: "commit-helper",
},
{
name: "an invalid frontmatter name falls back to the directory",
dir: "commit-helper",
content: skillFile("Commit Helper", "d"),
want: "commit-helper",
},
{
name: "the directory is normalised",
dir: "My_Skill v2!",
content: "---\ndescription: d\n---\nbody\n",
want: "my-skill-v2",
},
{
name: "extra keys are ignored",
dir: "x",
content: "---\nname: x\ndescription: d\ntools: Read, Bash\nlicense: MIT\n---\nbody\n",
want: "x",
},
{
name: "no description is a problem",
dir: "x",
content: "---\nname: x\n---\nbody\n",
want: "",
},
{
name: "nothing usable as a name is a problem",
dir: "!!!",
content: "---\nname: '???'\ndescription: d\n---\nbody\n",
want: "",
},
{
name: "no frontmatter is a problem",
dir: "x",
content: "just a body\n",
want: "",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
s, err := parseClaude([]byte(tc.content), tc.dir)
if tc.want == "" {
if err == nil {
t.Fatalf("parsed to %+v, want a problem", s)
}
return
}
if err != nil {
t.Fatalf("parseClaude: %v", err)
}
if s.Name != tc.want {
t.Errorf("Name = %q, want %q", s.Name, tc.want)
}
if !strings.HasPrefix(s.Body, "body") {
t.Errorf("Body = %q", s.Body)
}
})
}
}
func TestSlugify(t *testing.T) {
cases := map[string]string{
"pdf": "pdf",
"Frontend Design": "frontend-design",
"my_skill": "my-skill",
"--dashes--": "dashes",
"Über": "ber",
"!!!": "",
"": "",
}
for in, want := range cases {
if got := Slugify(in); got != want {
t.Errorf("Slugify(%q) = %q, want %q", in, got, want)
}
}
}
// A skill's own subdirectories are its reference material, and the scan has a
// floor: a SKILL.md buried deeper than anything Claude Code writes is not
// found, so a home directory with a deep tree under .claude is not walked.
func TestScanClaudeIsBounded(t *testing.T) {
root := t.TempDir()
write(t, filepath.Join(root, "a", "b", "c"), "deep-enough", skillFile("deep-enough", "d"))
write(t, filepath.Join(root, "a", "b", "c", "d"), "too-deep", skillFile("too-deep", "d"))
// Inside a skill, another SKILL.md is a template, not a skill.
write(t, filepath.Join(root, "outer"), "outer", skillFile("outer", "d"))
write(t, filepath.Join(root, "outer", "outer", "templates"), "inner", skillFile("inner", "d"))
found, problems := scanClaude(root, "test")
if len(problems) != 0 {
t.Fatalf("problems: %v", problems)
}
if got := names(found); got != "deep-enough,outer" {
t.Errorf("names = %q, want deep-enough,outer", got)
}
}
// A personal skill outranks a plugin's, and the project's outranks both, so
// that the shadowing reads the same way Discover's does.
func TestDiscoverClaudePrecedence(t *testing.T) {
h := newClaudeHome(t)
write(t, h.syncedDir, "debugging", skillFile("debugging", "personal"))
write(t, filepath.Join(h.work, ".claude", "skills"), "debugging", skillFile("debugging", "project"))
found, _ := DiscoverClaude(h.home, h.work)
dbg, ok := find(found, "debugging")
if !ok || dbg.Description != "project" {
t.Errorf("got %+v, want the project's", dbg)
}
found, _ = DiscoverClaude(h.home, t.TempDir())
dbg, ok = find(found, "debugging")
if !ok || dbg.Description != "personal" {
t.Errorf("got %+v, want the personal one over the plugin's", dbg)
}
}
func TestDiscoverClaudeWithNoClaudeIsSilent(t *testing.T) {
found, problems := DiscoverClaude(t.TempDir(), t.TempDir())
if len(found) != 0 || len(problems) != 0 {
t.Errorf("found %v, problems %v; want both empty", found, problems)
}
found, problems = DiscoverClaude("", "")
if len(found) != 0 || len(problems) != 0 {
t.Errorf("with no roots: found %v, problems %v; want both empty", found, problems)
}
}
func TestABrokenPluginManifestIsAProblemNotAFailure(t *testing.T) {
home := t.TempDir()
plugins := filepath.Join(home, ".claude", "plugins")
if err := os.MkdirAll(plugins, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(plugins, "installed_plugins.json"), []byte("{not json"), 0o644); err != nil {
t.Fatal(err)
}
write(t, filepath.Join(home, ".claude", "skills"), "still-here", skillFile("still-here", "d"))
found, problems := DiscoverClaude(home, t.TempDir())
if got := names(found); got != "still-here" {
t.Errorf("names = %q; a broken manifest must not take the personal skills with it", got)
}
if len(problems) != 1 || !strings.Contains(problems[0].Error(), "installed_plugins.json") {
t.Errorf("problems = %v, want one naming the manifest", problems)
}
}
func TestMergePrefersCodeysOwn(t *testing.T) {
own := []Skill{{Name: "shared", Source: "personal"}, {Name: "only-own", Source: "project"}}
claude := []Skill{{Name: "shared", Source: SourceClaude}, {Name: "a-claude", Source: "claude:x"}}
got := Merge(own, claude)
if names(got) != "a-claude,only-own,shared" {
t.Errorf("names = %q, want sorted a-claude,only-own,shared", names(got))
}
if s, _ := find(got, "shared"); s.Source != "personal" {
t.Errorf("the collision went to %q, want Codey's own", s.Source)
}
if len(Merge(nil, nil)) != 0 {
t.Error("merging nothing produced something")
}
}
func TestCopyClaudeMakesPersonalSkills(t *testing.T) {
h := newClaudeHome(t)
// A skill with reference material and a script, so the copy is a tree.
pdfDir := filepath.Join(h.syncedDir, "pdf")
if err := os.MkdirAll(filepath.Join(pdfDir, "scripts"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pdfDir, "scripts", "run.sh"), []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
// A link inside the tree is followed; one pointing out is not.
if err := os.Symlink("../SKILL.md", filepath.Join(pdfDir, "scripts", "inside")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
if err := os.Symlink(t.TempDir(), filepath.Join(pdfDir, "outside")); err != nil {
t.Fatal(err)
}
copied, problems, err := CopyClaude(h.home, h.work)
if err != nil {
t.Fatal(err)
}
if strings.Join(copied, ",") != "debugging,pdf" {
t.Errorf("copied = %v, want debugging,pdf", copied)
}
// The broken plugin skill is still the only problem.
if len(problems) != 1 || !strings.Contains(problems[0].Error(), "broken") {
t.Errorf("problems = %v", problems)
}
dest := filepath.Join(h.home, Dirname, "pdf")
info, err := os.Stat(filepath.Join(dest, "scripts", "run.sh"))
if err != nil {
t.Fatalf("the script was not copied: %v", err)
}
if info.Mode().Perm()&0o100 == 0 {
t.Errorf("the script lost its executable bit: %v", info.Mode())
}
if _, err := os.Stat(filepath.Join(dest, "scripts", "inside")); err != nil {
t.Errorf("a symlink inside the tree was not copied: %v", err)
}
if _, err := os.Lstat(filepath.Join(dest, "outside")); err == nil {
t.Error("a symlink pointing out of the tree was copied")
}
// And now they are ordinary personal skills, found by Discover.
found, problems := Discover(h.home, h.work)
if len(problems) != 0 {
t.Fatalf("Discover problems after the copy: %v", problems)
}
if got := names(found); got != "debugging,pdf" {
t.Errorf("Discover finds %q after the copy, want debugging,pdf", got)
}
if s, _ := find(found, "pdf"); s.Source != "personal" {
t.Errorf("Source = %q after the copy, want personal", s.Source)
}
}
// The one thing the copy must never do is replace a skill that is already
// there: it may have been edited, and nothing about a plugin's copy is worth
// more than an edit.
func TestCopyClaudeNeverOverwrites(t *testing.T) {
h := newClaudeHome(t)
mine := write(t, filepath.Join(h.home, Dirname), "pdf", skillFile("pdf", "my edited version"))
copied, problems, err := CopyClaude(h.home, h.work)
if err != nil {
t.Fatal(err)
}
if strings.Join(copied, ",") != "debugging" {
t.Errorf("copied = %v, want only debugging", copied)
}
var reported bool
for _, p := range problems {
if strings.Contains(p.Error(), mine) && strings.Contains(p.Error(), "already exists") {
reported = true
}
}
if !reported {
t.Errorf("the skipped skill was not reported: %v", problems)
}
raw, err := os.ReadFile(filepath.Join(mine, "SKILL.md"))
if err != nil || !strings.Contains(string(raw), "my edited version") {
t.Errorf("the existing skill was overwritten: %q %v", raw, err)
}
}
func TestCopyClaudeWithoutAHomeFails(t *testing.T) {
if _, _, err := CopyClaude("", t.TempDir()); err == nil {
t.Error("an unknown home did not fail")
}
}
// Nothing to copy is not an error: the wizard only offers the copy when there
// is something, but the config can say "copy" on a machine that has since
// lost its Claude install.
func TestCopyClaudeWithNothingToCopyIsQuiet(t *testing.T) {
copied, problems, err := CopyClaude(t.TempDir(), t.TempDir())
if err != nil || len(copied) != 0 || len(problems) != 0 {
t.Errorf("copied %v, problems %v, err %v; want nothing at all", copied, problems, err)
}
}