Add namespaced --category filter to skern skill list (#96) - #98
Conversation
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
left a comment
There was a problem hiding this comment.
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)
- Trim inconsistency with
hasTag.matchesCategoriesrunsstrings.TrimSpaceon stored tags;hasTagonly lowercases. So--categorytolerates surrounding whitespace in a stored tag that--tagwould 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. - 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. - Duplicate values not deduped (
lang:python,python→["python","python"]). Matching stays correct; cosmetic. - Optional: all
--categorytests 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. 🚀
…, 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>
|
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 Weird namespace never-matches silently. Duplicate values. Deduplicated at parse time, so Text output path. Added 2. Tag charset validation (d1e3d4c, ea454cb)Your The rule is now: lowercase alphanumeric segments joined by hyphens, optionally namespaced as
One deliberate asymmetry: only the write boundary is tightened. The filters stay case-insensitive, so a query can be typed in any case ( Verified against a built binary: hyphenated flat and categorical tags round-trip through both filters, Still out of scopeThe CI green on |
Closes #96.
Summary
Adds a repeatable, domain-agnostic
--category category:valueflag toskern skill listfor 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--tagfilter 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-untaggedopts into "absent = applies to all". A category the skill does declare must still match a requested value even with the flag —--include-untaggedonly 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:testingmatches skills tagged (lang:pythonorlang:go) andtopic:testing. Values can be supplied as repeated flags or a comma list;--category lang:python --category lang:go≡--category lang:python,go.Implementation
parseCategoryFilters(inskill_helpers.go) parses the repeated flag into anamespace -> []valuemap, lowercasing for case-insensitive matching (consistent withhasTag). Malformed input — missing colon, empty category, empty value — returns aValidationError(exit code 2).matchesCategoriessits besidehasTagand applies OR-within / AND-across with the untagged rule. Flat tags (no colon) are not categorical and are ignored by it — they remain--tagterritory.skill_list.goparses filters once up front (so validation errors surface immediately) and ANDs the matcher with the existing--tagcheck in the discovery loop.Edge cases handled (per acceptance criteria)
--category--include-untagged)Test plan
go test ./...— all packages passgofmtclean;golangci-lint run ./internal/cli/...→ 0 issuesparseCategoryFilters(9 cases incl. all malformed inputs) andmatchesCategories(13 cases: OR/AND, strict vs include-untagged, zero-tag, flat-tag, case-insensitivity)--jsoncontract test end-to-end (TestSkillList_FilterByCategory): single value, OR-within, AND-across, strict default,--include-untagged--tag+--categorycomposition (TestSkillList_TagAndCategory) and the exit-code-2 path (TestSkillList_FilterByCategory_Invalid)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--jsoncontract 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-untaggedto theskill listflag table plus a "Categorical-tag filtering" section documenting repeatability, the OR/AND model, the strict-vs---include-untaggedrule, and the exit-code-2 behavior.🤖 Generated with Claude Code