Skip to content

Commit 113f29d

Browse files
Add skill diff command (#46) (#66)
* Add skill diff command (#46) Compare two registry skills or a registry skill against its installed copy on a platform. Supports --json, --scope, and --platform flags. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Address review: add modified-by comparison, name validation, and missing tests - Compare modified-by field in skill diff for drift detection - Validate skill names upfront with skill.ValidateName - Fix Long description to accurately document --scope defaults - Add tests: project scope, invalid scope, explicit scope, invalid name, modified-by diff Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3793eff commit 113f29d

4 files changed

Lines changed: 697 additions & 0 deletions

File tree

internal/cli/skill.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ func newSkillCmd() *cobra.Command {
2121
cmd.AddCommand(newSkillInstallCmd())
2222
cmd.AddCommand(newSkillUninstallCmd())
2323
cmd.AddCommand(newSkillRecommendCmd())
24+
cmd.AddCommand(newSkillDiffCmd())
2425

2526
return cmd
2627
}

internal/cli/skill_diff.go

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
"path/filepath"
6+
"strings"
7+
8+
"github.com/devrimcavusoglu/skern/internal/output"
9+
"github.com/devrimcavusoglu/skern/internal/platform"
10+
"github.com/devrimcavusoglu/skern/internal/skill"
11+
"github.com/spf13/cobra"
12+
)
13+
14+
func newSkillDiffCmd() *cobra.Command {
15+
var (
16+
scope string
17+
platformFlag string
18+
)
19+
20+
cmd := &cobra.Command{
21+
Use: "diff <name> [name-b]",
22+
Short: "Compare two skills or a registry skill against its installed copy",
23+
Long: `Compare two skills side by side.
24+
25+
With one argument, compares a registry skill against its installed copy on a platform
26+
(requires --platform; --scope defaults to "user").
27+
28+
With two arguments, compares two registry skills by name
29+
(--scope filters to a specific scope; omit to search both).`,
30+
Args: cobra.RangeArgs(1, 2),
31+
RunE: func(cmd *cobra.Command, args []string) error {
32+
ctx := getContext(cmd)
33+
34+
for _, name := range args {
35+
if err := skill.ValidateName(name); err != nil {
36+
return &ValidationError{Message: err.Error()}
37+
}
38+
}
39+
40+
if len(args) == 2 {
41+
return diffTwoSkills(ctx, args[0], args[1], scope)
42+
}
43+
44+
return diffRegistryVsPlatform(ctx, args[0], scope, platformFlag)
45+
},
46+
}
47+
48+
cmd.Flags().StringVar(&scope, "scope", "", "skill scope (user or project)")
49+
cmd.Flags().StringVar(&platformFlag, "platform", "", "platform to compare against (claude-code, codex-cli, opencode)")
50+
51+
return cmd
52+
}
53+
54+
// diffTwoSkills compares two registry skills by name.
55+
func diffTwoSkills(ctx *CommandContext, nameA, nameB, scopeStr string) error {
56+
reg, err := ctx.NewRegistry()
57+
if err != nil {
58+
return err
59+
}
60+
61+
skillA, _, scopeA, err := resolveSkill(reg, nameA, scopeStr)
62+
if err != nil {
63+
return fmt.Errorf("resolving skill %q: %w", nameA, err)
64+
}
65+
66+
skillB, _, scopeB, err := resolveSkill(reg, nameB, scopeStr)
67+
if err != nil {
68+
return fmt.Errorf("resolving skill %q: %w", nameB, err)
69+
}
70+
71+
sourceA := fmt.Sprintf("registry (%s)", scopeA)
72+
sourceB := fmt.Sprintf("registry (%s)", scopeB)
73+
74+
result := compareSkills(skillA, nameA, sourceA, skillB, nameB, sourceB)
75+
text := formatDiffResult(result)
76+
ctx.Printer.PrintResult(result, text)
77+
return nil
78+
}
79+
80+
// diffRegistryVsPlatform compares a registry skill against its installed copy on a platform.
81+
func diffRegistryVsPlatform(ctx *CommandContext, name, scopeStr, platformFlag string) error {
82+
if platformFlag == "" {
83+
return &ValidationError{Message: "comparing a registry skill against a platform requires --platform flag"}
84+
}
85+
86+
if scopeStr == "" {
87+
scopeStr = "user"
88+
}
89+
90+
scopeVal, err := parseScope(scopeStr)
91+
if err != nil {
92+
return err
93+
}
94+
95+
platformType, err := platform.ParsePlatformType(platformFlag)
96+
if err != nil {
97+
return &ValidationError{Message: err.Error()}
98+
}
99+
100+
if platformType == platform.TypeAll {
101+
return &ValidationError{Message: "diff requires a specific platform, not \"all\""}
102+
}
103+
104+
reg, err := ctx.NewRegistry()
105+
if err != nil {
106+
return err
107+
}
108+
109+
registrySkill, _, err := reg.Get(name, scopeVal)
110+
if err != nil {
111+
return fmt.Errorf("skill %q not found in %s scope: %w", name, scopeStr, err)
112+
}
113+
114+
det, err := ctx.NewDetector()
115+
if err != nil {
116+
return err
117+
}
118+
119+
p := det.Get(platformType)
120+
if p == nil {
121+
return &ValidationError{Message: fmt.Sprintf("platform %q not recognized", platformFlag)}
122+
}
123+
124+
var platformDir string
125+
if scopeVal == skill.ScopeProject {
126+
platformDir = p.ProjectSkillsDir()
127+
} else {
128+
platformDir = p.UserSkillsDir()
129+
}
130+
131+
manifestPath := filepath.Join(platformDir, name, "SKILL.md")
132+
platformSkill, err := skill.ParseManifest(manifestPath)
133+
if err != nil {
134+
return fmt.Errorf("skill %q not installed on %s (%s scope): %w", name, platformFlag, scopeStr, err)
135+
}
136+
137+
sourceA := fmt.Sprintf("registry (%s)", scopeStr)
138+
sourceB := fmt.Sprintf("platform (%s)", platformFlag)
139+
140+
result := compareSkills(registrySkill, name, sourceA, platformSkill, name, sourceB)
141+
text := formatDiffResult(result)
142+
ctx.Printer.PrintResult(result, text)
143+
return nil
144+
}
145+
146+
// compareSkills compares two skills and produces a SkillDiffResult.
147+
func compareSkills(a *skill.Skill, nameA, sourceA string, b *skill.Skill, nameB, sourceB string) output.SkillDiffResult {
148+
var fields []output.FieldDiff
149+
150+
if a.Name != b.Name {
151+
fields = append(fields, output.FieldDiff{Field: "name", Left: a.Name, Right: b.Name})
152+
}
153+
154+
descA := strings.TrimSpace(a.Description)
155+
descB := strings.TrimSpace(b.Description)
156+
if descA != descB {
157+
fields = append(fields, output.FieldDiff{Field: "description", Left: descA, Right: descB})
158+
}
159+
160+
if a.Metadata.Version != b.Metadata.Version {
161+
fields = append(fields, output.FieldDiff{Field: "version", Left: a.Metadata.Version, Right: b.Metadata.Version})
162+
}
163+
164+
if a.Metadata.Author.Name != b.Metadata.Author.Name {
165+
fields = append(fields, output.FieldDiff{Field: "author.name", Left: a.Metadata.Author.Name, Right: b.Metadata.Author.Name})
166+
}
167+
if a.Metadata.Author.Type != b.Metadata.Author.Type {
168+
fields = append(fields, output.FieldDiff{Field: "author.type", Left: a.Metadata.Author.Type, Right: b.Metadata.Author.Type})
169+
}
170+
if a.Metadata.Author.Platform != b.Metadata.Author.Platform {
171+
fields = append(fields, output.FieldDiff{Field: "author.platform", Left: a.Metadata.Author.Platform, Right: b.Metadata.Author.Platform})
172+
}
173+
174+
tagsA := strings.Join(a.Tags, ", ")
175+
tagsB := strings.Join(b.Tags, ", ")
176+
if tagsA != tagsB {
177+
fields = append(fields, output.FieldDiff{Field: "tags", Left: tagsA, Right: tagsB})
178+
}
179+
180+
toolsA := strings.Join(a.AllowedTools, ", ")
181+
toolsB := strings.Join(b.AllowedTools, ", ")
182+
if toolsA != toolsB {
183+
fields = append(fields, output.FieldDiff{Field: "allowed-tools", Left: toolsA, Right: toolsB})
184+
}
185+
186+
modA := formatModifiedBy(a.Metadata.ModifiedBy)
187+
modB := formatModifiedBy(b.Metadata.ModifiedBy)
188+
if modA != modB {
189+
fields = append(fields, output.FieldDiff{Field: "modified-by", Left: modA, Right: modB})
190+
}
191+
192+
bodyDiff := a.Body != b.Body
193+
194+
result := output.SkillDiffResult{
195+
LeftName: nameA,
196+
LeftSource: sourceA,
197+
RightName: nameB,
198+
RightSource: sourceB,
199+
Identical: len(fields) == 0 && !bodyDiff,
200+
Fields: fields,
201+
BodyDiff: bodyDiff,
202+
}
203+
204+
if bodyDiff {
205+
result.LeftBody = a.Body
206+
result.RightBody = b.Body
207+
}
208+
209+
return result
210+
}
211+
212+
// formatDiffResult formats a diff result for text output.
213+
func formatDiffResult(r output.SkillDiffResult) string {
214+
var b strings.Builder
215+
216+
fmt.Fprintf(&b, "Comparing %s (%s) vs %s (%s)\n\n", r.LeftName, r.LeftSource, r.RightName, r.RightSource)
217+
218+
if r.Identical {
219+
b.WriteString("Skills are identical.\n")
220+
return b.String()
221+
}
222+
223+
if len(r.Fields) > 0 {
224+
b.WriteString("Metadata differences:\n")
225+
for _, f := range r.Fields {
226+
fmt.Fprintf(&b, " %s:\n", f.Field)
227+
fmt.Fprintf(&b, " - %s\n", displayValue(f.Left))
228+
fmt.Fprintf(&b, " + %s\n", displayValue(f.Right))
229+
}
230+
}
231+
232+
if r.BodyDiff {
233+
if len(r.Fields) > 0 {
234+
b.WriteString("\n")
235+
}
236+
b.WriteString("Body content differs.\n")
237+
}
238+
239+
return b.String()
240+
}
241+
242+
// displayValue returns the value or "(empty)" if blank.
243+
func displayValue(v string) string {
244+
if v == "" {
245+
return "(empty)"
246+
}
247+
return v
248+
}
249+
250+
// formatModifiedBy serializes a modified-by list into a comparable string.
251+
func formatModifiedBy(entries []skill.ModifiedByEntry) string {
252+
parts := make([]string, len(entries))
253+
for i, e := range entries {
254+
parts[i] = fmt.Sprintf("%s (%s/%s @ %s)", e.Name, e.Type, e.Platform, e.Date)
255+
}
256+
return strings.Join(parts, "; ")
257+
}

0 commit comments

Comments
 (0)