Skip to content

Add namespaced --category filter to skern skill list (#96) - #98

Merged
devrimcavusoglu merged 4 commits into
mainfrom
feature/category-tag-filter
Jul 28, 2026
Merged

Add namespaced --category filter to skern skill list (#96)#98
devrimcavusoglu merged 4 commits into
mainfrom
feature/category-tag-filter

Conversation

@devrimcavusoglu

Copy link
Copy Markdown
Owner

Closes #96.

Summary

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 namespace is whatever precedes the first :; skern never enumerates or special-cases known categories. The existing flat --tag filter is unchanged, and the two compose with AND.

Design decisions

The issue left two semantics questions open. Resolved as follows (these were confirmed with the maintainer before implementation):

1. Untagged / category-absent handling — strict by default, wildcard opt-in.
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 — --include-untagged only relaxes the absent case, never the present-but-mismatched case.

2. Combination — 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. Values can be supplied as repeated flags or a comma list; --category lang:python --category lang:go--category lang:python,go.

Implementation

  • parseCategoryFilters (in skill_helpers.go) parses the repeated flag into a namespace -> []value map, lowercasing for case-insensitive matching (consistent with hasTag). Malformed input — missing colon, empty category, empty value — returns a ValidationError (exit code 2).
  • matchesCategories sits beside hasTag and applies OR-within / AND-across with the untagged rule. Flat tags (no colon) are not categorical and are ignored by it — they remain --tag territory.
  • skill_list.go parses filters once up front (so validation errors surface immediately) and ANDs the matcher with the existing --tag check in the discovery loop.

Edge cases handled (per acceptance criteria)

  • Tag with no colon → not categorical, never matches a --category
  • Empty value / empty category / missing colon in the flag → exit code 2 with an actionable message
  • Skill with zero tags → category-absent (excluded by default, included with --include-untagged)
  • Case-insensitive matching on both namespace and value

Test plan

  • go test ./... — all packages pass
  • gofmt clean; golangci-lint run ./internal/cli/... → 0 issues
  • Unit tests: parseCategoryFilters (9 cases incl. all malformed inputs) and matchesCategories (13 cases: OR/AND, strict vs include-untagged, zero-tag, flat-tag, case-insensitivity)
  • --json contract test end-to-end (TestSkillList_FilterByCategory): single value, OR-within, AND-across, strict default, --include-untagged
  • --tag + --category composition (TestSkillList_TagAndCategory) and the exit-code-2 path (TestSkillList_FilterByCategory_Invalid)
  • Manual smoke test of the built binary confirms all five behaviors and exit code 2 on malformed input

Manual-test gate: the acceptance criteria name the agent-driven manual-test gate, which is an interactive release-time process (build binary + drive scenarios with an agent in /tmp). This change doesn't alter any existing scenario; the --json contract is covered by the automated tests above. The maintainer runs the full manual gate before release as usual.

Docs

docs/reference/commands.md: added --category / --include-untagged to the skill list flag table plus a "Categorical-tag filtering" section documenting repeatability, the OR/AND model, the strict-vs---include-untagged rule, and the exit-code-2 behavior.

🤖 Generated with Claude Code

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 <noreply@anthropic.com>

@Sophylax Sophylax left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: looks good to me

Checked out the branch, built the binary, ran the suite, and drove every claimed behavior end-to-end against a real registry. CI is green (5/5), go test ./internal/cli/... passes, gofmt/go vet clean.

Behavior verified

Behavior Result
--category lang:python single value ✅
--category lang:python,go OR-within ✅
--category lang:python --category topic:testing AND-across ✅
--category topic:testing strict default excludes untagged ✅
+ --include-untagged category-absent skills match ✅
present-but-mismatched + --include-untagged still excluded (not relaxed) ✅
Lang:Rust tag ↔ --category lang:rust case-insensitive ns + value ✅
flat tag under a --category namespace ignored ✅
no colon / empty category / empty value / empty value in comma-list exit code 2 ✅
--tag + --category compose with AND ✅

Implementation matches the docs and the resolved design decisions exactly. Test coverage is thorough — no gaps on the logic.

Non-blocking nits (none require a change before merge)

  1. Trim inconsistency with hasTag. matchesCategories runs strings.TrimSpace on stored tags; hasTag only lowercases. So --category tolerates surrounding whitespace in a stored tag that --tag would reject. Harmless (arguably nicer), but the two filters now differ — worth picking one convention. This is the only nit I'd ask you to weigh in on.
  2. Weird namespace never-matches silently. --category ",lang:python" splits on the first : to namespace ,lang, which is accepted but can never match (stored tags can't contain a comma). Edge input; note only.
  3. Duplicate values not deduped (lang:python,python["python","python"]). Matching stays correct; cosmetic.
  4. Optional: all --category tests go through --json; no test drives the text output path. Filter runs before rendering, so risk is ~0.

For context, zero-match still serializes skills: null rather than [] — but that's pre-existing behavior shared with --tag, not introduced here, so out of scope for this PR.

Nice, clean, domain-agnostic work. 🚀

devrimcavusoglu and others added 3 commits July 21, 2026 10:04
…, dedup values, test text path

Four non-blocking review nits from PR #98, all addressed:

- hasTag now trims surrounding whitespace on stored tags, matching the
  normalization matchesCategories already applied — the two filters share
  one convention.
- A comma in the category name (e.g. --category ",lang:python") is now a
  ValidationError instead of a silently never-matching namespace, with a
  hint that comma-separated value lists go after the colon.
- Duplicate values within a namespace are deduplicated at parse time
  (lang:python,python → ["python"]).
- Added a text-output (non-JSON) test for the category filter path, plus
  parser cases for the new error and dedup behavior and a hasTag
  normalization test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…category colon

Tags previously had no validation at all — any string passed through
--tags or frontmatter was stored as-is. Now a tag must be alphanumeric
segments joined by hyphens ("code-review"), optionally namespaced as
"category:value" with a single colon ("lang:python", "topic:ci-cd").
Uppercase stays allowed since tag matching is case-insensitive.

- skill.ValidateTag holds the rule; skill.Validate reports violations as
  errors (so `skern skill validate` catches hand-edited frontmatter).
- `skill create --tags` enforces it up front as a ValidationError (exit
  code 2), mirroring the existing name check, and trims each tag so
  "a, b" comma-lists with spaces normalize cleanly.
- `skill edit` has no tags flag, so create + validate cover every entry
  point.

Tests: ValidateTag table (18 cases), create rejection e2e, and an e2e
confirming hyphenated flat/categorical tags flow through --tag and
--category filters with space-after-comma input. Docs updated in
commands.md and skill-format.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Uppercase is now rejected at write time so stored tags have a single
canonical form, matching the lowercase-only rule nameRegex already
applies to skill names.

The tag *filters* stay case-insensitive: a query may be typed in any
case (`--category TOPIC:Code-Review` matches a stored
`topic:code-review`), and skills carrying legacy hand-edited uppercase
tags still match. Only the write boundary is tightened.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@devrimcavusoglu

Copy link
Copy Markdown
Owner Author

Thanks for the thorough pass, @Sophylax — especially for driving the behaviors against a real registry rather than trusting the tests. All four nits are addressed, plus one thing your review surfaced indirectly. Three new commits since your review:

1. Nits resolved (86f6c8c)

Trim inconsistency (the one you asked me to weigh in on). Picked the trimming convention and moved hasTag to it — it now applies TrimSpace to both the query and stored tags, matching what matchesCategories already did. Rationale: tolerating stray whitespace in hand-edited frontmatter is the friendlier of the two behaviors, and it would be odd for --tag to reject a tag that --category accepts. The two filters now share exactly one normalization rule.

Weird namespace never-matches silently. --category ",lang:python" is now a ValidationError (exit 2) instead of an accepted-but-unmatchable namespace, with a message pointing at the actual mistake: a comma-separated value list goes after the colon. Good catch — silent never-match is the worst failure mode for a filter.

Duplicate values. Deduplicated at parse time, so lang:python,python and --category lang:python --category lang:Python both collapse to ["python"].

Text output path. Added TestSkillList_FilterByCategory_TextOutput, which drives the non-JSON rendering path. You were right that the risk was ~0 (the filter runs before rendering), but it's cheap insurance against someone later moving the filter into the JSON branch.

2. Tag charset validation (d1e3d4c, ea454cb)

Your Lang:Rust case-insensitivity check prompted a question I hadn't asked: what is a legal tag? Answer, it turned out: anything at all — tags had no validation whatsoever, so --tags "my tag" or c++ was stored verbatim. That's a pre-existing gap rather than something this PR introduced, but --category gives tags real structural meaning, so leaving the charset unconstrained seemed like the wrong moment.

The rule is now: lowercase alphanumeric segments joined by hyphens, optionally namespaced as category:value with a single colon. Same shape rule nameRegex already applies to skill names, so names and tags are consistent.

  • skill.ValidateTag holds the rule; skill.Validate reports violations as errors, so skern skill validate catches hand-edited frontmatter.
  • skill create --tags enforces it up front as a ValidationError (exit 2), mirroring the existing name check, and trims each entry so --tags "a, b" normalizes cleanly.
  • skill edit has no tags flag, so create + validate cover every entry point.

One deliberate asymmetry: only the write boundary is tightened. The filters stay case-insensitive, so a query can be typed in any case (--category TOPIC:Code-Review matches a stored topic:code-review) and any skill with legacy uppercase frontmatter keeps matching instead of silently vanishing from filtered lists. This is called out in the ValidateTag doc comment and in the two matcher tests that use uppercase stored tags, so it doesn't read as an oversight later.

Verified against a built binary: hyphenated flat and categorical tags round-trip through both filters, --tags "topic:code-review, this-is-another" stores both tags with the space normalized away, and Featured / lang:Python / my_tag / a:b:c / tag- each exit 2.

Still out of scope

The skills: null vs [] zero-match serialization — agreed it's pre-existing behavior shared with --tag, so it stays out of this PR. Worth its own issue if the JSON contract should guarantee an array.

CI green on ea454cb (5/5); golangci-lint run ./... → 0 issues locally.

@devrimcavusoglu
devrimcavusoglu merged commit 109c56a into main Jul 28, 2026
5 checks passed
@devrimcavusoglu
devrimcavusoglu deleted the feature/category-tag-filter branch July 28, 2026 17:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a namespaced categorical-tag query to skern skill list

2 participants