From 54dbc1a27b200d207110058ebd1b81411f9ec38d Mon Sep 17 00:00:00 2001 From: devrimcavusoglu Date: Mon, 9 Mar 2026 18:28:58 +0300 Subject: [PATCH 1/2] 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 --- internal/cli/skill.go | 1 + internal/cli/skill_create.go | 9 + internal/cli/skill_version.go | 84 ++++++++ internal/cli/skill_version_test.go | 224 +++++++++++++++++++++ internal/output/types_skill.go | 17 ++ internal/skill/version.go | 106 ++++++++++ internal/skill/version_test.go | 309 +++++++++++++++++++++++++++++ 7 files changed, 750 insertions(+) create mode 100644 internal/cli/skill_version.go create mode 100644 internal/cli/skill_version_test.go create mode 100644 internal/skill/version.go create mode 100644 internal/skill/version_test.go diff --git a/internal/cli/skill.go b/internal/cli/skill.go index 18a3d0d..84bf671 100644 --- a/internal/cli/skill.go +++ b/internal/cli/skill.go @@ -22,6 +22,7 @@ func newSkillCmd() *cobra.Command { cmd.AddCommand(newSkillUninstallCmd()) cmd.AddCommand(newSkillRecommendCmd()) cmd.AddCommand(newSkillDiffCmd()) + cmd.AddCommand(newSkillVersionCmd()) return cmd } diff --git a/internal/cli/skill_create.go b/internal/cli/skill_create.go index 10c3d47..f63a2a0 100644 --- a/internal/cli/skill_create.go +++ b/internal/cli/skill_create.go @@ -22,6 +22,7 @@ func newSkillCreateCmd() *cobra.Command { force bool fromTemplate string tags []string + version string ) cmd := &cobra.Command{ @@ -105,6 +106,13 @@ func newSkillCreateCmd() *cobra.Command { s := skill.NewSkillWithBody(name, description, author, authorType, authorPlatform, body) s.Tags = tags + if version != "" { + if _, err := skill.ParseVersion(version); err != nil { + return &ValidationError{Message: err.Error()} + } + s.Metadata.Version = version + } + // Validate on create (warnings only, don't block) issues := skill.Validate(s) if len(issues) > 0 { @@ -136,6 +144,7 @@ func newSkillCreateCmd() *cobra.Command { cmd.Flags().BoolVar(&force, "force", false, "bypass overlap detection block") cmd.Flags().StringVar(&fromTemplate, "from-template", "", "path to a template file for the skill body") cmd.Flags().StringSliceVar(&tags, "tags", nil, "comma-separated tags for the skill") + cmd.Flags().StringVar(&version, "version", "", "initial version (default: 0.1.0)") return cmd } diff --git a/internal/cli/skill_version.go b/internal/cli/skill_version.go new file mode 100644 index 0000000..dc3d599 --- /dev/null +++ b/internal/cli/skill_version.go @@ -0,0 +1,84 @@ +package cli + +import ( + "fmt" + "path/filepath" + + "github.com/devrimcavusoglu/skern/internal/output" + "github.com/devrimcavusoglu/skern/internal/skill" + "github.com/spf13/cobra" +) + +func newSkillVersionCmd() *cobra.Command { + var ( + scope string + bump string + ) + + cmd := &cobra.Command{ + Use: "version ", + Short: "Show or bump a skill's version", + Long: "Display the current version of a skill, or bump it with --bump patch|minor|major.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := getContext(cmd) + name := args[0] + + reg, err := ctx.NewRegistry() + if err != nil { + return err + } + + s, skillDir, foundScope, err := resolveSkill(reg, name, scope) + if err != nil { + return err + } + + if bump == "" { + // Show current version + result := output.SkillVersionResult{ + Name: name, + Version: s.Metadata.Version, + Scope: string(foundScope), + Bumped: false, + } + text := fmt.Sprintf("%s\n", s.Metadata.Version) + ctx.Printer.PrintResult(result, text) + return nil + } + + // Validate bump level + if bump != "patch" && bump != "minor" && bump != "major" { + return &ValidationError{Message: fmt.Sprintf("invalid bump level %q: must be patch, minor, or major", bump)} + } + + previousVersion := s.Metadata.Version + newVersion, err := skill.BumpVersion(previousVersion, bump) + if err != nil { + return fmt.Errorf("bumping version: %w", err) + } + + s.Metadata.Version = newVersion + manifestPath := filepath.Join(skillDir, "SKILL.md") + if err := skill.WriteManifest(s, manifestPath); err != nil { + return fmt.Errorf("writing manifest: %w", err) + } + + result := output.SkillVersionResult{ + Name: name, + Version: newVersion, + Scope: string(foundScope), + PreviousVersion: previousVersion, + Bumped: true, + } + text := fmt.Sprintf("Bumped %q from %s to %s (%s)\n", name, previousVersion, newVersion, bump) + ctx.Printer.PrintResult(result, text) + return nil + }, + } + + cmd.Flags().StringVar(&scope, "scope", "", "skill scope (user or project)") + cmd.Flags().StringVar(&bump, "bump", "", "bump level: patch, minor, or major") + + return cmd +} diff --git a/internal/cli/skill_version_test.go b/internal/cli/skill_version_test.go new file mode 100644 index 0000000..fe76ccb --- /dev/null +++ b/internal/cli/skill_version_test.go @@ -0,0 +1,224 @@ +package cli + +import ( + "encoding/json" + "testing" + + "github.com/devrimcavusoglu/skern/internal/output" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- skill version (show) --- + +func TestSkillVersion_Show(t *testing.T) { + cc := testRegistry(t) + + _, err := runCmd(t, cc, "skill", "create", "ver-skill", "--description", "A skill") + require.NoError(t, err) + + out, err := runCmd(t, cc, "skill", "version", "ver-skill", "--json") + require.NoError(t, err) + + var result output.SkillVersionResult + require.NoError(t, json.Unmarshal([]byte(out), &result)) + assert.Equal(t, "ver-skill", result.Name) + assert.Equal(t, "0.1.0", result.Version) + assert.Equal(t, "user", result.Scope) + assert.False(t, result.Bumped) +} + +func TestSkillVersion_Show_Text(t *testing.T) { + cc := testRegistry(t) + + _, err := runCmd(t, cc, "skill", "create", "ver-text", "--description", "A skill") + require.NoError(t, err) + + out, err := runCmd(t, cc, "skill", "version", "ver-text") + require.NoError(t, err) + assert.Contains(t, out, "0.1.0") +} + +func TestSkillVersion_NotFound(t *testing.T) { + cc := testRegistry(t) + + _, err := runCmd(t, cc, "skill", "version", "nonexistent") + assert.Error(t, err) +} + +// --- skill version --bump --- + +func TestSkillVersion_BumpPatch(t *testing.T) { + cc := testRegistry(t) + + _, err := runCmd(t, cc, "skill", "create", "bump-patch", "--description", "A skill") + require.NoError(t, err) + + out, err := runCmd(t, cc, "skill", "version", "bump-patch", "--bump", "patch", "--json") + require.NoError(t, err) + + var result output.SkillVersionResult + require.NoError(t, json.Unmarshal([]byte(out), &result)) + assert.Equal(t, "bump-patch", result.Name) + assert.Equal(t, "0.1.1", result.Version) + assert.Equal(t, "0.1.0", result.PreviousVersion) + assert.True(t, result.Bumped) + + // Verify the change persisted + showOut, err := runCmd(t, cc, "skill", "show", "bump-patch", "--json") + require.NoError(t, err) + + var showResult output.SkillResult + require.NoError(t, json.Unmarshal([]byte(showOut), &showResult)) + assert.Equal(t, "0.1.1", showResult.Version) +} + +func TestSkillVersion_BumpMinor(t *testing.T) { + cc := testRegistry(t) + + _, err := runCmd(t, cc, "skill", "create", "bump-minor", "--description", "A skill") + require.NoError(t, err) + + out, err := runCmd(t, cc, "skill", "version", "bump-minor", "--bump", "minor", "--json") + require.NoError(t, err) + + var result output.SkillVersionResult + require.NoError(t, json.Unmarshal([]byte(out), &result)) + assert.Equal(t, "0.2.0", result.Version) + assert.Equal(t, "0.1.0", result.PreviousVersion) + assert.True(t, result.Bumped) +} + +func TestSkillVersion_BumpMajor(t *testing.T) { + cc := testRegistry(t) + + _, err := runCmd(t, cc, "skill", "create", "bump-major", "--description", "A skill") + require.NoError(t, err) + + out, err := runCmd(t, cc, "skill", "version", "bump-major", "--bump", "major", "--json") + require.NoError(t, err) + + var result output.SkillVersionResult + require.NoError(t, json.Unmarshal([]byte(out), &result)) + assert.Equal(t, "1.0.0", result.Version) + assert.Equal(t, "0.1.0", result.PreviousVersion) + assert.True(t, result.Bumped) +} + +func TestSkillVersion_BumpInvalidLevel(t *testing.T) { + cc := testRegistry(t) + + _, err := runCmd(t, cc, "skill", "create", "bump-bad", "--description", "A skill") + require.NoError(t, err) + + _, err = runCmd(t, cc, "skill", "version", "bump-bad", "--bump", "invalid") + assert.Error(t, err) +} + +func TestSkillVersion_BumpText(t *testing.T) { + cc := testRegistry(t) + + _, err := runCmd(t, cc, "skill", "create", "bump-text", "--description", "A skill") + require.NoError(t, err) + + out, err := runCmd(t, cc, "skill", "version", "bump-text", "--bump", "patch") + require.NoError(t, err) + assert.Contains(t, out, "Bumped") + assert.Contains(t, out, "0.1.0") + assert.Contains(t, out, "0.1.1") +} + +func TestSkillVersion_MultipleBumps(t *testing.T) { + cc := testRegistry(t) + + _, err := runCmd(t, cc, "skill", "create", "multi-bump", "--description", "A skill") + require.NoError(t, err) + + // Bump patch twice + _, err = runCmd(t, cc, "skill", "version", "multi-bump", "--bump", "patch") + require.NoError(t, err) + _, err = runCmd(t, cc, "skill", "version", "multi-bump", "--bump", "patch") + require.NoError(t, err) + + out, err := runCmd(t, cc, "skill", "version", "multi-bump", "--json") + require.NoError(t, err) + + var result output.SkillVersionResult + require.NoError(t, json.Unmarshal([]byte(out), &result)) + assert.Equal(t, "0.1.2", result.Version) +} + +func TestSkillVersion_Scoped(t *testing.T) { + cc := testRegistry(t) + + _, err := runCmd(t, cc, "skill", "create", "scoped-ver", "--scope", "project", "--description", "A skill") + require.NoError(t, err) + + out, err := runCmd(t, cc, "skill", "version", "scoped-ver", "--scope", "project", "--json") + require.NoError(t, err) + + var result output.SkillVersionResult + require.NoError(t, json.Unmarshal([]byte(out), &result)) + assert.Equal(t, "project", result.Scope) + assert.Equal(t, "0.1.0", result.Version) +} + +// --- skill create --version --- + +func TestSkillCreate_WithVersion(t *testing.T) { + cc := testRegistry(t) + + _, err := runCmd(t, cc, "skill", "create", "versioned-skill", + "--description", "A skill", "--version", "0.2.0") + require.NoError(t, err) + + out, err := runCmd(t, cc, "skill", "show", "versioned-skill", "--json") + require.NoError(t, err) + + var result output.SkillResult + require.NoError(t, json.Unmarshal([]byte(out), &result)) + assert.Equal(t, "0.2.0", result.Version) +} + +func TestSkillCreate_WithVersion_JSON(t *testing.T) { + cc := testRegistry(t) + + out, err := runCmd(t, cc, "skill", "create", "ver-json-skill", + "--description", "A skill", "--version", "1.0.0", "--json") + require.NoError(t, err) + + var createResult output.SkillCreateResult + require.NoError(t, json.Unmarshal([]byte(out), &createResult)) + assert.Equal(t, "ver-json-skill", createResult.Name) + + // Verify version was set + showOut, err := runCmd(t, cc, "skill", "show", "ver-json-skill", "--json") + require.NoError(t, err) + + var showResult output.SkillResult + require.NoError(t, json.Unmarshal([]byte(showOut), &showResult)) + assert.Equal(t, "1.0.0", showResult.Version) +} + +func TestSkillCreate_WithVersion_Invalid(t *testing.T) { + cc := testRegistry(t) + + _, err := runCmd(t, cc, "skill", "create", "bad-ver-skill", + "--description", "A skill", "--version", "bad") + assert.Error(t, err) +} + +func TestSkillCreate_DefaultVersion(t *testing.T) { + cc := testRegistry(t) + + _, err := runCmd(t, cc, "skill", "create", "default-ver", + "--description", "A skill") + require.NoError(t, err) + + out, err := runCmd(t, cc, "skill", "show", "default-ver", "--json") + require.NoError(t, err) + + var result output.SkillResult + require.NoError(t, json.Unmarshal([]byte(out), &result)) + assert.Equal(t, "0.1.0", result.Version) +} diff --git a/internal/output/types_skill.go b/internal/output/types_skill.go index 87b2d05..e345c0c 100644 --- a/internal/output/types_skill.go +++ b/internal/output/types_skill.go @@ -126,3 +126,20 @@ type SkillDiffResult struct { LeftBody string `json:"left_body,omitempty"` RightBody string `json:"right_body,omitempty"` } + +// SkillVersionResult is the JSON envelope for skill version output. +type SkillVersionResult struct { + Name string `json:"name"` + Version string `json:"version"` + Scope string `json:"scope"` + PreviousVersion string `json:"previous_version,omitempty"` + Bumped bool `json:"bumped"` +} + +// VersionCompareResult is the JSON envelope for version comparison output. +type VersionCompareResult struct { + Installed string `json:"installed"` + Available string `json:"available"` + Kind string `json:"kind,omitempty"` + Upgrade bool `json:"upgrade"` +} diff --git a/internal/skill/version.go b/internal/skill/version.go new file mode 100644 index 0000000..51c06c9 --- /dev/null +++ b/internal/skill/version.go @@ -0,0 +1,106 @@ +package skill + +import ( + "fmt" + "strconv" + "strings" +) + +// Version represents a semantic version (MAJOR.MINOR.PATCH). +type Version struct { + Major int + Minor int + Patch int +} + +// ParseVersion parses a semver string into a Version. +func ParseVersion(s string) (Version, error) { + parts := strings.Split(s, ".") + if len(parts) != 3 { + return Version{}, fmt.Errorf("invalid version %q: must be MAJOR.MINOR.PATCH", s) + } + + major, err := strconv.Atoi(parts[0]) + if err != nil || major < 0 { + return Version{}, fmt.Errorf("invalid version %q: major must be a non-negative integer", s) + } + + minor, err := strconv.Atoi(parts[1]) + if err != nil || minor < 0 { + return Version{}, fmt.Errorf("invalid version %q: minor must be a non-negative integer", s) + } + + patch, err := strconv.Atoi(parts[2]) + if err != nil || patch < 0 { + return Version{}, fmt.Errorf("invalid version %q: patch must be a non-negative integer", s) + } + + return Version{Major: major, Minor: minor, Patch: patch}, nil +} + +// String returns the semver string representation. +func (v Version) String() string { + return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) +} + +// BumpPatch returns a new Version with the patch number incremented. +func (v Version) BumpPatch() Version { + return Version{Major: v.Major, Minor: v.Minor, Patch: v.Patch + 1} +} + +// BumpMinor returns a new Version with the minor number incremented and patch reset to 0. +func (v Version) BumpMinor() Version { + return Version{Major: v.Major, Minor: v.Minor + 1, Patch: 0} +} + +// BumpMajor returns a new Version with the major number incremented and minor/patch reset to 0. +func (v Version) BumpMajor() Version { + return Version{Major: v.Major + 1, Minor: 0, Patch: 0} +} + +// CompareVersions compares two version strings and returns the upgrade kind. +// Returns ("", nil) if versions are equal. +// Returns ("patch"|"minor"|"major", nil) indicating the kind of upgrade from a to b. +// Returns a negative kind if b < a (downgrade): the kind still reflects the most significant difference. +func CompareVersions(a, b string) (kind string, newer bool, err error) { + va, err := ParseVersion(a) + if err != nil { + return "", false, fmt.Errorf("parsing version a: %w", err) + } + + vb, err := ParseVersion(b) + if err != nil { + return "", false, fmt.Errorf("parsing version b: %w", err) + } + + if va.Major != vb.Major { + return "major", vb.Major > va.Major, nil + } + if va.Minor != vb.Minor { + return "minor", vb.Minor > va.Minor, nil + } + if va.Patch != vb.Patch { + return "patch", vb.Patch > va.Patch, nil + } + + return "", false, nil +} + +// BumpVersion parses a version string, applies the given bump level, and returns the new version string. +func BumpVersion(version, level string) (string, error) { + v, err := ParseVersion(version) + if err != nil { + return "", err + } + + switch level { + case "patch": + return v.BumpPatch().String(), nil + case "minor": + return v.BumpMinor().String(), nil + case "major": + return v.BumpMajor().String(), nil + default: + return "", fmt.Errorf("invalid bump level %q: must be patch, minor, or major", level) + } +} diff --git a/internal/skill/version_test.go b/internal/skill/version_test.go new file mode 100644 index 0000000..d43fd8d --- /dev/null +++ b/internal/skill/version_test.go @@ -0,0 +1,309 @@ +package skill + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseVersion(t *testing.T) { + tests := []struct { + name string + input string + want Version + wantErr bool + }{ + { + name: "valid version", + input: "1.2.3", + want: Version{Major: 1, Minor: 2, Patch: 3}, + }, + { + name: "zero version", + input: "0.0.0", + want: Version{Major: 0, Minor: 0, Patch: 0}, + }, + { + name: "default version", + input: "0.1.0", + want: Version{Major: 0, Minor: 1, Patch: 0}, + }, + { + name: "large numbers", + input: "10.20.30", + want: Version{Major: 10, Minor: 20, Patch: 30}, + }, + { + name: "too few parts", + input: "1.2", + wantErr: true, + }, + { + name: "too many parts", + input: "1.2.3.4", + wantErr: true, + }, + { + name: "empty string", + input: "", + wantErr: true, + }, + { + name: "non-numeric major", + input: "x.1.0", + wantErr: true, + }, + { + name: "non-numeric minor", + input: "1.x.0", + wantErr: true, + }, + { + name: "non-numeric patch", + input: "1.0.x", + wantErr: true, + }, + { + name: "negative major", + input: "-1.0.0", + wantErr: true, + }, + { + name: "negative minor", + input: "1.-1.0", + wantErr: true, + }, + { + name: "negative patch", + input: "1.0.-1", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseVersion(tt.input) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestVersion_String(t *testing.T) { + tests := []struct { + name string + v Version + want string + }{ + { + name: "simple version", + v: Version{Major: 1, Minor: 2, Patch: 3}, + want: "1.2.3", + }, + { + name: "zero version", + v: Version{Major: 0, Minor: 0, Patch: 0}, + want: "0.0.0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, tt.v.String()) + }) + } +} + +func TestVersion_BumpPatch(t *testing.T) { + v := Version{Major: 1, Minor: 2, Patch: 3} + bumped := v.BumpPatch() + assert.Equal(t, Version{Major: 1, Minor: 2, Patch: 4}, bumped) +} + +func TestVersion_BumpMinor(t *testing.T) { + v := Version{Major: 1, Minor: 2, Patch: 3} + bumped := v.BumpMinor() + assert.Equal(t, Version{Major: 1, Minor: 3, Patch: 0}, bumped) +} + +func TestVersion_BumpMajor(t *testing.T) { + v := Version{Major: 1, Minor: 2, Patch: 3} + bumped := v.BumpMajor() + assert.Equal(t, Version{Major: 2, Minor: 0, Patch: 0}, bumped) +} + +func TestBumpVersion(t *testing.T) { + tests := []struct { + name string + version string + level string + want string + wantErr bool + }{ + { + name: "bump patch", + version: "1.2.3", + level: "patch", + want: "1.2.4", + }, + { + name: "bump minor", + version: "1.2.3", + level: "minor", + want: "1.3.0", + }, + { + name: "bump major", + version: "1.2.3", + level: "major", + want: "2.0.0", + }, + { + name: "bump default version patch", + version: "0.1.0", + level: "patch", + want: "0.1.1", + }, + { + name: "bump default version minor", + version: "0.1.0", + level: "minor", + want: "0.2.0", + }, + { + name: "bump default version major", + version: "0.1.0", + level: "major", + want: "1.0.0", + }, + { + name: "invalid version", + version: "bad", + level: "patch", + wantErr: true, + }, + { + name: "invalid level", + version: "1.0.0", + level: "invalid", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := BumpVersion(tt.version, tt.level) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestCompareVersions(t *testing.T) { + tests := []struct { + name string + a string + b string + wantKind string + wantNew bool + wantErr bool + }{ + { + name: "equal versions", + a: "1.0.0", + b: "1.0.0", + wantKind: "", + wantNew: false, + }, + { + name: "patch upgrade", + a: "1.0.0", + b: "1.0.1", + wantKind: "patch", + wantNew: true, + }, + { + name: "minor upgrade", + a: "1.0.0", + b: "1.1.0", + wantKind: "minor", + wantNew: true, + }, + { + name: "major upgrade", + a: "1.0.0", + b: "2.0.0", + wantKind: "major", + wantNew: true, + }, + { + name: "patch downgrade", + a: "1.0.1", + b: "1.0.0", + wantKind: "patch", + wantNew: false, + }, + { + name: "minor downgrade", + a: "1.1.0", + b: "1.0.0", + wantKind: "minor", + wantNew: false, + }, + { + name: "major downgrade", + a: "2.0.0", + b: "1.0.0", + wantKind: "major", + wantNew: false, + }, + { + name: "major takes precedence over minor", + a: "1.5.0", + b: "2.0.0", + wantKind: "major", + wantNew: true, + }, + { + name: "minor takes precedence over patch", + a: "1.0.5", + b: "1.1.0", + wantKind: "minor", + wantNew: true, + }, + { + name: "invalid first version", + a: "bad", + b: "1.0.0", + wantErr: true, + }, + { + name: "invalid second version", + a: "1.0.0", + b: "bad", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + kind, newer, err := CompareVersions(tt.a, tt.b) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantKind, kind) + assert.Equal(t, tt.wantNew, newer) + }) + } +} From 830adb9cb8b5c4f677abcb3e7b1468a4adb0d460 Mon Sep 17 00:00:00 2001 From: devrimcavusoglu Date: Tue, 17 Mar 2026 18:20:53 +0300 Subject: [PATCH 2/2] Fix default skill version and reject leading zeros in semver - Change default skill version from 0.1.0 to 0.0.1 (skills should start at 0.0.1, distinct from the CLI's own v0.1.0 release version) - Reject leading zeros in ParseVersion per strict semver spec (e.g. "01.2.3" is now invalid) - Update all tests and docs accordingly Co-Authored-By: Claude Opus 4.6 --- AGENTS.md | 2 +- internal/cli/skill_create.go | 2 +- internal/cli/skill_version_test.go | 26 +++++++++++++------------- internal/skill/scaffold.go | 2 +- internal/skill/scaffold_test.go | 2 +- internal/skill/version.go | 30 +++++++++++++++++++++--------- internal/skill/version_test.go | 29 ++++++++++++++++++++++------- 7 files changed, 60 insertions(+), 33 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f4567ef..e2c8ecd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -176,7 +176,7 @@ metadata: name: author-name type: human # human | agent platform: claude-code # only when type=agent - version: "0.1.0" + version: "0.0.1" modified-by: # append-only provenance list - name: codex-cli type: agent diff --git a/internal/cli/skill_create.go b/internal/cli/skill_create.go index f63a2a0..6d15425 100644 --- a/internal/cli/skill_create.go +++ b/internal/cli/skill_create.go @@ -144,7 +144,7 @@ func newSkillCreateCmd() *cobra.Command { cmd.Flags().BoolVar(&force, "force", false, "bypass overlap detection block") cmd.Flags().StringVar(&fromTemplate, "from-template", "", "path to a template file for the skill body") cmd.Flags().StringSliceVar(&tags, "tags", nil, "comma-separated tags for the skill") - cmd.Flags().StringVar(&version, "version", "", "initial version (default: 0.1.0)") + cmd.Flags().StringVar(&version, "version", "", "initial version (default: 0.0.1)") return cmd } diff --git a/internal/cli/skill_version_test.go b/internal/cli/skill_version_test.go index fe76ccb..aaa30e2 100644 --- a/internal/cli/skill_version_test.go +++ b/internal/cli/skill_version_test.go @@ -23,7 +23,7 @@ func TestSkillVersion_Show(t *testing.T) { var result output.SkillVersionResult require.NoError(t, json.Unmarshal([]byte(out), &result)) assert.Equal(t, "ver-skill", result.Name) - assert.Equal(t, "0.1.0", result.Version) + assert.Equal(t, "0.0.1", result.Version) assert.Equal(t, "user", result.Scope) assert.False(t, result.Bumped) } @@ -36,7 +36,7 @@ func TestSkillVersion_Show_Text(t *testing.T) { out, err := runCmd(t, cc, "skill", "version", "ver-text") require.NoError(t, err) - assert.Contains(t, out, "0.1.0") + assert.Contains(t, out, "0.0.1") } func TestSkillVersion_NotFound(t *testing.T) { @@ -60,8 +60,8 @@ func TestSkillVersion_BumpPatch(t *testing.T) { var result output.SkillVersionResult require.NoError(t, json.Unmarshal([]byte(out), &result)) assert.Equal(t, "bump-patch", result.Name) - assert.Equal(t, "0.1.1", result.Version) - assert.Equal(t, "0.1.0", result.PreviousVersion) + assert.Equal(t, "0.0.2", result.Version) + assert.Equal(t, "0.0.1", result.PreviousVersion) assert.True(t, result.Bumped) // Verify the change persisted @@ -70,7 +70,7 @@ func TestSkillVersion_BumpPatch(t *testing.T) { var showResult output.SkillResult require.NoError(t, json.Unmarshal([]byte(showOut), &showResult)) - assert.Equal(t, "0.1.1", showResult.Version) + assert.Equal(t, "0.0.2", showResult.Version) } func TestSkillVersion_BumpMinor(t *testing.T) { @@ -84,8 +84,8 @@ func TestSkillVersion_BumpMinor(t *testing.T) { var result output.SkillVersionResult require.NoError(t, json.Unmarshal([]byte(out), &result)) - assert.Equal(t, "0.2.0", result.Version) - assert.Equal(t, "0.1.0", result.PreviousVersion) + assert.Equal(t, "0.1.0", result.Version) + assert.Equal(t, "0.0.1", result.PreviousVersion) assert.True(t, result.Bumped) } @@ -101,7 +101,7 @@ func TestSkillVersion_BumpMajor(t *testing.T) { var result output.SkillVersionResult require.NoError(t, json.Unmarshal([]byte(out), &result)) assert.Equal(t, "1.0.0", result.Version) - assert.Equal(t, "0.1.0", result.PreviousVersion) + assert.Equal(t, "0.0.1", result.PreviousVersion) assert.True(t, result.Bumped) } @@ -124,8 +124,8 @@ func TestSkillVersion_BumpText(t *testing.T) { out, err := runCmd(t, cc, "skill", "version", "bump-text", "--bump", "patch") require.NoError(t, err) assert.Contains(t, out, "Bumped") - assert.Contains(t, out, "0.1.0") - assert.Contains(t, out, "0.1.1") + assert.Contains(t, out, "0.0.1") + assert.Contains(t, out, "0.0.2") } func TestSkillVersion_MultipleBumps(t *testing.T) { @@ -145,7 +145,7 @@ func TestSkillVersion_MultipleBumps(t *testing.T) { var result output.SkillVersionResult require.NoError(t, json.Unmarshal([]byte(out), &result)) - assert.Equal(t, "0.1.2", result.Version) + assert.Equal(t, "0.0.3", result.Version) } func TestSkillVersion_Scoped(t *testing.T) { @@ -160,7 +160,7 @@ func TestSkillVersion_Scoped(t *testing.T) { var result output.SkillVersionResult require.NoError(t, json.Unmarshal([]byte(out), &result)) assert.Equal(t, "project", result.Scope) - assert.Equal(t, "0.1.0", result.Version) + assert.Equal(t, "0.0.1", result.Version) } // --- skill create --version --- @@ -220,5 +220,5 @@ func TestSkillCreate_DefaultVersion(t *testing.T) { var result output.SkillResult require.NoError(t, json.Unmarshal([]byte(out), &result)) - assert.Equal(t, "0.1.0", result.Version) + assert.Equal(t, "0.0.1", result.Version) } diff --git a/internal/skill/scaffold.go b/internal/skill/scaffold.go index 6c194f1..8f94d72 100644 --- a/internal/skill/scaffold.go +++ b/internal/skill/scaffold.go @@ -31,7 +31,7 @@ func NewSkillWithBody(name, description, authorName, authorType, authorPlatform, Description: description, Metadata: Metadata{ Author: author, - Version: "0.1.0", + Version: "0.0.1", }, Body: body, } diff --git a/internal/skill/scaffold_test.go b/internal/skill/scaffold_test.go index 6d9e122..ab58799 100644 --- a/internal/skill/scaffold_test.go +++ b/internal/skill/scaffold_test.go @@ -11,7 +11,7 @@ func TestNewSkill_NameOnly(t *testing.T) { assert.Equal(t, "my-skill", s.Name) assert.Contains(t, s.Description, "TODO") - assert.Equal(t, "0.1.0", s.Metadata.Version) + assert.Equal(t, "0.0.1", s.Metadata.Version) assert.Contains(t, s.Body, "## Instructions") } diff --git a/internal/skill/version.go b/internal/skill/version.go index 51c06c9..2e7feab 100644 --- a/internal/skill/version.go +++ b/internal/skill/version.go @@ -6,6 +6,18 @@ import ( "strings" ) +// parseVersionPart parses a single version component, rejecting leading zeros per semver spec. +func parseVersionPart(s string) (int, error) { + if len(s) > 1 && s[0] == '0' { + return 0, fmt.Errorf("leading zeros not allowed: %q", s) + } + n, err := strconv.Atoi(s) + if err != nil || n < 0 { + return 0, fmt.Errorf("invalid part: %q", s) + } + return n, nil +} + // Version represents a semantic version (MAJOR.MINOR.PATCH). type Version struct { Major int @@ -20,19 +32,19 @@ func ParseVersion(s string) (Version, error) { return Version{}, fmt.Errorf("invalid version %q: must be MAJOR.MINOR.PATCH", s) } - major, err := strconv.Atoi(parts[0]) - if err != nil || major < 0 { - return Version{}, fmt.Errorf("invalid version %q: major must be a non-negative integer", s) + major, err := parseVersionPart(parts[0]) + if err != nil { + return Version{}, fmt.Errorf("invalid version %q: major must be a non-negative integer without leading zeros", s) } - minor, err := strconv.Atoi(parts[1]) - if err != nil || minor < 0 { - return Version{}, fmt.Errorf("invalid version %q: minor must be a non-negative integer", s) + minor, err := parseVersionPart(parts[1]) + if err != nil { + return Version{}, fmt.Errorf("invalid version %q: minor must be a non-negative integer without leading zeros", s) } - patch, err := strconv.Atoi(parts[2]) - if err != nil || patch < 0 { - return Version{}, fmt.Errorf("invalid version %q: patch must be a non-negative integer", s) + patch, err := parseVersionPart(parts[2]) + if err != nil { + return Version{}, fmt.Errorf("invalid version %q: patch must be a non-negative integer without leading zeros", s) } return Version{Major: major, Minor: minor, Patch: patch}, nil diff --git a/internal/skill/version_test.go b/internal/skill/version_test.go index d43fd8d..739ab9b 100644 --- a/internal/skill/version_test.go +++ b/internal/skill/version_test.go @@ -26,8 +26,8 @@ func TestParseVersion(t *testing.T) { }, { name: "default version", - input: "0.1.0", - want: Version{Major: 0, Minor: 1, Patch: 0}, + input: "0.0.1", + want: Version{Major: 0, Minor: 0, Patch: 1}, }, { name: "large numbers", @@ -79,6 +79,21 @@ func TestParseVersion(t *testing.T) { input: "1.0.-1", wantErr: true, }, + { + name: "leading zero major", + input: "01.0.0", + wantErr: true, + }, + { + name: "leading zero minor", + input: "1.01.0", + wantErr: true, + }, + { + name: "leading zero patch", + input: "1.0.01", + wantErr: true, + }, } for _, tt := range tests { @@ -165,19 +180,19 @@ func TestBumpVersion(t *testing.T) { }, { name: "bump default version patch", - version: "0.1.0", + version: "0.0.1", level: "patch", - want: "0.1.1", + want: "0.0.2", }, { name: "bump default version minor", - version: "0.1.0", + version: "0.0.1", level: "minor", - want: "0.2.0", + want: "0.1.0", }, { name: "bump default version major", - version: "0.1.0", + version: "0.0.1", level: "major", want: "1.0.0", },