Skip to content

Commit 6e6bd06

Browse files
Add skill tags/categories to frontmatter (#41) (#63)
- Add optional tags field to Skill struct and YAML frontmatter - Add --tags flag to skill create - Add --tag filter to skill list and skill search - Display tags in skill show output - Include tags in JSON output Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 319fae2 commit 6e6bd06

File tree

9 files changed

+196
-1
lines changed

9 files changed

+196
-1
lines changed

internal/cli/skill_create.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ func newSkillCreateCmd() *cobra.Command {
2121
scope string
2222
force bool
2323
fromTemplate string
24+
tags []string
2425
)
2526

2627
cmd := &cobra.Command{
@@ -102,6 +103,7 @@ func newSkillCreateCmd() *cobra.Command {
102103
}
103104

104105
s := skill.NewSkillWithBody(name, description, author, authorType, authorPlatform, body)
106+
s.Tags = tags
105107

106108
// Validate on create (warnings only, don't block)
107109
issues := skill.Validate(s)
@@ -133,6 +135,7 @@ func newSkillCreateCmd() *cobra.Command {
133135
cmd.Flags().StringVar(&scope, "scope", "user", "skill scope (user or project)")
134136
cmd.Flags().BoolVar(&force, "force", false, "bypass overlap detection block")
135137
cmd.Flags().StringVar(&fromTemplate, "from-template", "", "path to a template file for the skill body")
138+
cmd.Flags().StringSliceVar(&tags, "tags", nil, "comma-separated tags for the skill")
136139

137140
return cmd
138141
}

internal/cli/skill_helpers.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ func toSkillResult(s *skill.Skill, scope string, path string) output.SkillResult
6161
Type: s.Metadata.Author.Type,
6262
Platform: s.Metadata.Author.Platform,
6363
},
64+
Tags: s.Tags,
6465
Scope: scope,
6566
Path: path,
6667
AllowedTools: s.AllowedTools,
@@ -112,6 +113,9 @@ func formatSkillShow(s output.SkillResult) string {
112113
if s.Path != "" {
113114
fmt.Fprintf(&b, "Path: %s\n", s.Path)
114115
}
116+
if len(s.Tags) > 0 {
117+
fmt.Fprintf(&b, "Tags: %s\n", strings.Join(s.Tags, ", "))
118+
}
115119
if len(s.AllowedTools) > 0 {
116120
fmt.Fprintf(&b, "Tools: %s\n", strings.Join(s.AllowedTools, ", "))
117121
}
@@ -169,6 +173,17 @@ func formatSearchResults(query string, results []output.SkillResult) string {
169173
return b.String()
170174
}
171175

176+
// hasTag checks if a tag list contains the given tag (case-insensitive).
177+
func hasTag(tags []string, tag string) bool {
178+
t := strings.ToLower(tag)
179+
for _, v := range tags {
180+
if strings.ToLower(v) == t {
181+
return true
182+
}
183+
}
184+
return false
185+
}
186+
172187
// resolveSkill finds a skill by name, searching the specified scope or both scopes.
173188
func resolveSkill(reg *registry.Registry, name, scopeStr string) (*skill.Skill, string, skill.Scope, error) {
174189
if scopeStr != "" {

internal/cli/skill_list.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ import (
99
)
1010

1111
func newSkillListCmd() *cobra.Command {
12-
var scope string
12+
var (
13+
scope string
14+
tag string
15+
)
1316

1417
cmd := &cobra.Command{
1518
Use: "list",
@@ -50,6 +53,9 @@ func newSkillListCmd() *cobra.Command {
5053
}
5154

5255
for _, d := range discovered {
56+
if tag != "" && !hasTag(d.Skill.Tags, tag) {
57+
continue
58+
}
5359
r := toDiscoveredSkillResult(d)
5460
if files, err := skill.ListFiles(d.Path); err == nil && len(files) > 0 {
5561
r.Files = files
@@ -104,6 +110,7 @@ func newSkillListCmd() *cobra.Command {
104110
}
105111

106112
cmd.Flags().StringVar(&scope, "scope", "all", "skill scope (user, project, or all)")
113+
cmd.Flags().StringVar(&tag, "tag", "", "filter skills by tag")
107114

108115
return cmd
109116
}

internal/cli/skill_search.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import (
66
)
77

88
func newSkillSearchCmd() *cobra.Command {
9+
var tag string
10+
911
cmd := &cobra.Command{
1012
Use: "search <query>",
1113
Short: "Search for skills by name",
@@ -26,6 +28,9 @@ func newSkillSearchCmd() *cobra.Command {
2628

2729
var skillResults []output.SkillResult
2830
for _, d := range discovered {
31+
if tag != "" && !hasTag(d.Skill.Tags, tag) {
32+
continue
33+
}
2934
skillResults = append(skillResults, toDiscoveredSkillResult(d))
3035
}
3136

@@ -40,5 +45,7 @@ func newSkillSearchCmd() *cobra.Command {
4045
},
4146
}
4247

48+
cmd.Flags().StringVar(&tag, "tag", "", "filter results by tag")
49+
4350
return cmd
4451
}

internal/cli/skill_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -717,3 +717,89 @@ TODO: Add step-by-step instructions for the agent.
717717
assert.Contains(t, out, "claude-code")
718718
assert.Contains(t, out, "2025-01-15")
719719
}
720+
721+
// --- skill tags ---
722+
723+
func TestSkillCreate_WithTags(t *testing.T) {
724+
cc := testRegistry(t)
725+
726+
_, err := runCmd(t, cc, "skill", "create", "tagged-skill",
727+
"--description", "A tagged skill",
728+
"--tags", "devops,testing",
729+
"--json")
730+
require.NoError(t, err)
731+
732+
out, err := runCmd(t, cc, "skill", "show", "tagged-skill", "--json")
733+
require.NoError(t, err)
734+
735+
var result output.SkillResult
736+
require.NoError(t, json.Unmarshal([]byte(out), &result))
737+
assert.Equal(t, []string{"devops", "testing"}, result.Tags)
738+
}
739+
740+
func TestSkillCreate_WithTags_Show(t *testing.T) {
741+
cc := testRegistry(t)
742+
743+
_, err := runCmd(t, cc, "skill", "create", "tagged-text",
744+
"--description", "A tagged skill",
745+
"--tags", "ci,deploy")
746+
require.NoError(t, err)
747+
748+
out, err := runCmd(t, cc, "skill", "show", "tagged-text")
749+
require.NoError(t, err)
750+
assert.Contains(t, out, "Tags:")
751+
assert.Contains(t, out, "ci")
752+
assert.Contains(t, out, "deploy")
753+
}
754+
755+
func TestSkillList_FilterByTag(t *testing.T) {
756+
cc := testRegistry(t)
757+
758+
_, err := runCmd(t, cc, "skill", "create", "tool-a",
759+
"--description", "Tool A", "--tags", "devops")
760+
require.NoError(t, err)
761+
762+
_, err = runCmd(t, cc, "skill", "create", "tool-b",
763+
"--description", "Tool B", "--tags", "testing")
764+
require.NoError(t, err)
765+
766+
_, err = runCmd(t, cc, "skill", "create", "tool-c",
767+
"--description", "Tool C", "--tags", "devops,testing")
768+
require.NoError(t, err)
769+
770+
// Filter by devops — should get tool-a and tool-c
771+
out, err := runCmd(t, cc, "skill", "list", "--tag", "devops", "--json")
772+
require.NoError(t, err)
773+
774+
var result output.SkillListResult
775+
require.NoError(t, json.Unmarshal([]byte(out), &result))
776+
assert.Equal(t, 2, result.Count)
777+
778+
names := map[string]bool{}
779+
for _, s := range result.Skills {
780+
names[s.Name] = true
781+
}
782+
assert.True(t, names["tool-a"])
783+
assert.True(t, names["tool-c"])
784+
}
785+
786+
func TestSkillSearch_FilterByTag(t *testing.T) {
787+
cc := testRegistry(t)
788+
789+
_, err := runCmd(t, cc, "skill", "create", "code-lint",
790+
"--description", "Lint code", "--tags", "code-quality")
791+
require.NoError(t, err)
792+
793+
_, err = runCmd(t, cc, "skill", "create", "code-format",
794+
"--description", "Format code", "--tags", "formatting")
795+
require.NoError(t, err)
796+
797+
// Search "code" but filter by code-quality — should get only code-lint
798+
out, err := runCmd(t, cc, "skill", "search", "code", "--tag", "code-quality", "--json")
799+
require.NoError(t, err)
800+
801+
var result output.SkillSearchResult
802+
require.NoError(t, json.Unmarshal([]byte(out), &result))
803+
assert.Equal(t, 1, result.Count)
804+
assert.Equal(t, "code-lint", result.Results[0].Name)
805+
}

internal/output/types_skill.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ type SkillResult struct {
3434
Description string `json:"description"`
3535
Version string `json:"version"`
3636
Author AuthorResult `json:"author"`
37+
Tags []string `json:"tags,omitempty"`
3738
Scope string `json:"scope,omitempty"`
3839
Path string `json:"path,omitempty"`
3940
AllowedTools []string `json:"allowed_tools,omitempty"`

internal/skill/manifest.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
type frontmatter struct {
1313
Name string `yaml:"name"`
1414
Description string `yaml:"description"`
15+
Tags []string `yaml:"tags,omitempty"`
1516
AllowedTools []string `yaml:"allowed-tools,omitempty"`
1617
Metadata Metadata `yaml:"metadata"`
1718
}
@@ -41,6 +42,7 @@ func ParseManifest(path string) (*Skill, error) {
4142
return &Skill{
4243
Name: f.Name,
4344
Description: f.Description,
45+
Tags: f.Tags,
4446
AllowedTools: f.AllowedTools,
4547
Metadata: f.Metadata,
4648
Body: body,
@@ -52,6 +54,7 @@ func WriteManifest(s *Skill, path string) error {
5254
fm := frontmatter{
5355
Name: s.Name,
5456
Description: s.Description,
57+
Tags: s.Tags,
5558
AllowedTools: s.AllowedTools,
5659
Metadata: s.Metadata,
5760
}

internal/skill/manifest_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,3 +190,75 @@ func TestManifest_Roundtrip(t *testing.T) {
190190
assert.Equal(t, original.Metadata.ModifiedBy, parsed.Metadata.ModifiedBy)
191191
assert.Equal(t, original.Body, parsed.Body)
192192
}
193+
194+
func TestManifest_Roundtrip_Tags(t *testing.T) {
195+
original := &Skill{
196+
Name: "tagged-skill",
197+
Description: "A skill with tags.\n",
198+
Tags: []string{"code-review", "testing"},
199+
Metadata: Metadata{
200+
Author: Author{Name: "alice", Type: "human"},
201+
Version: "0.1.0",
202+
},
203+
Body: "## Instructions\n\nReview code.\n",
204+
}
205+
206+
path := filepath.Join(t.TempDir(), "SKILL.md")
207+
require.NoError(t, WriteManifest(original, path))
208+
209+
parsed, err := ParseManifest(path)
210+
require.NoError(t, err)
211+
212+
assert.Equal(t, original.Tags, parsed.Tags)
213+
}
214+
215+
func TestParseManifest_WithTags(t *testing.T) {
216+
content := `---
217+
name: my-skill
218+
description: A tagged skill.
219+
tags:
220+
- devops
221+
- ci-cd
222+
metadata:
223+
author:
224+
name: bob
225+
type: human
226+
version: "0.1.0"
227+
---
228+
229+
## Instructions
230+
231+
Deploy things.
232+
`
233+
234+
path := filepath.Join(t.TempDir(), "SKILL.md")
235+
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
236+
237+
s, err := ParseManifest(path)
238+
require.NoError(t, err)
239+
240+
assert.Equal(t, []string{"devops", "ci-cd"}, s.Tags)
241+
}
242+
243+
func TestParseManifest_NoTags(t *testing.T) {
244+
content := `---
245+
name: my-skill
246+
description: No tags.
247+
metadata:
248+
author:
249+
name: bob
250+
type: human
251+
version: "0.1.0"
252+
---
253+
254+
Body.
255+
`
256+
257+
path := filepath.Join(t.TempDir(), "SKILL.md")
258+
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
259+
260+
s, err := ParseManifest(path)
261+
require.NoError(t, err)
262+
263+
assert.Nil(t, s.Tags)
264+
}

internal/skill/skill.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ type Metadata struct {
4444
type Skill struct {
4545
Name string `yaml:"name" json:"name"`
4646
Description string `yaml:"description" json:"description"`
47+
Tags []string `yaml:"tags,omitempty" json:"tags,omitempty"`
4748
AllowedTools []string `yaml:"allowed-tools,omitempty" json:"allowed_tools,omitempty"`
4849
Metadata Metadata `yaml:"metadata" json:"metadata"`
4950
Body string `yaml:"-" json:"-"`

0 commit comments

Comments
 (0)