From 7577fa2ffaae56af3cd8fbd4b646cb0ab8f4bbab Mon Sep 17 00:00:00 2001 From: devrimcavusoglu Date: Mon, 15 Jun 2026 12:09:14 +0300 Subject: [PATCH] Add namespaced --category filter to skern skill list (#96) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a repeatable, domain-agnostic `--category category:value` flag to `skern skill list` for narrowing a skill set by structured tag dimensions (e.g. `lang:python`, `topic:testing`) without skern knowing what any category means. The existing flat `--tag` filter is unchanged; the two compose with AND. Semantics (the two design questions the issue left open): - Untagged / category-absent handling: strict by default — a skill with no tag in a requested category is excluded. `--include-untagged` opts into "absent = applies to all". A category the skill *does* declare must still match a requested value even with the flag. - Combination: OR within a category, AND across categories. Values can be supplied as repeated flags (`--category lang:python --category lang:go`) or a comma list (`--category lang:python,go`); the two forms are equivalent. The matcher (`matchesCategories`) sits beside `hasTag` in skill_helpers.go; `parseCategoryFilters` validates flag input and returns a ValidationError (exit code 2) for a missing colon, empty category, or empty value. Matching is case-insensitive, consistent with `hasTag`. Flat tags (no colon) are never categorical and remain `--tag` territory. Tests cover the parser, the matcher (OR/AND, strict vs include-untagged, zero-tag and flat-tag edge cases, case-insensitivity), and the end-to-end `--json` contract including --tag/--category composition and the exit-code-2 path. Docs updated in reference/commands.md. Co-Authored-By: Claude Fable 5 --- docs/reference/commands.md | 13 ++- internal/cli/skill_helpers.go | 86 ++++++++++++++ internal/cli/skill_list.go | 18 ++- internal/cli/skill_test.go | 211 ++++++++++++++++++++++++++++++++++ 4 files changed, 324 insertions(+), 4 deletions(-) diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 127f913..dca2fe2 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -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. diff --git a/internal/cli/skill_helpers.go b/internal/cli/skill_helpers.go index 76fa214..1be9218 100644 --- a/internal/cli/skill_helpers.go +++ b/internal/cli/skill_helpers.go @@ -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 != "" { diff --git a/internal/cli/skill_list.go b/internal/cli/skill_list.go index d9bd359..154ba09 100644 --- a/internal/cli/skill_list.go +++ b/internal/cli/skill_list.go @@ -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{ @@ -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 @@ -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 @@ -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 diff --git a/internal/cli/skill_test.go b/internal/cli/skill_test.go index 8767737..ddc2be0 100644 --- a/internal/cli/skill_test.go +++ b/internal/cli/skill_test.go @@ -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.