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) } }