Skip to content

Commit e9ed082

Browse files
Add skill versioning and version management (#44)
Add semver parsing, bumping, and comparison in internal/skill/version.go. Add `skern skill version` command with --bump patch|minor|major support. Add --version flag to `skern skill create` for setting initial version. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3793eff commit e9ed082

File tree

7 files changed

+750
-0
lines changed

7 files changed

+750
-0
lines changed

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(newSkillVersionCmd())
2425

2526
return cmd
2627
}

internal/cli/skill_create.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ func newSkillCreateCmd() *cobra.Command {
2222
force bool
2323
fromTemplate string
2424
tags []string
25+
version string
2526
)
2627

2728
cmd := &cobra.Command{
@@ -105,6 +106,13 @@ func newSkillCreateCmd() *cobra.Command {
105106
s := skill.NewSkillWithBody(name, description, author, authorType, authorPlatform, body)
106107
s.Tags = tags
107108

109+
if version != "" {
110+
if _, err := skill.ParseVersion(version); err != nil {
111+
return &ValidationError{Message: err.Error()}
112+
}
113+
s.Metadata.Version = version
114+
}
115+
108116
// Validate on create (warnings only, don't block)
109117
issues := skill.Validate(s)
110118
if len(issues) > 0 {
@@ -136,6 +144,7 @@ func newSkillCreateCmd() *cobra.Command {
136144
cmd.Flags().BoolVar(&force, "force", false, "bypass overlap detection block")
137145
cmd.Flags().StringVar(&fromTemplate, "from-template", "", "path to a template file for the skill body")
138146
cmd.Flags().StringSliceVar(&tags, "tags", nil, "comma-separated tags for the skill")
147+
cmd.Flags().StringVar(&version, "version", "", "initial version (default: 0.1.0)")
139148

140149
return cmd
141150
}

internal/cli/skill_version.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
"path/filepath"
6+
7+
"github.com/devrimcavusoglu/skern/internal/output"
8+
"github.com/devrimcavusoglu/skern/internal/skill"
9+
"github.com/spf13/cobra"
10+
)
11+
12+
func newSkillVersionCmd() *cobra.Command {
13+
var (
14+
scope string
15+
bump string
16+
)
17+
18+
cmd := &cobra.Command{
19+
Use: "version <name>",
20+
Short: "Show or bump a skill's version",
21+
Long: "Display the current version of a skill, or bump it with --bump patch|minor|major.",
22+
Args: cobra.ExactArgs(1),
23+
RunE: func(cmd *cobra.Command, args []string) error {
24+
ctx := getContext(cmd)
25+
name := args[0]
26+
27+
reg, err := ctx.NewRegistry()
28+
if err != nil {
29+
return err
30+
}
31+
32+
s, skillDir, foundScope, err := resolveSkill(reg, name, scope)
33+
if err != nil {
34+
return err
35+
}
36+
37+
if bump == "" {
38+
// Show current version
39+
result := output.SkillVersionResult{
40+
Name: name,
41+
Version: s.Metadata.Version,
42+
Scope: string(foundScope),
43+
Bumped: false,
44+
}
45+
text := fmt.Sprintf("%s\n", s.Metadata.Version)
46+
ctx.Printer.PrintResult(result, text)
47+
return nil
48+
}
49+
50+
// Validate bump level
51+
if bump != "patch" && bump != "minor" && bump != "major" {
52+
return &ValidationError{Message: fmt.Sprintf("invalid bump level %q: must be patch, minor, or major", bump)}
53+
}
54+
55+
previousVersion := s.Metadata.Version
56+
newVersion, err := skill.BumpVersion(previousVersion, bump)
57+
if err != nil {
58+
return fmt.Errorf("bumping version: %w", err)
59+
}
60+
61+
s.Metadata.Version = newVersion
62+
manifestPath := filepath.Join(skillDir, "SKILL.md")
63+
if err := skill.WriteManifest(s, manifestPath); err != nil {
64+
return fmt.Errorf("writing manifest: %w", err)
65+
}
66+
67+
result := output.SkillVersionResult{
68+
Name: name,
69+
Version: newVersion,
70+
Scope: string(foundScope),
71+
PreviousVersion: previousVersion,
72+
Bumped: true,
73+
}
74+
text := fmt.Sprintf("Bumped %q from %s to %s (%s)\n", name, previousVersion, newVersion, bump)
75+
ctx.Printer.PrintResult(result, text)
76+
return nil
77+
},
78+
}
79+
80+
cmd.Flags().StringVar(&scope, "scope", "", "skill scope (user or project)")
81+
cmd.Flags().StringVar(&bump, "bump", "", "bump level: patch, minor, or major")
82+
83+
return cmd
84+
}

internal/cli/skill_version_test.go

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
package cli
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
7+
"github.com/devrimcavusoglu/skern/internal/output"
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
// --- skill version (show) ---
13+
14+
func TestSkillVersion_Show(t *testing.T) {
15+
cc := testRegistry(t)
16+
17+
_, err := runCmd(t, cc, "skill", "create", "ver-skill", "--description", "A skill")
18+
require.NoError(t, err)
19+
20+
out, err := runCmd(t, cc, "skill", "version", "ver-skill", "--json")
21+
require.NoError(t, err)
22+
23+
var result output.SkillVersionResult
24+
require.NoError(t, json.Unmarshal([]byte(out), &result))
25+
assert.Equal(t, "ver-skill", result.Name)
26+
assert.Equal(t, "0.1.0", result.Version)
27+
assert.Equal(t, "user", result.Scope)
28+
assert.False(t, result.Bumped)
29+
}
30+
31+
func TestSkillVersion_Show_Text(t *testing.T) {
32+
cc := testRegistry(t)
33+
34+
_, err := runCmd(t, cc, "skill", "create", "ver-text", "--description", "A skill")
35+
require.NoError(t, err)
36+
37+
out, err := runCmd(t, cc, "skill", "version", "ver-text")
38+
require.NoError(t, err)
39+
assert.Contains(t, out, "0.1.0")
40+
}
41+
42+
func TestSkillVersion_NotFound(t *testing.T) {
43+
cc := testRegistry(t)
44+
45+
_, err := runCmd(t, cc, "skill", "version", "nonexistent")
46+
assert.Error(t, err)
47+
}
48+
49+
// --- skill version --bump ---
50+
51+
func TestSkillVersion_BumpPatch(t *testing.T) {
52+
cc := testRegistry(t)
53+
54+
_, err := runCmd(t, cc, "skill", "create", "bump-patch", "--description", "A skill")
55+
require.NoError(t, err)
56+
57+
out, err := runCmd(t, cc, "skill", "version", "bump-patch", "--bump", "patch", "--json")
58+
require.NoError(t, err)
59+
60+
var result output.SkillVersionResult
61+
require.NoError(t, json.Unmarshal([]byte(out), &result))
62+
assert.Equal(t, "bump-patch", result.Name)
63+
assert.Equal(t, "0.1.1", result.Version)
64+
assert.Equal(t, "0.1.0", result.PreviousVersion)
65+
assert.True(t, result.Bumped)
66+
67+
// Verify the change persisted
68+
showOut, err := runCmd(t, cc, "skill", "show", "bump-patch", "--json")
69+
require.NoError(t, err)
70+
71+
var showResult output.SkillResult
72+
require.NoError(t, json.Unmarshal([]byte(showOut), &showResult))
73+
assert.Equal(t, "0.1.1", showResult.Version)
74+
}
75+
76+
func TestSkillVersion_BumpMinor(t *testing.T) {
77+
cc := testRegistry(t)
78+
79+
_, err := runCmd(t, cc, "skill", "create", "bump-minor", "--description", "A skill")
80+
require.NoError(t, err)
81+
82+
out, err := runCmd(t, cc, "skill", "version", "bump-minor", "--bump", "minor", "--json")
83+
require.NoError(t, err)
84+
85+
var result output.SkillVersionResult
86+
require.NoError(t, json.Unmarshal([]byte(out), &result))
87+
assert.Equal(t, "0.2.0", result.Version)
88+
assert.Equal(t, "0.1.0", result.PreviousVersion)
89+
assert.True(t, result.Bumped)
90+
}
91+
92+
func TestSkillVersion_BumpMajor(t *testing.T) {
93+
cc := testRegistry(t)
94+
95+
_, err := runCmd(t, cc, "skill", "create", "bump-major", "--description", "A skill")
96+
require.NoError(t, err)
97+
98+
out, err := runCmd(t, cc, "skill", "version", "bump-major", "--bump", "major", "--json")
99+
require.NoError(t, err)
100+
101+
var result output.SkillVersionResult
102+
require.NoError(t, json.Unmarshal([]byte(out), &result))
103+
assert.Equal(t, "1.0.0", result.Version)
104+
assert.Equal(t, "0.1.0", result.PreviousVersion)
105+
assert.True(t, result.Bumped)
106+
}
107+
108+
func TestSkillVersion_BumpInvalidLevel(t *testing.T) {
109+
cc := testRegistry(t)
110+
111+
_, err := runCmd(t, cc, "skill", "create", "bump-bad", "--description", "A skill")
112+
require.NoError(t, err)
113+
114+
_, err = runCmd(t, cc, "skill", "version", "bump-bad", "--bump", "invalid")
115+
assert.Error(t, err)
116+
}
117+
118+
func TestSkillVersion_BumpText(t *testing.T) {
119+
cc := testRegistry(t)
120+
121+
_, err := runCmd(t, cc, "skill", "create", "bump-text", "--description", "A skill")
122+
require.NoError(t, err)
123+
124+
out, err := runCmd(t, cc, "skill", "version", "bump-text", "--bump", "patch")
125+
require.NoError(t, err)
126+
assert.Contains(t, out, "Bumped")
127+
assert.Contains(t, out, "0.1.0")
128+
assert.Contains(t, out, "0.1.1")
129+
}
130+
131+
func TestSkillVersion_MultipleBumps(t *testing.T) {
132+
cc := testRegistry(t)
133+
134+
_, err := runCmd(t, cc, "skill", "create", "multi-bump", "--description", "A skill")
135+
require.NoError(t, err)
136+
137+
// Bump patch twice
138+
_, err = runCmd(t, cc, "skill", "version", "multi-bump", "--bump", "patch")
139+
require.NoError(t, err)
140+
_, err = runCmd(t, cc, "skill", "version", "multi-bump", "--bump", "patch")
141+
require.NoError(t, err)
142+
143+
out, err := runCmd(t, cc, "skill", "version", "multi-bump", "--json")
144+
require.NoError(t, err)
145+
146+
var result output.SkillVersionResult
147+
require.NoError(t, json.Unmarshal([]byte(out), &result))
148+
assert.Equal(t, "0.1.2", result.Version)
149+
}
150+
151+
func TestSkillVersion_Scoped(t *testing.T) {
152+
cc := testRegistry(t)
153+
154+
_, err := runCmd(t, cc, "skill", "create", "scoped-ver", "--scope", "project", "--description", "A skill")
155+
require.NoError(t, err)
156+
157+
out, err := runCmd(t, cc, "skill", "version", "scoped-ver", "--scope", "project", "--json")
158+
require.NoError(t, err)
159+
160+
var result output.SkillVersionResult
161+
require.NoError(t, json.Unmarshal([]byte(out), &result))
162+
assert.Equal(t, "project", result.Scope)
163+
assert.Equal(t, "0.1.0", result.Version)
164+
}
165+
166+
// --- skill create --version ---
167+
168+
func TestSkillCreate_WithVersion(t *testing.T) {
169+
cc := testRegistry(t)
170+
171+
_, err := runCmd(t, cc, "skill", "create", "versioned-skill",
172+
"--description", "A skill", "--version", "0.2.0")
173+
require.NoError(t, err)
174+
175+
out, err := runCmd(t, cc, "skill", "show", "versioned-skill", "--json")
176+
require.NoError(t, err)
177+
178+
var result output.SkillResult
179+
require.NoError(t, json.Unmarshal([]byte(out), &result))
180+
assert.Equal(t, "0.2.0", result.Version)
181+
}
182+
183+
func TestSkillCreate_WithVersion_JSON(t *testing.T) {
184+
cc := testRegistry(t)
185+
186+
out, err := runCmd(t, cc, "skill", "create", "ver-json-skill",
187+
"--description", "A skill", "--version", "1.0.0", "--json")
188+
require.NoError(t, err)
189+
190+
var createResult output.SkillCreateResult
191+
require.NoError(t, json.Unmarshal([]byte(out), &createResult))
192+
assert.Equal(t, "ver-json-skill", createResult.Name)
193+
194+
// Verify version was set
195+
showOut, err := runCmd(t, cc, "skill", "show", "ver-json-skill", "--json")
196+
require.NoError(t, err)
197+
198+
var showResult output.SkillResult
199+
require.NoError(t, json.Unmarshal([]byte(showOut), &showResult))
200+
assert.Equal(t, "1.0.0", showResult.Version)
201+
}
202+
203+
func TestSkillCreate_WithVersion_Invalid(t *testing.T) {
204+
cc := testRegistry(t)
205+
206+
_, err := runCmd(t, cc, "skill", "create", "bad-ver-skill",
207+
"--description", "A skill", "--version", "bad")
208+
assert.Error(t, err)
209+
}
210+
211+
func TestSkillCreate_DefaultVersion(t *testing.T) {
212+
cc := testRegistry(t)
213+
214+
_, err := runCmd(t, cc, "skill", "create", "default-ver",
215+
"--description", "A skill")
216+
require.NoError(t, err)
217+
218+
out, err := runCmd(t, cc, "skill", "show", "default-ver", "--json")
219+
require.NoError(t, err)
220+
221+
var result output.SkillResult
222+
require.NoError(t, json.Unmarshal([]byte(out), &result))
223+
assert.Equal(t, "0.1.0", result.Version)
224+
}

internal/output/types_skill.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,20 @@ type SkillValidateResult struct {
106106
Warns int `json:"warnings"`
107107
Hints int `json:"hints"`
108108
}
109+
110+
// SkillVersionResult is the JSON envelope for skill version output.
111+
type SkillVersionResult struct {
112+
Name string `json:"name"`
113+
Version string `json:"version"`
114+
Scope string `json:"scope"`
115+
PreviousVersion string `json:"previous_version,omitempty"`
116+
Bumped bool `json:"bumped"`
117+
}
118+
119+
// VersionCompareResult is the JSON envelope for version comparison output.
120+
type VersionCompareResult struct {
121+
Installed string `json:"installed"`
122+
Available string `json:"available"`
123+
Kind string `json:"kind,omitempty"`
124+
Upgrade bool `json:"upgrade"`
125+
}

0 commit comments

Comments
 (0)