feat: Subpath Skill Selection — cherry-pick skills from large packages#33
Conversation
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
7-phase plan: core types → parser → resolver → CLI get → interactive add → update discovery → docs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- FR-006: Phase 3 now uses auto-discovery when Select is non-empty - FR-005: Canonicalize empty-select structured deps to simple strings - Add export-override test case to Phase 3 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace map[string]string with map[string]DependencySpec for manifest dependencies. DependencySpec supports both string (backward compatible) and structured object (url + select) forms via custom YAML unmarshaler. - Add DependencySpec with UnmarshalYAML for string-or-object parsing - Add addDependencies() writer with format-aware serialization - Add select path validation (absolute, traversal, empty checks) - Update all 14 consumer files for new type - Add round-trip tests for structured and mixed dependencies - Add validation tests for select path edge cases Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Parse optional #subpath fragment from dependency URLs using PURL- compatible syntax. The fragment is split before @ processing and normalized (strip leading ./, trailing /). - Add Subpath field to DepURL struct - Extract #fragment in ParseDepURL before @ split - Add normalizeSubpath() helper - Update String() to include #subpath in reconstruction - WithVersion() and PackageIdentity() correctly exclude subpath - Add 7 new parser test cases and subpath method tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Implement the core subpath selection feature in the resolver: - Add Select and AllSkillPaths fields to ResolvedDep - Thread Select from DependencySpec through collectDeps - Add filterBySelect() with path normalization and error on miss - Force auto-discovery when Select is non-empty (FR-006: consumer selection overrides package export list) - Merge Select lists in MVS: empty select (all) wins, else union - Add 7 tests: filter, merge, union, not-found, export-override Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Parse #subpath fragment from craft get URL arguments and create structured dependencies with Select for targeted skill installation. - Strip fragment from URL for manifest storage - Build DependencySpec with Select when subpath present - Compare both URL and Select for already-installed detection - Add selectFromSubpath helper Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
When adding a multi-skill package in a TTY, present an interactive numbered list of available skills for selection. Users can pick a subset or install all. - Add MultiSelect UI function with text-based numbered input - Add --all flag to skip interactive prompt - Auto-detect non-TTY and default to all skills - Discover skills via ListTree + DiscoverSkills before resolution - Fall back to all skills on discovery failure - Add 9 tests for MultiSelect covering various input patterns Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
After resolving selectively installed dependencies, compare all discovered skills against selected skills and print informational message about newly available skills. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Update README with structured dependency format, craft get #subpath, interactive craft add preview, --all flag, and update discovery. Add Docs.md technical reference. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Bypass pinfile cache when Select is non-empty (must-fix: FR-006/014/015) - Strip #fragment from URL in craft add, convert to Select (should-fix) - Allow reselection when URL matches in interactive mode (should-fix) - Normalize select paths at parse time in UnmarshalYAML (should-fix) - Fix Docs.md package paths and UI description (should-fix) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
| // Interactive skill selection: if running in a TTY with multiple | ||
| // skills available, let the user pick which skills to include. | ||
| if isInteractive { | ||
| selected, err := discoverAndSelect(cmd, parsed) | ||
| if err != nil { | ||
| cmd.PrintErrf("⚠ Could not discover skills: %v — installing all.\n", err) | ||
| } else if len(selected) > 0 { | ||
| m.Dependencies[alias] = manifest.DependencySpec{URL: depURL, Select: selected} | ||
| } | ||
| } |
There was a problem hiding this comment.
Must-address · Correctness
When a user runs craft add github.com/acme/skills@v1.0.0#skills/docx in a TTY, the #skills/docx subpath is set at line 110, then immediately overwritten by the interactive prompt result at line 122. The user's explicit subpath selection is silently replaced by whatever they pick interactively, and the #subpath in the URL becomes misleading.
Additionally, the error vs. success paths produce inconsistent behavior: if discoverAndSelect returns an error (line 119-120), the warning is logged but the original subpath-based Select is preserved. If it succeeds, the subpath Select is overwritten. This means errors silently produce different installed skill sets than success.
| // Interactive skill selection: if running in a TTY with multiple | |
| // skills available, let the user pick which skills to include. | |
| if isInteractive { | |
| selected, err := discoverAndSelect(cmd, parsed) | |
| if err != nil { | |
| cmd.PrintErrf("⚠ Could not discover skills: %v — installing all.\n", err) | |
| } else if len(selected) > 0 { | |
| m.Dependencies[alias] = manifest.DependencySpec{URL: depURL, Select: selected} | |
| } | |
| } | |
| // Interactive skill selection: if running in a TTY with multiple | |
| // skills available, let the user pick which skills to include. | |
| // When the user explicitly provided a #subpath, honor that intent | |
| // and skip the interactive prompt. | |
| if isInteractive && parsed.Subpath == "" { | |
| selected, err := discoverAndSelect(cmd, parsed) | |
| if err != nil { | |
| cmd.PrintErrf("⚠ Could not discover skills: %v — installing all.\n", err) | |
| } else if len(selected) > 0 { | |
| m.Dependencies[alias] = manifest.DependencySpec{URL: depURL, Select: selected} | |
| } | |
| } |
| for i, p := range paths { | ||
| p = strings.TrimPrefix(p, "./") | ||
| p = strings.TrimSuffix(p, "/") | ||
| out[i] = p |
There was a problem hiding this comment.
Must-address · Correctness
The two normalization functions disagree on whitespace handling. normalizeSubpath (depurl.go:196) includes strings.TrimSpace(), but normalizeSelectPaths here does not. A YAML select entry with leading/trailing whitespace (e.g., " skills/docx ") passes through normalizeSelectPaths unchanged, producing a path that will never match in filterBySelect's dirIndex lookup (resolver.go:637), resulting in a confusing "does not match any skill" error.
| for i, p := range paths { | |
| p = strings.TrimPrefix(p, "./") | |
| p = strings.TrimSuffix(p, "/") | |
| out[i] = p | |
| for i, p := range paths { | |
| p = strings.TrimSpace(p) // Match normalizeSubpath behavior | |
| p = strings.TrimPrefix(p, "./") | |
| p = strings.TrimSuffix(p, "/") | |
| out[i] = p |
Alternatively, extract a shared normalizePath function used by both call sites to guarantee consistency:
// Shared normalization for subpath/select paths.
func normalizePath(s string) string {
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "./")
s = strings.TrimSuffix(s, "/")
return s
}| func discoverAndSelect(cmd *cobra.Command, parsed *resolve.DepURL) ([]string, error) { | ||
| fetcher, err := newFetcher() |
There was a problem hiding this comment.
Should-address · Maintainability / Performance
discoverAndSelect creates its own newFetcher() at line 220, separate from the fetcher created at line 127 for resolve validation. Since the ListTree cache is per-fetcher-instance, the skill discovery in discoverAndSelect cannot share cached tree data with the subsequent resolve step. Both calls independently clone/fetch the same repository.
Other CLI commands (update.go:83, get.go) create a single fetcher instance and reuse it. Consider passing the fetcher as a parameter:
| func discoverAndSelect(cmd *cobra.Command, parsed *resolve.DepURL) ([]string, error) { | |
| fetcher, err := newFetcher() | |
| func discoverAndSelect(cmd *cobra.Command, parsed *resolve.DepURL, fetcher fetch.Fetcher) ([]string, error) { | |
| // Remove: fetcher, err := newFetcher() |
Then at the call site, create the fetcher earlier and pass it through.
| prefix := sel + "/" | ||
| if sel == "" { | ||
| prefix = "" | ||
| } | ||
| for path, content := range files { | ||
| if prefix == "" || strings.HasPrefix(path, prefix) { |
There was a problem hiding this comment.
Should-address · Correctness / Testing
The prefix-based file matching correctly appends / to the select path (prefix := sel + "/"), which prevents prefix collisions (e.g., selecting skills/doc won't match files under skills/docx/). However, there's no test verifying this specific scenario, and the trailing-slash defense is subtle enough that a future refactor could break it without a regression test.
Add a test case in resolver_test.go with overlapping skill prefixes:
func TestFilterBySelectPrefixCollision(t *testing.T) {
names := []string{"doc", "docx"}
dirs := []string{"skills/doc", "skills/docx"}
files := map[string][]byte{
"skills/doc/craft-skill.yaml": []byte("name: doc"),
"skills/doc/README.md": []byte("doc readme"),
"skills/docx/craft-skill.yaml": []byte("name: docx"),
"skills/docx/README.md": []byte("docx readme"),
}
gotNames, gotDirs, gotFiles, err := filterBySelect(names, dirs, files, []string{"skills/doc"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should include only "doc", not "docx"
if len(gotNames) != 1 || gotNames[0] != "doc" {
t.Errorf("expected [doc], got %v", gotNames)
}
if len(gotDirs) != 1 || gotDirs[0] != "skills/doc" {
t.Errorf("expected [skills/doc], got %v", gotDirs)
}
if len(gotFiles) != 2 {
t.Errorf("expected 2 files, got %d", len(gotFiles))
}
}| if semver.Compare(strings.TrimPrefix(latest, "v"), parsed.Version) > 0 { | ||
| newURL := parsed.WithVersion(latest) | ||
| m.Dependencies[alias] = newURL | ||
| m.Dependencies[alias] = manifest.DependencySpec{URL: newURL, Select: depSpec.Select} |
There was a problem hiding this comment.
Should-address · Testing
Select preservation on version bump is a key user expectation — when craft update bumps a package version, the user's skill selection should be carried forward. This line correctly preserves depSpec.Select, but there's no test verifying this behavior. If a future refactor accidentally drops Select from the DependencySpec construction, users would silently lose their skill selection after craft update.
Add a test that creates a manifest with Select, simulates a version bump, and verifies Select is preserved:
func TestUpdatePreservesSelect(t *testing.T) {
m := &manifest.Manifest{
SchemaVersion: 1,
Name: "test",
Skills: []string{"skills/test"},
Dependencies: map[string]manifest.DependencySpec{
"tools": {URL: "github.com/acme/tools@v1.0.0", Select: []string{"skills/docx"}},
},
}
depSpec := m.Dependencies["tools"]
parsed, _ := resolve.ParseDepURL(depSpec.URL)
newURL := parsed.WithVersion("v2.0.0")
m.Dependencies["tools"] = manifest.DependencySpec{URL: newURL, Select: depSpec.Select}
updated := m.Dependencies["tools"]
if len(updated.Select) != 1 || updated.Select[0] != "skills/docx" {
t.Errorf("expected Select [skills/docx], got %v", updated.Select)
}
}| func (d *DependencySpec) UnmarshalYAML(value *yaml.Node) error { | ||
| if value.Kind == yaml.ScalarNode { | ||
| d.URL = value.Value | ||
| return nil | ||
| } | ||
| if value.Kind == yaml.MappingNode { | ||
| type raw struct { | ||
| URL string `yaml:"url"` | ||
| Select []string `yaml:"select"` | ||
| } | ||
| var r raw | ||
| if err := value.Decode(&r); err != nil { | ||
| return err | ||
| } | ||
| if r.URL == "" { | ||
| return fmt.Errorf("structured dependency requires 'url' field") | ||
| } | ||
| d.URL = r.URL | ||
| d.Select = normalizeSelectPaths(r.Select) | ||
| return nil | ||
| } | ||
| return fmt.Errorf("dependency must be a string or object, got %v", value.Kind) | ||
| } |
There was a problem hiding this comment.
Should-address · Testing
The UnmarshalYAML method handles three node kinds (ScalarNode, MappingNode, default error). Only TestValidateStructuredDepMissingURL (validate_test.go:306-330) tests the missing-URL case via a full YAML parse. No direct unit tests exist for: sequence node input, null node, or empty scalar string.
Add direct unit tests for each code path:
func TestDependencySpecUnmarshalYAML(t *testing.T) {
tests := []struct {
name string
yaml string
wantURL string
wantSel []string
wantErr bool
}{
{"scalar string", `github.com/acme/tools@v1.0.0`, "github.com/acme/tools@v1.0.0", nil, false},
{"mapping with select", "url: github.com/acme/tools@v1.0.0\nselect:\n - skills/docx", "github.com/acme/tools@v1.0.0", []string{"skills/docx"}, false},
{"mapping url only", "url: github.com/acme/tools@v1.0.0", "github.com/acme/tools@v1.0.0", nil, false},
{"mapping no url", "select:\n - skills/docx", "", nil, true},
{"sequence node", "[a, b]", "", nil, true},
}
// ... run sub-tests
}Minor note: The error message at line 45 formats the Kind as an integer (got %v → e.g., got 4) rather than a human-readable type name. Consider using "got YAML node kind %d" for slightly clearer context, though this is a low-priority improvement since yaml.v3 doesn't provide a Kind.String() method and the error path is unlikely to trigger in practice.
| var newSkills []string | ||
| for _, p := range dep.AllSkillPaths { | ||
| if !installed[p] { | ||
| newSkills = append(newSkills, filepath.Base(p)) |
There was a problem hiding this comment.
Should-address · Maintainability
The new-skill notification uses filepath.Base(p) to extract display names from skill paths. If two new skills share the same base name but different paths (e.g., skills/v1/auth and skills/v2/auth), the notification would show: "New skills available: auth, auth" — duplicate entries with no distinguishing context.
Use the full relative path for clarity:
| newSkills = append(newSkills, filepath.Base(p)) | |
| newSkills = append(newSkills, p) |
| Host: matches[1], | ||
| Org: matches[2], | ||
| Repo: matches[3], | ||
| Raw: raw, |
There was a problem hiding this comment.
Could-consider · Maintainability
The DepURL.Raw field is populated here but String() (lines 185-191) was changed to always reconstruct from components instead of returning Raw. No production code reads Raw, though it is verified in tests. It's effectively vestigial state — set but not consumed in production.
Consider adding a brief comment on the field declaration (line 42) documenting its purpose, e.g.:
// Raw preserves the original (fragment-stripped) URL for diagnostic use and test verification.
Raw stringThis clarifies intent for future maintainers who may otherwise remove it as dead code.
| func MultiSelect(prompt string, items []string, w io.Writer, r io.Reader) ([]int, error) { | ||
| _, _ = fmt.Fprintln(w, prompt) | ||
| for i, item := range items { | ||
| _, _ = fmt.Fprintf(w, " [%d] %s\n", i+1, item) | ||
| } | ||
| _, _ = fmt.Fprint(w, "Enter numbers to include (e.g. 1,3,5), 'a' for all, or Enter for all: ") |
There was a problem hiding this comment.
Could-consider · Maintainability
If MultiSelect is called with items = []string{}, it prints the prompt and numbered list (no items), then asks for input. The user sees "Enter numbers to include..." but there's nothing to select. The current call site (discoverAndSelect at add.go:259) guards with len(skills) <= 1, but MultiSelect is a reusable UI component that should handle degenerate inputs gracefully.
| func MultiSelect(prompt string, items []string, w io.Writer, r io.Reader) ([]int, error) { | |
| _, _ = fmt.Fprintln(w, prompt) | |
| for i, item := range items { | |
| _, _ = fmt.Fprintf(w, " [%d] %s\n", i+1, item) | |
| } | |
| _, _ = fmt.Fprint(w, "Enter numbers to include (e.g. 1,3,5), 'a' for all, or Enter for all: ") | |
| func MultiSelect(prompt string, items []string, w io.Writer, r io.Reader) ([]int, error) { | |
| if len(items) == 0 { | |
| return nil, nil | |
| } | |
| _, _ = fmt.Fprintln(w, prompt) | |
| for i, item := range items { | |
| _, _ = fmt.Fprintf(w, " [%d] %s\n", i+1, item) | |
| } | |
| _, _ = fmt.Fprint(w, "Enter numbers to include (e.g. 1,3,5), 'a' for all, or Enter for all: ") |
| if sel == "" { | ||
| prefix = "" | ||
| } |
There was a problem hiding this comment.
Could-consider · Maintainability
The empty-sel guard handles the case where a select path normalizes to empty string, causing all files to be included. Given that validation at validate.go:61-62 rejects empty select paths before they reach the resolver, this guard is effectively defense-in-depth. Adding a brief comment would prevent future maintainers from removing it as "unnecessary."
| if sel == "" { | |
| prefix = "" | |
| } | |
| // Safety: empty sel should be rejected by manifest validation; | |
| // guard against inclusion of all files if validation is bypassed. | |
| if sel == "" { | |
| prefix = "" | |
| } |
- Fix subpath+interactive overwrite race in craft add (must) - Fix normalizeSelectPaths missing TrimSpace (must) - Share fetcher in discoverAndSelect to avoid duplicate clones - Use full path instead of filepath.Base in update new-skill display - Add prefix collision, Select preservation, normalization, and UnmarshalYAML edge case tests - Add empty-items guard in MultiSelect - Improve Raw field and defense-in-depth documentation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Summary
Add subpath-based skill selection so consumers can install specific skills from large packages instead of getting everything.
What Changed
New: Structured dependency format in
craft.yamlNew:
craft getwith#subpathNew: Interactive skill selection in
craft addWhen adding a multi-skill package,
craft addnow shows available skills and lets you pick which ones to install. Use--allto skip the prompt.New:
craft updateinforms about new skillsWhen updating a selectively installed package, craft tells you about newly available skills.
Implementation (7 phases)
DependencySpectype replacingmap[string]string(19 files)#subpathfragment support with PURL-compatible syntaxselect, force auto-discovery for FR-006craft get— Parse#subpathfrom URL argumentcraft add—MultiSelectUI,--allflag, TTY detectioncraft updateDiscovery — Inform about newly available skillsKey Design Decisions
#fragment separator aligned with ECMA-427selectscans all discoverable skills, not just package exportsselectnormalize to simple stringscraft.yamlfiles work unchangedTesting
References