Skip to content

Commit 2e93afc

Browse files
committed
Install skills flat by Name, not namespaced InstallName
Most agent clients (Claude Code, Copilot, etc.) only discover immediate subdirectories of their skills folder. When a skill repository used namespaced paths like skills/author/my-skill/, the installer created nested directories (e.g. .claude/skills/author/my-skill/) that clients could not find. This separates the skill's identity (InstallName, used for lockfile keys, search, filtering, display) from the filesystem path (Name, used for the install directory). Skills are now always installed flat: .claude/skills/my-skill/SKILL.md (not .claude/skills/author/my-skill/) Changes: - installer: use skill.Name for directory paths instead of InstallName - install.go: use skill.Name for overwrite checks and prompts - collisions: detect conflicts by Name since flat install means two skills with the same Name but different Namespace values will collide - update: clean up old namespaced directories when migrating to flat Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 3ad2958 commit 2e93afc

6 files changed

Lines changed: 57 additions & 40 deletions

File tree

internal/skills/discovery/collisions.go

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,22 @@ import (
66
"strings"
77
)
88

9-
// NameCollision represents a group of skills that share the same InstallName
10-
// and would overwrite each other when installed to the same directory.
9+
// NameCollision represents a group of skills that share the same install
10+
// directory name and would overwrite each other when installed.
1111
type NameCollision struct {
12-
Name string // the conflicting install name (may include namespace prefix)
12+
Name string // the conflicting skill name (directory name)
1313
DisplayNames []string // display names of each conflicting skill
1414
}
1515

