Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/concepts/skill-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ The main technique or pattern (before/after for techniques).
|-------|----------|-------------|
| `name` | Yes | Skill name matching `[a-z0-9]+([.-][a-z0-9]+)*`, 1-64 chars. Hyphens and dots are both valid separators (`code-review`, `myorg.bootstrap`). Must equal the directory name. |
| `description` | Yes | What the skill does — start with "Use when…". Max 1024 chars. |
| `tags` | No | List of classification tags |
| `tags` | No | List of classification tags. Lowercase alphanumeric segments joined by hyphens (`code-review`), optionally namespaced as `category:value` with a single colon (`lang:python`, `topic:ci-cd`). Filter matching is case-insensitive. |
| `allowed-tools` | No | Tools the skill may use. No empty entries. |
| `metadata.author.name` | No | Author name |
| `metadata.author.type` | No | `human` or `agent` |
Expand Down
15 changes: 13 additions & 2 deletions docs/reference/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ skern skill create <name> [flags]
| `--author` | — | Author name |
| `--author-type` | `human` | `human` or `agent` |
| `--author-platform` | — | Platform name (e.g. `claude-code`) — used with `agent` author type |
| `--tags` | — | Comma-separated list of tags |
| `--tags` | — | Comma-separated list of tags. Each tag is lowercase alphanumeric segments joined by hyphens, optionally namespaced as `category:value` (single colon); anything else exits with code 2 |
| `--version` | `0.0.1` | Initial semver version |
| `--scope` | `user` | `user` or `project` |
| `--force` | `false` | Bypass overlap block |
Expand Down 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 or comma-containing category name, 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
9 changes: 9 additions & 0 deletions internal/cli/skill_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ func newSkillCreateCmd() *cobra.Command {
return &ValidationError{Message: err.Error()}
}

// Normalize --tags (tolerate space after commas) and enforce the
// tag charset up front, like the name check above.
for i, tg := range tags {
tags[i] = strings.TrimSpace(tg)
if err := skill.ValidateTag(tags[i]); err != nil {
return &ValidationError{Message: err.Error()}
}
}

scopeVal, err := parseScope(scope)
if err != nil {
return err
Expand Down
102 changes: 99 additions & 3 deletions internal/cli/skill_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"slices"
"strings"

"github.com/devrimcavusoglu/skern/internal/output"
Expand Down Expand Up @@ -173,17 +174,112 @@ func formatSearchResults(query string, results []output.SkillResult) string {
return b.String()
}

// hasTag checks if a tag list contains the given tag (case-insensitive).
// hasTag checks if a tag list contains the given tag (case-insensitive,
// ignoring surrounding whitespace — the same normalization matchesCategories
// applies to stored tags).
func hasTag(tags []string, tag string) bool {
t := strings.ToLower(tag)
t := strings.ToLower(strings.TrimSpace(tag))
for _, v := range tags {
if strings.ToLower(v) == t {
if strings.ToLower(strings.TrimSpace(v)) == t {
return true
}
}
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, a category name containing a comma (comma-separated
// value lists go after the colon), or an empty value. Flat tags (no colon) are
// a different surface — they belong to --tag, not --category. Duplicate values
// within a namespace are deduplicated.
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)}
}
if strings.Contains(ns, ",") {
return nil, &ValidationError{Message: fmt.Sprintf("invalid --category %q: category name must not contain a comma (a comma-separated value list goes after the colon)", 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)}
}
if !slices.Contains(filters[ns], v) {
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
Loading