26f897c1ec
release / release-please (push) Failing after 1m49s
docs / build (push) Failing after 6m34s
release / build-and-upload (arm64, linux) (push) Has been cancelled
release / build-and-upload (arm64, windows) (push) Has been cancelled
release / build-darwin (amd64, darwin) (push) Has been cancelled
release / checksums (push) Has been cancelled
release / finalize (push) Has been cancelled
release / build-darwin (arm64, darwin) (push) Has been cancelled
release / build-and-upload (amd64, linux) (push) Has been cancelled
release / build-and-upload (amd64, windows) (push) Has been cancelled
docs / deploy (push) Has been cancelled
342 lines
10 KiB
Go
342 lines
10 KiB
Go
package skill
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestMarkdownFrontmatter(t *testing.T) {
|
|
md := Markdown()
|
|
if !strings.HasPrefix(md, "---\n") {
|
|
t.Fatalf("SKILL.md must start with YAML frontmatter, got:\n%s", md[:min(40, len(md))])
|
|
}
|
|
for _, want := range []string{
|
|
"name: " + Name + "\n",
|
|
"description: " + Description + "\n",
|
|
"user-invocable: true\n",
|
|
} {
|
|
if !strings.Contains(md, want) {
|
|
t.Errorf("frontmatter missing %q", want)
|
|
}
|
|
}
|
|
// Frontmatter block must be closed before the body.
|
|
if strings.Count(md, "---\n") < 2 {
|
|
t.Errorf("frontmatter not closed with a second --- delimiter")
|
|
}
|
|
if !strings.Contains(md, "no-mistakes axi run") {
|
|
t.Errorf("body should document the axi run command")
|
|
}
|
|
// The user-level install is a genuine user installation, so it must stay
|
|
// discoverable: the internal marker that hid the old vendored repo copies
|
|
// must not come back.
|
|
if strings.Contains(md, "internal: true") {
|
|
t.Errorf("Markdown() must not be marked internal")
|
|
}
|
|
}
|
|
|
|
func TestBodyDocumentsTaskFirstFlow(t *testing.T) {
|
|
md := Markdown()
|
|
for _, want := range []string{
|
|
"## Two ways to invoke",
|
|
"feature branch",
|
|
"Inspect `git status` before you change or commit anything",
|
|
"commit only the changes that belong to the user's task",
|
|
"passing the user's task as your `--intent`",
|
|
} {
|
|
if !strings.Contains(md, want) {
|
|
t.Errorf("body should document the task-first flow: missing %q", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBodyDocumentsAxiGateGuidance(t *testing.T) {
|
|
md := Markdown()
|
|
for _, want := range []string{
|
|
"inspect it with `no-mistakes axi status`",
|
|
"drive it with `no-mistakes axi respond`",
|
|
"when it still matches your current `HEAD`",
|
|
"**Review auto-fix is disabled by default**",
|
|
"blocking and",
|
|
"ask-user review findings park for your decision",
|
|
"`auto_fix.review > 0`",
|
|
} {
|
|
if !strings.Contains(md, want) {
|
|
t.Errorf("body should document AXI gate guidance: missing %q", want)
|
|
}
|
|
}
|
|
if strings.Contains(md, "drive it to an outcome with `axi respond`") {
|
|
t.Errorf("body should not tell agents to resume non-parked runs with axi respond")
|
|
}
|
|
}
|
|
|
|
func TestInstallWritesBothPaths(t *testing.T) {
|
|
root := t.TempDir()
|
|
written, err := Install(root)
|
|
if err != nil {
|
|
t.Fatalf("Install: %v", err)
|
|
}
|
|
wantRel := []string{
|
|
filepath.Join(".claude", "skills", Name, "SKILL.md"),
|
|
filepath.Join(".agents", "skills", Name, "SKILL.md"),
|
|
}
|
|
if len(written) != len(wantRel) {
|
|
t.Fatalf("written = %v, want %v", written, wantRel)
|
|
}
|
|
for i, rel := range wantRel {
|
|
if written[i] != rel {
|
|
t.Errorf("written[%d] = %q, want %q", i, written[i], rel)
|
|
}
|
|
data, err := os.ReadFile(filepath.Join(root, rel))
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", rel, err)
|
|
}
|
|
if string(data) != Markdown() {
|
|
t.Errorf("%s content does not match Markdown()", rel)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestInstallUserWritesUnderHome proves the init entry point resolves the
|
|
// user's home directory and installs there, never into the working directory.
|
|
func TestInstallUserWritesUnderHome(t *testing.T) {
|
|
home := t.TempDir()
|
|
// os.UserHomeDir reads HOME on Unix and USERPROFILE on Windows; set both
|
|
// so the test isolates the real home directory on every platform.
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("USERPROFILE", home)
|
|
|
|
written, err := InstallUser()
|
|
if err != nil {
|
|
t.Fatalf("InstallUser: %v", err)
|
|
}
|
|
if len(written) != len(InstallBases) {
|
|
t.Fatalf("written = %v, want one path per base", written)
|
|
}
|
|
for _, base := range InstallBases {
|
|
data, err := os.ReadFile(filepath.Join(home, base, Name, "SKILL.md"))
|
|
if err != nil {
|
|
t.Fatalf("skill not installed under home at %s: %v", base, err)
|
|
}
|
|
if string(data) != Markdown() {
|
|
t.Errorf("%s content does not match Markdown()", base)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInstallIsIdempotent(t *testing.T) {
|
|
root := t.TempDir()
|
|
if _, err := Install(root); err != nil {
|
|
t.Fatalf("first install: %v", err)
|
|
}
|
|
if _, err := Install(root); err != nil {
|
|
t.Fatalf("second install: %v", err)
|
|
}
|
|
data, err := os.ReadFile(filepath.Join(root, ".claude", "skills", Name, "SKILL.md"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(data) != Markdown() {
|
|
t.Errorf("content drifted after re-install")
|
|
}
|
|
}
|
|
|
|
// TestInstallSymlinkLayouts covers home directories that consolidate the two
|
|
// skill bases with a symlink. `.claude/skills` may link to `.agents/skills`,
|
|
// the whole `.claude` dir may link to `.agents`, or the link may point the
|
|
// other way. In every case Install must succeed and the skill must be
|
|
// reachable via both logical bases - including when the symlink target dir
|
|
// does not exist yet.
|
|
func TestInstallSymlinkLayouts(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
setup func(t *testing.T, root string)
|
|
}{
|
|
{
|
|
name: "claude_skills_link_target_exists",
|
|
setup: func(t *testing.T, root string) {
|
|
mkdirAll(t, filepath.Join(root, ".agents", "skills"))
|
|
mkdirAll(t, filepath.Join(root, ".claude"))
|
|
symlink(t, filepath.Join("..", ".agents", "skills"), filepath.Join(root, ".claude", "skills"))
|
|
},
|
|
},
|
|
{
|
|
name: "claude_skills_link_target_missing",
|
|
setup: func(t *testing.T, root string) {
|
|
mkdirAll(t, filepath.Join(root, ".claude"))
|
|
symlink(t, filepath.Join("..", ".agents", "skills"), filepath.Join(root, ".claude", "skills"))
|
|
},
|
|
},
|
|
{
|
|
name: "claude_dir_link",
|
|
setup: func(t *testing.T, root string) {
|
|
mkdirAll(t, filepath.Join(root, ".agents"))
|
|
symlink(t, ".agents", filepath.Join(root, ".claude"))
|
|
},
|
|
},
|
|
{
|
|
name: "agents_skills_link_reverse",
|
|
setup: func(t *testing.T, root string) {
|
|
mkdirAll(t, filepath.Join(root, ".claude", "skills"))
|
|
mkdirAll(t, filepath.Join(root, ".agents"))
|
|
symlink(t, filepath.Join("..", ".claude", "skills"), filepath.Join(root, ".agents", "skills"))
|
|
},
|
|
},
|
|
{
|
|
name: "agents_dir_link_reverse",
|
|
setup: func(t *testing.T, root string) {
|
|
mkdirAll(t, filepath.Join(root, ".claude"))
|
|
symlink(t, ".claude", filepath.Join(root, ".agents"))
|
|
},
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
root := t.TempDir()
|
|
tt.setup(t, root)
|
|
|
|
written, err := Install(root)
|
|
if err != nil {
|
|
t.Fatalf("Install: %v", err)
|
|
}
|
|
|
|
// Every reported path must be readable with current content.
|
|
for _, rel := range written {
|
|
data, err := os.ReadFile(filepath.Join(root, rel))
|
|
if err != nil {
|
|
t.Fatalf("read reported %s: %v", rel, err)
|
|
}
|
|
if string(data) != Markdown() {
|
|
t.Errorf("%s content does not match Markdown()", rel)
|
|
}
|
|
}
|
|
|
|
// The skill must be discoverable via both logical bases no matter
|
|
// which side carries the symlink.
|
|
for _, base := range InstallBases {
|
|
p := filepath.Join(root, base, Name, "SKILL.md")
|
|
data, err := os.ReadFile(p)
|
|
if err != nil {
|
|
t.Fatalf("skill not reachable via %s: %v", base, err)
|
|
}
|
|
if string(data) != Markdown() {
|
|
t.Errorf("%s content does not match Markdown()", base)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestInstallOverwritesStaleContent guards the upgrade path: an older SKILL.md
|
|
// left by a previous binary version must be refreshed to current content when
|
|
// Install runs again.
|
|
func TestInstallOverwritesStaleContent(t *testing.T) {
|
|
root := t.TempDir()
|
|
stale := filepath.Join(root, ".claude", "skills", Name, "SKILL.md")
|
|
mkdirAll(t, filepath.Dir(stale))
|
|
if err := os.WriteFile(stale, []byte("---\nname: "+Name+"\n---\nstale body\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := Install(root); err != nil {
|
|
t.Fatalf("Install: %v", err)
|
|
}
|
|
data, err := os.ReadFile(stale)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(data) != Markdown() {
|
|
t.Errorf("stale SKILL.md was not refreshed to current content")
|
|
}
|
|
}
|
|
|
|
func TestInstallRejectsSymlinkCycle(t *testing.T) {
|
|
root := t.TempDir()
|
|
symlink(t, ".agents", filepath.Join(root, ".claude"))
|
|
symlink(t, ".claude", filepath.Join(root, ".agents"))
|
|
|
|
if _, err := Install(root); err == nil {
|
|
t.Fatalf("Install succeeded with cyclic skill directory symlinks")
|
|
}
|
|
}
|
|
|
|
// TestVendored covers the legacy-detection helper init uses to tell users a
|
|
// repo still carries a vendored skill copy from an older no-mistakes version.
|
|
func TestVendored(t *testing.T) {
|
|
t.Run("clean_repo", func(t *testing.T) {
|
|
if got := Vendored(t.TempDir()); len(got) != 0 {
|
|
t.Errorf("Vendored on a clean repo = %v, want none", got)
|
|
}
|
|
})
|
|
|
|
t.Run("both_copies", func(t *testing.T) {
|
|
root := t.TempDir()
|
|
for _, base := range InstallBases {
|
|
dir := filepath.Join(root, base, Name)
|
|
mkdirAll(t, dir)
|
|
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("legacy"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
want := []string{
|
|
filepath.Join(".claude", "skills", Name, "SKILL.md"),
|
|
filepath.Join(".agents", "skills", Name, "SKILL.md"),
|
|
}
|
|
got := Vendored(root)
|
|
if len(got) != len(want) {
|
|
t.Fatalf("Vendored = %v, want %v", got, want)
|
|
}
|
|
for i := range want {
|
|
if got[i] != want[i] {
|
|
t.Errorf("Vendored[%d] = %q, want %q", i, got[i], want[i])
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("single_copy", func(t *testing.T) {
|
|
root := t.TempDir()
|
|
dir := filepath.Join(root, ".agents", "skills", Name)
|
|
mkdirAll(t, dir)
|
|
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("legacy"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := Vendored(root)
|
|
if len(got) != 1 || got[0] != filepath.Join(".agents", "skills", Name, "SKILL.md") {
|
|
t.Errorf("Vendored = %v, want only the .agents copy", got)
|
|
}
|
|
})
|
|
|
|
t.Run("unrelated_skill_ignored", func(t *testing.T) {
|
|
root := t.TempDir()
|
|
dir := filepath.Join(root, ".claude", "skills", "other-skill")
|
|
mkdirAll(t, dir)
|
|
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("other"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := Vendored(root); len(got) != 0 {
|
|
t.Errorf("Vendored must ignore unrelated skills, got %v", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
func mkdirAll(t *testing.T, dir string) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func symlink(t *testing.T, target, link string) {
|
|
t.Helper()
|
|
if err := os.Symlink(target, link); err != nil {
|
|
t.Skipf("symlinks unavailable: %v", err)
|
|
}
|
|
}
|
|
|
|
func min(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|