Skip to content
Open
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
13 changes: 12 additions & 1 deletion docs/reference/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,9 +174,20 @@ skern skill list [--scope user|project|all] [flags]
| Flag | Default | Description |
|------|---------|-------------|
| `--scope` | `all` | `user`, `project`, or `all` |
| `--tag` | — | Filter results to skills with this tag |
| `--tag` | — | Filter results to skills with this flat tag (exact, case-insensitive) |
| `--category` | — | Filter by a namespaced `category:value` tag (repeatable) |
| `--include-untagged` | `false` | Treat a skill with no tag in a requested category as matching that category |
| `--with-platforms` | `false` | Include `installed_on` per skill (the detected platforms where the skill is installed at the same scope) |

### Categorical-tag filtering (`--category`)

`--category` narrows the list by structured `category:value` tags (e.g. `lang:python`, `topic:testing`). It is fully category-agnostic — the namespace is whatever precedes the first `:`; skern never enumerates known categories. Flat tags with no colon are not categorical and are matched by `--tag` instead.

- **Repeatable, with comma-lists:** `--category lang:python --category lang:go` and `--category lang:python,go` are equivalent.
- **OR within a category, AND across categories:** `--category lang:python,go --category topic:testing` matches skills tagged (`lang:python` **or** `lang:go`) **and** `topic:testing`.
- **Strict by default:** a skill that carries no tag in a requested category is excluded. Pass `--include-untagged` to treat a category-absent skill as applying to all values of that category. A category the skill *does* declare must still match a requested value even with `--include-untagged`.
- Matching is case-insensitive. `--tag` and `--category` compose with AND. Malformed input (`--category value` with no colon, an empty category, or an empty value) exits with code 2.

Also runs pairwise overlap detection across all listed skills and appends a "Potential duplicates" section when matches are found (score >= 0.6). In `--json` mode they appear in the `duplicates` array.

Skills that cannot be parsed are reported as parse warnings rather than silently skipped — text mode prints `WARNING:` lines, `--json` mode populates the `parse_warnings` array.
Expand Down
86 changes: 86 additions & 0 deletions internal/cli/skill_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,92 @@ func hasTag(tags []string, tag string) bool {
return false
}

// parseCategoryFilters converts repeated --category flags into a namespace ->
// requested-values map. Each flag value has the form "category:value" and may
// carry a comma-separated value list ("lang:python,go"). Namespaces and values
// are lowercased so matching is case-insensitive, consistent with hasTag.
//
// Malformed input is a ValidationError (exit code 2): a value with no colon,
// an empty category name, or an empty value. Flat tags (no colon) are a
// different surface — they belong to --tag, not --category.
func parseCategoryFilters(raw []string) (map[string][]string, error) {
filters := map[string][]string{}
for _, entry := range raw {
ns, valStr, found := strings.Cut(entry, ":")
if !found {
return nil, &ValidationError{Message: fmt.Sprintf("invalid --category %q: expected format \"category:value\"", entry)}
}
ns = strings.ToLower(strings.TrimSpace(ns))
if ns == "" {
return nil, &ValidationError{Message: fmt.Sprintf("invalid --category %q: category name must not be empty", entry)}
}
for _, v := range strings.Split(valStr, ",") {
v = strings.ToLower(strings.TrimSpace(v))
if v == "" {
return nil, &ValidationError{Message: fmt.Sprintf("invalid --category %q: value must not be empty", entry)}
}
filters[ns] = append(filters[ns], v)
}
}
return filters, nil
}

// matchesCategories reports whether a skill's tags satisfy the requested
// category filters. Semantics: OR within a category (any requested value
// matches), AND across categories (every requested category must be satisfied).
//
// A skill is "category-absent" for a namespace when none of its tags carry that
// namespace. By default an absent category fails the match (strict). When
// includeUntagged is set, an absent category is treated as "applies to all" and
// passes — but a category the skill *does* declare must still match a requested
// value. An empty filter set matches everything.
func matchesCategories(tags []string, filters map[string][]string, includeUntagged bool) bool {
if len(filters) == 0 {
return true
}

// Index the skill's categorical tags as namespace -> set of values.
// Flat tags (no colon) and malformed tags (empty namespace/value) are
// not categorical and are ignored here.
skillCats := map[string]map[string]bool{}
for _, t := range tags {
ns, val, found := strings.Cut(t, ":")
if !found {
continue
}
ns = strings.ToLower(strings.TrimSpace(ns))
val = strings.ToLower(strings.TrimSpace(val))
if ns == "" || val == "" {
continue
}
if skillCats[ns] == nil {
skillCats[ns] = map[string]bool{}
}
skillCats[ns][val] = true
}

for ns, wanted := range filters {
have, present := skillCats[ns]
if !present {
if includeUntagged {
continue
}
return false
}
matched := false
for _, w := range wanted {
if have[w] {
matched = true
break
}
}
if !matched {
return false
}
}
return true
}

