Vectrum
Sign In
462 lines · 15.1 KB · Go
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)
	}
}