refactor: decouple hidden-dir filtering from discovery layer

Move --allow-hidden-dirs filtering logic from the discovery package to
the install command, addressing review feedback. Discovery functions now
always return all skills (including hidden-dir), and callers decide how
to handle them.

Changes:
- DiscoverSkillsWithOptions/DiscoverLocalSkillsWithOptions always return
  hidden-dir skills; callers filter using IsHiddenDirConvention()
- DiscoverSkills/DiscoverLocalSkills (convenience wrappers) auto-filter
  hidden-dir skills for backward compatibility with preview/update/publish
- Remove --allow-hidden-dirs reference from discovery error messages
- Add filterHiddenDirSkills in install.go with caller-side flag logic
- Inline warning using heredoc.Docf, remove printHiddenDirWarning
- Add inline comments in matchHiddenDirConventions (babakks nitpicks)
- Add non-hidden-namespaced dir and no-skills-at-all test cases
- Add --allow-hidden-dirs tests in TestNewCmdInstall, TestInstallRun,
  and TestRunLocalInstall

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Sam Morrow 2026-04-20 11:06:02 +02:00
parent 082f15a8fd
commit eaa018545a
No known key found for this signature in database
4 changed files with 659 additions and 14 deletions

View file

@ -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
}

View file

@ -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

View file

@ -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
}

View file

@ -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 {