// resolveSkill finds a skill by name, searching the specified scope or both scopes.
func resolveSkill(reg *registry.Registry, name, scopeStr string) (*skill.Skill, string, skill.Scope, error) {
if scopeStr != "" {
Expand Down
18 changes: 15 additions & 3 deletions internal/cli/skill_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ import (

func newSkillListCmd() *cobra.Command {
var (
scope string
tag string
withPlatforms bool
scope string
tag string
categories []string
includeUntagged bool
withPlatforms bool
)

cmd := &cobra.Command{
Expand All @@ -28,6 +30,11 @@ func newSkillListCmd() *cobra.Command {
return err
}

categoryFilters, err := parseCategoryFilters(categories)
if err != nil {
return err
}

var skillResults []output.SkillResult

var discovered []registry.DiscoveredSkill
Expand Down Expand Up @@ -88,6 +95,9 @@ func newSkillListCmd() *cobra.Command {
if tag != "" && !hasTag(d.Skill.Tags, tag) {
continue
}
if !matchesCategories(d.Skill.Tags, categoryFilters, includeUntagged) {
continue
}
r := toDiscoveredSkillResult(d)
if files, err := skill.ListFiles(d.Path); err == nil && len(files) > 0 {
r.Files = files
Expand Down Expand Up @@ -146,6 +156,8 @@ func newSkillListCmd() *cobra.Command {

cmd.Flags().StringVar(&scope, "scope", "all", "skill scope (user, project, or all)")
cmd.Flags().StringVar(&tag, "tag", "", "filter skills by tag")
cmd.Flags().StringArrayVar(&categories, "category", nil, "filter by namespaced tag \"category:value\" (repeatable; comma-lists values; OR within a category, AND across categories)")
cmd.Flags().BoolVar(&includeUntagged, "include-untagged", false, "treat a skill with no tag in a requested category as matching that category")
cmd.Flags().BoolVar(&withPlatforms, "with-platforms", false, "include the list of detected platforms each skill is installed on")

return cmd
Expand Down
211 changes: 211 additions & 0 deletions internal/cli/skill_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1010,6 +1010,217 @@ func TestSkillList_FilterByTag(t *testing.T) {
assert.True(t, names["tool-c"])
}

// --- categorical-tag filter (#96) ---

func TestParseCategoryFilters(t *testing.T) {
tests := []struct {
name string
raw []string
want map[string][]string
wantErr bool
}{
{name: "empty", raw: nil, want: map[string][]string{}},
{name: "single", raw: []string{"lang:python"}, want: map[string][]string{"lang": {"python"}}},
{
name: "comma list folds into one namespace",
raw: []string{"lang:python,go"},
want: map[string][]string{"lang": {"python", "go"}},
},
{
name: "repeated same namespace accumulates",
raw: []string{"lang:python", "lang:go"},
want: map[string][]string{"lang": {"python", "go"}},
},
{
name: "distinct namespaces",
raw: []string{"lang:python", "topic:testing"},
want: map[string][]string{"lang": {"python"}, "topic": {"testing"}},
},
{
name: "lowercased",
raw: []string{"Lang:Python"},
want: map[string][]string{"lang": {"python"}},
},
{name: "no colon", raw: []string{"python"}, wantErr: true},
{name: "empty namespace", raw: []string{":python"}, wantErr: true},
{name: "empty value", raw: []string{"lang:"}, wantErr: true},
{name: "empty value in comma list", raw: []string{"lang:python,"}, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseCategoryFilters(tt.raw)
if tt.wantErr {
require.Error(t, err)
var ve *ValidationError
assert.ErrorAs(t, err, &ve, "malformed --category must be a ValidationError (exit code 2)")
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}

func TestMatchesCategories(t *testing.T) {
tests := []struct {
name string
tags []string
filters map[string][]string
includeUntagged bool
want bool
}{
{name: "empty filter matches everything", tags: []string{"lang:go"}, filters: map[string][]string{}, want: true},
{name: "single match", tags: []string{"lang:python"}, filters: map[string][]string{"lang": {"python"}}, want: true},
{name: "single miss", tags: []string{"lang:go"}, filters: map[string][]string{"lang": {"python"}}, want: false},
{
name: "OR within category",
tags: []string{"lang:go"},
filters: map[string][]string{"lang": {"python", "go"}},
want: true,
},
{
name: "AND across categories satisfied",
tags: []string{"lang:python", "topic:testing"},
filters: map[string][]string{"lang": {"python"}, "topic": {"testing"}},
want: true,
},
{
name: "AND across categories one missing value fails",
tags: []string{"lang:python", "topic:docs"},
filters: map[string][]string{"lang": {"python"}, "topic": {"testing"}},
want: false,
},
{
name: "category absent fails by default",
tags: []string{"lang:python"},
filters: map[string][]string{"topic": {"testing"}},
want: false,
},
{
name: "category absent passes with includeUntagged",
tags: []string{"lang:python"},
filters: map[string][]string{"topic": {"testing"}},
includeUntagged: true,
want: true,
},
{
name: "includeUntagged still requires a present category to match",
tags: []string{"lang:go", "topic:docs"},
filters: map[string][]string{"lang": {"python"}, "topic": {"docs"}},
includeUntagged: true,
want: false,
},
{
name: "zero tags fails by default",
tags: nil,
filters: map[string][]string{"lang": {"python"}},
want: false,
},
{
name: "zero tags passes with includeUntagged",
tags: nil,
filters: map[string][]string{"lang": {"python"}},
includeUntagged: true,
want: true,
},
{
name: "flat tag is not categorical",
tags: []string{"python", "lang:go"},
filters: map[string][]string{"lang": {"python"}},
want: false,
},
{
name: "case-insensitive match",
tags: []string{"Lang:Python"},
filters: map[string][]string{"lang": {"python"}},
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, matchesCategories(tt.tags, tt.filters, tt.includeUntagged))
})
}
}

// TestSkillList_FilterByCategory covers the --json contract for the categorical
// filter end to end: OR within a category, AND across categories, and the
// strict-by-default untagged handling.
func TestSkillList_FilterByCategory(t *testing.T) {
cc := testRegistry(t)

mk := func(name, desc, tags string) {
t.Helper()
_, err := runCmd(t, cc, "skill", "create", name, "--description", desc, "--tags", tags)
require.NoError(t, err)
}
mk("py-test", "Python testing", "lang:python,topic:testing")
mk("py-docs", "Python docs", "lang:python,topic:docs")
mk("go-test", "Go testing", "lang:go,topic:testing")
mk("untyped", "No categories", "misc")

listNames := func(t *testing.T, args ...string) map[string]bool {
t.Helper()
out, err := runCmd(t, cc, append([]string{"skill", "list"}, args...)...)
require.NoError(t, err)
var result output.SkillListResult
require.NoError(t, json.Unmarshal([]byte(out), &result))
assert.Equal(t, len(result.Skills), result.Count)
names := map[string]bool{}
for _, s := range result.Skills {
names[s.Name] = true
}
return names
}

// Single category value.
got := listNames(t, "--category", "lang:python", "--json")
assert.Equal(t, map[string]bool{"py-test": true, "py-docs": true}, got)

// OR within a category.
got = listNames(t, "--category", "lang:python,go", "--json")
assert.Equal(t, map[string]bool{"py-test": true, "py-docs": true, "go-test": true}, got)

// AND across categories.
got = listNames(t, "--category", "lang:python", "--category", "topic:testing", "--json")
assert.Equal(t, map[string]bool{"py-test": true}, got)

// Strict by default: a skill with no tag in the category is excluded.
got = listNames(t, "--category", "topic:testing", "--json")
assert.Equal(t, map[string]bool{"py-test": true, "go-test": true}, got)

// --include-untagged: category-absent skills now match that category.
got = listNames(t, "--category", "topic:testing", "--include-untagged", "--json")
assert.Equal(t, map[string]bool{"py-test": true, "go-test": true, "untyped": true}, got)
}

func TestSkillList_FilterByCategory_Invalid(t *testing.T) {
cc := testRegistry(t)
_, err := runCmd(t, cc, "skill", "create", "x", "--description", "X", "--tags", "lang:go")
require.NoError(t, err)

_, err = runCmd(t, cc, "skill", "list", "--category", "python", "--json")
require.Error(t, err)
var ve *ValidationError
assert.ErrorAs(t, err, &ve)
}

// TestSkillList_TagAndCategory confirms --tag and --category compose (AND).
func TestSkillList_TagAndCategory(t *testing.T) {
cc := testRegistry(t)
_, err := runCmd(t, cc, "skill", "create", "a", "--description", "A", "--tags", "featured,lang:go")
require.NoError(t, err)
_, err = runCmd(t, cc, "skill", "create", "b", "--description", "B", "--tags", "lang:go")
require.NoError(t, err)

out, err := runCmd(t, cc, "skill", "list", "--tag", "featured", "--category", "lang:go", "--json")
require.NoError(t, err)
var result output.SkillListResult
require.NoError(t, json.Unmarshal([]byte(out), &result))
require.Equal(t, 1, result.Count)
assert.Equal(t, "a", result.Skills[0].Name)
}

// TestSkillList_WithPlatforms verifies that --with-platforms enriches each
// skill entry with the list of platforms where the skill is currently
// installed, scoped to the registry skill's scope.
Expand Down