diff --git a/internal/cli/skill.go b/internal/cli/skill.go index 6b2e6c9..18a3d0d 100644 --- a/internal/cli/skill.go +++ b/internal/cli/skill.go @@ -21,6 +21,7 @@ func newSkillCmd() *cobra.Command { cmd.AddCommand(newSkillInstallCmd()) cmd.AddCommand(newSkillUninstallCmd()) cmd.AddCommand(newSkillRecommendCmd()) + cmd.AddCommand(newSkillDiffCmd()) return cmd } diff --git a/internal/cli/skill_diff.go b/internal/cli/skill_diff.go new file mode 100644 index 0000000..a5ab8ea --- /dev/null +++ b/internal/cli/skill_diff.go @@ -0,0 +1,257 @@ +package cli + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/devrimcavusoglu/skern/internal/output" + "github.com/devrimcavusoglu/skern/internal/platform" + "github.com/devrimcavusoglu/skern/internal/skill" + "github.com/spf13/cobra" +) + +func newSkillDiffCmd() *cobra.Command { + var ( + scope string + platformFlag string + ) + + cmd := &cobra.Command{ + Use: "diff [name-b]", + Short: "Compare two skills or a registry skill against its installed copy", + Long: `Compare two skills side by side. + +With one argument, compares a registry skill against its installed copy on a platform +(requires --platform; --scope defaults to "user"). + +With two arguments, compares two registry skills by name +(--scope filters to a specific scope; omit to search both).`, + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := getContext(cmd) + + for _, name := range args { + if err := skill.ValidateName(name); err != nil { + return &ValidationError{Message: err.Error()} + } + } + + if len(args) == 2 { + return diffTwoSkills(ctx, args[0], args[1], scope) + } + + return diffRegistryVsPlatform(ctx, args[0], scope, platformFlag) + }, + } + + cmd.Flags().StringVar(&scope, "scope", "", "skill scope (user or project)") + cmd.Flags().StringVar(&platformFlag, "platform", "", "platform to compare against (claude-code, codex-cli, opencode)") + + return cmd +} + +// diffTwoSkills compares two registry skills by name. +func diffTwoSkills(ctx *CommandContext, nameA, nameB, scopeStr string) error { + reg, err := ctx.NewRegistry() + if err != nil { + return err + } + + skillA, _, scopeA, err := resolveSkill(reg, nameA, scopeStr) + if err != nil { + return fmt.Errorf("resolving skill %q: %w", nameA, err) + } + + skillB, _, scopeB, err := resolveSkill(reg, nameB, scopeStr) + if err != nil { + return fmt.Errorf("resolving skill %q: %w", nameB, err) + } + + sourceA := fmt.Sprintf("registry (%s)", scopeA) + sourceB := fmt.Sprintf("registry (%s)", scopeB) + + result := compareSkills(skillA, nameA, sourceA, skillB, nameB, sourceB) + text := formatDiffResult(result) + ctx.Printer.PrintResult(result, text) + return nil +} + +// diffRegistryVsPlatform compares a registry skill against its installed copy on a platform. +func diffRegistryVsPlatform(ctx *CommandContext, name, scopeStr, platformFlag string) error { + if platformFlag == "" { + return &ValidationError{Message: "comparing a registry skill against a platform requires --platform flag"} + } + + if scopeStr == "" { + scopeStr = "user" + } + + scopeVal, err := parseScope(scopeStr) + if err != nil { + return err + } + + platformType, err := platform.ParsePlatformType(platformFlag) + if err != nil { + return &ValidationError{Message: err.Error()} + } + + if platformType == platform.TypeAll { + return &ValidationError{Message: "diff requires a specific platform, not \"all\""} + } + + reg, err := ctx.NewRegistry() + if err != nil { + return err + } + + registrySkill, _, err := reg.Get(name, scopeVal) + if err != nil { + return fmt.Errorf("skill %q not found in %s scope: %w", name, scopeStr, err) + } + + det, err := ctx.NewDetector() + if err != nil { + return err + } + + p := det.Get(platformType) + if p == nil { + return &ValidationError{Message: fmt.Sprintf("platform %q not recognized", platformFlag)} + } + + var platformDir string + if scopeVal == skill.ScopeProject { + platformDir = p.ProjectSkillsDir() + } else { + platformDir = p.UserSkillsDir() + } + + manifestPath := filepath.Join(platformDir, name, "SKILL.md") + platformSkill, err := skill.ParseManifest(manifestPath) + if err != nil { + return fmt.Errorf("skill %q not installed on %s (%s scope): %w", name, platformFlag, scopeStr, err) + } + + sourceA := fmt.Sprintf("registry (%s)", scopeStr) + sourceB := fmt.Sprintf("platform (%s)", platformFlag) + + result := compareSkills(registrySkill, name, sourceA, platformSkill, name, sourceB) + text := formatDiffResult(result) + ctx.Printer.PrintResult(result, text) + return nil +} + +// compareSkills compares two skills and produces a SkillDiffResult. +func compareSkills(a *skill.Skill, nameA, sourceA string, b *skill.Skill, nameB, sourceB string) output.SkillDiffResult { + var fields []output.FieldDiff + + if a.Name != b.Name { + fields = append(fields, output.FieldDiff{Field: "name", Left: a.Name, Right: b.Name}) + } + + descA := strings.TrimSpace(a.Description) + descB := strings.TrimSpace(b.Description) + if descA != descB { + fields = append(fields, output.FieldDiff{Field: "description", Left: descA, Right: descB}) + } + + if a.Metadata.Version != b.Metadata.Version { + fields = append(fields, output.FieldDiff{Field: "version", Left: a.Metadata.Version, Right: b.Metadata.Version}) + } + + if a.Metadata.Author.Name != b.Metadata.Author.Name { + fields = append(fields, output.FieldDiff{Field: "author.name", Left: a.Metadata.Author.Name, Right: b.Metadata.Author.Name}) + } + if a.Metadata.Author.Type != b.Metadata.Author.Type { + fields = append(fields, output.FieldDiff{Field: "author.type", Left: a.Metadata.Author.Type, Right: b.Metadata.Author.Type}) + } + if a.Metadata.Author.Platform != b.Metadata.Author.Platform { + fields = append(fields, output.FieldDiff{Field: "author.platform", Left: a.Metadata.Author.Platform, Right: b.Metadata.Author.Platform}) + } + + tagsA := strings.Join(a.Tags, ", ") + tagsB := strings.Join(b.Tags, ", ") + if tagsA != tagsB { + fields = append(fields, output.FieldDiff{Field: "tags", Left: tagsA, Right: tagsB}) + } + + toolsA := strings.Join(a.AllowedTools, ", ") + toolsB := strings.Join(b.AllowedTools, ", ") + if toolsA != toolsB { + fields = append(fields, output.FieldDiff{Field: "allowed-tools", Left: toolsA, Right: toolsB}) + } + + modA := formatModifiedBy(a.Metadata.ModifiedBy) + modB := formatModifiedBy(b.Metadata.ModifiedBy) + if modA != modB { + fields = append(fields, output.FieldDiff{Field: "modified-by", Left: modA, Right: modB}) + } + + bodyDiff := a.Body != b.Body + + result := output.SkillDiffResult{ + LeftName: nameA, + LeftSource: sourceA, + RightName: nameB, + RightSource: sourceB, + Identical: len(fields) == 0 && !bodyDiff, + Fields: fields, + BodyDiff: bodyDiff, + } + + if bodyDiff { + result.LeftBody = a.Body + result.RightBody = b.Body + } + + return result +} + +// formatDiffResult formats a diff result for text output. +func formatDiffResult(r output.SkillDiffResult) string { + var b strings.Builder + + fmt.Fprintf(&b, "Comparing %s (%s) vs %s (%s)\n\n", r.LeftName, r.LeftSource, r.RightName, r.RightSource) + + if r.Identical { + b.WriteString("Skills are identical.\n") + return b.String() + } + + if len(r.Fields) > 0 { + b.WriteString("Metadata differences:\n") + for _, f := range r.Fields { + fmt.Fprintf(&b, " %s:\n", f.Field) + fmt.Fprintf(&b, " - %s\n", displayValue(f.Left)) + fmt.Fprintf(&b, " + %s\n", displayValue(f.Right)) + } + } + + if r.BodyDiff { + if len(r.Fields) > 0 { + b.WriteString("\n") + } + b.WriteString("Body content differs.\n") + } + + return b.String() +} + +// displayValue returns the value or "(empty)" if blank. +func displayValue(v string) string { + if v == "" { + return "(empty)" + } + return v +} + +// formatModifiedBy serializes a modified-by list into a comparable string. +func formatModifiedBy(entries []skill.ModifiedByEntry) string { + parts := make([]string, len(entries)) + for i, e := range entries { + parts[i] = fmt.Sprintf("%s (%s/%s @ %s)", e.Name, e.Type, e.Platform, e.Date) + } + return strings.Join(parts, "; ") +} diff --git a/internal/cli/skill_diff_test.go b/internal/cli/skill_diff_test.go new file mode 100644 index 0000000..a016f43 --- /dev/null +++ b/internal/cli/skill_diff_test.go @@ -0,0 +1,419 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/devrimcavusoglu/skern/internal/output" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- skill diff: two registry skills --- + +func TestSkillDiff_TwoSkills_Identical(t *testing.T) { + cc := testRegistry(t) + + _, err := runCmd(t, cc, "skill", "create", "diff-a", "--description", "Same skill") + require.NoError(t, err) + _, err = runCmd(t, cc, "skill", "create", "diff-b", "--description", "Same skill") + require.NoError(t, err) + + out, err := runCmd(t, cc, "skill", "diff", "diff-a", "diff-b", "--json") + require.NoError(t, err) + + var result output.SkillDiffResult + require.NoError(t, json.Unmarshal([]byte(out), &result)) + // Names differ, so not fully identical + assert.False(t, result.Identical) + assert.Equal(t, "diff-a", result.LeftName) + assert.Equal(t, "diff-b", result.RightName) + + // Should have a name field diff + found := false + for _, f := range result.Fields { + if f.Field == "name" { + found = true + assert.Equal(t, "diff-a", f.Left) + assert.Equal(t, "diff-b", f.Right) + } + } + assert.True(t, found, "expected name field diff") +} + +func TestSkillDiff_TwoSkills_DifferentMetadata(t *testing.T) { + cc := testRegistry(t) + + _, err := runCmd(t, cc, "skill", "create", "meta-a", + "--description", "First description", + "--author", "alice", "--author-type", "human", + "--tags", "devops") + require.NoError(t, err) + _, err = runCmd(t, cc, "skill", "create", "meta-b", + "--description", "Second description", + "--author", "bob", "--author-type", "agent", + "--tags", "testing") + require.NoError(t, err) + + out, err := runCmd(t, cc, "skill", "diff", "meta-a", "meta-b", "--json") + require.NoError(t, err) + + var result output.SkillDiffResult + require.NoError(t, json.Unmarshal([]byte(out), &result)) + assert.False(t, result.Identical) + + fieldMap := make(map[string]output.FieldDiff) + for _, f := range result.Fields { + fieldMap[f.Field] = f + } + + assert.Contains(t, fieldMap, "name") + assert.Contains(t, fieldMap, "description") + assert.Contains(t, fieldMap, "author.name") + assert.Contains(t, fieldMap, "author.type") + assert.Contains(t, fieldMap, "tags") + assert.Equal(t, "First description", fieldMap["description"].Left) + assert.Equal(t, "Second description", fieldMap["description"].Right) + assert.Equal(t, "alice", fieldMap["author.name"].Left) + assert.Equal(t, "bob", fieldMap["author.name"].Right) +} + +func TestSkillDiff_TwoSkills_DifferentBody(t *testing.T) { + cc, userDir, _ := testRegistryWithDirs(t) + + _, err := runCmd(t, cc, "skill", "create", "body-a", "--description", "Same desc", "--author", "alice") + require.NoError(t, err) + _, err = runCmd(t, cc, "skill", "create", "body-b", "--description", "Same desc", "--author", "alice") + require.NoError(t, err) + + // Overwrite body-b SKILL.md with a different body + skillMdPath := filepath.Join(userDir, "body-b", "SKILL.md") + content := `--- +name: body-b +description: Same desc +metadata: + author: + name: alice + type: human + version: "0.1.0" +--- + +## Custom Instructions + +Do something different. +` + require.NoError(t, os.WriteFile(skillMdPath, []byte(content), 0o644)) + + out, err := runCmd(t, cc, "skill", "diff", "body-a", "body-b", "--json") + require.NoError(t, err) + + var result output.SkillDiffResult + require.NoError(t, json.Unmarshal([]byte(out), &result)) + assert.False(t, result.Identical) + assert.True(t, result.BodyDiff) + assert.NotEmpty(t, result.LeftBody) + assert.NotEmpty(t, result.RightBody) + assert.NotEqual(t, result.LeftBody, result.RightBody) +} + +func TestSkillDiff_TwoSkills_TextOutput(t *testing.T) { + cc := testRegistry(t) + + _, err := runCmd(t, cc, "skill", "create", "text-a", "--description", "Desc A", "--author", "alice") + require.NoError(t, err) + _, err = runCmd(t, cc, "skill", "create", "text-b", "--description", "Desc B", "--author", "bob") + require.NoError(t, err) + + out, err := runCmd(t, cc, "skill", "diff", "text-a", "text-b") + require.NoError(t, err) + assert.Contains(t, out, "Comparing") + assert.Contains(t, out, "text-a") + assert.Contains(t, out, "text-b") + assert.Contains(t, out, "description") + assert.Contains(t, out, "author.name") +} + +func TestSkillDiff_TwoSkills_NotFound(t *testing.T) { + cc := testRegistry(t) + + _, err := runCmd(t, cc, "skill", "create", "exists", "--description", "A skill") + require.NoError(t, err) + + _, err = runCmd(t, cc, "skill", "diff", "exists", "nonexistent") + assert.Error(t, err) + assert.Contains(t, err.Error(), "nonexistent") +} + +// --- skill diff: registry vs platform --- + +func TestSkillDiff_RegistryVsPlatform_Identical(t *testing.T) { + cc, _, _ := testRegistryWithDirs(t) + home := t.TempDir() + project := t.TempDir() + withTestDetector(t, cc, home, project) + + _, err := runCmd(t, cc, "skill", "create", "diff-install", "--description", "A test skill") + require.NoError(t, err) + + _, err = runCmd(t, cc, "skill", "install", "diff-install", "--platform", "claude-code") + require.NoError(t, err) + + out, err := runCmd(t, cc, "skill", "diff", "diff-install", + "--platform", "claude-code", "--scope", "user", "--json") + require.NoError(t, err) + + var result output.SkillDiffResult + require.NoError(t, json.Unmarshal([]byte(out), &result)) + assert.True(t, result.Identical) + assert.Equal(t, "diff-install", result.LeftName) + assert.Equal(t, "diff-install", result.RightName) + assert.Contains(t, result.LeftSource, "registry") + assert.Contains(t, result.RightSource, "platform") +} + +func TestSkillDiff_RegistryVsPlatform_Drifted(t *testing.T) { + cc, _, _ := testRegistryWithDirs(t) + home := t.TempDir() + project := t.TempDir() + withTestDetector(t, cc, home, project) + + _, err := runCmd(t, cc, "skill", "create", "drift-skill", "--description", "Original") + require.NoError(t, err) + + _, err = runCmd(t, cc, "skill", "install", "drift-skill", "--platform", "claude-code") + require.NoError(t, err) + + // Modify the installed copy on the platform to simulate drift + installedPath := filepath.Join(home, ".claude", "skills", "drift-skill", "SKILL.md") + driftedContent := `--- +name: drift-skill +description: Modified on platform +metadata: + author: + name: skern + type: human + version: "0.2.0" +--- + +## Modified Instructions + +This was changed on the platform. +` + require.NoError(t, os.WriteFile(installedPath, []byte(driftedContent), 0o644)) + + out, err := runCmd(t, cc, "skill", "diff", "drift-skill", + "--platform", "claude-code", "--scope", "user", "--json") + require.NoError(t, err) + + var result output.SkillDiffResult + require.NoError(t, json.Unmarshal([]byte(out), &result)) + assert.False(t, result.Identical) + assert.True(t, result.BodyDiff) + + fieldMap := make(map[string]output.FieldDiff) + for _, f := range result.Fields { + fieldMap[f.Field] = f + } + + assert.Contains(t, fieldMap, "description") + assert.Contains(t, fieldMap, "version") +} + +func TestSkillDiff_RegistryVsPlatform_TextOutput(t *testing.T) { + cc, _, _ := testRegistryWithDirs(t) + home := t.TempDir() + project := t.TempDir() + withTestDetector(t, cc, home, project) + + _, err := runCmd(t, cc, "skill", "create", "text-diff", "--description", "A skill") + require.NoError(t, err) + + _, err = runCmd(t, cc, "skill", "install", "text-diff", "--platform", "claude-code") + require.NoError(t, err) + + out, err := runCmd(t, cc, "skill", "diff", "text-diff", + "--platform", "claude-code", "--scope", "user") + require.NoError(t, err) + assert.Contains(t, out, "identical") +} + +func TestSkillDiff_RegistryVsPlatform_NotInstalled(t *testing.T) { + cc, _, _ := testRegistryWithDirs(t) + home := t.TempDir() + project := t.TempDir() + withTestDetector(t, cc, home, project) + + _, err := runCmd(t, cc, "skill", "create", "not-installed", "--description", "A skill") + require.NoError(t, err) + + _, err = runCmd(t, cc, "skill", "diff", "not-installed", + "--platform", "claude-code", "--scope", "user") + assert.Error(t, err) + assert.Contains(t, err.Error(), "not installed") +} + +func TestSkillDiff_MissingPlatformFlag(t *testing.T) { + cc := testRegistry(t) + + _, err := runCmd(t, cc, "skill", "create", "missing-plat", "--description", "A skill") + require.NoError(t, err) + + _, err = runCmd(t, cc, "skill", "diff", "missing-plat") + assert.Error(t, err) + assert.Contains(t, err.Error(), "--platform") +} + +func TestSkillDiff_PlatformAll_Rejected(t *testing.T) { + cc, _, _ := testRegistryWithDirs(t) + home := t.TempDir() + project := t.TempDir() + withTestDetector(t, cc, home, project) + + _, err := runCmd(t, cc, "skill", "create", "all-plat", "--description", "A skill") + require.NoError(t, err) + + _, err = runCmd(t, cc, "skill", "diff", "all-plat", + "--platform", "all", "--scope", "user") + assert.Error(t, err) + assert.Contains(t, err.Error(), "specific platform") +} + +func TestSkillDiff_NoArgs(t *testing.T) { + cc := testRegistry(t) + + _, err := runCmd(t, cc, "skill", "diff") + assert.Error(t, err) +} + +func TestSkillDiff_RegistryVsPlatform_ProjectScope(t *testing.T) { + cc, _, projectDir := testRegistryWithDirs(t) + home := t.TempDir() + project := t.TempDir() + withTestDetector(t, cc, home, project) + + _, err := runCmd(t, cc, "skill", "create", "proj-diff", "--description", "A project skill", "--scope", "project") + require.NoError(t, err) + + _, err = runCmd(t, cc, "skill", "install", "proj-diff", "--platform", "claude-code", "--scope", "project") + require.NoError(t, err) + + out, err := runCmd(t, cc, "skill", "diff", "proj-diff", + "--platform", "claude-code", "--scope", "project", "--json") + require.NoError(t, err) + + var result output.SkillDiffResult + require.NoError(t, json.Unmarshal([]byte(out), &result)) + assert.True(t, result.Identical) + assert.Contains(t, result.LeftSource, "project") + assert.Contains(t, result.RightSource, "platform") + _ = projectDir // used indirectly by the registry +} + +func TestSkillDiff_InvalidScope(t *testing.T) { + cc := testRegistry(t) + + _, err := runCmd(t, cc, "skill", "create", "scope-test", "--description", "A skill") + require.NoError(t, err) + + _, err = runCmd(t, cc, "skill", "diff", "scope-test", "--platform", "claude-code", "--scope", "invalid") + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid scope") +} + +func TestSkillDiff_TwoSkills_ExplicitScope(t *testing.T) { + cc := testRegistry(t) + + _, err := runCmd(t, cc, "skill", "create", "scoped-a", "--description", "Desc A") + require.NoError(t, err) + _, err = runCmd(t, cc, "skill", "create", "scoped-b", "--description", "Desc B") + require.NoError(t, err) + + out, err := runCmd(t, cc, "skill", "diff", "scoped-a", "scoped-b", "--scope", "user", "--json") + require.NoError(t, err) + + var result output.SkillDiffResult + require.NoError(t, json.Unmarshal([]byte(out), &result)) + assert.False(t, result.Identical) + assert.Contains(t, result.LeftSource, "user") + assert.Contains(t, result.RightSource, "user") +} + +func TestSkillDiff_InvalidName(t *testing.T) { + cc := testRegistry(t) + + _, err := runCmd(t, cc, "skill", "diff", "INVALID_NAME", "--platform", "claude-code") + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid") +} + +func TestSkillDiff_DifferentModifiedBy(t *testing.T) { + cc, userDir, _ := testRegistryWithDirs(t) + + _, err := runCmd(t, cc, "skill", "create", "mod-a", "--description", "Same desc", "--author", "alice") + require.NoError(t, err) + _, err = runCmd(t, cc, "skill", "create", "mod-b", "--description", "Same desc", "--author", "alice") + require.NoError(t, err) + + // Add modified-by entry to mod-b + skillMdPath := filepath.Join(userDir, "mod-b", "SKILL.md") + content := `--- +name: mod-b +description: Same desc +metadata: + author: + name: alice + type: human + version: "0.1.0" + modified-by: + - name: codex-cli + type: agent + platform: codex-cli + date: "2026-01-15T10:30:00Z" +--- + +## Instructions + +Add your instructions here. +` + require.NoError(t, os.WriteFile(skillMdPath, []byte(content), 0o644)) + + out, err := runCmd(t, cc, "skill", "diff", "mod-a", "mod-b", "--json") + require.NoError(t, err) + + var result output.SkillDiffResult + require.NoError(t, json.Unmarshal([]byte(out), &result)) + assert.False(t, result.Identical) + + fieldMap := make(map[string]output.FieldDiff) + for _, f := range result.Fields { + fieldMap[f.Field] = f + } + + assert.Contains(t, fieldMap, "modified-by") + assert.Empty(t, fieldMap["modified-by"].Left) + assert.Contains(t, fieldMap["modified-by"].Right, "codex-cli") +} + +func TestSkillDiff_RegistryVsPlatform_DefaultScope(t *testing.T) { + cc, _, _ := testRegistryWithDirs(t) + home := t.TempDir() + project := t.TempDir() + withTestDetector(t, cc, home, project) + + _, err := runCmd(t, cc, "skill", "create", "default-scope", "--description", "A skill") + require.NoError(t, err) + + _, err = runCmd(t, cc, "skill", "install", "default-scope", "--platform", "claude-code") + require.NoError(t, err) + + // Omit --scope; should default to user + out, err := runCmd(t, cc, "skill", "diff", "default-scope", + "--platform", "claude-code", "--json") + require.NoError(t, err) + + var result output.SkillDiffResult + require.NoError(t, json.Unmarshal([]byte(out), &result)) + assert.True(t, result.Identical) +} diff --git a/internal/output/types_skill.go b/internal/output/types_skill.go index 1c8022d..87b2d05 100644 --- a/internal/output/types_skill.go +++ b/internal/output/types_skill.go @@ -106,3 +106,23 @@ type SkillValidateResult struct { Warns int `json:"warnings"` Hints int `json:"hints"` } + +// FieldDiff represents a difference in a single metadata field between two skills. +type FieldDiff struct { + Field string `json:"field"` + Left string `json:"left"` + Right string `json:"right"` +} + +// SkillDiffResult is the JSON envelope for skill diff output. +type SkillDiffResult struct { + LeftName string `json:"left_name"` + LeftSource string `json:"left_source"` + RightName string `json:"right_name"` + RightSource string `json:"right_source"` + Identical bool `json:"identical"` + Fields []FieldDiff `json:"fields,omitempty"` + BodyDiff bool `json:"body_diff"` + LeftBody string `json:"left_body,omitempty"` + RightBody string `json:"right_body,omitempty"` +}