package claudecode import ( "encoding/json" "errors" "os" "path/filepath" "sort" "strings" "testing" ) func TestEmitPluginBundle_Layout(t *testing.T) { dir := t.TempDir() written, err := EmitPluginBundle(PluginBundleSpec{ TargetDir: dir, Version: "0.18.2", }) if err != nil { t.Fatalf("EmitPluginBundle: %v", err) } // The emitter should write exactly: 1 plugin.json + 1 README + // 1 LICENSE + 1 .mcp.json + N commands + N skills + 1 hooks.json // + 1 hook handler script. wantPaths := []string{ ".claude-plugin/plugin.json", ".mcp.json", "LICENSE", "README.md", "hooks-handlers/gortex-hook.sh", "hooks/hooks.json", } for _, name := range sortedKeys(SlashCommands) { wantPaths = append(wantPaths, filepath.Join("commands", name)) } for _, name := range sortedKeys(GlobalSkills) { wantPaths = append(wantPaths, filepath.Join("skills", name, "SKILL.md")) } for _, name := range sortedKeys(SubAgents) { wantPaths = append(wantPaths, filepath.Join("agents", name)) } sort.Strings(wantPaths) got := append([]string(nil), written...) sort.Strings(got) if len(got) != len(wantPaths) { t.Fatalf("file count mismatch: got %d, want %d.\ngot=%v\nwant=%v", len(got), len(wantPaths), got, wantPaths) } for i := range got { if got[i] != wantPaths[i] { t.Errorf("path[%d] mismatch: got %q, want %q", i, got[i], wantPaths[i]) } } // Every path the emitter claimed to write must exist on disk. for _, rel := range written { if _, err := os.Stat(filepath.Join(dir, rel)); err != nil { t.Errorf("missing file %s: %v", rel, err) } } } func TestEmitPluginBundle_PluginManifestShape(t *testing.T) { dir := t.TempDir() if _, err := EmitPluginBundle(PluginBundleSpec{ TargetDir: dir, Version: "0.18.2", }); err != nil { t.Fatal(err) } body, err := os.ReadFile(filepath.Join(dir, ".claude-plugin", "plugin.json")) if err != nil { t.Fatal(err) } var manifest map[string]any if err := json.Unmarshal(body, &manifest); err != nil { t.Fatalf("plugin.json: invalid JSON: %v", err) } // Required keys per the marketplace schema we observed in // claude-plugins-official: name, description, author. We add // version + homepage for our own metadata. for _, key := range []string{"name", "description", "author", "version", "homepage"} { if _, ok := manifest[key]; !ok { t.Errorf("plugin.json missing required key %q", key) } } if got := manifest["name"]; got != "gortex" { t.Errorf("plugin.json name = %v, want gortex", got) } if got := manifest["version"]; got != "0.18.2" { t.Errorf("plugin.json version = %v, want 0.18.2", got) } if !strings.HasSuffix(string(body), "\n") { t.Errorf("plugin.json should end with a newline (got %q)", string(body[len(body)-3:])) } } func TestEmitPluginBundle_MCPJSONShape(t *testing.T) { dir := t.TempDir() if _, err := EmitPluginBundle(PluginBundleSpec{ TargetDir: dir, Version: "0.18.2", }); err != nil { t.Fatal(err) } body, err := os.ReadFile(filepath.Join(dir, ".mcp.json")) if err != nil { t.Fatal(err) } var mcp map[string]any if err := json.Unmarshal(body, &mcp); err != nil { t.Fatalf(".mcp.json: invalid JSON: %v", err) } gx, ok := mcp["gortex"].(map[string]any) if !ok { t.Fatalf(".mcp.json: missing 'gortex' entry, got %v", mcp) } if got := gx["command"]; got != "gortex" { t.Errorf(".mcp.json command = %v, want gortex", got) } args, ok := gx["args"].([]any) if !ok || len(args) != 1 || args[0] != "mcp" { t.Errorf(".mcp.json args = %v, want [mcp]", args) } } func TestEmitPluginBundle_HooksShape(t *testing.T) { dir := t.TempDir() if _, err := EmitPluginBundle(PluginBundleSpec{ TargetDir: dir, Version: "0.18.2", }); err != nil { t.Fatal(err) } body, err := os.ReadFile(filepath.Join(dir, "hooks", "hooks.json")) if err != nil { t.Fatal(err) } var doc map[string]any if err := json.Unmarshal(body, &doc); err != nil { t.Fatalf("hooks.json: invalid JSON: %v", err) } hooks, ok := doc["hooks"].(map[string]any) if !ok { t.Fatalf("hooks.json: missing top-level 'hooks' object: %v", doc) } // All four event kinds must be present; each must reference the // hooks-handlers script via ${CLAUDE_PLUGIN_ROOT}. for _, event := range []string{"PreToolUse", "PreCompact", "Stop", "SessionStart"} { entries, ok := hooks[event].([]any) if !ok || len(entries) == 0 { t.Errorf("hooks.json: event %s missing or empty", event) continue } entry := entries[0].(map[string]any) inner, ok := entry["hooks"].([]any) if !ok || len(inner) == 0 { t.Errorf("hooks.json: %s has no inner hooks", event) continue } first := inner[0].(map[string]any) cmd, _ := first["command"].(string) if !strings.Contains(cmd, "${CLAUDE_PLUGIN_ROOT}") { t.Errorf("hooks.json: %s command does not reference ${CLAUDE_PLUGIN_ROOT}: %q", event, cmd) } if !strings.Contains(cmd, "gortex-hook.sh") { t.Errorf("hooks.json: %s command does not invoke gortex-hook.sh: %q", event, cmd) } } // PreToolUse should carry the canonical matcher. pre := hooks["PreToolUse"].([]any) matcher, _ := pre[0].(map[string]any)["matcher"].(string) if matcher != CurrentPreToolUseMatcher { t.Errorf("hooks.json: PreToolUse matcher = %q, want %q", matcher, CurrentPreToolUseMatcher) } } func TestEmitPluginBundle_HookHandlerExecBit(t *testing.T) { dir := t.TempDir() if _, err := EmitPluginBundle(PluginBundleSpec{ TargetDir: dir, Version: "0.18.2", }); err != nil { t.Fatal(err) } info, err := os.Stat(filepath.Join(dir, "hooks-handlers", "gortex-hook.sh")) if err != nil { t.Fatal(err) } if info.Mode()&0o111 == 0 { t.Errorf("hooks-handlers/gortex-hook.sh should be executable (mode=%v)", info.Mode()) } } func TestEmitPluginBundle_SkillBodiesMatchSource(t *testing.T) { dir := t.TempDir() if _, err := EmitPluginBundle(PluginBundleSpec{ TargetDir: dir, Version: "0.18.2", }); err != nil { t.Fatal(err) } // Single-source-of-truth check: the bytes written under // skills//SKILL.md must equal GlobalSkills[] exactly. // Drift means content.go was edited without re-running the // emitter; the CI guard catches that case but we double-check // here in unit tests. for name, want := range GlobalSkills { got, err := os.ReadFile(filepath.Join(dir, "skills", name, "SKILL.md")) if err != nil { t.Errorf("read skill %s: %v", name, err) continue } if string(got) != want { t.Errorf("skill %s body drift: bytes do not match GlobalSkills[%q]", name, name) } } } func TestEmitPluginBundle_CommandBodiesMatchSource(t *testing.T) { dir := t.TempDir() if _, err := EmitPluginBundle(PluginBundleSpec{ TargetDir: dir, Version: "0.18.2", }); err != nil { t.Fatal(err) } for name, want := range SlashCommands { got, err := os.ReadFile(filepath.Join(dir, "commands", name)) if err != nil { t.Errorf("read command %s: %v", name, err) continue } if string(got) != want { t.Errorf("command %s body drift", name) } } } func TestEmitPluginBundle_Idempotent(t *testing.T) { dir := t.TempDir() first, err := EmitPluginBundle(PluginBundleSpec{TargetDir: dir, Version: "0.18.2"}) if err != nil { t.Fatal(err) } second, err := EmitPluginBundle(PluginBundleSpec{TargetDir: dir, Version: "0.18.2"}) if err != nil { t.Fatalf("second emit: %v", err) } if len(first) != len(second) { t.Fatalf("file count drift between runs: %d vs %d", len(first), len(second)) } // Every file's bytes must be identical across the two runs. for _, rel := range first { path := filepath.Join(dir, rel) body1, err := os.ReadFile(path) if err != nil { t.Fatal(err) } // Re-emit and re-read: same path, must equal first body. body2, err := os.ReadFile(path) if err != nil { t.Fatal(err) } if string(body1) != string(body2) { t.Errorf("non-idempotent output at %s", rel) } } } func TestEmitPluginBundle_RejectsBadInputs(t *testing.T) { cases := map[string]PluginBundleSpec{ "missing-target": {Version: "0.18.2"}, "missing-version": {TargetDir: t.TempDir()}, "bad-variant": {TargetDir: t.TempDir(), Version: "0.18.2", LayoutVariant: "weird"}, } for name, spec := range cases { _, err := EmitPluginBundle(spec) if err == nil { t.Errorf("%s: expected error, got nil", name) } } } func TestEmitPluginBundle_VariantDefaultsToAnthropic(t *testing.T) { dir := t.TempDir() // Empty LayoutVariant should fall through to LayoutVariantAnthropic. _, err := EmitPluginBundle(PluginBundleSpec{TargetDir: dir, Version: "0.18.2"}) if err != nil { t.Fatalf("empty variant: %v", err) } // Verify by checking that the Anthropic-shaped manifest exists. if _, err := os.Stat(filepath.Join(dir, ".claude-plugin", "plugin.json")); err != nil { t.Errorf("default variant did not produce .claude-plugin/plugin.json: %v", err) } } func TestEmitPluginBundle_OverwritesExistingFiles(t *testing.T) { dir := t.TempDir() // Pre-seed an old README to ensure overwrite works. if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("OLD\n"), 0o644); err != nil { t.Fatal(err) } if _, err := EmitPluginBundle(PluginBundleSpec{TargetDir: dir, Version: "0.18.2"}); err != nil { t.Fatal(err) } body, err := os.ReadFile(filepath.Join(dir, "README.md")) if err != nil { t.Fatal(err) } if string(body) == "OLD\n" { t.Errorf("emitter did not overwrite existing README.md") } } func TestPluginPathHelpers(t *testing.T) { if PluginManifestPath() != filepath.Join(".claude-plugin", "plugin.json") { t.Errorf("PluginManifestPath wrong: %s", PluginManifestPath()) } if PluginMCPPath() != ".mcp.json" { t.Errorf("PluginMCPPath wrong: %s", PluginMCPPath()) } if PluginHooksPath() != filepath.Join("hooks", "hooks.json") { t.Errorf("PluginHooksPath wrong: %s", PluginHooksPath()) } cmds := PluginCommandPaths() if len(cmds) != len(SlashCommands) { t.Errorf("PluginCommandPaths count: got %d, want %d", len(cmds), len(SlashCommands)) } skills := PluginSkillPaths() if len(skills) != len(GlobalSkills) { t.Errorf("PluginSkillPaths count: got %d, want %d", len(skills), len(GlobalSkills)) } subagents := PluginSubAgentPaths() if len(subagents) != len(SubAgents) { t.Errorf("PluginSubAgentPaths count: got %d, want %d", len(subagents), len(SubAgents)) } } func TestEmitPluginBundle_SubAgentBodiesMatchSource(t *testing.T) { dir := t.TempDir() if _, err := EmitPluginBundle(PluginBundleSpec{ TargetDir: dir, Version: "0.18.2", }); err != nil { t.Fatal(err) } for name, want := range SubAgents { got, err := os.ReadFile(filepath.Join(dir, "agents", name)) if err != nil { t.Errorf("read sub-agent %s: %v", name, err) continue } if string(got) != want { t.Errorf("sub-agent %s body drift: bytes do not match SubAgents[%q]", name, name) } } } func TestEmitPluginBundle_MissingTargetParent(t *testing.T) { // MkdirAll handles non-existent parents — verify that. dir := filepath.Join(t.TempDir(), "deep", "nested", "path") _, err := EmitPluginBundle(PluginBundleSpec{TargetDir: dir, Version: "0.18.2"}) if err != nil { t.Errorf("EmitPluginBundle should create parent dirs: %v", err) } if _, err := os.Stat(filepath.Join(dir, ".claude-plugin", "plugin.json")); err != nil { if errors.Is(err, os.ErrNotExist) { t.Errorf("emitter did not create plugin.json at deep target") } } }