16-
// FindNameCollisions detects skills that share the same InstallName and returns a
17-
// sorted slice of collisions. Callers decide how to present the conflict to
18-
// the user (different flows need different error messages).
16+
// FindNameCollisions detects skills whose Name fields collide (meaning they
17+
// would be installed to the same directory) and returns a sorted slice of
18+
// collisions. Skills are installed flat by Name, so two skills with the same
19+
// Name but different Namespace values still conflict. Callers decide how to
20+
// present the conflict to the user.
1921
func FindNameCollisions(skills []Skill) []NameCollision {
2022
byName := make(map[string][]Skill)
2123
for _, s := range skills {
22-
byName[s.InstallName()] = append(byName[s.InstallName()], s)
24+
byName[s.Name] = append(byName[s.Name], s)
2325
}
2426

2527
var collisions []NameCollision

internal/skills/installer/installer.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,10 @@ func InstallLocal(opts *LocalOptions) (*Result, error) {
178178
}
179179

180180
func installLocalSkill(sourceRoot string, skill discovery.Skill, baseDir string) error {
181-
skillDir := filepath.Join(baseDir, filepath.FromSlash(skill.InstallName()))
181+
// Use skill.Name (not InstallName) so skills are always installed flat.
182+
// Most agent clients only discover immediate subdirectories of their
183+
// skills folder and do not find skills nested under namespace directories.
184+
skillDir := filepath.Join(baseDir, skill.Name)
182185
if err := os.MkdirAll(skillDir, 0o755); err != nil {
183186
return fmt.Errorf("could not create directory %s: %w", skillDir, err)
184187
}
@@ -246,7 +249,8 @@ func installLocalSkill(sourceRoot string, skill discovery.Skill, baseDir string)
246249
}
247250

248251
func installSkill(opts *Options, skill discovery.Skill, baseDir string) error {
249-
skillDir := filepath.Join(baseDir, filepath.FromSlash(skill.InstallName()))
252+
// Use skill.Name (not InstallName) for a flat directory layout.
253+
skillDir := filepath.Join(baseDir, skill.Name)
250254
if err := os.MkdirAll(skillDir, 0o755); err != nil {
251255
return fmt.Errorf("could not create directory %s: %w", skillDir, err)
252256
}

pkg/cmd/skills/install/install.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -971,7 +971,7 @@ func truncateDescription(s string, maxWidth int) string {
971971
func checkOverwrite(opts *InstallOptions, skills []discovery.Skill, targetDir string, canPrompt bool) ([]discovery.Skill, error) {
972972
var existing, fresh []discovery.Skill
973973
for _, s := range skills {
974-
dir := filepath.Join(targetDir, filepath.FromSlash(s.InstallName()))
974+
dir := filepath.Join(targetDir, s.Name)
975975
if _, err := os.Stat(dir); err == nil {
976976
existing = append(existing, s)
977977
} else {
@@ -1013,7 +1013,7 @@ func checkOverwrite(opts *InstallOptions, skills []discovery.Skill, targetDir st
10131013
}
10141014

10151015
func existingSkillPrompt(targetDir string, incoming discovery.Skill) string {
1016-
skillFile := filepath.Join(targetDir, filepath.FromSlash(incoming.InstallName()), "SKILL.md")
1016+
skillFile := filepath.Join(targetDir, incoming.Name, "SKILL.md")
10171017
data, err := os.ReadFile(skillFile)
10181018
if err != nil {
10191019
return fmt.Sprintf("Skill %q already exists. Overwrite?", incoming.DisplayName())

pkg/cmd/skills/install/install_test.go

Lines changed: 17 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -857,7 +857,7 @@ func TestInstallRun(t *testing.T) {
857857
wantErr: "conflicting names",
858858
},
859859
{
860-
name: "remote install all with namespaced skills avoids collisions",
860+
name: "remote install all with namespaced skills detects collisions",
861861
isTTY: true,
862862
stubs: func(reg *httpmock.Registry) {
863863
stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123")
@@ -868,7 +868,7 @@ func TestInstallRun(t *testing.T) {
868868
`{"path": "skills/bob/xlsx-pro", "type": "tree", "sha": "treeB"}, ` +
869869
`{"path": "skills/bob/xlsx-pro/SKILL.md", "type": "blob", "sha": "blobB"}`
870870
stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", treeJSON)
871-
// Extra blob stubs consumed by FetchDescriptionsConcurrent during interactive selection.
871+
// Blob stubs consumed by FetchDescriptionsConcurrent during interactive selection.
872872
contentA := base64.StdEncoding.EncodeToString([]byte("---\nname: xlsx-pro\ndescription: Alice\n---\n# A\n"))
873873
contentB := base64.StdEncoding.EncodeToString([]byte("---\nname: xlsx-pro\ndescription: Bob\n---\n# B\n"))
874874
reg.Register(
@@ -877,10 +877,6 @@ func TestInstallRun(t *testing.T) {
877877
reg.Register(
878878
httpmock.REST("GET", "repos/monalisa/skills-repo/git/blobs/blobB"),
879879
httpmock.StringResponse(fmt.Sprintf(`{"sha": "blobB", "content": %q, "encoding": "base64"}`, contentB)))
880-
stubInstallFiles(reg, "monalisa", "skills-repo", "treeA", "blobA",
881-
"---\nname: xlsx-pro\ndescription: Alice\n---\n# A\n")
882-
stubInstallFiles(reg, "monalisa", "skills-repo", "treeB", "blobB",
883-
"---\nname: xlsx-pro\ndescription: Bob\n---\n# B\n")
884880
},
885881
opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions {
886882
t.Helper()
@@ -901,7 +897,7 @@ func TestInstallRun(t *testing.T) {
901897
Dir: t.TempDir(),
902898
}
903899
},
904-
wantStdout: "Installed",
900+
wantErr: "conflicting names",
905901
},
906902
{
907903
name: "remote install friendlyDir shows tilde for home paths",
@@ -1670,7 +1666,7 @@ func TestRunLocalInstall(t *testing.T) {
16701666
wantStdout: "Installed direct-skill",
16711667
},
16721668
{
1673-
name: "namespaced skills install to separate directories",
1669+
name: "namespaced skills with same name collide in flat install",
16741670
isTTY: true,
16751671
setup: func(t *testing.T, sourceDir, _ string) {
16761672
t.Helper()
@@ -1699,38 +1695,25 @@ func TestRunLocalInstall(t *testing.T) {
16991695
GitClient: &git.Client{RepoDir: t.TempDir()},
17001696
}
17011697
},
1702-
verify: func(t *testing.T, targetDir string) {
1703-
t.Helper()
1704-
_, err := os.Stat(filepath.Join(targetDir, "alice", "xlsx-pro", "SKILL.md"))
1705-
assert.NoError(t, err, "alice/xlsx-pro should be installed")
1706-
_, err = os.Stat(filepath.Join(targetDir, "bob", "xlsx-pro", "SKILL.md"))
1707-
assert.NoError(t, err, "bob/xlsx-pro should be installed")
1708-
},
1709-
wantStdout: "Installed alice/xlsx-pro",
1698+
wantErr: "conflicting names",
17101699
},
17111700
{
1712-
name: "local install with --force overwrites namespaced skill",
1701+
name: "local install with --force overwrites namespaced skill flat",
17131702
isTTY: true,
17141703
setup: func(t *testing.T, sourceDir, targetDir string) {
17151704
t.Helper()
1716-
for _, ns := range []string{"alice", "bob"} {
1717-
writeLocalTestSkill(t, sourceDir, filepath.Join("skills", ns, "xlsx-pro"),
1718-
fmt.Sprintf("---\nname: xlsx-pro\ndescription: %s xlsx-pro\n---\n# Test\n", ns))
1719-
}
1720-
require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "alice", "xlsx-pro"), 0o755))
1705+
writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "alice", "xlsx-pro"),
1706+
"---\nname: xlsx-pro\ndescription: alice xlsx-pro\n---\n# Test\n")
1707+
require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "xlsx-pro"), 0o755))
1708+
require.NoError(t, os.WriteFile(filepath.Join(targetDir, "xlsx-pro", "SKILL.md"), []byte("old"), 0o644))
17211709
},
17221710
opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions {
17231711
t.Helper()
1724-
pm := &prompter.PrompterMock{
1725-
MultiSelectWithSearchFunc: func(_, _ string, _, _ []string, _ func(string) prompter.MultiSelectSearchResult) ([]string, error) {
1726-
return []string{allSkillsKey}, nil
1727-
},
1728-
}
17291712
return &InstallOptions{
17301713
IO: ios,
17311714
SkillSource: sourceDir,
17321715
localPath: sourceDir,
1733-
Prompter: pm,
1716+
SkillName: "xlsx-pro",
17341717
Force: true,
17351718
Agent: "github-copilot",
17361719
Scope: "project",
@@ -1739,6 +1722,12 @@ func TestRunLocalInstall(t *testing.T) {
17391722
GitClient: &git.Client{RepoDir: t.TempDir()},
17401723
}
17411724
},
1725+
verify: func(t *testing.T, targetDir string) {
1726+
t.Helper()
1727+
content, err := os.ReadFile(filepath.Join(targetDir, "xlsx-pro", "SKILL.md"))
1728+
require.NoError(t, err)
1729+
assert.Contains(t, string(content), "alice xlsx-pro")
1730+
},
17421731
wantStdout: "Installed",
17431732
},
17441733
{

pkg/cmd/skills/update/update.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,24 @@ func updateRun(opts *UpdateOptions) error {
414414
failed = true
415415
continue
416416
}
417+
418+
// When the install location has changed (e.g. migrating from a
419+
// namespaced layout to flat), remove the old directory so that the
420+
// stale copy does not shadow the freshly installed one.
421+
newDir := filepath.Join(installOpts.Dir, u.skill.Name)
422+
if installOpts.Dir == "" && u.local.host != nil {
423+
if d, err := u.local.host.InstallDir(u.local.scope, gitRoot, homeDir); err == nil {
424+
newDir = filepath.Join(d, u.skill.Name)
425+
}
426+
}
427+
if newDir != "" && u.local.dir != "" && filepath.Clean(newDir) != filepath.Clean(u.local.dir) {
428+
_ = os.RemoveAll(u.local.dir)
429+
// Remove the parent if it is now empty (leftover namespace directory).
430+
parent := filepath.Dir(u.local.dir)
431+
if entries, readErr := os.ReadDir(parent); readErr == nil && len(entries) == 0 {
432+
_ = os.Remove(parent)
433+
}
434+
}
417435
if opts.IO.IsStdoutTTY() {
418436
fmt.Fprintf(opts.IO.Out, "%s Updated %s\n", cs.SuccessIcon(), u.local.name)
419437
} else {

pkg/cmd/skills/update/update_test.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -726,10 +726,14 @@ func TestUpdateRun(t *testing.T) {
726726
},
727727
verify: func(t *testing.T, dir string) {
728728
t.Helper()
729-
content, err := os.ReadFile(filepath.Join(dir, "monalisa", "code-review", "SKILL.md"))
729+
// After update, skill should be installed flat (not namespaced).
730+
content, err := os.ReadFile(filepath.Join(dir, "code-review", "SKILL.md"))
730731
require.NoError(t, err)
731732
assert.Contains(t, string(content), "github-repo: https://github.com/monalisa/octocat-skills")
732733
assert.NotContains(t, string(content), "Old namespaced content")
734+
// Old namespaced directory should be cleaned up.
735+
_, err = os.Stat(filepath.Join(dir, "monalisa", "code-review"))
736+
assert.True(t, os.IsNotExist(err), "old namespaced directory should be removed")
733737
},
734738
wantStdout: "Updated monalisa/code-review",
735739
},

0 commit comments

Comments
 (0)