diff --git a/internal/skills/discovery/discovery.go b/internal/skills/discovery/discovery.go index 2d6c1ee7256..b2c8baaed93 100644 --- a/internal/skills/discovery/discovery.go +++ b/internal/skills/discovery/discovery.go @@ -66,6 +66,8 @@ func (s Skill) DisplayName() string { return "[plugins] " + name case "root": return "[root] " + name + case "hidden-dir", "hidden-dir-namespaced": + return "[hidden-dir] " + name default: return name } @@ -82,6 +84,23 @@ func (s Skill) InstallName() string { return s.Name } +// IsHiddenDirConvention returns true if the skill was discovered in a hidden +// (dot-prefixed) directory such as .claude/skills/ or .agents/skills/. +func (s Skill) IsHiddenDirConvention() bool { + return s.Convention == "hidden-dir" || s.Convention == "hidden-dir-namespaced" +} + +// HasHiddenDirSkills returns true if any of the given skills were discovered +// in hidden directories. +func HasHiddenDirSkills(skills []Skill) bool { + for _, s := range skills { + if s.IsHiddenDirConvention() { + return true + } + } + return false +} + // ResolvedRef contains the resolved git reference and its SHA. type ResolvedRef struct { Ref string // fully qualified ref (refs/heads/*, refs/tags/*) or commit SHA @@ -393,8 +412,87 @@ func matchSkillConventions(entry treeEntry) *skillMatch { return nil } -// DiscoverSkills finds all skills in a repository at the given commit SHA. +// matchHiddenDirConventions checks if a blob path matches a skill convention +// under a hidden (dot-prefixed) root directory. These patterns mirror the +// standard skills/ conventions but rooted under .{host}/skills/: +// +// - .{host}/skills/*/SKILL.md -> "hidden-dir" +// - .{host}/skills/{scope}/*/SKILL.md -> "hidden-dir-namespaced" +func matchHiddenDirConventions(entry treeEntry) *skillMatch { + if path.Base(entry.Path) != "SKILL.md" { + return nil + } + + // .{host}/skills/* + // .{host}/skills/{scope}/* + dir := path.Dir(entry.Path) + skillName := path.Base(dir) + + if !validateName(skillName) { + return nil + } + + // .{host}/skills + // .{host}/skills/{scope} + parentDir := path.Dir(dir) + + // .{host}/skills/*/SKILL.md + if path.Base(parentDir) == "skills" { + hiddenRoot := path.Dir(parentDir) + if path.Dir(hiddenRoot) == "." && strings.HasPrefix(hiddenRoot, ".") { + return &skillMatch{entry: entry, name: skillName, skillDir: dir, convention: "hidden-dir"} + } + } + + // .{host}/skills/{scope}/*/SKILL.md + grandparentDir := path.Dir(parentDir) + if path.Base(grandparentDir) == "skills" { + hiddenRoot := path.Dir(grandparentDir) + if path.Dir(hiddenRoot) == "." && strings.HasPrefix(hiddenRoot, ".") { + namespace := path.Base(parentDir) + if !validateName(namespace) { + return nil + } + return &skillMatch{entry: entry, name: skillName, namespace: namespace, skillDir: dir, convention: "hidden-dir-namespaced"} + } + } + + return nil +} + +// DiscoverOptions controls optional discovery behaviors. +type DiscoverOptions struct { +} + +// DiscoverSkills finds all non-hidden-dir skills in a repository at the given +// commit SHA. Hidden-dir skills are excluded; use DiscoverSkillsWithOptions to +// retrieve all skills including those in hidden directories. func DiscoverSkills(client *api.Client, host, owner, repo, commitSHA string) ([]Skill, error) { + all, err := DiscoverSkillsWithOptions(client, host, owner, repo, commitSHA, DiscoverOptions{}) + if err != nil { + return nil, err + } + var skills []Skill + for _, s := range all { + if !s.IsHiddenDirConvention() { + skills = append(skills, s) + } + } + if len(skills) == 0 { + return nil, fmt.Errorf( + "no skills found in %s/%s\n"+ + " Expected skills in skills/*/SKILL.md, skills/{scope}/*/SKILL.md,\n"+ + " */SKILL.md, or plugins/*/skills/*/SKILL.md\n"+ + " This repository may be a curated list rather than a skills publisher", + owner, repo, + ) + } + return skills, nil +} + +// DiscoverSkillsWithOptions finds all skills in a repository at the given +// commit SHA, with configurable discovery behavior. +func DiscoverSkillsWithOptions(client *api.Client, host, owner, repo, commitSHA string, opts DiscoverOptions) ([]Skill, error) { apiPath := fmt.Sprintf("repos/%s/%s/git/trees/%s?recursive=true", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(commitSHA)) var tree treeResponse if err := client.REST(host, "GET", apiPath, nil, &tree); err != nil { @@ -419,6 +517,9 @@ func DiscoverSkills(client *api.Client, host, owner, repo, commitSHA string) ([] continue } m := matchSkillConventions(entry) + if m == nil { + m = matchHiddenDirConventions(entry) + } if m == nil { continue } @@ -703,9 +804,35 @@ func FetchBlob(client *api.Client, host, owner, repo, sha string) (string, error return string(decoded), nil } -// DiscoverLocalSkills finds skills in a local directory using the same -// conventions as remote discovery. +// DiscoverLocalSkills finds non-hidden-dir skills in a local directory using +// the same conventions as remote discovery. Hidden-dir skills are excluded; use +// DiscoverLocalSkillsWithOptions to retrieve all skills including those in +// hidden directories. func DiscoverLocalSkills(dir string) ([]Skill, error) { + all, err := DiscoverLocalSkillsWithOptions(dir, DiscoverOptions{}) + if err != nil { + return nil, err + } + var skills []Skill + for _, s := range all { + if !s.IsHiddenDirConvention() { + skills = append(skills, s) + } + } + if len(skills) == 0 { + return nil, fmt.Errorf( + "no skills found in %s\n"+ + " Expected SKILL.md in the directory, or skills in skills/*/SKILL.md,\n"+ + " skills/{scope}/*/SKILL.md, */SKILL.md, or plugins/*/skills/*/SKILL.md", + dir, + ) + } + return skills, nil +} + +// DiscoverLocalSkillsWithOptions finds skills in a local directory using the +// same conventions as remote discovery, with configurable discovery behavior. +func DiscoverLocalSkillsWithOptions(dir string, opts DiscoverOptions) ([]Skill, error) { absDir, err := filepath.Abs(dir) if err != nil { return nil, fmt.Errorf("could not resolve path: %w", err) @@ -751,6 +878,9 @@ func DiscoverLocalSkills(dir string) ([]Skill, error) { entry := treeEntry{Path: relPath, Type: "blob"} m := matchSkillConventions(entry) + if m == nil { + m = matchHiddenDirConventions(entry) + } if m == nil { return nil } diff --git a/internal/skills/discovery/discovery_test.go b/internal/skills/discovery/discovery_test.go index 8929e17871b..41600f21c72 100644 --- a/internal/skills/discovery/discovery_test.go +++ b/internal/skills/discovery/discovery_test.go @@ -116,6 +116,155 @@ func TestMatchSkillConventions(t *testing.T) { } } +func TestMatchHiddenDirConventions(t *testing.T) { + tests := []struct { + name string + path string + wantNil bool + wantName string + wantNamespace string + wantConvention string + }{ + { + name: "claude skills directory", + path: ".claude/skills/code-review/SKILL.md", + wantName: "code-review", + wantConvention: "hidden-dir", + }, + { + name: "agents skills directory", + path: ".agents/skills/git-commit/SKILL.md", + wantName: "git-commit", + wantConvention: "hidden-dir", + }, + { + name: "github skills directory", + path: ".github/skills/issue-triage/SKILL.md", + wantName: "issue-triage", + wantConvention: "hidden-dir", + }, + { + name: "copilot skills directory", + path: ".copilot/skills/pr-summary/SKILL.md", + wantName: "pr-summary", + wantConvention: "hidden-dir", + }, + { + name: "namespaced hidden dir skill", + path: ".claude/skills/monalisa/code-review/SKILL.md", + wantName: "code-review", + wantNamespace: "monalisa", + wantConvention: "hidden-dir-namespaced", + }, + { + name: "not a SKILL.md file", + path: ".claude/skills/code-review/README.md", + wantNil: true, + }, + { + name: "too shallow - just hidden dir and SKILL.md", + path: ".claude/SKILL.md", + wantNil: true, + }, + { + name: "no skills subdirectory", + path: ".claude/code-review/SKILL.md", + wantNil: true, + }, + { + name: "non-hidden dir does not match", + path: "visible/skills/code-review/SKILL.md", + wantNil: true, + }, + { + name: "non-hidden-namespaced dir does not match", + path: "visible/skills/monalisa/code-review/SKILL.md", + wantNil: true, + }, + { + name: "too deeply nested hidden dir", + path: ".claude/nested/skills/code-review/SKILL.md", + wantNil: true, + }, + { + name: "invalid skill name", + path: ".claude/skills/../SKILL.md", + wantNil: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := matchHiddenDirConventions(treeEntry{Path: tt.path, Type: "blob"}) + if tt.wantNil { + assert.Nil(t, m) + return + } + require.NotNil(t, m) + assert.Equal(t, tt.wantName, m.name) + assert.Equal(t, tt.wantNamespace, m.namespace) + assert.Equal(t, tt.wantConvention, m.convention) + }) + } +} + +func TestHasHiddenDirSkills(t *testing.T) { + tests := []struct { + name string + skills []Skill + want bool + }{ + { + name: "empty list", + skills: nil, + want: false, + }, + { + name: "only standard skills", + skills: []Skill{{Convention: "skills"}, {Convention: "root"}}, + want: false, + }, + { + name: "has hidden-dir skill", + skills: []Skill{{Convention: "skills"}, {Convention: "hidden-dir"}}, + want: true, + }, + { + name: "has hidden-dir-namespaced skill", + skills: []Skill{{Convention: "hidden-dir-namespaced"}}, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, HasHiddenDirSkills(tt.skills)) + }) + } +} + +func TestDisplayNameHiddenDir(t *testing.T) { + tests := []struct { + name string + skill Skill + wantName string + }{ + { + name: "hidden-dir skill", + skill: Skill{Name: "code-review", Convention: "hidden-dir"}, + wantName: "[hidden-dir] code-review", + }, + { + name: "hidden-dir-namespaced skill", + skill: Skill{Name: "code-review", Namespace: "monalisa", Convention: "hidden-dir-namespaced"}, + wantName: "[hidden-dir] monalisa/code-review", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.wantName, tt.skill.DisplayName()) + }) + } +} + func TestValidateName(t *testing.T) { tests := []struct { name string @@ -740,6 +889,82 @@ func TestDiscoverSkills(t *testing.T) { } } +func TestDiscoverSkillsWithOptions(t *testing.T) { + hiddenDirTree := map[string]interface{}{ + "sha": "abc123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": ".claude/skills/code-review", "type": "tree", "sha": "tree-sha-1"}, + {"path": ".claude/skills/code-review/SKILL.md", "type": "blob", "sha": "blob-1"}, + {"path": ".agents/skills/git-commit", "type": "tree", "sha": "tree-sha-2"}, + {"path": ".agents/skills/git-commit/SKILL.md", "type": "blob", "sha": "blob-2"}, + {"path": "README.md", "type": "blob", "sha": "readme"}, + }, + } + + mixedTree := map[string]interface{}{ + "sha": "abc123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "skills/standard-skill", "type": "tree", "sha": "tree-sha-1"}, + {"path": "skills/standard-skill/SKILL.md", "type": "blob", "sha": "blob-1"}, + {"path": ".claude/skills/hidden-skill", "type": "tree", "sha": "tree-sha-2"}, + {"path": ".claude/skills/hidden-skill/SKILL.md", "type": "blob", "sha": "blob-2"}, + }, + } + + emptyTree := map[string]interface{}{ + "sha": "abc123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "README.md", "type": "blob", "sha": "readme"}, + }, + } + + tests := []struct { + name string + tree map[string]interface{} + wantSkills []string + wantErr string + }{ + { + name: "returns hidden-dir skills", + tree: hiddenDirTree, + wantSkills: []string{"code-review", "git-commit"}, + }, + { + name: "mixed tree returns all skills", + tree: mixedTree, + wantSkills: []string{"hidden-skill", "standard-skill"}, + }, + { + name: "no skills at all", + tree: emptyTree, + wantErr: "no skills found", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/abc123"), + httpmock.JSONResponse(tt.tree)) + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + + skills, err := DiscoverSkillsWithOptions(client, "github.com", "monalisa", "octocat-skills", "abc123", DiscoverOptions{}) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + var names []string + for _, s := range skills { + names = append(names, s.Name) + } + assert.Equal(t, tt.wantSkills, names) + }) + } +} + func TestDiscoverSkillByPath(t *testing.T) { tests := []struct { name string @@ -984,6 +1209,64 @@ func TestDiscoverLocalSkills(t *testing.T) { } } +func TestDiscoverLocalSkillsWithOptions(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T, dir string) + wantSkills []string + wantErr string + }{ + { + name: "returns hidden dir skills", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, ".claude", "skills", "code-review") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# code-review"), 0o644)) + }, + wantSkills: []string{"code-review"}, + }, + { + name: "mixed standard and hidden returns all", + setup: func(t *testing.T, dir string) { + t.Helper() + for _, p := range []string{"skills/standard", ".agents/skills/hidden"} { + skillDir := filepath.Join(dir, filepath.FromSlash(p)) + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + name := filepath.Base(p) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# "+name), 0o644)) + } + }, + wantSkills: []string{"standard", "hidden"}, + }, + { + name: "no skills at all", + setup: func(t *testing.T, _ string) { t.Helper() }, + wantErr: "no skills found", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "repo") + require.NoError(t, os.MkdirAll(dir, 0o755)) + tt.setup(t, dir) + + skills, err := DiscoverLocalSkillsWithOptions(dir, DiscoverOptions{}) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + var names []string + for _, s := range skills { + names = append(names, s.Name) + } + assert.ElementsMatch(t, tt.wantSkills, names) + }) + } +} + func TestMatchesSkillPath(t *testing.T) { tests := []struct { name string diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index 5f715ff7ef9..88cd2673163 100644 --- a/pkg/cmd/skills/install/install.go +++ b/pkg/cmd/skills/install/install.go @@ -47,15 +47,16 @@ type InstallOptions struct { GitClient *git.Client Remotes func() (ghContext.Remotes, error) - SkillSource string // owner/repo or local path (when --from-local is set) - SkillName string // possibly with @version suffix - Agent string - Scope string - ScopeChanged bool // true when --scope was explicitly set - Pin string - Dir string // overrides --agent and --scope - Force bool - FromLocal bool // treat SkillSource as a local directory path + SkillSource string // owner/repo or local path (when --from-local is set) + SkillName string // possibly with @version suffix + Agent string + Scope string + ScopeChanged bool // true when --scope was explicitly set + Pin string + Dir string // overrides --agent and --scope + Force bool + FromLocal bool // treat SkillSource as a local directory path + AllowHiddenDirs bool // include skills in dot-prefixed directories repo ghrepo.Interface // set when SkillSource is a GitHub repository localPath string // set when FromLocal is true @@ -161,6 +162,9 @@ func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru # Pin to a specific git ref $ gh skill install github/awesome-copilot git-commit --pin v2.0.0 + + # Install skills from hidden directories (e.g. .claude/skills/) + $ gh skill install owner/repo --allow-hidden-dirs `), Aliases: []string{"add"}, Args: cobra.MaximumNArgs(2), @@ -205,6 +209,7 @@ func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru cmd.Flags().StringVar(&opts.Dir, "dir", "", "Install to a custom directory (overrides --agent and --scope)") cmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "Overwrite existing skills without prompting") cmd.Flags().BoolVar(&opts.FromLocal, "from-local", false, "Treat the argument as a local directory path instead of a repository") + cmd.Flags().BoolVar(&opts.AllowHiddenDirs, "allow-hidden-dirs", false, "Include skills in hidden directories (e.g. .claude/skills/, .agents/skills/)") cmdutil.DisableAuthCheckFlag(cmd.Flags().Lookup("from-local")) return cmd @@ -417,12 +422,17 @@ func runLocalInstall(opts *InstallOptions) error { } opts.IO.StartProgressIndicatorWithLabel("Discovering skills") - skills, err := discovery.DiscoverLocalSkills(absSource) + allSkills, err := discovery.DiscoverLocalSkillsWithOptions(absSource, discovery.DiscoverOptions{}) opts.IO.StopProgressIndicator() if err != nil { return err } + skills, err := filterHiddenDirSkills(opts, allSkills) + if err != nil { + return err + } + if canPrompt { fmt.Fprintf(opts.IO.ErrOut, "Found %d skill(s)\n", len(skills)) } @@ -553,7 +563,7 @@ func resolveVersion(opts *InstallOptions, client *api.Client, hostname string) ( func discoverSkills(opts *InstallOptions, client *api.Client, hostname string, resolved *discovery.ResolvedRef) ([]discovery.Skill, error) { opts.IO.StartProgressIndicatorWithLabel("Discovering skills") - skills, err := discovery.DiscoverSkills(client, hostname, opts.repo.RepoOwner(), opts.repo.RepoName(), resolved.SHA) + allSkills, err := discovery.DiscoverSkillsWithOptions(client, hostname, opts.repo.RepoOwner(), opts.repo.RepoName(), resolved.SHA, discovery.DiscoverOptions{}) opts.IO.StopProgressIndicator() if err != nil { var treeTooLarge *discovery.TreeTooLargeError @@ -564,6 +574,10 @@ func discoverSkills(opts *InstallOptions, client *api.Client, hostname string, r } return nil, err } + skills, filterErr := filterHiddenDirSkills(opts, allSkills) + if filterErr != nil { + return nil, filterErr + } logConventions(opts.IO, skills) for _, s := range skills { if !discovery.IsSpecCompliant(s.Name) { @@ -1111,3 +1125,42 @@ func kiroResourcePath(installDir, gitRoot string) string { } return filepath.ToSlash(installDir) } + +// filterHiddenDirSkills separates hidden-dir skills from the full list and +// applies the --allow-hidden-dirs flag logic. When the flag is set, all skills +// are returned and a warning is printed. When the flag is not set, hidden-dir +// skills are excluded and an error is returned if no standard skills remain. +func filterHiddenDirSkills(opts *InstallOptions, allSkills []discovery.Skill) ([]discovery.Skill, error) { + cs := opts.IO.ColorScheme() + + if opts.AllowHiddenDirs { + if discovery.HasHiddenDirSkills(allSkills) { + fmt.Fprint(opts.IO.ErrOut, heredoc.Docf(` + %[1]s Skills in hidden directories (e.g. .claude/, .agents/) may be installed + copies from another publisher. Verify the skill's origin and check for a + canonical source. + `, cs.WarningIcon())) + } + return allSkills, nil + } + + var standard []discovery.Skill + var hiddenCount int + for _, s := range allSkills { + if s.IsHiddenDirConvention() { + hiddenCount++ + } else { + standard = append(standard, s) + } + } + + if len(standard) == 0 && hiddenCount > 0 { + return nil, fmt.Errorf( + "no standard skills found, but %d skill(s) exist in hidden directories\n"+ + " Use --allow-hidden-dirs to include them", + hiddenCount, + ) + } + + return standard, nil +} diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go index 120738fd052..c557c93e7fa 100644 --- a/pkg/cmd/skills/install/install_test.go +++ b/pkg/cmd/skills/install/install_test.go @@ -119,6 +119,11 @@ func TestNewCmdInstall(t *testing.T) { cli: "--from-local ./local-dir --pin v1.0.0", wantErr: true, }, + { + name: "allow-hidden-dirs flag", + cli: "monalisa/skills-repo --allow-hidden-dirs", + wantOpts: InstallOptions{SkillSource: "monalisa/skills-repo", Scope: "project", AllowHiddenDirs: true}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -157,6 +162,7 @@ func TestNewCmdInstall(t *testing.T) { assert.Equal(t, tt.wantOpts.Dir, gotOpts.Dir) assert.Equal(t, tt.wantOpts.Force, gotOpts.Force) assert.Equal(t, tt.wantOpts.FromLocal, gotOpts.FromLocal) + assert.Equal(t, tt.wantOpts.AllowHiddenDirs, gotOpts.AllowHiddenDirs) if tt.wantLocalPath { assert.NotEmpty(t, gotOpts.localPath, "expected localPath to be set") } else { @@ -256,6 +262,14 @@ func singleSkillTreeJSON(name, treeSHA, blobSHA string) string { ) } +// hiddenDirSkillTreeJSON returns tree entries for a hidden-dir skill under .claude/skills/. +func hiddenDirSkillTreeJSON(name, treeSHA, blobSHA string) string { + return fmt.Sprintf( + `{"path": ".claude/skills/%s", "type": "tree", "sha": %q}, {"path": ".claude/skills/%s/SKILL.md", "type": "blob", "sha": %q}`, + name, treeSHA, name, blobSHA, + ) +} + func TestInstallRun(t *testing.T) { tests := []struct { name string @@ -1327,6 +1341,110 @@ func TestInstallRun(t *testing.T) { wantStdout: "Installed git-commit", wantStderr: "Installing to", }, + { + name: "hidden-dir skills excluded without --allow-hidden-dirs", + isTTY: false, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + hiddenDirSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + } + }, + wantErr: "no standard skills found, but 1 skill(s) exist in hidden directories", + }, + { + name: "hidden-dir skills included with --allow-hidden-dirs", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + hiddenDirSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + AllowHiddenDirs: true, + } + }, + wantStdout: "Installed git-commit", + wantStderr: "Skills in hidden directories", + }, + { + name: "mixed tree without --allow-hidden-dirs returns only standard", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")+", "+ + hiddenDirSkillTreeJSON("hidden-skill", "treeSHA2", "blobSHA2")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "mixed tree with --allow-hidden-dirs returns all", + isTTY: false, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")+", "+ + hiddenDirSkillTreeJSON("hidden-skill", "treeSHA2", "blobSHA2")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA2", "blobSHA2", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "hidden-skill", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + AllowHiddenDirs: true, + } + }, + wantStdout: "Installed hidden-skill", + wantStderr: "Skills in hidden directories", + }, } for _, tt := range tests { @@ -1853,6 +1971,67 @@ func TestRunLocalInstall(t *testing.T) { }, wantErr: "not found in local directory", }, + { + name: "local hidden-dir skills excluded without --allow-hidden-dirs", + isTTY: false, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join(".claude", "skills", "code-review"), heredoc.Doc(` + --- + name: code-review + description: Reviews code + --- + # Code Review + `)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + SkillName: "code-review", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantErr: "no standard skills found, but 1 skill(s) exist in hidden directories", + }, + { + name: "local hidden-dir skills included with --allow-hidden-dirs", + isTTY: false, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join(".claude", "skills", "code-review"), heredoc.Doc(` + --- + name: code-review + description: Reviews code + --- + # Code Review + `)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + SkillName: "code-review", + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + AllowHiddenDirs: true, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantStdout: "Installed code-review", + wantStderr: "Skills in hidden directories", + }, } for _, tt := range tests {