Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions internal/cli/skill.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ func newSkillCmd() *cobra.Command {
cmd.AddCommand(newSkillInstallCmd())
cmd.AddCommand(newSkillUninstallCmd())
cmd.AddCommand(newSkillRecommendCmd())
cmd.AddCommand(newSkillDiffCmd())

return cmd
}
257 changes: 257 additions & 0 deletions internal/cli/skill_diff.go
Original file line number Diff line number Diff line change
@@ -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> [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, "; ")
}
Loading
Loading