Skip to content

Commit 96b9af3

Browse files
Merge pull request cli#13266 from cli/sammorrowdrums/fix-skill-install-flat-path
Install skills flat by Name, not namespaced InstallName
2 parents 5a121bf + 2e93afc commit 96b9af3

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)