diff --git a/acceptance/testdata/skills/.gitkeep b/acceptance/testdata/skills/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/internal/skills/discovery/collisions_test.go b/internal/skills/discovery/collisions_test.go index b499c497a..fff5199ba 100644 --- a/internal/skills/discovery/collisions_test.go +++ b/internal/skills/discovery/collisions_test.go @@ -21,13 +21,13 @@ func TestFindNameCollisions(t *testing.T) { want: nil, }, { - name: "single collision", + name: "single collision with different conventions", skills: []Skill{ {Name: "pr-summary", Path: "skills/pr-summary"}, - {Name: "pr-summary", Path: "skills/monalisa/pr-summary"}, + {Name: "pr-summary", Path: "plugins/hubot/skills/pr-summary", Convention: "plugins"}, }, want: []NameCollision{ - {Name: "pr-summary", DisplayNames: []string{"pr-summary", "pr-summary"}}, + {Name: "pr-summary", DisplayNames: []string{"pr-summary", "[plugins] pr-summary"}}, }, }, { @@ -53,10 +53,28 @@ func TestFindNameCollisions(t *testing.T) { } func TestFormatCollisions(t *testing.T) { - collisions := []NameCollision{ - {Name: "pr-summary", DisplayNames: []string{"skills/pr-summary", "plugins/hubot/pr-summary"}}, - {Name: "code-review", DisplayNames: []string{"skills/code-review", "skills/monalisa/code-review"}}, + tests := []struct { + name string + collisions []NameCollision + want string + }{ + { + name: "formats multiple collisions", + collisions: []NameCollision{ + {Name: "pr-summary", DisplayNames: []string{"skills/pr-summary", "plugins/hubot/pr-summary"}}, + {Name: "code-review", DisplayNames: []string{"skills/code-review", "skills/monalisa/code-review"}}, + }, + want: "pr-summary: skills/pr-summary, plugins/hubot/pr-summary\n code-review: skills/code-review, skills/monalisa/code-review", + }, + { + name: "nil input returns empty string", + collisions: nil, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, FormatCollisions(tt.collisions)) + }) } - got := FormatCollisions(collisions) - assert.Equal(t, "pr-summary: skills/pr-summary, plugins/hubot/pr-summary\n code-review: skills/code-review, skills/monalisa/code-review", got) } diff --git a/internal/skills/discovery/discovery_test.go b/internal/skills/discovery/discovery_test.go index 5368ad23a..974052530 100644 --- a/internal/skills/discovery/discovery_test.go +++ b/internal/skills/discovery/discovery_test.go @@ -2,8 +2,12 @@ package discovery import ( "net/http" + "os" + "path/filepath" + "strings" "testing" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/pkg/httpmock" "github.com/stretchr/testify/assert" @@ -73,6 +77,29 @@ func TestMatchSkillConventions(t *testing.T) { path: "skills/code-review/README.md", wantNil: true, }, + { + name: "plugin skill from different author", + path: "plugins/monalisa/skills/code-review/SKILL.md", + wantName: "code-review", + wantNamespace: "monalisa", + wantConvention: "plugins", + }, + { + name: "root convention single-skill repo", + path: "code-review/SKILL.md", + wantName: "code-review", + wantConvention: "root", + }, + { + name: "root convention excludes skills dir", + path: "skills/SKILL.md", + wantNil: true, + }, + { + name: "root convention excludes dot-prefixed", + path: ".hidden/SKILL.md", + wantNil: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -89,32 +116,6 @@ func TestMatchSkillConventions(t *testing.T) { } } -func TestDuplicatePluginSkills_DifferentAuthors(t *testing.T) { - entries := []treeEntry{ - {Path: "plugins/monalisa/skills/code-review/SKILL.md", Type: "blob"}, - {Path: "plugins/hubot/skills/code-review/SKILL.md", Type: "blob"}, - } - - seen := make(map[string]bool) - var matches []skillMatch - for _, e := range entries { - m := matchSkillConventions(e) - if m == nil || seen[m.skillDir] { - continue - } - seen[m.skillDir] = true - matches = append(matches, *m) - } - - require.Len(t, matches, 2) - assert.Equal(t, "monalisa", matches[0].namespace) - assert.Equal(t, "hubot", matches[1].namespace) - assert.NotEqual(t, - Skill{Name: matches[0].name, Namespace: matches[0].namespace}.InstallName(), - Skill{Name: matches[1].name, Namespace: matches[1].namespace}.InstallName(), - ) -} - func TestValidateName(t *testing.T) { tests := []struct { name string @@ -122,8 +123,8 @@ func TestValidateName(t *testing.T) { want bool }{ {name: "empty", input: "", want: false}, - {name: "too long", input: string(make([]byte, 65)), want: false}, - {name: "max length", input: "a" + string(make([]byte, 63)), want: false}, // 64 'a's would be valid but []byte gives null bytes + {name: "too long", input: strings.Repeat("a", 65), want: false}, + {name: "max length is valid", input: strings.Repeat("a", 64), want: true}, {name: "contains slash", input: "foo/bar", want: false}, {name: "contains dotdot", input: "foo..bar", want: false}, {name: "starts with dot", input: ".hidden", want: false}, @@ -261,6 +262,57 @@ func TestResolveRef(t *testing.T) { wantRef: "main", wantSHA: "branch-sha", }, + { + name: "annotated tag dereference failure", + version: "v4.0", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v4.0"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "tag-obj-sha", "type": "tag"}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/tags/tag-obj-sha"), + httpmock.StatusStringResponse(500, "server error")) + }, + wantErr: "could not dereference annotated tag", + }, + { + name: "empty tag_name in latest release falls back to default branch", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.JSONResponse(map[string]interface{}{"tag_name": ""})) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills"), + httpmock.JSONResponse(map[string]interface{}{"default_branch": "main"})) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/main"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "fallback-sha"}, + })) + }, + wantRef: "main", + wantSHA: "fallback-sha", + }, + { + name: "empty default_branch falls back to main", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StatusStringResponse(404, "not found")) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills"), + httpmock.JSONResponse(map[string]interface{}{"default_branch": ""})) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/main"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "main-sha"}, + })) + }, + wantRef: "main", + wantSHA: "main-sha", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -339,3 +391,549 @@ func TestFetchBlob(t *testing.T) { }) } } + +func TestDiscoverSkills(t *testing.T) { + tests := []struct { + name string + stubs func(*httpmock.Registry) + wantSkills []string + wantErr string + }{ + { + name: "discovers skills from tree", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/abc123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "abc123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "skills/code-review", "type": "tree", "sha": "tree-sha-1"}, + {"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "blob-1"}, + {"path": "skills/issue-triage", "type": "tree", "sha": "tree-sha-2"}, + {"path": "skills/issue-triage/SKILL.md", "type": "blob", "sha": "blob-2"}, + {"path": "README.md", "type": "blob", "sha": "readme"}, + }, + })) + }, + wantSkills: []string{"code-review", "issue-triage"}, + }, + { + name: "truncated tree returns error", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/abc123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "abc123", "truncated": true, "tree": []map[string]interface{}{}, + })) + }, + wantErr: "too large", + }, + { + name: "no skills found", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/abc123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "abc123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "README.md", "type": "blob", "sha": "readme"}, + }, + })) + }, + wantErr: "no skills found", + }, + { + name: "API error", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/abc123"), + httpmock.StatusStringResponse(500, "server error")) + }, + wantErr: "could not fetch repository tree", + }, + { + name: "deduplicates skills from same directory", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/abc123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "abc123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "skills/code-review", "type": "tree", "sha": "tree-sha"}, + {"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "blob-1"}, + {"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "blob-2"}, + }, + })) + }, + wantSkills: []string{"code-review"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + tt.stubs(reg) + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + + skills, err := DiscoverSkills(client, "github.com", "monalisa", "octocat-skills", "abc123") + 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 + skillPath string + stubs func(*httpmock.Registry) + wantName string + wantNS string + wantErr string + }{ + { + name: "discovers skill by path", + skillPath: "skills/code-review", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/skills"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "code-review", "path": "skills/code-review", "sha": "tree-sha", "type": "dir"}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree-sha", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "blob-sha"}, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blob-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "blob-sha", "encoding": "base64", "content": "IyBTa2lsbA==", + })) + }, + wantName: "code-review", + }, + { + name: "namespaced path sets namespace", + skillPath: "skills/monalisa/issue-triage", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/skills/monalisa"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "issue-triage", "path": "skills/monalisa/issue-triage", "sha": "tree-sha", "type": "dir"}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree-sha", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "blob-sha"}, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blob-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "blob-sha", "encoding": "base64", "content": "IyBTa2lsbA==", + })) + }, + wantName: "issue-triage", + wantNS: "monalisa", + }, + { + name: "strips trailing SKILL.md from path", + skillPath: "skills/code-review/SKILL.md", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/skills"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "code-review", "path": "skills/code-review", "sha": "tree-sha", "type": "dir"}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree-sha", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "blob-sha"}, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blob-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "blob-sha", "encoding": "base64", "content": "IyBTa2lsbA==", + })) + }, + wantName: "code-review", + }, + { + name: "invalid skill name", + skillPath: "skills/.hidden-skill", + wantErr: "invalid skill name", + }, + { + name: "skill directory not found", + skillPath: "skills/nonexistent", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/skills"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "other-skill", "path": "skills/other-skill", "sha": "tree-sha", "type": "dir"}, + })) + }, + wantErr: "skill directory", + }, + { + name: "no SKILL.md in directory", + skillPath: "skills/code-review", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/skills"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "code-review", "path": "skills/code-review", "sha": "tree-sha", "type": "dir"}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree-sha", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "README.md", "type": "blob", "sha": "readme"}, + }, + })) + }, + wantErr: "no SKILL.md found", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + if tt.stubs != nil { + tt.stubs(reg) + } + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + + skill, err := DiscoverSkillByPath(client, "github.com", "monalisa", "octocat-skills", "abc123", tt.skillPath) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantName, skill.Name) + assert.Equal(t, tt.wantNS, skill.Namespace) + }) + } +} + +func TestDiscoverLocalSkills(t *testing.T) { + tests := []struct { + name string + createDir bool + setup func(t *testing.T, dir string) + wantSkills []string + wantErr string + }{ + { + name: "discovers skills in skills/ directory", + createDir: true, + setup: func(t *testing.T, dir string) { + t.Helper() + for _, name := range []string{"code-review", "issue-triage"} { + skillDir := filepath.Join(dir, "skills", name) + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# "+name), 0o644)) + } + }, + wantSkills: []string{"code-review", "issue-triage"}, + }, + { + name: "single skill at root", + createDir: true, + setup: func(t *testing.T, dir string) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: root-skill + --- + # Root + `)), 0o644)) + }, + wantSkills: []string{"root-skill"}, + }, + { + name: "no skills found", + createDir: true, + setup: func(t *testing.T, dir string) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("# Not a skill"), 0o644)) + }, + wantErr: "no skills found", + }, + { + name: "nonexistent directory", + setup: func(t *testing.T, dir string) {}, + wantErr: "could not access", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "repo") + if tt.createDir { + require.NoError(t, os.MkdirAll(dir, 0o755)) + } + tt.setup(t, dir) + + skills, err := DiscoverLocalSkills(dir) + 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 + path string + wantName string + }{ + {name: "skills convention", path: "skills/code-review/SKILL.md", wantName: "code-review"}, + {name: "namespaced convention", path: "skills/monalisa/issue-triage/SKILL.md", wantName: "issue-triage"}, + {name: "plugins convention", path: "plugins/hubot/skills/pr-summary/SKILL.md", wantName: "pr-summary"}, + {name: "non-skill file", path: "README.md", wantName: ""}, + {name: "non-SKILL.md in skill dir", path: "skills/code-review/prompt.txt", wantName: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.wantName, MatchesSkillPath(tt.path)) + }) + } +} + +func TestDiscoverSkillFiles(t *testing.T) { + tests := []struct { + name string + stubs func(*httpmock.Registry) + wantPaths []string + wantErr string + }{ + { + name: "returns files with skill path prefix", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "sha1", "size": 10}, + {"path": "scripts/setup.sh", "type": "blob", "sha": "sha2", "size": 50}, + {"path": "scripts", "type": "tree", "sha": "treesub"}, + }, + })) + }, + wantPaths: []string{"skills/code-review/SKILL.md", "skills/code-review/scripts/setup.sh"}, + }, + { + name: "truncated tree falls back to walkTree", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree123", "truncated": true, "tree": []map[string]interface{}{}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree123", + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "sha1", "size": 10}, + }, + })) + }, + wantPaths: []string{"skills/code-review/SKILL.md"}, + }, + { + name: "API error", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree123"), + httpmock.StatusStringResponse(500, "server error")) + }, + wantErr: "could not fetch skill tree", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + tt.stubs(reg) + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + + files, err := DiscoverSkillFiles(client, "github.com", "monalisa", "octocat-skills", "tree123", "skills/code-review") + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + var paths []string + for _, f := range files { + paths = append(paths, f.Path) + } + assert.Equal(t, tt.wantPaths, paths) + }) + } +} + +func TestListSkillFiles(t *testing.T) { + tests := []struct { + name string + stubs func(*httpmock.Registry) + wantPaths []string + wantErr string + }{ + { + name: "returns relative paths", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "sha1", "size": 10}, + {"path": "prompt.txt", "type": "blob", "sha": "sha2", "size": 20}, + }, + })) + }, + wantPaths: []string{"SKILL.md", "prompt.txt"}, + }, + { + name: "truncated tree falls back to walkTree with nested subtree", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree123", "truncated": true, "tree": []map[string]interface{}{}, + })) + // walkTree fetches the top-level tree non-recursively + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree123", + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "sha1", "size": 10}, + {"path": "scripts", "type": "tree", "sha": "subtree1"}, + }, + })) + // walkTree recurses into the "scripts" subtree + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/subtree1"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "subtree1", + "tree": []map[string]interface{}{ + {"path": "setup.sh", "type": "blob", "sha": "sha2", "size": 50}, + }, + })) + }, + wantPaths: []string{"SKILL.md", "scripts/setup.sh"}, + }, + { + name: "API error", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree123"), + httpmock.StatusStringResponse(500, "server error")) + }, + wantErr: "could not fetch skill tree", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + tt.stubs(reg) + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + + files, err := ListSkillFiles(client, "github.com", "monalisa", "octocat-skills", "tree123") + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + var paths []string + for _, f := range files { + paths = append(paths, f.Path) + } + assert.Equal(t, tt.wantPaths, paths) + }) + } +} + +func TestFetchDescriptionsConcurrent(t *testing.T) { + tests := []struct { + name string + skills []Skill + stubs func(*httpmock.Registry) + wantDescs []string + }{ + { + name: "fetches descriptions for skills without one", + skills: []Skill{ + {Name: "code-review", BlobSHA: "blob1"}, + {Name: "issue-triage", Description: "already set"}, + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blob1"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "blob1", "encoding": "base64", + "content": "LS0tCm5hbWU6IGNvZGUtcmV2aWV3CmRlc2NyaXB0aW9uOiBSZXZpZXdzIFBScwotLS0KIyBUZXN0", + })) + }, + wantDescs: []string{"Reviews PRs", "already set"}, + }, + { + name: "no-op when all descriptions set", + skills: []Skill{ + {Name: "code-review", Description: "set"}, + }, + stubs: func(reg *httpmock.Registry) {}, + wantDescs: []string{"set"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + tt.stubs(reg) + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + + FetchDescriptionsConcurrent(client, "github.com", "monalisa", "octocat-skills", tt.skills, nil) + var descs []string + for _, s := range tt.skills { + descs = append(descs, s.Description) + } + assert.Equal(t, tt.wantDescs, descs) + }) + } +} diff --git a/internal/skills/frontmatter/frontmatter_test.go b/internal/skills/frontmatter/frontmatter_test.go index 02bd1ee0e..51fe09133 100644 --- a/internal/skills/frontmatter/frontmatter_test.go +++ b/internal/skills/frontmatter/frontmatter_test.go @@ -4,6 +4,7 @@ import ( "strings" "testing" + "github.com/MakeNowJust/heredoc" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -18,8 +19,14 @@ func TestParse(t *testing.T) { wantErr bool }{ { - name: "valid frontmatter", - content: "---\nname: test-skill\ndescription: A test skill\n---\n# Body\n", + name: "valid frontmatter", + content: heredoc.Doc(` + --- + name: test-skill + description: A test skill + --- + # Body + `), wantName: "test-skill", wantDesc: "A test skill", wantBody: "# Body\n", @@ -71,18 +78,24 @@ func TestInjectGitHubMetadata(t *testing.T) { wantNotContain []string }{ { - name: "injects metadata without pin", - content: "---\nname: my-skill\ndescription: desc\n---\n# Body\n", - owner: "owner", - repo: "repo", + name: "injects metadata without pin", + content: heredoc.Doc(` + --- + name: my-skill + description: desc + --- + # Body + `), + owner: "monalisa", + repo: "octocat-skills", ref: "v1.0.0", sha: "abc123", treeSHA: "tree456", pinnedRef: "", skillPath: "skills/my-skill", wantContains: []string{ - "github-owner: owner", - "github-repo: repo", + "github-owner: monalisa", + "github-repo: octocat-skills", "github-ref: v1.0.0", "github-sha: abc123", "github-tree-sha: tree456", @@ -94,10 +107,15 @@ func TestInjectGitHubMetadata(t *testing.T) { }, }, { - name: "injects pinned ref", - content: "---\nname: my-skill\n---\n# Body\n", - owner: "owner", - repo: "repo", + name: "injects pinned ref", + content: heredoc.Doc(` + --- + name: my-skill + --- + # Body + `), + owner: "monalisa", + repo: "octocat-skills", ref: "v1.0.0", sha: "abc", treeSHA: "tree", @@ -107,6 +125,22 @@ func TestInjectGitHubMetadata(t *testing.T) { "github-pinned: v1.0.0", }, }, + { + name: "injects metadata into content with no frontmatter", + content: "# Body only\n", + owner: "monalisa", + repo: "octocat-skills", + ref: "v1.0.0", + sha: "abc123", + treeSHA: "tree456", + pinnedRef: "", + skillPath: "skills/my-skill", + wantContains: []string{ + "github-owner: monalisa", + "github-repo: octocat-skills", + "# Body only", + }, + }, } for _, tt := range tests { @@ -124,13 +158,49 @@ func TestInjectGitHubMetadata(t *testing.T) { } func TestInjectLocalMetadata(t *testing.T) { - content := "---\nname: my-skill\nmetadata:\n github-owner: old\n github-repo: old\n---\n# Body\n" - got, err := InjectLocalMetadata(content, "/home/user/skills/my-skill") - require.NoError(t, err) - - assert.Contains(t, got, "local-path: /home/user/skills/my-skill") - assert.NotContains(t, got, "github-owner") - assert.NotContains(t, got, "github-repo") + tests := []struct { + name string + content string + wantContains []string + wantNotContain []string + }{ + { + name: "strips all github keys and injects local-path", + content: heredoc.Doc(` + --- + name: my-skill + metadata: + github-owner: old + github-repo: old + github-ref: v1.0.0 + github-sha: abc123 + github-tree-sha: tree456 + github-pinned: v1.0.0 + github-path: skills/my-skill + --- + # Body + `), + wantContains: []string{"local-path: /home/monalisa/skills/my-skill"}, + wantNotContain: []string{"github-owner", "github-repo", "github-ref", "github-sha", "github-tree-sha", "github-pinned", "github-path"}, + }, + { + name: "injects into content with no existing metadata", + content: "# Body only\n", + wantContains: []string{"local-path: /home/monalisa/skills/my-skill"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := InjectLocalMetadata(tt.content, "/home/monalisa/skills/my-skill") + require.NoError(t, err) + for _, s := range tt.wantContains { + assert.Contains(t, got, s) + } + for _, s := range tt.wantNotContain { + assert.NotContains(t, got, s) + } + }) + } } func TestSerialize(t *testing.T) { @@ -158,6 +228,12 @@ func TestSerialize(t *testing.T) { body: "", wantSuffix: "---\n", }, + { + name: "body without trailing newline gets one added", + frontmatter: map[string]interface{}{"name": "test"}, + body: "# No trailing newline", + wantSuffix: "# No trailing newline\n", + }, } for _, tt := range tests { diff --git a/internal/skills/installer/installer.go b/internal/skills/installer/installer.go index ed2db5074..8ae3da28f 100644 --- a/internal/skills/installer/installer.go +++ b/internal/skills/installer/installer.go @@ -1,6 +1,7 @@ package installer import ( + "context" "errors" "fmt" "os" @@ -9,6 +10,7 @@ import ( "sync" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/safepaths" "github.com/cli/cli/v2/internal/skills/discovery" "github.com/cli/cli/v2/internal/skills/frontmatter" @@ -295,3 +297,29 @@ func installSkill(opts *Options, skill discovery.Skill, baseDir string) error { return nil } + +// ResolveGitRoot returns the git repository root using the provided client, +// falling back to the current working directory on error. +func ResolveGitRoot(gc *git.Client) string { + if gc != nil && gc.RepoDir != "" { + return gc.RepoDir + } + if gc != nil { + if root, err := gc.ToplevelDir(context.Background()); err == nil { + return root + } + } + if cwd, err := os.Getwd(); err == nil { + return cwd + } + return "" +} + +// ResolveHomeDir returns the user's home directory, or "" on error. +func ResolveHomeDir() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return home +} diff --git a/internal/skills/installer/installer_test.go b/internal/skills/installer/installer_test.go index 2f6e09ca8..0637e9c19 100644 --- a/internal/skills/installer/installer_test.go +++ b/internal/skills/installer/installer_test.go @@ -6,26 +6,30 @@ import ( "net/http" "os" "path/filepath" - "strings" + "sync/atomic" "testing" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/skills/discovery" + "github.com/cli/cli/v2/internal/skills/registry" "github.com/cli/cli/v2/pkg/httpmock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestInstallLocalSkill(t *testing.T) { +func TestInstallLocal(t *testing.T) { tests := []struct { - name string - skill discovery.Skill - setup func(t *testing.T, srcDir string) - verify func(t *testing.T, destDir string) + name string + skills []discovery.Skill + useAgentHost bool + setup func(t *testing.T, srcDir string) + verify func(t *testing.T, destDir string) + wantErr string }{ { - name: "copies files", - skill: discovery.Skill{Name: "code-review", Path: "skills/code-review"}, + name: "copies files via Dir", + skills: []discovery.Skill{{Name: "code-review", Path: "skills/code-review"}}, setup: func(t *testing.T, srcDir string) { t.Helper() skillSrc := filepath.Join(srcDir, "skills", "code-review") @@ -44,8 +48,8 @@ func TestInstallLocalSkill(t *testing.T) { }, }, { - name: "nested directories", - skill: discovery.Skill{Name: "issue-triage", Path: "skills/issue-triage"}, + name: "nested directories", + skills: []discovery.Skill{{Name: "issue-triage", Path: "skills/issue-triage"}}, setup: func(t *testing.T, srcDir string) { t.Helper() deep := filepath.Join(srcDir, "skills", "issue-triage", "prompts", "templates") @@ -62,15 +66,15 @@ func TestInstallLocalSkill(t *testing.T) { }, }, { - name: "skips symlinks", - skill: discovery.Skill{Name: "pr-summary", Path: "skills/pr-summary"}, + name: "skips symlinks", + skills: []discovery.Skill{{Name: "pr-summary", Path: "skills/pr-summary"}}, setup: func(t *testing.T, srcDir string) { t.Helper() skillSrc := filepath.Join(srcDir, "skills", "pr-summary") require.NoError(t, os.MkdirAll(skillSrc, 0o755)) require.NoError(t, os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte("# PR Summary"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(skillSrc, "prompt.txt"), []byte("summarize"), 0o644)) - os.Symlink(filepath.Join(skillSrc, "prompt.txt"), filepath.Join(skillSrc, "link.txt")) + require.NoError(t, os.Symlink(filepath.Join(skillSrc, "prompt.txt"), filepath.Join(skillSrc, "link.txt"))) }, verify: func(t *testing.T, destDir string) { t.Helper() @@ -81,8 +85,8 @@ func TestInstallLocalSkill(t *testing.T) { }, }, { - name: "injects metadata into SKILL.md", - skill: discovery.Skill{Name: "copilot-helper", Path: "skills/copilot-helper"}, + name: "injects metadata into SKILL.md", + skills: []discovery.Skill{{Name: "copilot-helper", Path: "skills/copilot-helper"}}, setup: func(t *testing.T, srcDir string) { t.Helper() skillSrc := filepath.Join(srcDir, "skills", "copilot-helper") @@ -93,10 +97,53 @@ func TestInstallLocalSkill(t *testing.T) { t.Helper() content, err := os.ReadFile(filepath.Join(destDir, "copilot-helper", "SKILL.md")) require.NoError(t, err) - assert.True(t, strings.Contains(string(content), "local-path"), - "expected SKILL.md to contain local-path metadata, got: %s", string(content)) + assert.Contains(t, string(content), "local-path") }, }, + { + name: "multiple skills", + skills: []discovery.Skill{ + {Name: "code-review", Path: "skills/code-review"}, + {Name: "issue-triage", Path: "skills/issue-triage"}, + }, + setup: func(t *testing.T, srcDir string) { + t.Helper() + for _, name := range []string{"code-review", "issue-triage"} { + skillSrc := filepath.Join(srcDir, "skills", name) + require.NoError(t, os.MkdirAll(skillSrc, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte("# "+name), 0o644)) + } + }, + verify: func(t *testing.T, destDir string) { + t.Helper() + _, err := os.Stat(filepath.Join(destDir, "code-review", "SKILL.md")) + assert.NoError(t, err) + _, err = os.Stat(filepath.Join(destDir, "issue-triage", "SKILL.md")) + assert.NoError(t, err) + }, + }, + { + name: "resolves install dir from AgentHost and Scope", + skills: []discovery.Skill{{Name: "code-review", Path: "skills/code-review"}}, + useAgentHost: true, + setup: func(t *testing.T, srcDir string) { + t.Helper() + skillSrc := filepath.Join(srcDir, "skills", "code-review") + require.NoError(t, os.MkdirAll(skillSrc, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte("# Code Review"), 0o644)) + }, + verify: func(t *testing.T, destDir string) { + t.Helper() + _, err := os.Stat(filepath.Join(destDir, ".github", "skills", "code-review", "SKILL.md")) + assert.NoError(t, err) + }, + }, + { + name: "no dir or agent host", + skills: []discovery.Skill{{Name: "code-review"}}, + setup: func(t *testing.T, srcDir string) {}, + wantErr: "either Dir or AgentHost must be specified", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -104,8 +151,32 @@ func TestInstallLocalSkill(t *testing.T) { destDir := t.TempDir() tt.setup(t, srcDir) - err := installLocalSkill(srcDir, tt.skill, destDir) + opts := &LocalOptions{ + SourceDir: srcDir, + Skills: tt.skills, + Dir: destDir, + } + if tt.useAgentHost { + host, err := registry.FindByID("github-copilot") + require.NoError(t, err) + opts.Dir = "" + opts.AgentHost = host + opts.Scope = registry.ScopeProject + opts.GitRoot = destDir + } + if tt.wantErr != "" { + opts.Dir = "" + } + + result, err := InstallLocal(opts) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } require.NoError(t, err) + assert.NotEmpty(t, result.Dir) + assert.Len(t, result.Installed, len(tt.skills)) tt.verify(t, destDir) }) } @@ -258,23 +329,31 @@ func stubTreeAndBlob(reg *httpmock.Registry, treeSHA string) { } func TestInstall(t *testing.T) { + var progressCount atomic.Int32 + tests := []struct { name string skills []discovery.Skill stubs func(*httpmock.Registry) + onProgress func(done, total int) wantInstalled []string wantErr string }{ { - name: "single skill", + name: "single skill calls OnProgress", skills: []discovery.Skill{ {Name: "code-review", Path: "skills/code-review", TreeSHA: "tree-cr"}, }, - stubs: func(reg *httpmock.Registry) { stubTreeAndBlob(reg, "tree-cr") }, + stubs: func(reg *httpmock.Registry) { stubTreeAndBlob(reg, "tree-cr") }, + onProgress: func(done, total int) { + + progressCount.Add(1) + + }, wantInstalled: []string{"code-review"}, }, { - name: "multiple skills concurrently", + name: "multiple skills concurrently with progress", skills: []discovery.Skill{ {Name: "code-review", Path: "skills/code-review", TreeSHA: "tree-cr"}, {Name: "issue-triage", Path: "skills/issue-triage", TreeSHA: "tree-it"}, @@ -283,8 +362,28 @@ func TestInstall(t *testing.T) { stubTreeAndBlob(reg, "tree-cr") stubTreeAndBlob(reg, "tree-it") }, + onProgress: func(done, total int) { + + progressCount.Add(1) + + }, wantInstalled: []string{"code-review", "issue-triage"}, }, + { + name: "partial failure returns successful installs and error", + skills: []discovery.Skill{ + {Name: "code-review", Path: "skills/code-review", TreeSHA: "tree-cr"}, + {Name: "issue-triage", Path: "skills/issue-triage", TreeSHA: "tree-fail"}, + }, + stubs: func(reg *httpmock.Registry) { + stubTreeAndBlob(reg, "tree-cr") + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-fail"), + httpmock.StatusStringResponse(500, "server error")) + }, + wantInstalled: []string{"code-review"}, + wantErr: "failed to install skill", + }, { name: "no dir or agent host", skills: []discovery.Skill{{Name: "code-review"}}, @@ -294,7 +393,10 @@ func TestInstall(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + progressCount.Store(0) + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) destDir := t.TempDir() reg := &httpmock.Registry{} @@ -303,16 +405,17 @@ func TestInstall(t *testing.T) { client := api.NewClientFromHTTP(&http.Client{Transport: reg}) opts := &Options{ - Host: "github.com", - Owner: "monalisa", - Repo: "octocat-skills", - Ref: "v1.0", - SHA: "commit123", - Client: client, - Skills: tt.skills, - Dir: destDir, + Host: "github.com", + Owner: "monalisa", + Repo: "octocat-skills", + Ref: "v1.0", + SHA: "commit123", + Client: client, + Skills: tt.skills, + Dir: destDir, + OnProgress: tt.onProgress, } - if tt.wantErr != "" { + if tt.wantErr != "" && len(tt.wantInstalled) == 0 { opts.Dir = "" } @@ -320,19 +423,58 @@ func TestInstall(t *testing.T) { if tt.wantErr != "" { require.Error(t, err) assert.Contains(t, err.Error(), tt.wantErr) + if len(tt.wantInstalled) > 0 { + require.NotNil(t, result, "partial failure should return non-nil result") + assert.ElementsMatch(t, tt.wantInstalled, result.Installed) + } return } require.NoError(t, err) assert.ElementsMatch(t, tt.wantInstalled, result.Installed) assert.Equal(t, destDir, result.Dir) - homeDir, _ := os.UserHomeDir() + homeDir, _ = os.UserHomeDir() lockPath := filepath.Join(homeDir, ".agents", ".skill-lock.json") lockData, err := os.ReadFile(lockPath) require.NoError(t, err, "lockfile should have been written") for _, name := range tt.wantInstalled { assert.Contains(t, string(lockData), name) } + if tt.onProgress != nil { + assert.True(t, progressCount.Load() > 0, "OnProgress should have been called") + } + }) + } +} + +func TestResolveGitRoot(t *testing.T) { + tests := []struct { + name string + client *git.Client + wantDir string + }{ + { + name: "returns RepoDir when set", + client: &git.Client{RepoDir: "/monalisa/repo"}, + wantDir: "/monalisa/repo", + }, + { + name: "nil client falls back to cwd", + client: nil, + }, + { + name: "empty RepoDir falls back to ToplevelDir or cwd", + client: &git.Client{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ResolveGitRoot(tt.client) + if tt.wantDir != "" { + assert.Equal(t, tt.wantDir, got) + } else { + assert.NotEmpty(t, got, "should fall back to ToplevelDir or cwd") + } }) } } diff --git a/internal/skills/lockfile/lockfile.go b/internal/skills/lockfile/lockfile.go index 5761d24cf..3a6ccd893 100644 --- a/internal/skills/lockfile/lockfile.go +++ b/internal/skills/lockfile/lockfile.go @@ -131,6 +131,11 @@ func newFile() *file { } } +var ( + lockRetries = 30 + lockRetryInterval = 100 * time.Millisecond +) + // acquireLock creates an exclusive lock file to serialize concurrent access. // Returns an unlock function. If locking fails after retries, it proceeds // unlocked rather than blocking the user indefinitely. @@ -146,7 +151,7 @@ func acquireLock() (unlock func()) { return func() {} } - for i := 0; i < 30; i++ { + for range lockRetries { f, createErr := os.OpenFile(lkPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) if createErr == nil { f.Close() @@ -162,7 +167,7 @@ func acquireLock() (unlock func()) { os.Remove(lkPath) continue } - time.Sleep(100 * time.Millisecond) + time.Sleep(lockRetryInterval) } // Best-effort: proceed without lock. diff --git a/internal/skills/lockfile/lockfile_test.go b/internal/skills/lockfile/lockfile_test.go index b53d6aafc..d4a44f76d 100644 --- a/internal/skills/lockfile/lockfile_test.go +++ b/internal/skills/lockfile/lockfile_test.go @@ -5,16 +5,18 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// setupHome redirects HOME to a temp dir and returns the expected lockfile path. -func setupHome(t *testing.T) string { +// setupTestHome redirects HOME to a temp dir and returns the expected lockfile path. +func setupTestHome(t *testing.T) string { t.Helper() home := t.TempDir() t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) return filepath.Join(home, agentsDir, lockFile) } @@ -39,7 +41,7 @@ func TestRecordInstall(t *testing.T) { treeSHA: "abc123", verify: func(t *testing.T, lockPath string) { t.Helper() - f := readLockfile(t, lockPath) + f := readTestLockfile(t, lockPath) require.Contains(t, f.Skills, "code-review") e := f.Skills["code-review"] assert.Equal(t, "monalisa/octocat-skills", e.Source) @@ -62,30 +64,10 @@ func TestRecordInstall(t *testing.T) { pinnedRef: "v1.0.0", verify: func(t *testing.T, lockPath string) { t.Helper() - f := readLockfile(t, lockPath) + f := readTestLockfile(t, lockPath) assert.Equal(t, "v1.0.0", f.Skills["pr-summary"].PinnedRef) }, }, - { - name: "update preserves InstalledAt and updates treeSHA", - setup: func(t *testing.T) { - t.Helper() - require.NoError(t, RecordInstall("code-review", "monalisa", "octocat-skills", "skills/code-review/SKILL.md", "old-sha", "")) - }, - skill: "code-review", - owner: "monalisa", - repo: "octocat-skills", - skillPath: "skills/code-review/SKILL.md", - treeSHA: "new-sha", - verify: func(t *testing.T, lockPath string) { - t.Helper() - f := readLockfile(t, lockPath) - e := f.Skills["code-review"] - assert.Equal(t, "new-sha", e.SkillFolderHash, "treeSHA should be updated") - // InstalledAt should be preserved (not empty proves it wasn't clobbered) - assert.NotEmpty(t, e.InstalledAt, "InstalledAt should be preserved from first install") - }, - }, { name: "multiple skills coexist", setup: func(t *testing.T) { @@ -99,15 +81,118 @@ func TestRecordInstall(t *testing.T) { treeSHA: "sha2", verify: func(t *testing.T, lockPath string) { t.Helper() - f := readLockfile(t, lockPath) + f := readTestLockfile(t, lockPath) assert.Contains(t, f.Skills, "code-review") assert.Contains(t, f.Skills, "issue-triage") }, }, + { + name: "succeeds despite stale lock file", + setup: func(t *testing.T) { + t.Helper() + lockPath, err := lockfilePath() + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Dir(lockPath), 0o755)) + lkPath := lockPath + ".lk" + f, err := os.Create(lkPath) + require.NoError(t, err) + f.Close() + staleTime := time.Now().Add(-60 * time.Second) + require.NoError(t, os.Chtimes(lkPath, staleTime, staleTime)) + }, + skill: "code-review", + owner: "monalisa", + repo: "octocat-skills", + skillPath: "skills/code-review/SKILL.md", + treeSHA: "abc123", + verify: func(t *testing.T, lockPath string) { + t.Helper() + f := readTestLockfile(t, lockPath) + require.Contains(t, f.Skills, "code-review") + _, err := os.Stat(lockPath + ".lk") + assert.True(t, os.IsNotExist(err), "stale lock should be removed after RecordInstall") + }, + }, + { + name: "proceeds without lock after retries exhausted", + setup: func(t *testing.T) { + t.Helper() + // Reduce retries to avoid 3s wait in tests. + origRetries := lockRetries + origInterval := lockRetryInterval + lockRetries = 1 + lockRetryInterval = 0 + t.Cleanup(func() { + lockRetries = origRetries + lockRetryInterval = origInterval + }) + // Create a fresh (non-stale) lock file that won't be broken. + lockPath, err := lockfilePath() + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Dir(lockPath), 0o755)) + f, err := os.Create(lockPath + ".lk") + require.NoError(t, err) + f.Close() + }, + skill: "code-review", + owner: "monalisa", + repo: "octocat-skills", + skillPath: "skills/code-review/SKILL.md", + treeSHA: "abc123", + verify: func(t *testing.T, lockPath string) { + t.Helper() + f := readTestLockfile(t, lockPath) + require.Contains(t, f.Skills, "code-review", "should succeed best-effort without lock") + }, + }, + { + name: "recovers from corrupt lockfile", + setup: func(t *testing.T) { + t.Helper() + lockPath, err := lockfilePath() + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Dir(lockPath), 0o755)) + require.NoError(t, os.WriteFile(lockPath, []byte("{invalid json"), 0o644)) + }, + skill: "code-review", + owner: "monalisa", + repo: "octocat-skills", + skillPath: "skills/code-review/SKILL.md", + treeSHA: "abc123", + verify: func(t *testing.T, lockPath string) { + t.Helper() + f := readTestLockfile(t, lockPath) + assert.Equal(t, lockVersion, f.Version) + require.Contains(t, f.Skills, "code-review") + }, + }, + { + name: "recovers from wrong version lockfile", + setup: func(t *testing.T) { + t.Helper() + lockPath, err := lockfilePath() + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Dir(lockPath), 0o755)) + data, _ := json.Marshal(file{Version: 999, Skills: map[string]entry{"old-skill": {}}}) + require.NoError(t, os.WriteFile(lockPath, data, 0o644)) + }, + skill: "code-review", + owner: "monalisa", + repo: "octocat-skills", + skillPath: "skills/code-review/SKILL.md", + treeSHA: "abc123", + verify: func(t *testing.T, lockPath string) { + t.Helper() + f := readTestLockfile(t, lockPath) + assert.Equal(t, lockVersion, f.Version) + require.Contains(t, f.Skills, "code-review") + assert.NotContains(t, f.Skills, "old-skill", "wrong-version data should be discarded") + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - lockPath := setupHome(t) + lockPath := setupTestHome(t) if tt.setup != nil { tt.setup(t) } @@ -117,73 +202,25 @@ func TestRecordInstall(t *testing.T) { tt.verify(t, lockPath) }) } + + // This case lives outside the table because it needs to read the lockfile + // between two RecordInstall calls to capture the first InstalledAt value. + t.Run("update preserves InstalledAt and updates treeSHA", func(t *testing.T) { + lockPath := setupTestHome(t) + + require.NoError(t, RecordInstall("code-review", "monalisa", "octocat-skills", "skills/code-review/SKILL.md", "old-sha", "")) + firstInstalledAt := readTestLockfile(t, lockPath).Skills["code-review"].InstalledAt + + require.NoError(t, RecordInstall("code-review", "monalisa", "octocat-skills", "skills/code-review/SKILL.md", "new-sha", "")) + entry := readTestLockfile(t, lockPath).Skills["code-review"] + + assert.Equal(t, "new-sha", entry.SkillFolderHash, "treeSHA should be updated") + assert.Equal(t, firstInstalledAt, entry.InstalledAt, "InstalledAt should be preserved from first install") + }) } -func TestRead(t *testing.T) { - tests := []struct { - name string - setup func(t *testing.T, lockPath string) - wantSkill bool - }{ - { - name: "missing file returns fresh state", - setup: func(t *testing.T, lockPath string) {}, - }, - { - name: "corrupt JSON returns fresh state", - setup: func(t *testing.T, lockPath string) { - t.Helper() - require.NoError(t, os.MkdirAll(filepath.Dir(lockPath), 0o755)) - require.NoError(t, os.WriteFile(lockPath, []byte("{invalid json"), 0o644)) - }, - }, - { - name: "wrong version returns fresh state", - setup: func(t *testing.T, lockPath string) { - t.Helper() - require.NoError(t, os.MkdirAll(filepath.Dir(lockPath), 0o755)) - data, _ := json.Marshal(file{Version: 999, Skills: map[string]entry{"x": {}}}) - require.NoError(t, os.WriteFile(lockPath, data, 0o644)) - }, - }, - { - name: "valid lockfile", - setup: func(t *testing.T, lockPath string) { - t.Helper() - require.NoError(t, os.MkdirAll(filepath.Dir(lockPath), 0o755)) - f := &file{ - Version: lockVersion, - Skills: map[string]entry{ - "code-review": {Source: "monalisa/octocat-skills", SourceType: "github"}, - }, - } - data, err := json.MarshalIndent(f, "", " ") - require.NoError(t, err) - require.NoError(t, os.WriteFile(lockPath, data, 0o644)) - }, - wantSkill: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - lockPath := setupHome(t) - tt.setup(t, lockPath) - - loaded, err := read() - require.NoError(t, err) - assert.Equal(t, lockVersion, loaded.Version) - - if tt.wantSkill { - assert.Contains(t, loaded.Skills, "code-review") - } else { - assert.Empty(t, loaded.Skills) - } - }) - } -} - -// readLockfile is a test helper that reads and parses the lockfile from disk. -func readLockfile(t *testing.T, path string) *file { +// readTestLockfile is a test helper that reads and parses the lockfile from disk. +func readTestLockfile(t *testing.T, path string) *file { t.Helper() data, err := os.ReadFile(path) require.NoError(t, err, "lockfile should exist at %s", path) diff --git a/internal/skills/registry/registry_test.go b/internal/skills/registry/registry_test.go index f37c35e96..e17668b87 100644 --- a/internal/skills/registry/registry_test.go +++ b/internal/skills/registry/registry_test.go @@ -70,6 +70,20 @@ func TestInstallDir(t *testing.T) { homeDir: "/home/monalisa", wantErr: true, }, + { + name: "user scope without home dir", + scope: ScopeUser, + gitRoot: "/tmp/monalisa-repo", + homeDir: "", + wantErr: true, + }, + { + name: "invalid scope", + scope: "bogus", + gitRoot: "/tmp/monalisa-repo", + homeDir: "/home/monalisa", + wantErr: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -95,6 +109,7 @@ func TestRepoNameFromRemote(t *testing.T) { {"git@github.com:monalisa/octocat-skills", "monalisa/octocat-skills"}, {"ssh://git@github.com/monalisa/octocat-skills.git", "monalisa/octocat-skills"}, {"ssh://git@github.com/monalisa/octocat-skills", "monalisa/octocat-skills"}, + {"not-a-url", ""}, {"", ""}, } for _, tt := range tests { diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index eac2e4a00..8188c8f3f 100644 --- a/pkg/cmd/skills/install/install.go +++ b/pkg/cmd/skills/install/install.go @@ -1,7 +1,6 @@ package install import ( - "context" "errors" "fmt" "io" @@ -284,8 +283,8 @@ func installRun(opts *installOptions) error { return err } - gitRoot := resolveGitRoot(opts.GitClient) - homeDir := resolveHomeDir() + gitRoot := installer.ResolveGitRoot(opts.GitClient) + homeDir := installer.ResolveHomeDir() source = ghrepo.FullName(opts.repo) type hostPlan struct { @@ -423,8 +422,8 @@ func runLocalInstall(opts *installOptions) error { return err } - gitRoot := resolveGitRoot(opts.GitClient) - homeDir := resolveHomeDir() + gitRoot := installer.ResolveGitRoot(opts.GitClient) + homeDir := installer.ResolveHomeDir() type hostPlan struct { host *registry.AgentHost @@ -570,7 +569,7 @@ func logConventions(io *iostreams.IOStreams, skills []discovery.Skill) { fmt.Fprintf(io.ErrOut, "Note: found %d namespaced skill(s) in skills/{author}/ directories\n", n) } if n, ok := conventions["plugins"]; ok { - fmt.Fprintf(io.ErrOut, "Note: found %d skill(s) using the Claude Code plugins/ convention\n", n) + fmt.Fprintf(io.ErrOut, "Note: found %d skill(s) using the plugins/ convention\n", n) } if n, ok := conventions["root"]; ok { fmt.Fprintf(io.ErrOut, "Note: found %d skill(s) at the repository root\n", n) @@ -952,7 +951,7 @@ func printFileTree(w io.Writer, cs *iostreams.ColorScheme, dir string, skillName func printTreeDir(w io.Writer, cs *iostreams.ColorScheme, dir, indent string) { entries, err := os.ReadDir(dir) if err != nil { - fmt.Fprintf(w, "%s%s\n", indent, cs.Gray("(could not read directory)")) + fmt.Fprintf(w, "%s%s\n", indent, cs.Muted("(could not read directory)")) return } for i, entry := range entries { @@ -965,10 +964,10 @@ func printTreeDir(w io.Writer, cs *iostreams.ColorScheme, dir, indent string) { } name := entry.Name() if entry.IsDir() { - fmt.Fprintf(w, "%s%s%s\n", indent, cs.Gray(connector), cs.Bold(name+"/")) - printTreeDir(w, cs, filepath.Join(dir, name), indent+cs.Gray(childIndent)) + fmt.Fprintf(w, "%s%s%s\n", indent, cs.Muted(connector), cs.Bold(name+"/")) + printTreeDir(w, cs, filepath.Join(dir, name), indent+cs.Muted(childIndent)) } else { - fmt.Fprintf(w, "%s%s%s\n", indent, cs.Gray(connector), name) + fmt.Fprintf(w, "%s%s%s\n", indent, cs.Muted(connector), name) } } } @@ -990,28 +989,3 @@ func printReviewHint(w io.Writer, cs *iostreams.ColorScheme, repo string, skillN } fmt.Fprintln(w) } - -func resolveGitRoot(gc *git.Client) string { - if gc == nil { - if cwd, err := os.Getwd(); err == nil { - return cwd - } - return "" - } - root, err := gc.ToplevelDir(context.Background()) - if err != nil { - if cwd, err := os.Getwd(); err == nil { - return cwd - } - return "" - } - return root -} - -func resolveHomeDir() string { - home, err := os.UserHomeDir() - if err != nil { - return "" - } - return home -} diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go index 658815b63..a4f67f1f1 100644 --- a/pkg/cmd/skills/install/install_test.go +++ b/pkg/cmd/skills/install/install_test.go @@ -1,6 +1,7 @@ package install import ( + "bytes" "encoding/base64" "fmt" "net/http" @@ -9,11 +10,11 @@ import ( "strings" "testing" + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" - "github.com/cli/cli/v2/internal/skills/discovery" - "github.com/cli/cli/v2/internal/skills/registry" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" @@ -22,633 +23,90 @@ import ( "github.com/stretchr/testify/require" ) -func TestNewCmdInstall_Help(t *testing.T) { - ios, _, _, _ := iostreams.Test() - f := &cmdutil.Factory{ - IOStreams: ios, - Prompter: &prompter.PrompterMock{}, - GitClient: &git.Client{}, - } - - cmd := NewCmdInstall(f, func(opts *installOptions) error { - return nil - }) - - assert.Equal(t, "install []", cmd.Use) - assert.NotEmpty(t, cmd.Short) - assert.NotEmpty(t, cmd.Long) - assert.NotEmpty(t, cmd.Example) -} - -func TestNewCmdInstall_Alias(t *testing.T) { - ios, _, _, _ := iostreams.Test() - f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{}} - cmd := NewCmdInstall(f, func(_ *installOptions) error { return nil }) - assert.Contains(t, cmd.Aliases, "add") -} - -func TestNewCmdInstall_Flags(t *testing.T) { - ios, _, _, _ := iostreams.Test() - f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{}} - cmd := NewCmdInstall(f, func(_ *installOptions) error { return nil }) - - flags := []string{"agent", "scope", "pin", "all", "dir", "force"} - for _, name := range flags { - assert.NotNil(t, cmd.Flags().Lookup(name), "missing flag: --%s", name) - } -} - -func TestNewCmdInstall_MaxArgs(t *testing.T) { - ios, _, _, _ := iostreams.Test() - f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{}} - cmd := NewCmdInstall(f, func(_ *installOptions) error { return nil }) - - cmd.SetArgs([]string{"a", "b", "c"}) - err := cmd.Execute() - assert.Error(t, err) -} - -func TestResolveRepoArg(t *testing.T) { - tests := []struct { - input string - owner string - repo string - wantErr bool - }{ - {"github/awesome-copilot", "github", "awesome-copilot", false}, - {"owner/repo", "owner", "repo", false}, - {"a/b", "a", "b", false}, - {"https://github.com/owner/repo", "owner", "repo", false}, - {"https://github.com/owner/repo.git", "owner", "repo", false}, - {"invalid", "", "", true}, - {"", "", "", true}, - } - - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - repo, _, err := resolveRepoArg(tt.input, false, nil) - if tt.wantErr { - assert.Error(t, err) - return - } - require.NoError(t, err) - assert.Equal(t, tt.owner, repo.RepoOwner()) - assert.Equal(t, tt.repo, repo.RepoName()) - }) - } -} - -func TestParseSkillFromOpts(t *testing.T) { - tests := []struct { - name string - skillName string - pin string - wantName string - wantVer string - }{ - { - name: "name with version", - skillName: "git-commit@v1.2.0", - wantName: "git-commit", - wantVer: "v1.2.0", - }, - { - name: "name without version", - skillName: "git-commit", - wantName: "git-commit", - wantVer: "", - }, - { - name: "inline version takes precedence over pin", - skillName: "git-commit@v1.0.0", - pin: "v2.0.0", - wantName: "git-commit", - wantVer: "v1.0.0", - }, - { - name: "pin flag alone", - skillName: "git-commit", - pin: "v3.0.0", - wantName: "git-commit", - wantVer: "v3.0.0", - }, - { - name: "empty", - skillName: "", - wantName: "", - wantVer: "", - }, - { - name: "@ at start is not version", - skillName: "@foo", - wantName: "@foo", - wantVer: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - opts := &installOptions{SkillName: tt.skillName, Pin: tt.pin} - parseSkillFromOpts(opts) - assert.Equal(t, tt.wantName, opts.SkillName) - assert.Equal(t, tt.wantVer, opts.version) - }) - } -} - -func TestInstallRun_NonInteractive_NoRepo(t *testing.T) { - ios, _, _, _ := iostreams.Test() - ios.SetStdoutTTY(false) - - opts := &installOptions{ - IO: ios, - GitClient: &git.Client{RepoDir: t.TempDir()}, - } - - err := installRun(opts) - assert.Error(t, err) - assert.Equal(t, "must specify a repository to install from", err.Error()) -} - -func TestInstallRun_NonInteractive_NoSkill(t *testing.T) { - ios, _, _, _ := iostreams.Test() - ios.SetStdoutTTY(false) - - opts := &installOptions{IO: ios, repo: ghrepo.New("o", "r")} - skills := []discovery.Skill{{Name: "test-skill", Path: "skills/test-skill"}} - _, err := selectSkillsWithSelector(opts, skills, false, skillSelector{matchByName: matchSkillByName, sourceHint: "REPO"}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "must specify a skill name or use --all") -} - -func TestSelectSkills_All(t *testing.T) { - ios, _, _, _ := iostreams.Test() - skills := []discovery.Skill{ - {Name: "a"}, - {Name: "b"}, - } - opts := &installOptions{All: true, IO: ios, repo: ghrepo.New("o", "r")} - got, err := selectSkillsWithSelector(opts, skills, false, skillSelector{matchByName: matchSkillByName, sourceHint: "REPO"}) - require.NoError(t, err) - assert.Len(t, got, 2) -} - -func TestSelectSkills_ByName(t *testing.T) { - ios, _, _, _ := iostreams.Test() - skills := []discovery.Skill{ - {Name: "alpha"}, - {Name: "beta"}, - } - opts := &installOptions{SkillName: "beta", IO: ios, repo: ghrepo.New("o", "r")} - got, err := selectSkillsWithSelector(opts, skills, false, skillSelector{matchByName: matchSkillByName, sourceHint: "REPO"}) - require.NoError(t, err) - assert.Len(t, got, 1) - assert.Equal(t, "beta", got[0].Name) -} - -func TestSelectSkills_NotFound(t *testing.T) { - ios, _, _, _ := iostreams.Test() - skills := []discovery.Skill{ - {Name: "alpha"}, - } - opts := &installOptions{SkillName: "nonexistent", IO: ios, repo: ghrepo.New("o", "r")} - _, err := selectSkillsWithSelector(opts, skills, false, skillSelector{matchByName: matchSkillByName, sourceHint: "REPO"}) - assert.Error(t, err) -} - -func TestSkillSearchFunc_EmptyQuery(t *testing.T) { - skills := []discovery.Skill{ - {Name: "alpha", Description: "first skill"}, - {Name: "beta", Description: "second skill"}, - } - fn := skillSearchFunc(skills, 40) - result := fn("") - assert.Nil(t, result.Err) - assert.Len(t, result.Keys, 2) - assert.Equal(t, "alpha", result.Keys[0]) - assert.Equal(t, "beta", result.Keys[1]) - assert.Equal(t, 0, result.MoreResults) -} - -func TestSkillSearchFunc_FilterByName(t *testing.T) { - skills := []discovery.Skill{ - {Name: "git-commit"}, - {Name: "code-review"}, - {Name: "git-push"}, - } - fn := skillSearchFunc(skills, 40) - result := fn("git") - assert.Nil(t, result.Err) - assert.Len(t, result.Keys, 2) - assert.Equal(t, "git-commit", result.Keys[0]) - assert.Equal(t, "git-push", result.Keys[1]) -} - -func TestSkillSearchFunc_FilterByDescription(t *testing.T) { - skills := []discovery.Skill{ - {Name: "alpha", Description: "handles authentication"}, - {Name: "beta", Description: "builds docker images"}, - } - fn := skillSearchFunc(skills, 40) - result := fn("docker") - assert.Nil(t, result.Err) - assert.Len(t, result.Keys, 1) - assert.Equal(t, "beta", result.Keys[0]) -} - -func TestSkillSearchFunc_CaseInsensitive(t *testing.T) { - skills := []discovery.Skill{ - {Name: "Git-Commit"}, - } - fn := skillSearchFunc(skills, 40) - result := fn("GIT") - assert.Nil(t, result.Err) - assert.Len(t, result.Keys, 1) -} - -func TestSkillSearchFunc_MoreResults(t *testing.T) { - skills := make([]discovery.Skill, 50) - for i := range skills { - skills[i] = discovery.Skill{Name: fmt.Sprintf("skill-%d", i)} - } - fn := skillSearchFunc(skills, 40) - result := fn("") - assert.Equal(t, maxSearchResults, len(result.Keys)) - assert.Equal(t, 50-maxSearchResults, result.MoreResults) -} - -func TestMatchSelectedSkills(t *testing.T) { - skills := []discovery.Skill{ - {Name: "alpha"}, - {Name: "beta"}, - {Name: "gamma"}, - } - got, err := matchSelectedSkills(skills, []string{"alpha", "gamma"}) - require.NoError(t, err) - assert.Len(t, got, 2) - assert.Equal(t, "alpha", got[0].Name) - assert.Equal(t, "gamma", got[1].Name) -} - -func TestMatchSelectedSkills_NoMatch(t *testing.T) { - skills := []discovery.Skill{{Name: "alpha"}} - _, err := matchSelectedSkills(skills, []string{"nonexistent"}) - assert.Error(t, err) -} - -func TestResolveHosts_ByFlag(t *testing.T) { - ios, _, _, _ := iostreams.Test() - opts := &installOptions{Agent: "claude-code", IO: ios} - hosts, err := resolveHosts(opts, false) - require.NoError(t, err) - assert.Len(t, hosts, 1) - assert.Equal(t, "claude-code", hosts[0].ID) -} - -func TestResolveHosts_InvalidFlag(t *testing.T) { - ios, _, _, _ := iostreams.Test() - opts := &installOptions{Agent: "nonexistent", IO: ios} - _, err := resolveHosts(opts, false) - assert.Error(t, err) -} - -func TestResolveHosts_DefaultNonInteractive(t *testing.T) { - ios, _, _, _ := iostreams.Test() - opts := &installOptions{IO: ios} - hosts, err := resolveHosts(opts, false) - require.NoError(t, err) - assert.Len(t, hosts, 1) - assert.Equal(t, "github-copilot", hosts[0].ID) -} - -func TestResolveHosts_MultiSelect(t *testing.T) { - ios, _, _, _ := iostreams.Test() - pm := &prompter.PrompterMock{ - MultiSelectFunc: func(_ string, _ []string, _ []string) ([]int, error) { - return []int{0, 1}, nil - }, - } - opts := &installOptions{IO: ios, Prompter: pm} - hosts, err := resolveHosts(opts, true) - require.NoError(t, err) - assert.Len(t, hosts, 2) -} - -func TestResolveHosts_NoneSelected(t *testing.T) { - ios, _, _, _ := iostreams.Test() - pm := &prompter.PrompterMock{ - MultiSelectFunc: func(_ string, _ []string, _ []string) ([]int, error) { - return []int{}, nil - }, - } - opts := &installOptions{IO: ios, Prompter: pm} - _, err := resolveHosts(opts, true) - assert.Error(t, err) -} - -func TestTruncateDescription(t *testing.T) { - tests := []struct { - name string - input string - maxWidth int - }{ - {"short stays short", "A short description", 60}, - {"newlines collapsed", "Line one.\nLine two.\nLine three.", 60}, - {"excessive whitespace", " lots of spaces ", 60}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := truncateDescription(tt.input, tt.maxWidth) - assert.NotContains(t, got, "\n") - }) - } - - long := "Execute git commit with conventional commit message analysis and intelligent staging" - got := truncateDescription(long, 30) - assert.LessOrEqual(t, len(got), 33) // allow room for ellipsis -} - -func TestIsLocalPath(t *testing.T) { - tests := []struct { - arg string - want bool - }{ - {".", true}, - {"./skills", true}, - {"../other", true}, - {"/tmp/skills", true}, - {"~/skills", true}, - {"github/awesome-copilot", false}, - {"owner/repo", false}, - {"", false}, - } - for _, tt := range tests { - t.Run(tt.arg, func(t *testing.T) { - got := isLocalPath(tt.arg) - assert.Equal(t, tt.want, got) - }) - } -} - -func TestIsSkillPath(t *testing.T) { - tests := []struct { - name string - want bool - }{ - {"skills/test-skill", true}, - {"skills/author/skill", true}, - {"plugins/author/skills/skill", true}, - {"skills/author/skill/SKILL.md", true}, - {"git-commit", false}, - {"", false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, isSkillPath(tt.name)) - }) - } -} - -func TestRunLocalInstall_NonInteractive(t *testing.T) { - dir := t.TempDir() - skillDir := filepath.Join(dir, "skills", "test-local") - require.NoError(t, os.MkdirAll(skillDir, 0o755)) - content := "---\nname: test-local\ndescription: A local skill\n---\n# Test\n" - require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) - - targetDir := t.TempDir() - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(false) - ios.SetColorEnabled(false) - - opts := &installOptions{ - IO: ios, - SkillSource: dir, - localPath: dir, - All: true, - Force: true, - Agent: "github-copilot", - Scope: "project", - Dir: targetDir, - GitClient: &git.Client{RepoDir: t.TempDir()}, - } - - err := installRun(opts) - require.NoError(t, err) - - assert.Contains(t, stdout.String(), "Installed test-local") - - installed, err := os.ReadFile(filepath.Join(targetDir, "test-local", "SKILL.md")) - require.NoError(t, err) - assert.Contains(t, string(installed), "local-path") -} - -func TestRunLocalInstall_SingleSkillDir(t *testing.T) { - dir := t.TempDir() - content := "---\nname: direct-skill\ndescription: Direct\n---\n# Direct\n" - require.NoError(t, os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o644)) - - targetDir := t.TempDir() - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(false) - ios.SetColorEnabled(false) - - opts := &installOptions{ - IO: ios, - SkillSource: dir, - localPath: dir, - All: true, - Force: true, - Agent: "github-copilot", - Scope: "project", - Dir: targetDir, - GitClient: &git.Client{RepoDir: t.TempDir()}, - } - - err := installRun(opts) - require.NoError(t, err) - - assert.Contains(t, stdout.String(), "Installed direct-skill") -} - -func TestCollisionError(t *testing.T) { - t.Run("no collisions", func(t *testing.T) { - skills := []discovery.Skill{ - {Name: "a"}, - {Name: "b"}, - } - assert.NoError(t, collisionError(skills, "REPO")) - }) - - t.Run("no collisions with different namespaces", func(t *testing.T) { - skills := []discovery.Skill{ - {Name: "xlsx-pro", Namespace: "author1"}, - {Name: "xlsx-pro", Namespace: "author2"}, - } - assert.NoError(t, collisionError(skills, "REPO")) - }) - - t.Run("has collisions same name no namespace", func(t *testing.T) { - skills := []discovery.Skill{ - {Name: "xlsx-pro", Convention: "skills"}, - {Name: "xlsx-pro", Convention: "root"}, - } - err := collisionError(skills, "REPO") - assert.Error(t, err) - assert.Contains(t, err.Error(), "conflicting names") - assert.Contains(t, err.Error(), "gh skills install REPO") - }) - - t.Run("local source hint", func(t *testing.T) { - skills := []discovery.Skill{ - {Name: "xlsx-pro", Convention: "skills"}, - {Name: "xlsx-pro", Convention: "root"}, - } - err := collisionError(skills, "PATH") - assert.Error(t, err) - assert.Contains(t, err.Error(), "conflicting names") - assert.Contains(t, err.Error(), "gh skills install PATH") - }) -} - -func TestMatchSkillByName_Ambiguous(t *testing.T) { - ios, _, _, _ := iostreams.Test() - skills := []discovery.Skill{ - {Name: "xlsx-pro", Namespace: "alice"}, - {Name: "xlsx-pro", Namespace: "bob"}, - } - opts := &installOptions{SkillName: "xlsx-pro", IO: ios, repo: ghrepo.New("o", "r")} - _, err := matchSkillByName(opts, skills) - assert.Error(t, err) - assert.Contains(t, err.Error(), "ambiguous") -} - -func TestMatchSkillByName_NamespacedExact(t *testing.T) { - ios, _, _, _ := iostreams.Test() - skills := []discovery.Skill{ - {Name: "xlsx-pro", Namespace: "alice"}, - {Name: "xlsx-pro", Namespace: "bob"}, - } - opts := &installOptions{SkillName: "bob/xlsx-pro", IO: ios, repo: ghrepo.New("o", "r")} - got, err := matchSkillByName(opts, skills) - require.NoError(t, err) - assert.Len(t, got, 1) - assert.Equal(t, "bob", got[0].Namespace) -} - -func TestFriendlyDir(t *testing.T) { - // Test home directory path - home, err := os.UserHomeDir() - require.NoError(t, err) - got := friendlyDir(filepath.Join(home, ".github", "skills")) - assert.True(t, strings.HasPrefix(got, "~"), "expected ~ prefix, got %q", got) -} - -func TestResolveScope_ExplicitFlag(t *testing.T) { - ios, _, _, _ := iostreams.Test() - opts := &installOptions{ - IO: ios, - Scope: "user", - ScopeChanged: true, - GitClient: &git.Client{RepoDir: t.TempDir()}, - } - scope, err := resolveScope(opts, true) - require.NoError(t, err) - assert.Equal(t, "user", string(scope)) -} - -func TestResolveScope_DirBypasses(t *testing.T) { - ios, _, _, _ := iostreams.Test() - opts := &installOptions{ - IO: ios, - Dir: "/tmp/custom", - Scope: "project", - GitClient: &git.Client{RepoDir: t.TempDir()}, - } - scope, err := resolveScope(opts, true) - require.NoError(t, err) - assert.Equal(t, "project", string(scope)) -} - -func TestCheckOverwrite_NoExisting(t *testing.T) { - ios, _, _, _ := iostreams.Test() - targetDir := t.TempDir() - skills := []discovery.Skill{{Name: "new-skill"}} - host := ®istry.AgentHost{ID: "test", ProjectDir: "skills"} - opts := &installOptions{IO: ios, Dir: targetDir} - - got, err := checkOverwrite(opts, skills, host, registry.ScopeProject, "/tmp", "/home", false) - require.NoError(t, err) - assert.Len(t, got, 1) -} - -func TestCheckOverwrite_ExistingWithForce(t *testing.T) { - targetDir := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "existing-skill"), 0o755)) - - ios, _, _, _ := iostreams.Test() - skills := []discovery.Skill{{Name: "existing-skill"}} - host := ®istry.AgentHost{ID: "test", ProjectDir: "skills"} - opts := &installOptions{IO: ios, Dir: targetDir, Force: true} - - got, err := checkOverwrite(opts, skills, host, registry.ScopeProject, "/tmp", "/home", false) - require.NoError(t, err) - assert.Len(t, got, 1) -} - -func TestCheckOverwrite_ExistingNonInteractive(t *testing.T) { - targetDir := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "existing-skill"), 0o755)) - - ios, _, _, _ := iostreams.Test() - skills := []discovery.Skill{{Name: "existing-skill"}} - host := ®istry.AgentHost{ID: "test", ProjectDir: "skills"} - opts := &installOptions{IO: ios, Dir: targetDir} - - _, err := checkOverwrite(opts, skills, host, registry.ScopeProject, "/tmp", "/home", false) - assert.Error(t, err) - assert.Contains(t, err.Error(), "already installed") -} - func TestNewCmdInstall(t *testing.T) { tests := []struct { - name string - input string - wantOpts installOptions - wantErr bool + name string + cli string + wantOpts installOptions + wantLocalPath bool + wantErr bool }{ { name: "repo argument only", - input: "owner/repo", - wantOpts: installOptions{SkillSource: "owner/repo", Scope: "project"}, + cli: "monalisa/skills-repo", + wantOpts: installOptions{SkillSource: "monalisa/skills-repo", Scope: "project"}, }, { name: "repo and skill", - input: "owner/repo my-skill", - wantOpts: installOptions{SkillSource: "owner/repo", SkillName: "my-skill", Scope: "project"}, + cli: "monalisa/skills-repo git-commit", + wantOpts: installOptions{SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Scope: "project"}, }, { - name: "with all flags", - input: "owner/repo my-skill --agent github-copilot --scope user --pin v1.0.0 --force", - wantOpts: installOptions{SkillSource: "owner/repo", SkillName: "my-skill", Agent: "github-copilot", Scope: "user", Pin: "v1.0.0", Force: true}, + name: "all flags", + cli: "monalisa/skills-repo git-commit --agent github-copilot --scope user --pin v1.0.0 --force", + wantOpts: installOptions{ + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "user", + Pin: "v1.0.0", + Force: true, + }, }, { name: "all flag", - input: "owner/repo --all", - wantOpts: installOptions{SkillSource: "owner/repo", All: true, Scope: "project"}, + cli: "monalisa/skills-repo --all", + wantOpts: installOptions{SkillSource: "monalisa/skills-repo", All: true, Scope: "project"}, }, { name: "dir flag", - input: "owner/repo my-skill --dir /tmp/skills", - wantOpts: installOptions{SkillSource: "owner/repo", SkillName: "my-skill", Dir: "/tmp/skills", Scope: "project"}, + cli: "monalisa/skills-repo git-commit --dir ./custom-skills", + wantOpts: installOptions{SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Dir: "./custom-skills", Scope: "project"}, }, { name: "too many args", - input: "a b c", + cli: "a b c", wantErr: true, }, + { + name: "invalid agent flag", + cli: "monalisa/skills-repo git-commit --agent nonexistent", + wantErr: true, + }, + { + name: "pin conflicts with inline version", + cli: "monalisa/skills-repo git-commit@v1.0.0 --pin v2.0.0", + wantErr: true, + }, + { + name: "alias add works", + cli: "monalisa/skills-repo git-commit", + wantOpts: installOptions{SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Scope: "project"}, + }, + { + name: "dot-slash local path sets localPath", + cli: "./local-dir", + wantOpts: installOptions{SkillSource: "./local-dir", Scope: "project"}, + wantLocalPath: true, + }, + { + name: "absolute path sets localPath", + cli: "/absolute/path", + wantOpts: installOptions{SkillSource: "/absolute/path", Scope: "project"}, + wantLocalPath: true, + }, + { + name: "tilde path sets localPath", + cli: "~/skills", + wantOpts: installOptions{SkillSource: "~/skills", Scope: "project"}, + wantLocalPath: true, + }, + { + name: "owner/repo does not set localPath", + cli: "monalisa/skills-repo", + wantOpts: installOptions{SkillSource: "monalisa/skills-repo", Scope: "project"}, + }, } - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ios, _, _, _ := iostreams.Test() @@ -664,20 +122,20 @@ func TestNewCmdInstall(t *testing.T) { return nil }) - args, err := shlex.Split(tt.input) + args, err := shlex.Split(tt.cli) require.NoError(t, err) cmd.SetArgs(args) - cmd.SetIn(&strings.Reader{}) - cmd.SetOut(&strings.Builder{}) - cmd.SetErr(&strings.Builder{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) - _, err = cmd.ExecuteC() + err = cmd.Execute() if tt.wantErr { - assert.Error(t, err) + require.Error(t, err) return } require.NoError(t, err) + require.NotNil(t, gotOpts) assert.Equal(t, tt.wantOpts.SkillSource, gotOpts.SkillSource) assert.Equal(t, tt.wantOpts.SkillName, gotOpts.SkillName) assert.Equal(t, tt.wantOpts.Agent, gotOpts.Agent) @@ -686,205 +144,1634 @@ func TestNewCmdInstall(t *testing.T) { assert.Equal(t, tt.wantOpts.Dir, gotOpts.Dir) assert.Equal(t, tt.wantOpts.All, gotOpts.All) assert.Equal(t, tt.wantOpts.Force, gotOpts.Force) + if tt.wantLocalPath { + assert.NotEmpty(t, gotOpts.localPath, "expected localPath to be set") + } else { + assert.Empty(t, gotOpts.localPath, "expected localPath to be empty") + } + }) + } + + // Verify command metadata separately. + t.Run("command metadata", func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{}} + cmd := NewCmdInstall(f, nil) + + assert.Equal(t, "install []", cmd.Use) + assert.NotEmpty(t, cmd.Short) + assert.NotEmpty(t, cmd.Long) + assert.NotEmpty(t, cmd.Example) + assert.Contains(t, cmd.Aliases, "add") + + for _, flag := range []string{"agent", "scope", "pin", "all", "dir", "force"} { + assert.NotNil(t, cmd.Flags().Lookup(flag), "missing flag: --%s", flag) + } + }) +} + +// --- HTTP stub helpers --- + +// stubResolveVersion registers API stubs for latest release + tag resolution. +func stubResolveVersion(reg *httpmock.Registry, owner, repo, tag, sha string) { + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/%s/%s/releases/latest", owner, repo)), + httpmock.StringResponse(fmt.Sprintf(`{"tag_name": %q}`, tag)), + ) + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/%s/%s/git/ref/tags/%s", owner, repo, tag)), + httpmock.StringResponse(fmt.Sprintf(`{"object": {"sha": %q, "type": "commit"}}`, sha)), + ) +} + +// stubDiscoverTree registers the single recursive-tree call used by DiscoverSkills. +func stubDiscoverTree(reg *httpmock.Registry, owner, repo, sha, treeJSON string) { + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/%s/%s/git/trees/%s", owner, repo, sha)), + httpmock.StringResponse(fmt.Sprintf(`{"sha": %q, "tree": [%s]}`, sha, treeJSON)), + ) +} + +// stubInstallFiles registers subtree + blob stubs for installer.Install (one skill). +func stubInstallFiles(reg *httpmock.Registry, owner, repo, treeSHA, blobSHA, content string) { + encoded := base64.StdEncoding.EncodeToString([]byte(content)) + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/%s/%s/git/trees/%s", owner, repo, treeSHA)), + httpmock.StringResponse(fmt.Sprintf(`{"tree": [{"path": "SKILL.md", "type": "blob", "sha": %q, "size": 50}]}`, blobSHA)), + ) + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/%s/%s/git/blobs/%s", owner, repo, blobSHA)), + httpmock.StringResponse(fmt.Sprintf(`{"sha": %q, "content": %q, "encoding": "base64"}`, blobSHA, encoded)), + ) +} + +// stubSkillByPath registers stubs for DiscoverSkillByPath (contents API + tree). +func stubSkillByPath(reg *httpmock.Registry, owner, repo, sha, skillPath, skillName, treeSHA string) { + parentPath := skillPath + if idx := strings.LastIndex(skillPath, "/"); idx >= 0 { + parentPath = skillPath[:idx] + } + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/%s/%s/contents/%s", owner, repo, parentPath)), + httpmock.StringResponse(fmt.Sprintf(`[{"name": %q, "path": %q, "sha": %q, "type": "dir"}]`, skillName, skillPath, treeSHA)), + ) +} + +// writeLocalTestSkill creates a skill directory with a SKILL.md file. +func writeLocalTestSkill(t *testing.T, baseDir, subPath, content string) { + t.Helper() + skillDir := filepath.Join(baseDir, subPath) + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) +} + +// --- Skill content constants --- + +var gitCommitContent = heredoc.Doc(` + --- + name: git-commit + description: Writes commits + --- + # Git Commit +`) + +// singleSkillTreeJSON returns tree entries for a single skill with the given name. +func singleSkillTreeJSON(name, treeSHA, blobSHA string) string { + return fmt.Sprintf( + `{"path": "skills/%s", "type": "tree", "sha": %q}, {"path": "skills/%s/SKILL.md", "type": "blob", "sha": %q}`, + name, treeSHA, name, blobSHA, + ) +} + +func TestInstallRun(t *testing.T) { + tests := []struct { + name string + isTTY bool + setup func(t *testing.T) + stubs func(*httpmock.Registry) + opts func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions + verify func(t *testing.T) + wantErr string + wantStdout string + wantStderr string + }{ + { + name: "non-interactive without repo errors", + isTTY: false, + opts: func(ios *iostreams.IOStreams, _ *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantErr: "must specify a repository to install from", + }, + { + name: "non-interactive without skill name errors", + 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")) + }, + 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", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + } + }, + wantErr: "must specify a skill name or use --all", + }, + { + name: "remote install writes files with tracking metadata", + 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")) + 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: "remote install with --all installs multiple skills", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + treeJSON := fmt.Sprintf("%s, %s", + singleSkillTreeJSON("code-review", "tree0", "blob0"), + singleSkillTreeJSON("git-commit", "tree1", "blob1")) + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", treeJSON) + stubInstallFiles(reg, "monalisa", "skills-repo", "tree0", "blob0", + "---\nname: code-review\ndescription: Reviews\n---\n# B\n") + stubInstallFiles(reg, "monalisa", "skills-repo", "tree1", "blob1", + "---\nname: git-commit\ndescription: Commits\n---\n# A\n") + }, + 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", + All: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed", + }, + { + name: "remote install with --agent claude-code", + 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")) + 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: "claude-code", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "remote install defaults to github-copilot non-interactively", + 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")) + 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", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "remote install with --scope user", + 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")) + 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: "user", + ScopeChanged: true, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "remote install with --dir bypasses scope resolution", + 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")) + 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", + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "remote install with --force overwrites existing skill", + 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")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + targetDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "git-commit"), 0o755)) + 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: targetDir, + Force: true, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "remote install existing skill without force non-interactive errors", + 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")) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + targetDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "git-commit"), 0o755)) + 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: targetDir, + } + }, + wantErr: "already installed", + }, + { + name: "remote install skill not found errors", + 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")) + }, + 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: "nonexistent", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantErr: `skill "nonexistent" not found`, + }, + { + name: "remote install ambiguous skill name errors", + isTTY: false, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + // Two namespaced skills with the same name + treeJSON := `{"path": "skills/alice", "type": "tree", "sha": "nsA"}, ` + + `{"path": "skills/alice/xlsx-pro", "type": "tree", "sha": "treeA"}, ` + + `{"path": "skills/alice/xlsx-pro/SKILL.md", "type": "blob", "sha": "blobA"}, ` + + `{"path": "skills/bob", "type": "tree", "sha": "nsB"}, ` + + `{"path": "skills/bob/xlsx-pro", "type": "tree", "sha": "treeB"}, ` + + `{"path": "skills/bob/xlsx-pro/SKILL.md", "type": "blob", "sha": "blobB"}` + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", treeJSON) + }, + 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: "xlsx-pro", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantErr: "ambiguous", + }, + { + name: "remote install namespaced exact match resolves ambiguity", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + treeJSON := `{"path": "skills/alice", "type": "tree", "sha": "nsA"}, ` + + `{"path": "skills/alice/xlsx-pro", "type": "tree", "sha": "treeA"}, ` + + `{"path": "skills/alice/xlsx-pro/SKILL.md", "type": "blob", "sha": "blobA"}, ` + + `{"path": "skills/bob", "type": "tree", "sha": "nsB"}, ` + + `{"path": "skills/bob/xlsx-pro", "type": "tree", "sha": "treeB"}, ` + + `{"path": "skills/bob/xlsx-pro/SKILL.md", "type": "blob", "sha": "blobB"}` + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", treeJSON) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeB", "blobB", + "---\nname: xlsx-pro\ndescription: Bob version\n---\n# B\n") + }, + 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: "bob/xlsx-pro", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed bob/xlsx-pro", + }, + { + name: "remote install with invalid repo argument errors", + isTTY: false, + opts: func(ios *iostreams.IOStreams, _ *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "invalid", + SkillName: "git-commit", + } + }, + wantErr: "invalid repository reference", + }, + { + name: "remote install with pin flag resolves version", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/ref/tags/v2.0.0"), + httpmock.StringResponse(`{"object": {"sha": "def456", "type": "commit"}}`), + ) + stubDiscoverTree(reg, "monalisa", "skills-repo", "def456", + singleSkillTreeJSON("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", + Pin: "v2.0.0", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + wantStderr: "v2.0.0", + }, + { + name: "remote install outputs review hint", + 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")) + 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", + wantStderr: "prompt injections or malicious scripts", + }, + { + name: "remote install outputs file tree for TTY", + 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")) + 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: "SKILL.md", + }, + { + name: "remote install with inline version parses name and version", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/ref/tags/v1.2.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("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@v1.2.0", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + wantStderr: "v1.2.0", + }, + { + name: "remote install by skill path skips full discovery", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubSkillByPath(reg, "monalisa", "skills-repo", "abc123", "skills/git-commit", "git-commit", "treeSHA") + // DiscoverSkillByPath: tree + blob (for fetchDescription) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + // installer.Install: tree + blob (again, for writing files) + 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: "skills/git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "remote install with URL repo argument", + 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")) + 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: "https://github.com/monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "remote install all with collisions errors", + isTTY: false, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + // Two skills with the same install name: skills/xlsx-pro and root xlsx-pro + treeJSON := `{"path": "skills/xlsx-pro", "type": "tree", "sha": "tree0"}, ` + + `{"path": "skills/xlsx-pro/SKILL.md", "type": "blob", "sha": "blob0"}, ` + + `{"path": "xlsx-pro", "type": "tree", "sha": "tree1"}, ` + + `{"path": "xlsx-pro/SKILL.md", "type": "blob", "sha": "blob1"}` + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", treeJSON) + }, + 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", + All: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantErr: "conflicting names", + }, + { + name: "remote install all with namespaced skills avoids collisions", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + treeJSON := `{"path": "skills/alice", "type": "tree", "sha": "nsA"}, ` + + `{"path": "skills/alice/xlsx-pro", "type": "tree", "sha": "treeA"}, ` + + `{"path": "skills/alice/xlsx-pro/SKILL.md", "type": "blob", "sha": "blobA"}, ` + + `{"path": "skills/bob", "type": "tree", "sha": "nsB"}, ` + + `{"path": "skills/bob/xlsx-pro", "type": "tree", "sha": "treeB"}, ` + + `{"path": "skills/bob/xlsx-pro/SKILL.md", "type": "blob", "sha": "blobB"}` + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", treeJSON) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeA", "blobA", + "---\nname: xlsx-pro\ndescription: Alice\n---\n# A\n") + stubInstallFiles(reg, "monalisa", "skills-repo", "treeB", "blobB", + "---\nname: xlsx-pro\ndescription: Bob\n---\n# B\n") + }, + 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", + All: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed", + }, + { + name: "remote install friendlyDir shows tilde for home paths", + 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")) + 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: "user", + ScopeChanged: true, + } + }, + wantStdout: "~", + }, + { + name: "interactive skill selection via prompt", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + // 31 skills to exercise maxSearchResults cap + one without description + var treeEntries []string + for i := range 31 { + name := fmt.Sprintf("skill-%02d", i) + treeEntries = append(treeEntries, + fmt.Sprintf(`{"path": "skills/%s", "type": "tree", "sha": "tree-%s"}`, name, name), + fmt.Sprintf(`{"path": "skills/%s/SKILL.md", "type": "blob", "sha": "blob-%s"}`, name, name)) + } + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + strings.Join(treeEntries, ", ")) + // Blob stubs for FetchDescriptionsConcurrent (one per skill) + for i := range 31 { + name := fmt.Sprintf("skill-%02d", i) + blobSHA := fmt.Sprintf("blob-%s", name) + var content string + if i == 0 { + // First skill has no description (exercises else branch in label building) + content = fmt.Sprintf("---\nname: %s\n---\n# Skill\n", name) + } else { + content = fmt.Sprintf("---\nname: %s\ndescription: Does %s things\n---\n# Skill\n", name, name) + } + encoded := base64.StdEncoding.EncodeToString([]byte(content)) + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/monalisa/octocat-skills/git/blobs/%s", blobSHA)), + httpmock.StringResponse(fmt.Sprintf(`{"sha": %q, "content": %q, "encoding": "base64"}`, blobSHA, encoded))) + } + // Install stubs for the selected skill (skill-01) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-skill-01", "blob-skill-01", + "---\nname: skill-01\ndescription: Does skill-01 things\n---\n# Skill\n") + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + pm := &prompter.PrompterMock{ + MultiSelectWithSearchFunc: func(prompt, searchPrompt string, defaults, persistentOptions []string, searchFunc func(string) prompter.MultiSelectSearchResult) ([]string, error) { + // Exercise searchFunc: empty query hits maxSearchResults cap (31 > 30) + all := searchFunc("") + if all.MoreResults == 0 { + return nil, fmt.Errorf("expected MoreResults > 0 for 31 skills") + } + // Non-empty query filters down + filtered := searchFunc("skill-01") + if len(filtered.Keys) == 0 { + return nil, fmt.Errorf("search returned no results") + } + return []string{filtered.Keys[0]}, nil + }, + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + return 0, nil + }, + } + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/octocat-skills", + Agent: "github-copilot", + Force: true, + } + }, + wantStdout: "Installed skill-01", + }, + { + name: "interactive scope prompt", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + pm := &prompter.PrompterMock{ + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + return 0, nil + }, + } + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Agent: "github-copilot", + Force: true, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "interactive overwrite confirmation declined", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + destDir := t.TempDir() + writeLocalTestSkill(t, destDir, "git-commit", gitCommitContent) + pm := &prompter.PrompterMock{ + ConfirmFunc: func(prompt string, defaultValue bool) (bool, error) { + return false, nil + }, + } + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: destDir, + } + }, + wantStderr: "No skills to install", + }, + { + name: "interactive host selection via MultiSelect", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", 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()}, + Prompter: &prompter.PrompterMock{ + MultiSelectFunc: func(prompt string, defaults []string, options []string) ([]int, error) { + return []int{0}, nil // select first agent + }, + SelectFunc: func(prompt string, defaultValue string, options []string) (int, error) { + return 0, nil // project scope + }, + }, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Force: true, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "scope prompt uses Remotes for repo name", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + pm := &prompter.PrompterMock{ + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + return 0, nil // project scope + }, + } + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + Remotes: func() (context.Remotes, error) { + return context.Remotes{ + {Remote: &git.Remote{Name: "origin"}, Repo: ghrepo.New("monalisa", "octocat-skills")}, + }, nil + }, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Agent: "github-copilot", + Force: true, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "interactive overwrite shows source info", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + destDir := t.TempDir() + existingContent := heredoc.Doc(` + --- + name: git-commit + description: Writes commits + metadata: + github-owner: someowner + github-repo: somerepo + github-ref: v0.5.0 + --- + # Git Commit + `) + writeLocalTestSkill(t, destDir, "git-commit", existingContent) + pm := &prompter.PrompterMock{ + ConfirmFunc: func(prompt string, defaultValue bool) (bool, error) { + assert.Contains(t, prompt, "someowner/somerepo@v0.5.0") + return true, nil + }, + } + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: destDir, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "select all skills in interactive prompt", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + // Blob stub for FetchDescriptionsConcurrent + encoded := base64.StdEncoding.EncodeToString([]byte(gitCommitContent)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blob-gc"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "blob-gc", "content": %q, "encoding": "base64"}`, encoded))) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + pm := &prompter.PrompterMock{ + MultiSelectWithSearchFunc: func(prompt, searchPrompt string, defaults, persistentOptions []string, searchFunc func(string) prompter.MultiSelectSearchResult) ([]string, error) { + return []string{"(all skills)"}, nil + }, + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + return 0, nil + }, + } + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/octocat-skills", + Agent: "github-copilot", + Force: true, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "interactive repo prompt via Input", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + pm := &prompter.PrompterMock{ + InputFunc: func(prompt, defaultValue string) (string, error) { + return "monalisa/octocat-skills", nil + }, + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + return 0, nil + }, + } + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillName: "git-commit", + Agent: "github-copilot", + Force: true, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "interactive scope prompt selects user scope", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + pm := &prompter.PrompterMock{ + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + return 1, nil // user scope + }, + } + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Agent: "github-copilot", + Force: true, + } + }, + wantStdout: "~", + }, + { + name: "interactive overwrite without metadata shows plain prompt", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + destDir := t.TempDir() + // Existing skill without github metadata in frontmatter + writeLocalTestSkill(t, destDir, "git-commit", heredoc.Doc(` + --- + name: git-commit + description: No metadata + --- + # Git Commit + `)) + pm := &prompter.PrompterMock{ + ConfirmFunc: func(prompt string, defaultValue bool) (bool, error) { + assert.Contains(t, prompt, "already exists") + assert.NotContains(t, prompt, "installed from") + return true, nil + }, + } + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: destDir, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "remote install single exact match by name", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + treeJSON := `{"path": "skills/alice", "type": "tree", "sha": "nsA"}, ` + + `{"path": "skills/alice/xlsx-pro", "type": "tree", "sha": "treeA"}, ` + + `{"path": "skills/alice/xlsx-pro/SKILL.md", "type": "blob", "sha": "blobA"}, ` + + `{"path": "skills/git-commit", "type": "tree", "sha": "treeGC"}, ` + + `{"path": "skills/git-commit/SKILL.md", "type": "blob", "sha": "blobGC"}` + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", treeJSON) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeGC", "blobGC", 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: "multi-host install outputs per-host headers", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + // Two install rounds (one per host) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + pm := &prompter.PrompterMock{ + MultiSelectFunc: func(prompt string, defaults []string, options []string) ([]int, error) { + return []int{0, 1}, nil // select two agents + }, + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + return 0, nil // project scope + }, + } + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Force: true, + } + }, + wantStdout: "Installed git-commit", + wantStderr: "Installing to", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + + reg := &httpmock.Registry{} + defer reg.Verify(t) + + if tt.stubs != nil { + tt.stubs(reg) + } + if tt.setup != nil { + tt.setup(t) + } + + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(tt.isTTY) + ios.SetStdinTTY(tt.isTTY) + ios.SetStderrTTY(tt.isTTY) + opts := tt.opts(ios, reg) + + err := installRun(opts) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + if tt.wantStdout != "" { + assert.Contains(t, stdout.String(), tt.wantStdout) + } + if tt.wantStderr != "" { + assert.Contains(t, stderr.String(), tt.wantStderr) + } + if tt.verify != nil { + tt.verify(t) + } }) } } -func TestInstallRun_RemoteInstall(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - - skillContent := "---\nname: test-skill\ndescription: A test\n---\n# Test\n" - encodedContent := base64.StdEncoding.EncodeToString([]byte(skillContent)) - - reg := &httpmock.Registry{} - reg.Register( - httpmock.REST("GET", "repos/owner/repo/releases/latest"), - httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), - ) - reg.Register( - httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), - httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), - ) - reg.Register( - httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"), - httpmock.StringResponse(`{"sha": "abc123", "tree": [{"path": "skills/test-skill", "type": "tree", "sha": "treeSHA"}, {"path": "skills/test-skill/SKILL.md", "type": "blob", "sha": "blobSHA"}]}`), - ) - reg.Register( - httpmock.REST("GET", "repos/owner/repo/git/trees/treeSHA"), - httpmock.StringResponse(`{"tree": [{"path": "SKILL.md", "type": "blob", "sha": "blobSHA", "size": 50}]}`), - ) - reg.Register( - httpmock.REST("GET", "repos/owner/repo/git/blobs/blobSHA"), - httpmock.StringResponse(fmt.Sprintf(`{"sha": "blobSHA", "content": "%s", "encoding": "base64"}`, encodedContent)), - ) - - targetDir := t.TempDir() - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetColorEnabled(false) - - opts := &installOptions{ - IO: ios, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil +func TestRunLocalInstall(t *testing.T) { + tests := []struct { + name string + isTTY bool + setup func(t *testing.T, sourceDir, targetDir string) + opts func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions + verify func(t *testing.T, targetDir string) + wantErr string + wantStdout string + wantStderr string + }{ + { + name: "installs skill with local-path metadata", + isTTY: false, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "git-commit"), heredoc.Doc(` + --- + name: git-commit + description: A local skill + --- + # Git Commit + `)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + All: true, + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + verify: func(t *testing.T, targetDir string) { + t.Helper() + data, err := os.ReadFile(filepath.Join(targetDir, "git-commit", "SKILL.md")) + require.NoError(t, err) + assert.Contains(t, string(data), "local-path") + }, + wantStdout: "Installed git-commit", + }, + { + name: "single skill directory (SKILL.md at root)", + isTTY: false, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + content := heredoc.Doc(` + --- + name: direct-skill + description: Direct + --- + # Direct + `) + require.NoError(t, os.WriteFile(filepath.Join(sourceDir, "SKILL.md"), []byte(content), 0o644)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + All: true, + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantStdout: "Installed direct-skill", + }, + { + name: "namespaced skills install to separate directories", + isTTY: false, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + for _, ns := range []string{"alice", "bob"} { + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", ns, "xlsx-pro"), + fmt.Sprintf("---\nname: xlsx-pro\ndescription: %s xlsx-pro\n---\n# Test\n", ns)) + } + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + All: true, + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + verify: func(t *testing.T, targetDir string) { + t.Helper() + _, err := os.Stat(filepath.Join(targetDir, "alice", "xlsx-pro", "SKILL.md")) + assert.NoError(t, err, "alice/xlsx-pro should be installed") + _, err = os.Stat(filepath.Join(targetDir, "bob", "xlsx-pro", "SKILL.md")) + assert.NoError(t, err, "bob/xlsx-pro should be installed") + }, + wantStdout: "Installed alice/xlsx-pro", + }, + { + name: "local install with --force overwrites namespaced skill", + isTTY: false, + setup: func(t *testing.T, sourceDir, targetDir string) { + t.Helper() + for _, ns := range []string{"alice", "bob"} { + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", ns, "xlsx-pro"), + fmt.Sprintf("---\nname: xlsx-pro\ndescription: %s xlsx-pro\n---\n# Test\n", ns)) + } + require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "alice", "xlsx-pro"), 0o755)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + All: true, + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantStdout: "Installed", + }, + { + name: "local install existing skill without force non-interactive errors", + isTTY: false, + setup: func(t *testing.T, sourceDir, targetDir string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "git-commit"), heredoc.Doc(` + --- + name: git-commit + description: A local skill + --- + # Git Commit + `)) + require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "git-commit"), 0o755)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantErr: "already installed", + }, + { + name: "local install with no skills found errors", + isTTY: false, + setup: func(_ *testing.T, _, _ string) {}, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + All: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantErr: "no skills found", + }, + { + name: "local install outputs review hint", + isTTY: true, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "git-commit"), heredoc.Doc(` + --- + name: git-commit + description: A local skill + --- + # Git Commit + `)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + All: true, + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantStderr: "Review the installed files before use", + }, + { + name: "local install with --agent claude-code", + isTTY: true, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "git-commit"), heredoc.Doc(` + --- + name: git-commit + description: A local skill + --- + # Git Commit + `)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + All: true, + Force: true, + Agent: "claude-code", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "local install by skill name selects one", + isTTY: false, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "git-commit"), heredoc.Doc(` + --- + name: git-commit + description: A local skill + --- + # Git Commit + `)) + writeLocalTestSkill(t, sourceDir, filepath.Join("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: "git-commit", + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "local install outputs file tree for TTY", + isTTY: true, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + skillDir := filepath.Join(sourceDir, "skills", "git-commit") + require.NoError(t, os.MkdirAll(filepath.Join(skillDir, "scripts"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), + []byte("---\nname: git-commit\ndescription: Commits\n---\n# A\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "scripts", "run.sh"), + []byte("#!/bin/bash"), 0o644)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + All: true, + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantStdout: "SKILL.md", + }, + { + name: "local path with tilde expansion", + isTTY: false, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "git-commit"), heredoc.Doc(` + --- + name: git-commit + description: A local skill + --- + # Git Commit + `)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + t.Helper() + t.Setenv("HOME", sourceDir) + t.Setenv("USERPROFILE", sourceDir) + return &installOptions{ + IO: ios, + SkillSource: "~/", + localPath: "~/", + All: true, + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "local path with bare tilde expansion", + isTTY: false, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "git-commit"), heredoc.Doc(` + --- + name: git-commit + description: A local skill + --- + # Git Commit + `)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + t.Helper() + t.Setenv("HOME", sourceDir) + t.Setenv("USERPROFILE", sourceDir) + return &installOptions{ + IO: ios, + SkillSource: "~", + localPath: "~", + All: true, + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "local skill not found by name", + isTTY: false, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "git-commit"), heredoc.Doc(` + --- + name: git-commit + description: A local skill + --- + # Git Commit + `)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + SkillName: "nonexistent-skill", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantErr: "not found in local directory", }, - GitClient: &git.Client{RepoDir: t.TempDir()}, - SkillSource: "owner/repo", - SkillName: "test-skill", - Agent: "github-copilot", - Scope: "project", - Dir: targetDir, } - defer reg.Verify(t) - err := installRun(opts) - require.NoError(t, err) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) - assert.Contains(t, stdout.String(), "Installed test-skill") + sourceDir := t.TempDir() + targetDir := t.TempDir() - installed, readErr := os.ReadFile(filepath.Join(targetDir, "test-skill", "SKILL.md")) - require.NoError(t, readErr) - assert.Contains(t, string(installed), "github-owner: owner") - assert.Contains(t, string(installed), "github-repo: repo") -} + if tt.setup != nil { + tt.setup(t, sourceDir, targetDir) + } -func TestPrintFileTree(t *testing.T) { - dir := t.TempDir() - skillDir := filepath.Join(dir, "my-skill") - require.NoError(t, os.MkdirAll(filepath.Join(skillDir, "scripts"), 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# test"), 0o644)) - require.NoError(t, os.WriteFile(filepath.Join(skillDir, "scripts", "run.sh"), []byte("#!/bin/bash"), 0o644)) + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(tt.isTTY) + ios.SetStdinTTY(tt.isTTY) + ios.SetStderrTTY(tt.isTTY) + opts := tt.opts(ios, sourceDir, targetDir) - ios, _, stdout, _ := iostreams.Test() - cs := ios.ColorScheme() + err := installRun(opts) - printFileTree(stdout, cs, dir, []string{"my-skill"}) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } - out := stdout.String() - assert.Contains(t, out, "my-skill/") - assert.Contains(t, out, "SKILL.md") - assert.Contains(t, out, "scripts/") - assert.Contains(t, out, "run.sh") -} - -func TestPrintFileTree_Empty(t *testing.T) { - ios, _, stdout, _ := iostreams.Test() - cs := ios.ColorScheme() - - printFileTree(stdout, cs, t.TempDir(), nil) - assert.Empty(t, stdout.String()) -} - -func TestPrintTreeDir_Unreadable(t *testing.T) { - ios, _, stdout, _ := iostreams.Test() - cs := ios.ColorScheme() - - printTreeDir(stdout, cs, filepath.Join(t.TempDir(), "nonexistent"), " ") - assert.Contains(t, stdout.String(), "(could not read directory)") -} - -func TestPrintReviewHint_Remote(t *testing.T) { - ios, _, _, stderr := iostreams.Test() - cs := ios.ColorScheme() - - printReviewHint(stderr, cs, "owner/repo", []string{"my-skill", "other-skill"}) - - out := stderr.String() - assert.Contains(t, out, "prompt injections or malicious scripts") - assert.Contains(t, out, "gh skills preview owner/repo my-skill") - assert.Contains(t, out, "gh skills preview owner/repo other-skill") -} - -func TestPrintReviewHint_Local(t *testing.T) { - ios, _, _, stderr := iostreams.Test() - cs := ios.ColorScheme() - - printReviewHint(stderr, cs, "", []string{"my-skill"}) - - out := stderr.String() - assert.Contains(t, out, "prompt injections or malicious scripts") - assert.Contains(t, out, "Review the installed files before use.") - assert.NotContains(t, out, "gh skills preview") -} - -func TestPrintReviewHint_Empty(t *testing.T) { - ios, _, _, stderr := iostreams.Test() - cs := ios.ColorScheme() - - printReviewHint(stderr, cs, "owner/repo", nil) - assert.Empty(t, stderr.String()) -} - -func TestSelectSkills_AllWithNamespacedSkills(t *testing.T) { - ios, _, _, _ := iostreams.Test() - skills := []discovery.Skill{ - {Name: "xlsx-pro", Namespace: "alice", Convention: "skills-namespaced"}, - {Name: "xlsx-pro", Namespace: "bob", Convention: "skills-namespaced"}, - {Name: "other-skill", Convention: "skills"}, + require.NoError(t, err) + if tt.wantStdout != "" { + assert.Contains(t, stdout.String(), tt.wantStdout) + } + if tt.wantStderr != "" { + assert.Contains(t, stderr.String(), tt.wantStderr) + } + if tt.verify != nil { + tt.verify(t, targetDir) + } + }) } - opts := &installOptions{All: true, IO: ios, repo: ghrepo.New("o", "r")} - got, err := selectSkillsWithSelector(opts, skills, false, skillSelector{matchByName: matchSkillByName, sourceHint: "REPO"}) - require.NoError(t, err) - assert.Len(t, got, 3) -} - -func TestRunLocalInstall_NamespacedSkills(t *testing.T) { - dir := t.TempDir() - - // Create two skills with the same name under different namespaces - for _, ns := range []string{"alice", "bob"} { - skillDir := filepath.Join(dir, "skills", ns, "xlsx-pro") - require.NoError(t, os.MkdirAll(skillDir, 0o755)) - content := fmt.Sprintf("---\nname: xlsx-pro\ndescription: %s xlsx-pro\n---\n# Test\n", ns) - require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) - } - - targetDir := t.TempDir() - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(false) - ios.SetColorEnabled(false) - - opts := &installOptions{ - IO: ios, - SkillSource: dir, - localPath: dir, - All: true, - Force: true, - Agent: "github-copilot", - Scope: "project", - Dir: targetDir, - GitClient: &git.Client{RepoDir: t.TempDir()}, - } - - err := installRun(opts) - require.NoError(t, err) - - out := stdout.String() - assert.Contains(t, out, "Installed alice/xlsx-pro") - assert.Contains(t, out, "Installed bob/xlsx-pro") - - // Both should be installed in separate directories - _, err = os.Stat(filepath.Join(targetDir, "alice", "xlsx-pro", "SKILL.md")) - assert.NoError(t, err, "alice/xlsx-pro should be installed") - _, err = os.Stat(filepath.Join(targetDir, "bob", "xlsx-pro", "SKILL.md")) - assert.NoError(t, err, "bob/xlsx-pro should be installed") -} - -func TestCheckOverwrite_NamespacedSkill(t *testing.T) { - ios, _, _, _ := iostreams.Test() - targetDir := t.TempDir() - - // Pre-create a namespaced skill directory - require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "alice", "xlsx-pro"), 0o755)) - - skills := []discovery.Skill{ - {Name: "xlsx-pro", Namespace: "alice"}, - {Name: "xlsx-pro", Namespace: "bob"}, - } - host := ®istry.AgentHost{ID: "test", ProjectDir: "skills"} - opts := &installOptions{IO: ios, Dir: targetDir, Force: true} - - got, err := checkOverwrite(opts, skills, host, registry.ScopeProject, "/tmp", "/home", false) - require.NoError(t, err) - assert.Len(t, got, 2, "both skills should be installable (force mode)") } diff --git a/pkg/cmd/skills/preview/preview.go b/pkg/cmd/skills/preview/preview.go index 9541f4230..f06d4c182 100644 --- a/pkg/cmd/skills/preview/preview.go +++ b/pkg/cmd/skills/preview/preview.go @@ -199,16 +199,16 @@ func renderAllFiles(opts *previewOptions, cs *iostreams.ColorScheme, skill disco totalBytes := 0 for _, f := range extraFiles { if fetched >= maxFiles { - fmt.Fprintf(out, "\n%s\n", cs.Gray(fmt.Sprintf("(skipped remaining files — showing first %d)", maxFiles))) + fmt.Fprintf(out, "\n%s\n", cs.Muted(fmt.Sprintf("(skipped remaining files — showing first %d)", maxFiles))) break } if totalBytes+f.Size > maxTotalBytes && fetched > 0 { - fmt.Fprintf(out, "\n%s\n", cs.Gray("(skipped remaining files — size limit reached)")) + fmt.Fprintf(out, "\n%s\n", cs.Muted("(skipped remaining files — size limit reached)")) break } fileContent, fetchErr := discovery.FetchBlob(apiClient, hostname, owner, repo, f.SHA) if fetchErr != nil { - fmt.Fprintf(out, "\n%s\n\n%s\n", cs.Bold("── "+f.Path+" ──"), cs.Gray("(could not fetch file)")) + fmt.Fprintf(out, "\n%s\n\n%s\n", cs.Bold("── "+f.Path+" ──"), cs.Muted("(could not fetch file)")) continue } fetched++ @@ -373,10 +373,10 @@ func printTree(w io.Writer, cs *iostreams.ColorScheme, nodes []*treeNode, indent childIndent = " " } if node.isDir { - fmt.Fprintf(w, "%s%s%s\n", indent, cs.Gray(connector), cs.Bold(node.name+"/")) - printTree(w, cs, node.children, indent+cs.Gray(childIndent)) + fmt.Fprintf(w, "%s%s%s\n", indent, cs.Muted(connector), cs.Bold(node.name+"/")) + printTree(w, cs, node.children, indent+cs.Muted(childIndent)) } else { - fmt.Fprintf(w, "%s%s%s\n", indent, cs.Gray(connector), node.name) + fmt.Fprintf(w, "%s%s%s\n", indent, cs.Muted(connector), node.name) } } } diff --git a/pkg/cmd/skills/preview/preview_test.go b/pkg/cmd/skills/preview/preview_test.go index b77455828..4c3864809 100644 --- a/pkg/cmd/skills/preview/preview_test.go +++ b/pkg/cmd/skills/preview/preview_test.go @@ -3,9 +3,12 @@ package preview import ( "encoding/base64" "fmt" + "io" "net/http" + "strings" "testing" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/cmdutil" @@ -13,6 +16,7 @@ import ( "github.com/cli/cli/v2/pkg/iostreams" "github.com/google/shlex" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestNewCmdPreview(t *testing.T) { @@ -62,31 +66,32 @@ func TestNewCmdPreview(t *testing.T) { args, _ := shlex.Split(tt.input) cmd.SetArgs(args) - cmd.SetOut(&discardWriter{}) - cmd.SetErr(&discardWriter{}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) err := cmd.Execute() if tt.wantErr { - assert.Error(t, err) + require.Error(t, err) return } - assert.NoError(t, err) + require.NoError(t, err) assert.Equal(t, tt.wantRepo, gotOpts.RepoArg) assert.Equal(t, tt.wantSkillName, gotOpts.SkillName) }) } } -func TestNewCmdPreview_Alias(t *testing.T) { - ios, _, _, _ := iostreams.Test() - f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}} - cmd := NewCmdPreview(f, func(_ *previewOptions) error { return nil }) - assert.Contains(t, cmd.Aliases, "show") -} - func TestPreviewRun(t *testing.T) { - skillContent := "---\nname: my-skill\ndescription: A test skill\n---\n# My Skill\n\nThis is the skill content." + skillContent := heredoc.Doc(` + --- + name: my-skill + description: A test skill + --- + # My Skill + + This is the skill content. + `) encodedContent := base64.StdEncoding.EncodeToString([]byte(skillContent)) tests := []struct { @@ -266,11 +271,11 @@ func TestPreviewRun(t *testing.T) { err := previewRun(tt.opts) if tt.wantErr != "" { - assert.EqualError(t, err, tt.wantErr) + require.EqualError(t, err, tt.wantErr) return } - assert.NoError(t, err) + require.NoError(t, err) if tt.wantStdout != "" { assert.Contains(t, stdout.String(), tt.wantStdout) } @@ -338,12 +343,19 @@ func TestPreviewRun_Interactive(t *testing.T) { } err := previewRun(opts) - assert.NoError(t, err) + require.NoError(t, err) assert.Contains(t, stdout.String(), "Selected Skill") } func TestPreviewRun_ShowsFileTree(t *testing.T) { - skillContent := "---\nname: my-skill\ndescription: test\n---\n# My Skill\nBody." + skillContent := heredoc.Doc(` + --- + name: my-skill + description: test + --- + # My Skill + Body. + `) encodedContent := base64.StdEncoding.EncodeToString([]byte(skillContent)) scriptContent := "#!/bin/bash\necho hello" @@ -426,7 +438,7 @@ func TestPreviewRun_ShowsFileTree(t *testing.T) { } err := previewRun(opts) - assert.NoError(t, err) + require.NoError(t, err) out := stdout.String() assert.Contains(t, out, "echo hello") @@ -450,7 +462,7 @@ func TestPreviewRun_ShowsFileTree(t *testing.T) { } err := previewRun(opts) - assert.NoError(t, err) + require.NoError(t, err) out := stdout.String() assert.Contains(t, out, "my-skill/") @@ -460,7 +472,174 @@ func TestPreviewRun_ShowsFileTree(t *testing.T) { }) } -// discardWriter is a no-op writer for suppressing cobra output in tests. -type discardWriter struct{} +func TestPreviewRun_RenderLimits(t *testing.T) { + skillContent := heredoc.Doc(` + --- + name: my-skill + description: test + --- + # My Skill + `) + encodedSkill := base64.StdEncoding.EncodeToString([]byte(skillContent)) -func (d *discardWriter) Write(p []byte) (int, error) { return len(p), nil } + // Helper: build a tree JSON with N extra files (beyond SKILL.md) + buildTree := func(n int) string { + entries := []string{ + `{"path": "skills/my-skill", "type": "tree", "sha": "treeSHA"}`, + `{"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blobSKILL"}`, + } + for i := range n { + entries = append(entries, fmt.Sprintf( + `{"path": "skills/my-skill/file%03d.txt", "type": "blob", "sha": "blob%03d"}`, i, i)) + } + return fmt.Sprintf(`{"sha":"abc123","truncated":false,"tree":[%s]}`, + strings.Join(entries, ",")) + } + + // Helper: build subtree JSON with N extra files + buildSubtree := func(n int, sizes []int) string { + entries := []string{ + `{"path": "SKILL.md", "type": "blob", "sha": "blobSKILL", "size": 50}`, + } + for i := range n { + sz := 10 + if i < len(sizes) { + sz = sizes[i] + } + entries = append(entries, fmt.Sprintf( + `{"path": "file%03d.txt", "type": "blob", "sha": "blob%03d", "size": %d}`, i, i, sz)) + } + return fmt.Sprintf(`{"tree":[%s]}`, strings.Join(entries, ",")) + } + + // Common stubs for resolve + discover + registerBase := func(reg *httpmock.Registry, treeJSON, subtreeJSON string) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/trees/abc123"), + httpmock.StringResponse(treeJSON), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/trees/treeSHA"), + httpmock.StringResponse(subtreeJSON), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/blobs/blobSKILL"), + httpmock.StringResponse(`{"sha": "blobSKILL", "content": "`+encodedSkill+`", "encoding": "base64"}`), + ) + } + + t.Run("maxFiles cap truncates at 20", func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + n := 22 + treeJSON := buildTree(n) + subtreeJSON := buildSubtree(n, nil) + registerBase(reg, treeJSON, subtreeJSON) + + // Register blob stubs for files 0-19 (first 20 get fetched) + tinyContent := base64.StdEncoding.EncodeToString([]byte("tiny")) + for i := range 20 { + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/monalisa/skills-repo/git/blobs/blob%03d", i)), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "blob%03d", "content": "%s", "encoding": "base64"}`, i, tinyContent)), + ) + } + // Files 20 and 21 should NOT be fetched + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(false) + ios.SetStdinTTY(false) + + opts := &previewOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: &prompter.PrompterMock{}, + repo: ghrepo.New("monalisa", "skills-repo"), + SkillName: "my-skill", + } + + err := previewRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, "showing first 20") + assert.Contains(t, out, "file019.txt") // last fetched + }) + + t.Run("maxBytes cap stops fetching", func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + // Two files: first is 500KB, second would exceed 512KB cap + sizes := []int{500 * 1024, 100 * 1024} + treeJSON := buildTree(2) + subtreeJSON := buildSubtree(2, sizes) + registerBase(reg, treeJSON, subtreeJSON) + + bigContent := base64.StdEncoding.EncodeToString(make([]byte, 500*1024)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/blobs/blob000"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "blob000", "content": "%s", "encoding": "base64"}`, bigContent)), + ) + // blob001 should NOT be fetched — size limit reached + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(false) + ios.SetStdinTTY(false) + + opts := &previewOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: &prompter.PrompterMock{}, + repo: ghrepo.New("monalisa", "skills-repo"), + SkillName: "my-skill", + } + + err := previewRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, "size limit reached") + }) + + t.Run("blob fetch error shows fallback message", func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + treeJSON := buildTree(1) + subtreeJSON := buildSubtree(1, nil) + registerBase(reg, treeJSON, subtreeJSON) + + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/blobs/blob000"), + httpmock.StatusStringResponse(500, "server error"), + ) + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(false) + ios.SetStdinTTY(false) + + opts := &previewOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: &prompter.PrompterMock{}, + repo: ghrepo.New("monalisa", "skills-repo"), + SkillName: "my-skill", + } + + err := previewRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, "could not fetch file") + }) +} diff --git a/pkg/cmd/skills/publish/publish.go b/pkg/cmd/skills/publish/publish.go index 9a9200131..200e3e2dd 100644 --- a/pkg/cmd/skills/publish/publish.go +++ b/pkg/cmd/skills/publish/publish.go @@ -15,7 +15,6 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/git" - giturl "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" @@ -40,10 +39,9 @@ type publishOptions struct { Dir string // directory to validate (default: cwd) // Flags - Fix bool // --fix flag: auto-fix issues where possible - Plugins bool // --plugins flag: generate .claude-plugin/ manifest - DryRun bool // --dry-run flag: validate only, don't publish - Tag string // --tag flag: release tag to create + Fix bool // --fix flag: auto-fix issues where possible + DryRun bool // --dry-run flag: validate only, don't publish + Tag string // --tag flag: release tag to create // Testing overrides client *api.Client // injectable for tests; nil means use factory HttpClient @@ -126,8 +124,6 @@ func NewCmdPublish(f *cmdutil.Factory, runF func(*publishOptions) error) *cobra. Use --dry-run to validate without publishing. Use --tag to publish non-interactively with a specific tag. Use --fix to automatically strip install metadata from committed files. - Use --plugins to generate a .claude-plugin/plugin.json manifest for - Claude Code plugin discovery. `), Example: heredoc.Doc(` # Validate and publish interactively @@ -141,9 +137,6 @@ func NewCmdPublish(f *cmdutil.Factory, runF func(*publishOptions) error) *cobra. # Validate and strip install metadata $ gh skills publish --fix - - # Generate Claude Code plugin manifest - $ gh skills publish --plugins `), Aliases: []string{"validate"}, Args: cobra.MaximumNArgs(1), @@ -159,7 +152,6 @@ func NewCmdPublish(f *cmdutil.Factory, runF func(*publishOptions) error) *cobra. } cmd.Flags().BoolVar(&opts.Fix, "fix", false, "Auto-fix issues where possible (e.g. strip install metadata)") - cmd.Flags().BoolVar(&opts.Plugins, "plugins", false, "Generate .claude-plugin/ manifest for Claude Code plugin discovery") cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Validate without publishing") cmd.Flags().StringVar(&opts.Tag, "tag", "", "Version tag for the release (e.g. v1.0.0)") @@ -181,7 +173,6 @@ func publishRun(opts *publishOptions) error { return fmt.Errorf("could not resolve path: %w", err) } - cs := opts.IO.ColorScheme() canPrompt := opts.IO.CanPrompt() // Use injected client or create one from the factory HttpClient @@ -405,19 +396,6 @@ func publishRun(opts *publishOptions) error { renderDiagnosticsPlain(opts, diagnostics, errors, warnings) } - // Generate Claude Code plugin manifest if requested - if opts.Plugins { - pluginDiags := generateClaudePlugin(dir, skillDirs, owner, repo) - for _, d := range pluginDiags { - switch d.severity { - case "error": - fmt.Fprintf(opts.IO.ErrOut, "%s %s\n", cs.FailureIcon(), d.message) - default: - fmt.Fprintf(opts.IO.Out, "%s %s\n", cs.SuccessIcon(), d.message) - } - } - } - if errors > 0 { return fmt.Errorf("validation failed with %d error(s)", errors) } @@ -577,9 +555,9 @@ func runPublishRelease(opts *publishOptions, client *api.Client, host, owner, re // 4. Inform if not on default branch var currentBranch string if opts.GitClient != nil { - bc := *opts.GitClient - bc.RepoDir = dir - if b, err := bc.CurrentBranch(context.Background()); err == nil { + branchGitClient := opts.GitClient.Copy() + branchGitClient.RepoDir = dir + if b, err := branchGitClient.CurrentBranch(context.Background()); err == nil { currentBranch = b } } @@ -597,7 +575,7 @@ func runPublishRelease(opts *publishOptions, client *api.Client, host, owner, re } if !confirmed { fmt.Fprintf(opts.IO.ErrOut, "Publish cancelled.\n") - return nil + return cmdutil.CancelError } } @@ -825,9 +803,9 @@ func checkInstalledSkillDirs(gitClient *git.Client, repoDir string) []publishDia } if gitClient != nil { - ic := *gitClient - ic.RepoDir = repoDir - if ic.IsIgnored(context.Background(), relPath) { + ignoreGitClient := gitClient.Copy() + ignoreGitClient.RepoDir = repoDir + if ignoreGitClient.IsIgnored(context.Background(), relPath) { continue } } @@ -899,7 +877,7 @@ func detectGitHubRemote(gitClient *git.Client) (owner, repo string) { // parseGitHubURL extracts owner/repo from a GitHub remote URL. // Only GitHub.com URLs are recognized. func parseGitHubURL(rawURL string) (owner, repo string) { - u, err := giturl.ParseURL(rawURL) + u, err := git.ParseURL(rawURL) if err != nil { return "", "" } @@ -921,16 +899,16 @@ func detectMissingRepoDiagnostic(gitClient *git.Client, dir string) []publishDia return nil } - dc := *gitClient - dc.RepoDir = dir - if _, err := dc.GitDir(context.Background()); err != nil { + dirGitClient := gitClient.Copy() + dirGitClient.RepoDir = dir + if _, err := dirGitClient.GitDir(context.Background()); err != nil { return []publishDiagnostic{{ severity: "warning", message: "not a git repository — initialize with: git init && gh repo create", }} } - remotes, err := dc.Remotes(context.Background()) + remotes, err := dirGitClient.Remotes(context.Background()) if err != nil || len(remotes) == 0 { return []publishDiagnostic{{ severity: "warning", @@ -940,7 +918,7 @@ func detectMissingRepoDiagnostic(gitClient *git.Client, dir string) []publishDia var urls []string for _, r := range remotes { - if url, err := dc.RemoteURL(context.Background(), r.Name); err == nil { + if url, err := dirGitClient.RemoteURL(context.Background(), r.Name); err == nil { urls = append(urls, url) } } @@ -1062,185 +1040,3 @@ func stripGitHubMetadata(content string) (string, error) { return frontmatter.Serialize(result.RawYAML, result.Body) } - -// claudePluginJSON is the .claude-plugin/plugin.json structure. -type claudePluginJSON struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - Version string `json:"version,omitempty"` - Author *claudeAuthor `json:"author,omitempty"` - Homepage string `json:"homepage,omitempty"` - Repository string `json:"repository,omitempty"` - License string `json:"license,omitempty"` - Keywords []string `json:"keywords,omitempty"` -} - -type claudeAuthor struct { - Name string `json:"name"` -} - -// claudeMarketplaceJSON is the .claude-plugin/marketplace.json structure. -type claudeMarketplaceJSON struct { - Name string `json:"name"` - Owner claudeAuthor `json:"owner"` - Plugins []claudeMarketplacePlugin `json:"plugins"` -} - -type claudeMarketplacePlugin struct { - Name string `json:"name"` - Source string `json:"source"` - Description string `json:"description,omitempty"` -} - -// generateClaudePlugin creates .claude-plugin/plugin.json (and optionally -// marketplace.json for multi-skill repos). -func generateClaudePlugin(dir string, skillDirs []string, owner, repo string) []publishDiagnostic { - var diags []publishDiagnostic - - pluginDir := filepath.Join(dir, ".claude-plugin") - pluginPath := filepath.Join(pluginDir, "plugin.json") - - // Don't overwrite existing plugin.json - if _, err := os.Stat(pluginPath); err == nil { - diags = append(diags, publishDiagnostic{ - severity: "info", - message: ".claude-plugin/plugin.json already exists (skipped)", - }) - return diags - } - - pluginName := filepath.Base(dir) - if repo != "" { - pluginName = repo - } - - description := buildPluginDescription(dir, skillDirs) - - plugin := claudePluginJSON{ - Name: pluginName, - Description: description, - Version: "1.0.0", - Keywords: []string{"agent-skills"}, - } - - if owner != "" && repo != "" { - plugin.Repository = fmt.Sprintf("https://github.com/%s/%s", owner, repo) - plugin.Homepage = fmt.Sprintf("https://github.com/%s/%s", owner, repo) - plugin.Author = &claudeAuthor{Name: owner} - } - - // Collect license from any skill - for _, skillName := range skillDirs { - skillPath := filepath.Join(dir, "skills", skillName, "SKILL.md") - content, err := os.ReadFile(skillPath) - if err != nil { - continue - } - result, err := frontmatter.Parse(string(content)) - if err != nil { - continue - } - if result.Metadata.License != "" { - plugin.License = result.Metadata.License - break - } - } - - if err := os.MkdirAll(pluginDir, 0o755); err != nil { - diags = append(diags, publishDiagnostic{ - severity: "error", - message: fmt.Sprintf("could not create .claude-plugin/: %v", err), - }) - return diags - } - - data, err := json.MarshalIndent(plugin, "", " ") - if err != nil { - diags = append(diags, publishDiagnostic{ - severity: "error", - message: fmt.Sprintf("could not serialize plugin.json: %v", err), - }) - return diags - } - - if err := os.WriteFile(pluginPath, append(data, '\n'), 0o644); err != nil { - diags = append(diags, publishDiagnostic{ - severity: "error", - message: fmt.Sprintf("could not write plugin.json: %v", err), - }) - return diags - } - - diags = append(diags, publishDiagnostic{ - severity: "info", - message: fmt.Sprintf("generated .claude-plugin/plugin.json for %q with %d skill(s)", pluginName, len(skillDirs)), - }) - - // Generate marketplace.json for multi-skill repos with a GitHub remote - if len(skillDirs) > 1 && owner != "" && repo != "" { - marketplacePath := filepath.Join(pluginDir, "marketplace.json") - if _, err := os.Stat(marketplacePath); err != nil { - mDiags := generateMarketplace(marketplacePath, pluginName, owner, skillDirs, dir) - diags = append(diags, mDiags...) - } - } - - return diags -} - -// generateMarketplace creates a marketplace.json for plugin marketplace discovery. -func generateMarketplace(path, pluginName, owner string, skillDirs []string, dir string) []publishDiagnostic { - desc := buildPluginDescription(dir, skillDirs) - plugins := []claudeMarketplacePlugin{{ - Name: pluginName, - Source: ".", - Description: desc, - }} - - marketplace := claudeMarketplaceJSON{ - Name: pluginName, - Owner: claudeAuthor{Name: owner}, - Plugins: plugins, - } - - data, err := json.MarshalIndent(marketplace, "", " ") - if err != nil { - return []publishDiagnostic{{ - severity: "error", - message: fmt.Sprintf("could not serialize marketplace.json: %v", err), - }} - } - - if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil { - return []publishDiagnostic{{ - severity: "error", - message: fmt.Sprintf("could not write marketplace.json: %v", err), - }} - } - - return []publishDiagnostic{{ - severity: "info", - message: "generated .claude-plugin/marketplace.json for plugin marketplace discovery", - }} -} - -// buildPluginDescription creates a description from skill names and descriptions. -func buildPluginDescription(dir string, skillDirs []string) string { - if len(skillDirs) == 1 { - skillPath := filepath.Join(dir, "skills", skillDirs[0], "SKILL.md") - if content, err := os.ReadFile(skillPath); err == nil { - if result, err := frontmatter.Parse(string(content)); err == nil && result.Metadata.Description != "" { - return result.Metadata.Description - } - } - } - - var names []string - for _, name := range skillDirs { - names = append(names, name) - } - if len(names) <= 5 { - return fmt.Sprintf("Agent skills: %s", strings.Join(names, ", ")) - } - return fmt.Sprintf("Agent skills collection with %d skills", len(names)) -} diff --git a/pkg/cmd/skills/publish/publish_test.go b/pkg/cmd/skills/publish/publish_test.go index 56e3b1e0a..e4c368b70 100644 --- a/pkg/cmd/skills/publish/publish_test.go +++ b/pkg/cmd/skills/publish/publish_test.go @@ -2,24 +2,25 @@ package publish import ( "bytes" - "encoding/json" - "fmt" "net/http" "os" "os/exec" "path/filepath" - "strings" "testing" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func testPublishGitClient(t *testing.T, remoteURLs map[string]string) *git.Client { +func newTestGitClient(t *testing.T, remoteURLs map[string]string) *git.Client { t.Helper() dir := t.TempDir() runGit := func(args ...string) { @@ -38,77 +39,29 @@ func testPublishGitClient(t *testing.T, remoteURLs map[string]string) *git.Clien return &git.Client{RepoDir: dir} } -func TestPublishCmd_Help(t *testing.T) { - ios, _, _, _ := iostreams.Test() - f := stubFactory(ios) - cmd := NewCmdPublish(&f, nil) - if cmd.Use == "" { - t.Error("publish command has no Use string") - } - if cmd.Short == "" { - t.Error("publish command has no Short description") - } -} - -func TestPublishCmd_Alias(t *testing.T) { - ios, _, _, _ := iostreams.Test() - f := stubFactory(ios) - cmd := NewCmdPublish(&f, nil) - found := false - for _, alias := range cmd.Aliases { - if alias == "validate" { - found = true - break - } - } - if !found { - t.Error("publish command should have 'validate' alias") - } -} - -func TestPublish_ValidSkill(t *testing.T) { - dir := t.TempDir() - skillDir := filepath.Join(dir, "skills", "git-commit") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatal(err) - } - - content := `--- -name: git-commit -description: A skill for writing good git commits -allowed-tools: git -license: MIT ---- -You are a git commit expert. -` - if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { - t.Fatal(err) - } - - ios, _, stdout, _ := iostreams.Test() - - reg := &httpmock.Registry{} - defer reg.Verify(t) +// stubAllSecureRemote registers the standard stubs for a fully-configured remote +// repo (topics, tags, rulesets, security) so publishRun skips all remote warnings. +func stubAllSecureRemote(reg *httpmock.Registry, owner, repo string) { reg.Register( - httpmock.REST("GET", "repos/test/skills-repo/topics"), + httpmock.REST("GET", "repos/"+owner+"/"+repo+"/topics"), httpmock.JSONResponse(map[string]interface{}{ "names": []string{"agent-skills"}, }), ) reg.Register( - httpmock.REST("GET", "repos/test/skills-repo/tags"), + httpmock.REST("GET", "repos/"+owner+"/"+repo+"/tags"), httpmock.JSONResponse([]map[string]interface{}{ {"name": "v1.0.0"}, }), ) reg.Register( - httpmock.REST("GET", "repos/test/skills-repo/rulesets"), + httpmock.REST("GET", "repos/"+owner+"/"+repo+"/rulesets"), httpmock.JSONResponse([]map[string]interface{}{ {"id": 1, "name": "tags", "target": "tag", "enforcement": "active"}, }), ) reg.Register( - httpmock.REST("GET", "repos/test/skills-repo"), + httpmock.REST("GET", "repos/"+owner+"/"+repo), httpmock.JSONResponse(map[string]interface{}{ "security_and_analysis": map[string]interface{}{ "secret_scanning": map[string]interface{}{"status": "enabled"}, @@ -116,944 +69,1267 @@ You are a git commit expert. }, }), ) - - opts := &publishOptions{ - IO: ios, - Dir: dir, - GitClient: testPublishGitClient(t, map[string]string{ - "origin": "https://github.com/test/skills-repo.git", - }), - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), - host: "github.com", - } - - err := publishRun(opts) - if err != nil { - t.Fatalf("expected no error, got: %v", err) - } - - out := stdout.String() - if !strings.Contains(out, "ok") { - t.Errorf("expected 'ok' output, got: %s", out) - } } -func TestPublish_MissingName(t *testing.T) { - dir := t.TempDir() - skillDir := filepath.Join(dir, "skills", "git-commit") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatal(err) - } - - content := `--- -description: A skill for writing good git commits ---- -Body text. -` - if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { - t.Fatal(err) - } - - ios, _, stdout, _ := iostreams.Test() - - opts := &publishOptions{ - IO: ios, - Dir: dir, - } - - err := publishRun(opts) - if err == nil { - t.Fatal("expected error for missing name") - } - - out := stdout.String() - if !strings.Contains(out, "missing required field: name") { - t.Errorf("expected name error in output, got: %s", out) - } -} - -func TestPublish_NameMismatch(t *testing.T) { - dir := t.TempDir() - skillDir := filepath.Join(dir, "skills", "git-commit") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatal(err) - } - - content := `--- -name: wrong-name -description: A skill ---- -Body. -` - if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { - t.Fatal(err) - } - - ios, _, stdout, _ := iostreams.Test() - - opts := &publishOptions{ - IO: ios, - Dir: dir, - } - - err := publishRun(opts) - if err == nil { - t.Fatal("expected error for name mismatch") - } - - out := stdout.String() - if !strings.Contains(out, "does not match directory name") { - t.Errorf("expected name mismatch error, got: %s", out) - } -} - -func TestPublish_NonSpecCompliantName(t *testing.T) { - dir := t.TempDir() - skillDir := filepath.Join(dir, "skills", "My_Skill") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatal(err) - } - - content := `--- -name: My_Skill -description: A skill with non-compliant name ---- -Body. -` - if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { - t.Fatal(err) - } - - ios, _, stdout, _ := iostreams.Test() - - opts := &publishOptions{ - IO: ios, - Dir: dir, - } - - err := publishRun(opts) - if err == nil { - t.Fatal("expected error for non-spec-compliant name") - } - - out := stdout.String() - if !strings.Contains(out, "naming convention") { - t.Errorf("expected naming convention error, got: %s", out) - } -} - -func TestPublish_AllowedToolsArray(t *testing.T) { - dir := t.TempDir() - skillDir := filepath.Join(dir, "skills", "bad-tools") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatal(err) - } - - content := `--- -name: bad-tools -description: A skill with array allowed-tools -allowed-tools: - - git - - curl ---- -Body. -` - if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { - t.Fatal(err) - } - - ios, _, stdout, _ := iostreams.Test() - - opts := &publishOptions{ - IO: ios, - Dir: dir, - } - - err := publishRun(opts) - if err == nil { - t.Fatal("expected error for array allowed-tools") - } - - out := stdout.String() - if !strings.Contains(out, "allowed-tools must be a string") { - t.Errorf("expected allowed-tools error, got: %s", out) - } -} - -func TestPublish_StripMetadata(t *testing.T) { - dir := t.TempDir() - skillDir := filepath.Join(dir, "skills", "test-skill") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatal(err) - } - - content := `--- -name: test-skill -description: A test skill -metadata: - github-owner: someone - github-repo: something - github-ref: v1.0.0 - github-sha: abc123 - github-tree-sha: def456 ---- -Body. -` - skillPath := filepath.Join(skillDir, "SKILL.md") - if err := os.WriteFile(skillPath, []byte(content), 0o644); err != nil { - t.Fatal(err) - } - - ios, _, _, _ := iostreams.Test() - - opts := &publishOptions{ - IO: ios, - Dir: dir, - Fix: true, - } - - err := publishRun(opts) - if err != nil { - t.Fatalf("expected no error with --fix, got: %v", err) - } - - fixed, err := os.ReadFile(skillPath) - if err != nil { - t.Fatal(err) - } - - fixedStr := string(fixed) - if strings.Contains(fixedStr, "github-owner") { - t.Errorf("expected github-owner to be stripped, got:\n%s", fixedStr) - } - if strings.Contains(fixedStr, "github-sha") { - t.Errorf("expected github-sha to be stripped, got:\n%s", fixedStr) - } - if strings.Contains(fixedStr, "metadata:") { - t.Errorf("expected empty metadata map to be removed, got:\n%s", fixedStr) - } -} - -func TestPublish_MetadataWithoutFix(t *testing.T) { - dir := t.TempDir() - skillDir := filepath.Join(dir, "skills", "test-skill") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatal(err) - } - - content := `--- -name: test-skill -description: A test skill -metadata: - github-owner: someone - github-sha: abc123 ---- -Body. -` - if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { - t.Fatal(err) - } - - ios, _, stdout, _ := iostreams.Test() - - opts := &publishOptions{ - IO: ios, - Dir: dir, - Fix: false, - } - - err := publishRun(opts) - if err == nil { - t.Fatal("expected error without --fix when metadata present") - } - - out := stdout.String() - if !strings.Contains(out, "install metadata") { - t.Errorf("expected install metadata error, got: %s", out) - } - if !strings.Contains(out, "--fix") { - t.Errorf("expected --fix suggestion, got: %s", out) - } -} - -func TestPublish_NoSkillsDir(t *testing.T) { - dir := t.TempDir() - ios, _, _, _ := iostreams.Test() - - opts := &publishOptions{ - IO: ios, - Dir: dir, - } - - err := publishRun(opts) - if err == nil { - t.Fatal("expected error for missing skills/ directory") - } - if !strings.Contains(err.Error(), "no skills/ directory") { - t.Errorf("expected 'no skills/ directory' error, got: %v", err) - } -} - -func TestPublish_MissingSKILLMD(t *testing.T) { - dir := t.TempDir() - skillDir := filepath.Join(dir, "skills", "empty-skill") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatal(err) - } - - ios, _, stdout, _ := iostreams.Test() - - opts := &publishOptions{ - IO: ios, - Dir: dir, - } - - err := publishRun(opts) - if err == nil { - t.Fatal("expected error for missing SKILL.md") - } - - out := stdout.String() - if !strings.Contains(out, "missing SKILL.md") { - t.Errorf("expected missing SKILL.md error, got: %s", out) - } -} - -func TestPublish_DryRun(t *testing.T) { - dir := t.TempDir() - skillDir := filepath.Join(dir, "skills", "good-skill") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatal(err) - } - - content := `--- -name: good-skill -description: A good skill -license: MIT ---- -Body. -` - if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { - t.Fatal(err) - } - - ios, _, _, stderr := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStderrTTY(true) - - reg := &httpmock.Registry{} - defer reg.Verify(t) - reg.Register( - httpmock.REST("GET", "repos/test/skills-repo/topics"), - httpmock.JSONResponse(map[string]interface{}{ - "names": []string{"agent-skills"}, - }), - ) - reg.Register( - httpmock.REST("GET", "repos/test/skills-repo/tags"), - httpmock.JSONResponse([]map[string]interface{}{ - {"name": "v1.0.0"}, - }), - ) - reg.Register( - httpmock.REST("GET", "repos/test/skills-repo/rulesets"), - httpmock.JSONResponse([]map[string]interface{}{ - {"id": 1, "name": "tags", "target": "tag", "enforcement": "active"}, - }), - ) - reg.Register( - httpmock.REST("GET", "repos/test/skills-repo"), - httpmock.JSONResponse(map[string]interface{}{ - "security_and_analysis": map[string]interface{}{ - "secret_scanning": map[string]interface{}{"status": "enabled"}, - "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, - }, - }), - ) - - opts := &publishOptions{ - IO: ios, - Dir: dir, - DryRun: true, - GitClient: testPublishGitClient(t, map[string]string{ - "origin": "https://github.com/test/skills-repo.git", - }), - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), - host: "github.com", - } - - err := publishRun(opts) - if err != nil { - t.Fatalf("expected no error, got: %v", err) - } - - errOut := stderr.String() - if !strings.Contains(errOut, "Dry run complete") { - t.Errorf("stderr should confirm dry run, got: %s", errOut) - } -} - -func TestPublish_LicenseWarning(t *testing.T) { - dir := t.TempDir() - skillDir := filepath.Join(dir, "skills", "no-license") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatal(err) - } - - content := `--- -name: no-license -description: A skill without license ---- -Body. -` - if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { - t.Fatal(err) - } - - ios, _, stdout, _ := iostreams.Test() - - opts := &publishOptions{ - IO: ios, - Dir: dir, - } - - err := publishRun(opts) - if err != nil { - t.Fatalf("expected no error (warnings only), got: %v", err) - } - - out := stdout.String() - if !strings.Contains(out, "license") { - t.Errorf("expected license warning, got: %s", out) - } -} - -func TestSuggestNextTag(t *testing.T) { +func TestNewCmdPublish(t *testing.T) { tests := []struct { - input string - want string + name string + cli string + wantsErr bool + wantsOpts publishOptions }{ - {"v1.0.0", "v1.0.1"}, - {"v2.3.4", "v2.3.5"}, - {"1.0.0", "1.0.1"}, - {"v0.0.9", "v0.0.10"}, - {"not-semver", ""}, - {"v1", ""}, - {"v1.0", ""}, + { + name: "all flags", + cli: "./monalisa-skills --dry-run --fix --tag v1.0.0", + wantsOpts: publishOptions{ + Dir: "./monalisa-skills", + DryRun: true, + Fix: true, + Tag: "v1.0.0", + }, + }, + { + name: "directory only", + cli: "./octocat-repo", + wantsOpts: publishOptions{ + Dir: "./octocat-repo", + }, + }, + { + name: "no args leaves dir empty", + cli: "", + wantsOpts: publishOptions{}, + }, + { + name: "dry-run flag only", + cli: "--dry-run", + wantsOpts: publishOptions{ + DryRun: true, + }, + }, } for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - got := suggestNextTag(tt.input) - if got != tt.want { - t.Errorf("suggestNextTag(%q) = %q, want %q", tt.input, got, tt.want) + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := cmdutil.Factory{IOStreams: ios} + + var gotOpts *publishOptions + cmd := NewCmdPublish(&f, func(opts *publishOptions) error { + gotOpts = opts + return nil + }) + + args, err := shlex.Split(tt.cli) + require.NoError(t, err) + cmd.SetArgs(args) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + err = cmd.Execute() + if tt.wantsErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.NotNil(t, gotOpts) + assert.Equal(t, tt.wantsOpts.Dir, gotOpts.Dir) + assert.Equal(t, tt.wantsOpts.DryRun, gotOpts.DryRun) + assert.Equal(t, tt.wantsOpts.Fix, gotOpts.Fix) + assert.Equal(t, tt.wantsOpts.Tag, gotOpts.Tag) + }) + } +} + +func TestPublishRun(t *testing.T) { + tests := []struct { + name string + isTTY bool + setup func(t *testing.T, dir string) + stubs func(*httpmock.Registry) + opts func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions + verify func(t *testing.T, dir string) + wantErr string + wantStdout string + wantStderr string + }{ + { + name: "no skills directory", + setup: func(_ *testing.T, _ string) {}, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{IO: ios, Dir: dir} + }, + wantErr: "no skills/ directory", + }, + { + name: "missing SKILL.md", + setup: func(t *testing.T, dir string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "skills", "empty-skill"), 0o755)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{IO: ios, Dir: dir} + }, + wantErr: "validation failed", + wantStdout: "missing SKILL.md", + }, + { + name: "missing name in frontmatter", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "git-commit", heredoc.Doc(` + --- + description: A skill for writing good git commits + --- + Body text. + `)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{IO: ios, Dir: dir} + }, + wantErr: "validation failed", + wantStdout: "missing required field: name", + }, + { + name: "name does not match directory", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "git-commit", heredoc.Doc(` + --- + name: wrong-name + description: A skill + --- + Body. + `)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{IO: ios, Dir: dir} + }, + wantErr: "validation failed", + wantStdout: "does not match directory name", + }, + { + name: "non-spec-compliant name", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "My_Skill", heredoc.Doc(` + --- + name: My_Skill + description: A skill with non-compliant name + --- + Body. + `)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{IO: ios, Dir: dir} + }, + wantErr: "validation failed", + wantStdout: "naming convention", + }, + { + name: "valid skill dry-run passes validation", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "good-skill", heredoc.Doc(` + --- + name: good-skill + description: A good skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "monalisa", "skills-repo") + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + DryRun: true, + GitClient: newTestGitClient(t, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStdout: "1 skill(s) validated successfully", + wantStderr: "Dry run complete", + }, + { + name: "valid skill with --tag publishes release", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "git-commit", heredoc.Doc(` + --- + name: git-commit + description: A skill for writing good git commits + allowed-tools: git + license: MIT + --- + You are a git commit expert. + `)) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "monalisa", "skills-repo") + // topic already present, so no PUT needed + // immutable releases check + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/immutable-releases"), + httpmock.JSONResponse(map[string]interface{}{"enabled": true}), + ) + // default branch for branch comparison + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{"default_branch": "main"}), + ) + // create release + reg.Register( + httpmock.REST("POST", "repos/monalisa/skills-repo/releases"), + httpmock.JSONResponse(map[string]interface{}{ + "html_url": "https://github.com/monalisa/skills-repo/releases/tag/v1.0.1", + }), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + Tag: "v1.0.1", + Prompter: &prompter.PrompterMock{ + ConfirmFunc: func(msg string, def bool) (bool, error) { return true, nil }, + }, + GitClient: newTestGitClient(t, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStdout: "Published v1.0.1", + }, + { + name: "strip metadata with --fix", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "test-skill", heredoc.Doc(` + --- + name: test-skill + description: A test skill + metadata: + github-owner: someone + github-repo: something + github-ref: v1.0.0 + github-sha: abc123 + github-tree-sha: def456 + --- + Body. + `)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{IO: ios, Dir: dir, Fix: true} + }, + wantStdout: "stripped install metadata", + verify: func(t *testing.T, dir string) { + t.Helper() + fixed, err := os.ReadFile(filepath.Join(dir, "skills", "test-skill", "SKILL.md")) + require.NoError(t, err) + fixedStr := string(fixed) + assert.NotContains(t, fixedStr, "github-owner") + assert.NotContains(t, fixedStr, "github-sha") + assert.NotContains(t, fixedStr, "metadata:") + }, + }, + { + name: "metadata without --fix errors with hint", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "test-skill", heredoc.Doc(` + --- + name: test-skill + description: A test skill + metadata: + github-owner: someone + github-sha: abc123 + --- + Body. + `)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{IO: ios, Dir: dir, Fix: false} + }, + wantErr: "validation failed", + wantStdout: "--fix", + }, + { + name: "missing license warning", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "no-license", heredoc.Doc(` + --- + name: no-license + description: A skill without license + --- + Body. + `)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{IO: ios, Dir: dir} + }, + wantStdout: "license", + }, + { + name: "allowed-tools array error", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "bad-tools", heredoc.Doc(` + --- + name: bad-tools + description: A skill with array allowed-tools + allowed-tools: + - git + - curl + --- + Body. + `)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{IO: ios, Dir: dir} + }, + wantErr: "validation failed", + wantStdout: "allowed-tools must be a string", + }, + { + name: "security warnings when features disabled", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/octocat/secure-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{ + "names": []string{"agent-skills"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/secure-repo/tags"), + httpmock.JSONResponse([]interface{}{}), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/secure-repo/rulesets"), + httpmock.JSONResponse([]map[string]interface{}{ + {"id": 1, "name": "branch-only", "target": "branch", "enforcement": "active"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/secure-repo"), + httpmock.JSONResponse(map[string]interface{}{ + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "disabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "disabled"}, + }, + }), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + GitClient: newTestGitClient(t, map[string]string{ + "origin": "https://github.com/octocat/secure-repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStdout: "secret scanning is not enabled", + }, + { + name: "tag protection warning", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/octocat/tag-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{ + "names": []string{"agent-skills"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/tag-repo/tags"), + httpmock.JSONResponse([]interface{}{}), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/tag-repo/rulesets"), + httpmock.JSONResponse([]interface{}{}), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/tag-repo"), + httpmock.JSONResponse(map[string]interface{}{ + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "enabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, + }, + }), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + GitClient: newTestGitClient(t, map[string]string{ + "origin": "https://github.com/octocat/tag-repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStdout: "tag protection", + }, + { + name: "code files trigger code scanning info", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "code-skill", heredoc.Doc(` + --- + name: code-skill + description: A skill with code + license: MIT + --- + Body. + `)) + scriptDir := filepath.Join(dir, "skills", "code-skill", "scripts") + require.NoError(t, os.MkdirAll(scriptDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(scriptDir, "helper.sh"), []byte("#!/bin/bash"), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/octocat/code-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{ + "names": []string{"agent-skills"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/code-repo/tags"), + httpmock.JSONResponse([]interface{}{}), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/code-repo/rulesets"), + httpmock.JSONResponse([]map[string]interface{}{ + {"id": 1, "name": "tags", "target": "tag", "enforcement": "active"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/code-repo"), + httpmock.JSONResponse(map[string]interface{}{ + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "enabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, + }, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/code-repo/code-scanning/alerts"), + httpmock.StatusStringResponse(404, "not found"), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + DryRun: true, + GitClient: newTestGitClient(t, map[string]string{ + "origin": "https://github.com/octocat/code-repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStderr: "code scanning", + }, + { + name: "manifest files trigger dependabot info", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "dep-skill", heredoc.Doc(` + --- + name: dep-skill + description: A skill with manifests + license: MIT + --- + Body. + `)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, "skills", "dep-skill", "package.json"), + []byte("{}"), 0o644, + )) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/octocat/dep-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{ + "names": []string{"agent-skills"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/dep-repo/tags"), + httpmock.JSONResponse([]interface{}{}), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/dep-repo/rulesets"), + httpmock.JSONResponse([]map[string]interface{}{ + {"id": 1, "name": "tags", "target": "tag", "enforcement": "active"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/dep-repo"), + httpmock.JSONResponse(map[string]interface{}{ + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "enabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, + }, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/dep-repo/vulnerability-alerts"), + httpmock.StatusStringResponse(404, "not found"), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + DryRun: true, + GitClient: newTestGitClient(t, map[string]string{ + "origin": "https://github.com/octocat/dep-repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStderr: "Dependabot", + }, + { + name: "installed skill dirs not gitignored warns", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".github", "skills", "installed"), 0o755)) + runGitInDir(t, dir, "init", "--initial-branch=main") + runGitInDir(t, dir, "config", "user.email", "monalisa@github.com") + runGitInDir(t, dir, "config", "user.name", "Monalisa Octocat") + + return &publishOptions{ + IO: ios, + Dir: dir, + GitClient: &git.Client{RepoDir: dir}, + } + }, + wantStdout: ".gitignore", + }, + { + name: "installed skill dirs gitignored no warning", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".github", "skills", "installed"), 0o755)) + + runGitInDir(t, dir, "init", "--initial-branch=main") + runGitInDir(t, dir, "config", "user.email", "monalisa@github.com") + runGitInDir(t, dir, "config", "user.name", "Monalisa Octocat") + require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitignore"), []byte(".github/skills\n"), 0o644)) + runGitInDir(t, dir, "add", ".gitignore") + runGitInDir(t, dir, "commit", "-m", "init") + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + GitClient: &git.Client{RepoDir: dir}, + } + }, + wantStdout: "no git remote", + verify: func(t *testing.T, dir string) { + t.Helper() + // The key assertion: .gitignored dirs should NOT produce a warning + }, + }, + { + name: "no GitHub remote warns", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + runGitInDir(t, dir, "init", "--initial-branch=main") + runGitInDir(t, dir, "config", "user.email", "monalisa@github.com") + runGitInDir(t, dir, "config", "user.name", "Monalisa Octocat") + runGitInDir(t, dir, "remote", "add", "origin", "https://gitlab.com/hubot/bar.git") + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + GitClient: &git.Client{RepoDir: dir}, + } + }, + wantStdout: "not a GitHub repository", + }, + { + name: "fallback remote detection uses non-origin GitHub remote", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "octocat", "repo") + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + DryRun: true, + GitClient: newTestGitClient(t, map[string]string{ + "origin": "https://gitlab.com/hubot/bar.git", + "upstream": "git@github.com:octocat/repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStderr: "octocat/repo", + }, + { + name: "publish adds missing topic via --tag", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + // topic missing + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{ + "names": []string{"golang"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/tags"), + httpmock.JSONResponse([]interface{}{}), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/rulesets"), + httpmock.JSONResponse([]map[string]interface{}{ + {"id": 1, "name": "tags", "target": "tag", "enforcement": "active"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{ + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "enabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, + }, + }), + ) + // addAgentSkillsTopic fetches topics again then PUTs + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{ + "names": []string{"golang"}, + }), + ) + reg.Register( + httpmock.REST("PUT", "repos/monalisa/skills-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{}), + ) + // immutable releases + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/immutable-releases"), + httpmock.JSONResponse(map[string]interface{}{"enabled": true}), + ) + // default branch + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{"default_branch": "main"}), + ) + // create release + reg.Register( + httpmock.REST("POST", "repos/monalisa/skills-repo/releases"), + httpmock.JSONResponse(map[string]interface{}{ + "html_url": "https://github.com/monalisa/skills-repo/releases/tag/v1.0.0", + }), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + Tag: "v1.0.0", + Prompter: &prompter.PrompterMock{ + ConfirmFunc: func(msg string, def bool) (bool, error) { return true, nil }, + }, + GitClient: newTestGitClient(t, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStdout: "Added \"agent-skills\" topic", + }, + { + name: "tag suggestion uses existing tags", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{ + "names": []string{"agent-skills"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/tags"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "v2.3.4"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/rulesets"), + httpmock.JSONResponse([]map[string]interface{}{ + {"id": 1, "name": "tags", "target": "tag", "enforcement": "active"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{ + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "enabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, + }, + }), + ) + // immutable releases + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/immutable-releases"), + httpmock.JSONResponse(map[string]interface{}{"enabled": true}), + ) + // default branch + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{"default_branch": "main"}), + ) + // create release with the suggested v2.3.5 tag + reg.Register( + httpmock.REST("POST", "repos/monalisa/skills-repo/releases"), + httpmock.JSONResponse(map[string]interface{}{ + "html_url": "https://github.com/monalisa/skills-repo/releases/tag/v2.3.5", + }), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + Tag: "v2.3.5", + GitClient: newTestGitClient(t, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStdout: "Published v2.3.5", + }, + { + name: "duplicate tag errors", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "monalisa", "skills-repo") + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + Tag: "v1.0.0", // same as stubAllSecureRemote's existing tag + GitClient: newTestGitClient(t, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantErr: "tag v1.0.0 already exists", + }, + { + name: "valid skill non-tty plain output", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "git-commit", heredoc.Doc(` + --- + name: git-commit + description: A skill for writing good git commits + allowed-tools: git + license: MIT + --- + You are a git commit expert. + `)) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "monalisa", "skills-repo") + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + GitClient: newTestGitClient(t, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStdout: "ok", + }, + { + name: "no remote and non-tty shows validation passed message", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + } + }, + wantStdout: "ok", + }, + { + name: "interactive publish with topic and semver tag", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + // No topic yet — first GET for diagnostic check + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{"names": []string{}}), + ) + // Second GET inside addAgentSkillsTopic + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{"names": []string{}}), + ) + // Add topic + reg.Register( + httpmock.REST("PUT", "repos/monalisa/skills-repo/topics"), + httpmock.StringResponse("{}"), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/tags"), + httpmock.JSONResponse([]map[string]interface{}{}), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/rulesets"), + httpmock.JSONResponse([]map[string]interface{}{}), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{ + "default_branch": "main", + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "enabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, + }, + }), + ) + // Immutable releases already enabled + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/immutable-releases"), + httpmock.JSONResponse(map[string]interface{}{"enabled": true}), + ) + // Create release + reg.Register( + httpmock.REST("POST", "repos/monalisa/skills-repo/releases"), + httpmock.JSONResponse(map[string]interface{}{ + "html_url": "https://github.com/monalisa/skills-repo/releases/tag/v1.0.0", + }), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + t.Helper() + confirmCall := 0 + return &publishOptions{ + IO: ios, + Dir: dir, + Prompter: &prompter.PrompterMock{ + ConfirmFunc: func(msg string, def bool) (bool, error) { + confirmCall++ + return true, nil // accept topic + final confirm + }, + SelectFunc: func(msg string, def string, opts []string) (int, error) { + return 0, nil // semver strategy + }, + InputFunc: func(msg string, def string) (string, error) { + return "v1.0.0", nil // accept suggested tag + }, + }, + GitClient: newTestGitClient(t, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStdout: "Published v1.0.0", + }, + { + name: "interactive publish with custom tag", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "monalisa", "skills-repo") + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/immutable-releases"), + httpmock.JSONResponse(map[string]interface{}{"enabled": true}), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{"default_branch": "main"}), + ) + reg.Register( + httpmock.REST("POST", "repos/monalisa/skills-repo/releases"), + httpmock.JSONResponse(map[string]interface{}{ + "html_url": "https://github.com/monalisa/skills-repo/releases/tag/beta-1", + }), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + Prompter: &prompter.PrompterMock{ + ConfirmFunc: func(msg string, def bool) (bool, error) { + return true, nil + }, + SelectFunc: func(msg string, def string, opts []string) (int, error) { + return 1, nil // custom tag strategy + }, + InputFunc: func(msg string, def string) (string, error) { + return "beta-1", nil + }, + }, + GitClient: newTestGitClient(t, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStdout: "Published beta-1", + }, + { + name: "interactive publish declined at final confirm", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "monalisa", "skills-repo") + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/immutable-releases"), + httpmock.JSONResponse(map[string]interface{}{"enabled": true}), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{"default_branch": "main"}), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + t.Helper() + confirmCall := 0 + return &publishOptions{ + IO: ios, + Dir: dir, + Prompter: &prompter.PrompterMock{ + ConfirmFunc: func(msg string, def bool) (bool, error) { + confirmCall++ + if confirmCall >= 1 { + return false, nil // decline final confirm + } + return true, nil + }, + SelectFunc: func(msg string, def string, opts []string) (int, error) { + return 0, nil + }, + InputFunc: func(msg string, def string) (string, error) { + return "v1.0.1", nil + }, + }, + GitClient: newTestGitClient(t, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantErr: "CancelError", + wantStderr: "Publish cancelled", + }, + { + name: "interactive immutable releases prompt", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "monalisa", "skills-repo") + // Immutable releases NOT enabled + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/immutable-releases"), + httpmock.JSONResponse(map[string]interface{}{"enabled": false}), + ) + // Enable immutable releases + reg.Register( + httpmock.REST("PATCH", "repos/monalisa/skills-repo/immutable-releases"), + httpmock.StringResponse("{}"), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{"default_branch": "main"}), + ) + reg.Register( + httpmock.REST("POST", "repos/monalisa/skills-repo/releases"), + httpmock.JSONResponse(map[string]interface{}{ + "html_url": "https://github.com/monalisa/skills-repo/releases/tag/v1.0.1", + }), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + Prompter: &prompter.PrompterMock{ + ConfirmFunc: func(msg string, def bool) (bool, error) { + return true, nil // accept all confirms (immutable + final) + }, + SelectFunc: func(msg string, def string, opts []string) (int, error) { + return 0, nil + }, + InputFunc: func(msg string, def string) (string, error) { + return "v1.0.1", nil + }, + }, + GitClient: newTestGitClient(t, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStdout: "Enabled immutable releases", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + tt.setup(t, dir) + + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(tt.isTTY) + ios.SetStdinTTY(tt.isTTY) + ios.SetStderrTTY(tt.isTTY) + + reg := &httpmock.Registry{} + defer reg.Verify(t) + if tt.stubs != nil { + tt.stubs(reg) + } + + opts := tt.opts(ios, dir, reg) + err := publishRun(opts) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + } + if tt.wantStdout != "" { + assert.Contains(t, stdout.String(), tt.wantStdout) + } + if tt.wantStderr != "" { + assert.Contains(t, stderr.String(), tt.wantStderr) + } + if tt.verify != nil { + tt.verify(t, dir) } }) } } -func TestParseGitHubURL(t *testing.T) { - tests := []struct { - url string - wantOwner string - wantRepo string - }{ - {"git@github.com:github/gh-skills.git", "github", "gh-skills"}, - {"https://github.com/github/gh-skills.git", "github", "gh-skills"}, - {"https://github.com/github/gh-skills", "github", "gh-skills"}, - {"git@github.com:owner/repo.git", "owner", "repo"}, - {"https://gitlab.com/owner/repo.git", "", ""}, - {"not-a-url", "", ""}, - } - for _, tt := range tests { - t.Run(tt.url, func(t *testing.T) { - owner, repo := parseGitHubURL(tt.url) - if owner != tt.wantOwner || repo != tt.wantRepo { - t.Errorf("parseGitHubURL(%q) = (%q, %q), want (%q, %q)", tt.url, owner, repo, tt.wantOwner, tt.wantRepo) - } - }) - } +// writeSkill creates skills//SKILL.md with the given content. +func writeSkill(t *testing.T, dir, name, content string) { + t.Helper() + skillDir := filepath.Join(dir, "skills", name) + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) } -func TestRepoHasTopic(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - reg.Register( - httpmock.REST("GET", "repos/owner/repo/topics"), - httpmock.JSONResponse(map[string]interface{}{ - "names": []string{"golang", "agent-skills"}, - }), - ) - - if !repoHasTopic(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo") { - t.Error("expected true when topic present") - } -} - -func TestRepoHasTopic_Missing(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - reg.Register( - httpmock.REST("GET", "repos/owner/repo/topics"), - httpmock.JSONResponse(map[string]interface{}{ - "names": []string{"golang"}, - }), - ) - - if repoHasTopic(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo") { - t.Error("expected false when topic missing") - } -} - -func TestFetchTags_NoTags(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - reg.Register( - httpmock.REST("GET", "repos/owner/repo/tags"), - httpmock.JSONResponse([]interface{}{}), - ) - - tags := fetchTags(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo") - if len(tags) != 0 { - t.Errorf("expected no tags, got %d", len(tags)) - } -} - -func TestFetchTags_WithTags(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - reg.Register( - httpmock.REST("GET", "repos/owner/repo/tags"), - httpmock.JSONResponse([]map[string]interface{}{ - {"name": "v1.2.3"}, - }), - ) - - tags := fetchTags(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo") - if len(tags) != 1 { - t.Fatalf("expected 1 tag, got %d", len(tags)) - } - if tags[0].Name != "v1.2.3" { - t.Errorf("expected v1.2.3, got %s", tags[0].Name) - } -} - -func TestCheckTagProtection_Active(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - reg.Register( - httpmock.REST("GET", "repos/owner/repo/rulesets"), - httpmock.JSONResponse([]map[string]interface{}{ - {"id": 1, "name": "protect-tags", "target": "tag", "enforcement": "active"}, - }), - ) - - diags := checkTagProtection(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo") - if len(diags) != 0 { - t.Errorf("expected no diagnostics when tag protection active, got: %v", diags) - } -} - -func TestCheckTagProtection_Missing(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - reg.Register( - httpmock.REST("GET", "repos/owner/repo/rulesets"), - httpmock.JSONResponse([]map[string]interface{}{ - {"id": 1, "name": "branch-protection", "target": "branch", "enforcement": "active"}, - }), - ) - - diags := checkTagProtection(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo") - if len(diags) != 1 { - t.Fatalf("expected 1 diagnostic, got %d", len(diags)) - } - if !strings.Contains(diags[0].message, "tag protection") { - t.Errorf("expected tag protection warning, got: %s", diags[0].message) - } -} - -func TestCheckSecuritySettings_AllEnabled(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - reg.Register( - httpmock.REST("GET", "repos/owner/repo"), - httpmock.JSONResponse(map[string]interface{}{ - "security_and_analysis": map[string]interface{}{ - "secret_scanning": map[string]interface{}{"status": "enabled"}, - "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, - }, - }), - ) - - skillsDir := t.TempDir() - - diags := checkSecuritySettings(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo", skillsDir) - if len(diags) != 0 { - t.Errorf("expected no diagnostics when all security enabled, got %d: %v", len(diags), diags) - } -} - -func TestCheckSecuritySettings_NoneEnabled(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - reg.Register( - httpmock.REST("GET", "repos/owner/repo"), - httpmock.JSONResponse(map[string]interface{}{ - "security_and_analysis": map[string]interface{}{ - "secret_scanning": map[string]interface{}{"status": "disabled"}, - "secret_scanning_push_protection": map[string]interface{}{"status": "disabled"}, - }, - }), - ) - - skillsDir := t.TempDir() - - diags := checkSecuritySettings(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo", skillsDir) - if len(diags) != 2 { - t.Errorf("expected 2 diagnostics (secret scanning + push protection), got %d: %v", len(diags), diags) - } - for _, d := range diags { - if d.severity != "warning" { - t.Errorf("secret scanning diagnostics should be warnings, got %q: %s", d.severity, d.message) - } - } -} - -func TestCheckSecuritySettings_WithCodeFiles(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - reg.Register( - httpmock.REST("GET", "repos/owner/repo"), - httpmock.JSONResponse(map[string]interface{}{ - "security_and_analysis": map[string]interface{}{ - "secret_scanning": map[string]interface{}{"status": "enabled"}, - "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, - }, - }), - ) - reg.Register( - httpmock.REST("GET", "repos/owner/repo/code-scanning/alerts"), - httpmock.StatusStringResponse(404, "not found"), - ) - - skillsDir := t.TempDir() - scriptDir := filepath.Join(skillsDir, "my-skill", "scripts") - if err := os.MkdirAll(scriptDir, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(scriptDir, "helper.sh"), []byte("#!/bin/bash"), 0o644); err != nil { - t.Fatal(err) - } - - diags := checkSecuritySettings(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo", skillsDir) - hasCodeScanInfo := false - for _, d := range diags { - if strings.Contains(d.message, "code scanning") { - hasCodeScanInfo = true - if d.severity != "info" { - t.Errorf("code scanning suggestion should be info, got %q", d.severity) - } - } - } - if !hasCodeScanInfo { - t.Error("expected code scanning info when code files present") - } -} - -func TestCheckSecuritySettings_WithManifests(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - reg.Register( - httpmock.REST("GET", "repos/owner/repo"), - httpmock.JSONResponse(map[string]interface{}{ - "security_and_analysis": map[string]interface{}{ - "secret_scanning": map[string]interface{}{"status": "enabled"}, - "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, - }, - }), - ) - reg.Register( - httpmock.REST("GET", "repos/owner/repo/vulnerability-alerts"), - httpmock.StatusStringResponse(404, "not found"), - ) - - skillsDir := t.TempDir() - skillDir := filepath.Join(skillsDir, "my-skill") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(skillDir, "package.json"), []byte("{}"), 0o644); err != nil { - t.Fatal(err) - } - - diags := checkSecuritySettings(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo", skillsDir) - hasDependabotInfo := false - for _, d := range diags { - if strings.Contains(d.message, "Dependabot") { - hasDependabotInfo = true - if d.severity != "info" { - t.Errorf("Dependabot suggestion should be info, got %q", d.severity) - } - } - } - if !hasDependabotInfo { - t.Error("expected Dependabot info when manifest files present") - } -} - -func TestDetectCodeAndManifests(t *testing.T) { - dir := t.TempDir() - - hasCode, hasManifests := detectCodeAndManifests(dir) - if hasCode || hasManifests { - t.Error("empty dir should have no code or manifests") - } - - if err := os.WriteFile(filepath.Join(dir, "run.sh"), []byte("#!/bin/bash"), 0o644); err != nil { - t.Fatal(err) - } - hasCode, hasManifests = detectCodeAndManifests(dir) - if !hasCode { - t.Error("should detect .sh as code") - } - if hasManifests { - t.Error("should not detect manifests") - } - - if err := os.WriteFile(filepath.Join(dir, "requirements.txt"), []byte("flask"), 0o644); err != nil { - t.Fatal(err) - } - hasCode, hasManifests = detectCodeAndManifests(dir) - if !hasCode || !hasManifests { - t.Error("should detect both code and manifests") - } -} - -func TestCheckInstalledSkillDirs_NotPresent(t *testing.T) { - dir := t.TempDir() - diags := checkInstalledSkillDirs(nil, dir) - if len(diags) != 0 { - t.Errorf("expected no diagnostics for empty dir, got %d", len(diags)) - } -} - -func TestCheckInstalledSkillDirs_PresentNotIgnored(t *testing.T) { - gitClient := testPublishGitClient(t, nil) - dir := gitClient.RepoDir - - installedDir := filepath.Join(dir, ".github", "skills", "some-skill") - if err := os.MkdirAll(installedDir, 0o755); err != nil { - t.Fatal(err) - } - - diags := checkInstalledSkillDirs(gitClient, dir) - if len(diags) == 0 { - t.Fatal("expected warning for unignored .github/skills/") - } - if diags[0].severity != "warning" { - t.Errorf("expected warning, got %q", diags[0].severity) - } - if !strings.Contains(diags[0].message, ".gitignore") { - t.Errorf("expected .gitignore mention, got: %s", diags[0].message) - } -} - -func TestCheckInstalledSkillDirs_PresentAndIgnored(t *testing.T) { - gitClient := testPublishGitClient(t, nil) - dir := gitClient.RepoDir - - installedDir := filepath.Join(dir, ".github", "skills", "some-skill") - if err := os.MkdirAll(installedDir, 0o755); err != nil { - t.Fatal(err) - } - - // Add .gitignore so git check-ignore recognises the path. - if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte(".github/skills\n"), 0o644); err != nil { - t.Fatal(err) - } - runGit := func(args ...string) { - t.Helper() - cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) - cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "HOME="+dir) - out, err := cmd.CombinedOutput() - require.NoError(t, err, "git %v: %s", args, out) - } - runGit("add", ".gitignore") - runGit("commit", "-m", "init") - - diags := checkInstalledSkillDirs(gitClient, dir) - if len(diags) != 0 { - t.Errorf("expected no diagnostics when gitignored, got %d: %v", len(diags), diags) - } -} - -func TestGenerateClaudePlugin(t *testing.T) { - dir := t.TempDir() - - for _, name := range []string{"git-commit", "code-review"} { - skillDir := filepath.Join(dir, "skills", name) - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatal(err) - } - content := fmt.Sprintf("---\nname: %s\ndescription: A %s skill\nlicense: MIT\n---\nBody.\n", name, name) - if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { - t.Fatal(err) - } - } - - diags := generateClaudePlugin(dir, []string{"git-commit", "code-review"}, "testowner", "testrepo") - - var generated int - for _, d := range diags { - if d.severity == "error" { - t.Errorf("unexpected error: %s", d.message) - } - if d.severity == "info" && strings.Contains(d.message, "generated") { - generated++ - } - } - if generated != 2 { - t.Errorf("expected 2 generated files, got %d", generated) - } - - pluginData, err := os.ReadFile(filepath.Join(dir, ".claude-plugin", "plugin.json")) - if err != nil { - t.Fatalf("plugin.json not created: %v", err) - } - var plugin claudePluginJSON - if err := json.Unmarshal(pluginData, &plugin); err != nil { - t.Fatalf("invalid plugin.json: %v", err) - } - if plugin.Name != "testrepo" { - t.Errorf("plugin.Name = %q, want %q", plugin.Name, "testrepo") - } - if plugin.License != "MIT" { - t.Errorf("plugin.License = %q, want %q", plugin.License, "MIT") - } - if plugin.Repository != "https://github.com/testowner/testrepo" { - t.Errorf("plugin.Repository = %q", plugin.Repository) - } - - marketData, err := os.ReadFile(filepath.Join(dir, ".claude-plugin", "marketplace.json")) - if err != nil { - t.Fatalf("marketplace.json not created: %v", err) - } - var marketplace claudeMarketplaceJSON - if err := json.Unmarshal(marketData, &marketplace); err != nil { - t.Fatalf("invalid marketplace.json: %v", err) - } - if marketplace.Name != "testrepo" { - t.Errorf("marketplace.Name = %q, want %q", marketplace.Name, "testrepo") - } - if len(marketplace.Plugins) != 1 || marketplace.Plugins[0].Source != "." { - t.Errorf("marketplace.Plugins = %+v", marketplace.Plugins) - } -} - -func TestGenerateClaudePlugin_SkipsExisting(t *testing.T) { - dir := t.TempDir() - - skillDir := filepath.Join(dir, "skills", "my-skill") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: my-skill\ndescription: test\n---\nBody.\n"), 0o644); err != nil { - t.Fatal(err) - } - - pluginDir := filepath.Join(dir, ".claude-plugin") - if err := os.MkdirAll(pluginDir, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(`{"name":"existing"}`), 0o644); err != nil { - t.Fatal(err) - } - - diags := generateClaudePlugin(dir, []string{"my-skill"}, "owner", "repo") - - for _, d := range diags { - if d.severity == "error" { - t.Errorf("unexpected error: %s", d.message) - } - if strings.Contains(d.message, "generated") { - t.Error("should not regenerate existing plugin.json") - } - } -} - -func TestDetectGitHubRemote(t *testing.T) { - gitClient := testPublishGitClient(t, map[string]string{ - "origin": "https://github.com/myorg/myrepo.git", - }) - - owner, repo := detectGitHubRemote(gitClient) - if owner != "myorg" || repo != "myrepo" { - t.Errorf("expected myorg/myrepo, got %s/%s", owner, repo) - } -} - -func TestDetectGitHubRemote_Fallback(t *testing.T) { - gitClient := testPublishGitClient(t, map[string]string{ - "origin": "https://gitlab.com/foo/bar.git", - "upstream": "git@github.com:org/repo.git", - }) - - owner, repo := detectGitHubRemote(gitClient) - if owner != "org" || repo != "repo" { - t.Errorf("expected org/repo, got %s/%s", owner, repo) - } -} - -func TestDetectGitHubRemote_NoGitHub(t *testing.T) { - gitClient := testPublishGitClient(t, map[string]string{ - "origin": "https://gitlab.com/foo/bar.git", - }) - - owner, repo := detectGitHubRemote(gitClient) - if owner != "" || repo != "" { - t.Errorf("expected empty, got %s/%s", owner, repo) - } -} - -func TestPublishCmd_RunFHook(t *testing.T) { - ios, _, _, _ := iostreams.Test() - f := stubFactory(ios) - - var capturedOpts *publishOptions - cmd := NewCmdPublish(&f, func(opts *publishOptions) error { - capturedOpts = opts - return nil - }) - - cmd.SetArgs([]string{"./my-skills", "--dry-run", "--fix", "--tag", "v1.0.0"}) - cmd.SetOut(&bytes.Buffer{}) - cmd.SetErr(&bytes.Buffer{}) - - err := cmd.Execute() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if capturedOpts == nil { - t.Fatal("runF was not called") - } - if capturedOpts.Dir != "./my-skills" { - t.Errorf("Dir = %q, want %q", capturedOpts.Dir, "./my-skills") - } - if !capturedOpts.DryRun { - t.Error("expected DryRun to be true") - } - if !capturedOpts.Fix { - t.Error("expected Fix to be true") - } - if capturedOpts.Tag != "v1.0.0" { - t.Errorf("Tag = %q, want %q", capturedOpts.Tag, "v1.0.0") - } -} - -// stubFactory creates a minimal cmdutil.Factory for tests. -func stubFactory(ios *iostreams.IOStreams) cmdutil.Factory { - return cmdutil.Factory{ - IOStreams: ios, - } +// runGitInDir runs a git command in the given directory with isolation env vars. +func runGitInDir(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "HOME="+dir) + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git %v: %s", args, out) } diff --git a/pkg/cmd/skills/search/search.go b/pkg/cmd/skills/search/search.go index 0d7e39043..6a0cc95d0 100644 --- a/pkg/cmd/skills/search/search.go +++ b/pkg/cmd/skills/search/search.go @@ -365,18 +365,32 @@ func truncateForProcessing(skills []skillResult, page, limit int) []skillResult } // enrichSkills fetches descriptions and star counts concurrently. +// Each function collects results into a map; merges happen after both complete +// to avoid concurrent writes to the shared skills slice. func enrichSkills(client *api.Client, host string, skills []skillResult) { + var descMap map[int]string + var starsMap map[int]int + var wg sync.WaitGroup wg.Add(2) go func() { defer wg.Done() - fetchDescriptions(client, host, skills) + descMap = fetchDescriptions(client, host, skills) }() go func() { defer wg.Done() - fetchRepoStars(client, host, skills) + starsMap = fetchRepoStars(client, host, skills) }() wg.Wait() + + for i := range skills { + if desc, ok := descMap[i]; ok { + skills[i].Description = desc + } + if stars, ok := starsMap[i]; ok { + skills[i].Stars = stars + } + } } // paginate slices results to the requested page window. @@ -423,7 +437,7 @@ func renderResults(opts *searchOptions, skills []skillResult, totalPages int) er cs := opts.IO.ColorScheme() header := fmt.Sprintf("\n%s Showing %s matching %q", cs.SuccessIcon(), - pluralize(len(skills), "skill"), + text.Pluralize(len(skills), "skill"), opts.Query, ) if totalPages > 1 { @@ -498,14 +512,14 @@ func promptInstall(opts *searchOptions, skills []skillResult) error { for i, s := range skills { starStr := "" if s.Stars > 0 { - starStr = " " + cs.Gray("★ "+formatStars(s.Stars)) + starStr = " " + cs.Muted("★ "+formatStars(s.Stars)) } descStr := "" if s.Description != "" { - desc := collapseWhitespace(s.Description) - descStr = "\n " + cs.Gray(text.Truncate(descWidth, desc)) + desc := strings.Join(strings.Fields(s.Description), " ") + descStr = "\n " + cs.Muted(text.Truncate(descWidth, desc)) } - options[i] = s.SkillName + " " + cs.Gray(s.Repo) + starStr + descStr + options[i] = s.SkillName + " " + cs.Muted(s.Repo) + starStr + descStr } indices, err := opts.Prompter.MultiSelect( @@ -564,7 +578,7 @@ func promptInstall(opts *searchOptions, skills []skillResult) error { // - Exact skill name match (10 000 points) // - Partial skill name match (1 000 points) // - Description contains query (100 points) -// - Repository stars (logarithmic bonus, up to ~700 points) +// - Repository stars (sqrt bonus, ~2 400 for 6k stars) func relevanceScore(s skillResult, query string) int { term := strings.ToLower(query) termHyphen := strings.ReplaceAll(term, " ", "-") @@ -574,7 +588,7 @@ func relevanceScore(s skillResult, query string) int { // use hyphens as word separators (e.g. query "mcp apps" → "mcp-apps"). skillLower := strings.ToLower(s.SkillName) if skillLower == term || skillLower == termHyphen { - score += 10_000 + score += 3_000 } else if strings.Contains(skillLower, term) || strings.Contains(skillLower, termHyphen) { score += 1_000 } @@ -584,10 +598,10 @@ func relevanceScore(s skillResult, query string) int { score += 100 } - // Stars bonus: use log₁₀ scaling so popular repos rank higher without - // completely drowning out less-popular but more relevant results. + // Stars bonus: use √n scaling so popular repos rank meaningfully higher + // without completely drowning out less-popular but more relevant results. if s.Stars > 0 { - score += int(math.Log10(float64(s.Stars)) * 150) + score += int(math.Sqrt(float64(s.Stars)) * 30) } return score @@ -763,12 +777,14 @@ func splitRepo(fullName string) (string, string) { // fetchDescriptions fetches SKILL.md frontmatter descriptions concurrently // for all search results. Each result may come from a different repo. -func fetchDescriptions(client *api.Client, host string, skills []skillResult) { +func fetchDescriptions(client *api.Client, host string, skills []skillResult) map[int]string { const maxWorkers = 10 sem := make(chan struct{}, maxWorkers) var wg sync.WaitGroup var mu sync.Mutex + descs := make(map[int]string) + for i := range skills { if skills[i].BlobSHA == "" { continue @@ -789,11 +805,13 @@ func fetchDescriptions(client *api.Client, host string, skills []skillResult) { } mu.Lock() - skills[idx].Description = result.Metadata.Description + descs[idx] = result.Metadata.Description mu.Unlock() }(i) } wg.Wait() + + return descs } // extractSkillName derives the skill name from a SKILL.md path, but only if @@ -803,21 +821,8 @@ func extractSkillName(filePath string) string { return discovery.MatchesSkillPath(filePath) } -func pluralize(count int, singular string) string { - if count == 1 { - return fmt.Sprintf("%d %s", count, singular) - } - return fmt.Sprintf("%d %ss", count, singular) -} - -// collapseWhitespace replaces runs of whitespace (newlines, tabs, etc.) -// with a single space. -func collapseWhitespace(s string) string { - fields := strings.Fields(s) - return strings.Join(fields, " ") -} - // formatStars formats a star count for display (e.g. 1700 → "1.7k"). +// TODO kw: Could be swaped for go-humanize. func formatStars(n int) string { if n >= 1000 { return fmt.Sprintf("%.1fk", float64(n)/1000) @@ -832,7 +837,7 @@ type repoInfo struct { // fetchRepoStars fetches stargazer counts for each unique repository in // the result set, using bounded concurrency. -func fetchRepoStars(client *api.Client, host string, skills []skillResult) { +func fetchRepoStars(client *api.Client, host string, skills []skillResult) map[int]int { const maxWorkers = 10 sem := make(chan struct{}, maxWorkers) var wg sync.WaitGroup @@ -865,9 +870,11 @@ func fetchRepoStars(client *api.Client, host string, skills []skillResult) { } wg.Wait() - for i := range skills { - if stars, ok := repoStars[skills[i].Repo]; ok { - skills[i].Stars = stars + result := make(map[int]int, len(skills)) + for i, s := range skills { + if stars, ok := repoStars[s.Repo]; ok { + result[i] = stars } } + return result } diff --git a/pkg/cmd/skills/search/search_test.go b/pkg/cmd/skills/search/search_test.go index db266f460..e3b8b26d6 100644 --- a/pkg/cmd/skills/search/search_test.go +++ b/pkg/cmd/skills/search/search_test.go @@ -1,7 +1,9 @@ package search import ( + "io" "net/http" + "strings" "testing" "github.com/cli/cli/v2/internal/config" @@ -88,19 +90,15 @@ func TestNewCmdSearch(t *testing.T) { argv := []string{} if tt.args != "" { - for _, part := range splitOnSpaces(tt.args) { - if part != "" { - argv = append(argv, part) - } - } + argv = strings.Fields(tt.args) } cmd.SetArgs(argv) - cmd.SetOut(&discardWriter{}) - cmd.SetErr(&discardWriter{}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) _, err := cmd.ExecuteC() if tt.wantErr != "" { - assert.Error(t, err) + require.Error(t, err) assert.Contains(t, err.Error(), tt.wantErr) return } @@ -252,6 +250,78 @@ func TestSearchRun(t *testing.T) { }, wantErr: rateLimitErrorMessage, }, + { + name: "HTTP 429 returns rate limit error", + tty: false, + opts: &searchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + httpStubs: func(reg *httpmock.Registry) { + for range 3 { + reg.Register( + httpmock.REST("GET", "search/code"), + httpmock.StatusStringResponse(429, `{"message": "Too Many Requests"}`), + ) + } + }, + wantErr: rateLimitErrorMessage, + }, + { + name: "HTTP 403 with Retry-After returns rate limit error", + tty: false, + opts: &searchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + httpStubs: func(reg *httpmock.Registry) { + for range 3 { + reg.Register( + httpmock.REST("GET", "search/code"), + httpmock.WithHeader( + httpmock.StatusJSONResponse(403, map[string]string{"message": "secondary rate limit"}), + "Retry-After", "60", + ), + ) + } + }, + wantErr: rateLimitErrorMessage, + }, + { + name: "no results with owner scope", + tty: true, + opts: &searchOptions{Query: "nonexistent", Owner: "monalisa", Page: 1, Limit: defaultLimit}, + httpStubs: func(reg *httpmock.Registry) { + // With --owner set, only path + primary searches fire (no owner search). + for range 2 { + reg.Register( + httpmock.REST("GET", "search/code"), + httpmock.StringResponse(emptyCodeResponse), + ) + } + }, + wantErr: `no skills found matching "nonexistent" from owner "monalisa"`, + }, + { + name: "enriches results with blob descriptions", + tty: false, + opts: &searchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + httpStubs: func(reg *httpmock.Registry) { + codeResponse := `{"total_count": 1, "incomplete_results": false, "items": [ + {"name": "SKILL.md", "path": "skills/terraform/SKILL.md", "sha": "abc123", + "repository": {"full_name": "org/repo"}} + ]}` + stubKeywordSearch(reg, codeResponse) + // Blob fetch for description enrichment + reg.Register( + httpmock.REST("GET", "repos/org/repo/git/blobs/abc123"), + httpmock.JSONResponse(map[string]string{ + "content": "LS0tCmRlc2NyaXB0aW9uOiBBdXRvbWF0ZXMgVGVycmFmb3JtIGluZnJhc3RydWN0dXJlCi0tLQojIFRlcnJhZm9ybSBTa2lsbAo=", + "encoding": "base64", + }), + ) + // Repo stars fetch + reg.Register( + httpmock.REST("GET", "repos/org/repo"), + httpmock.JSONResponse(map[string]int{"stargazers_count": 42}), + ) + }, + wantStdout: "org/repo\tterraform\tAutomates Terraform infrastructure\t42\n", + }, } for _, tt := range tests { @@ -396,28 +466,3 @@ func TestFormatStars(t *testing.T) { assert.Equal(t, "1.7k", formatStars(1700)) assert.Equal(t, "12.5k", formatStars(12500)) } - -func splitOnSpaces(s string) []string { - var parts []string - current := "" - for _, c := range s { - if c == ' ' { - if current != "" { - parts = append(parts, current) - current = "" - } - } else { - current += string(c) - } - } - if current != "" { - parts = append(parts, current) - } - return parts -} - -type discardWriter struct{} - -func (d *discardWriter) Write(p []byte) (n int, err error) { - return len(p), nil -} diff --git a/pkg/cmd/skills/update/update.go b/pkg/cmd/skills/update/update.go index 42995a315..1dfe76007 100644 --- a/pkg/cmd/skills/update/update.go +++ b/pkg/cmd/skills/update/update.go @@ -1,7 +1,6 @@ package update import ( - "context" "fmt" "net/http" "os" @@ -38,6 +37,7 @@ type updateOptions struct { All bool // --all flag (update without prompting) Force bool // --force flag (re-download even if SHAs match) DryRun bool // --dry-run flag (report only, no changes) + Unpin bool // --unpin flag (clear pinned ref and include in update) Dir string // --dir flag (scan a custom directory) } @@ -86,7 +86,8 @@ func NewCmdUpdate(f *cmdutil.Factory, runF func(*updateOptions) error) *cobra.Co checks only those specific skills. Pinned skills (installed with --pin) are skipped with a notice. - Use "gh skills install --pin " to change the pinned version. + Use --unpin to clear the pinned version and include those skills + in the update. Skills without GitHub metadata (e.g. installed manually or by another tool) are prompted for their source repository in interactive mode. @@ -116,6 +117,9 @@ func NewCmdUpdate(f *cmdutil.Factory, runF func(*updateOptions) error) *cobra.Co # Check for updates without applying (read-only) $ gh skills update --dry-run + + # Unpin skills and update them to latest + $ gh skills update --unpin `), RunE: func(cmd *cobra.Command, args []string) error { opts.Skills = args @@ -129,6 +133,7 @@ func NewCmdUpdate(f *cmdutil.Factory, runF func(*updateOptions) error) *cobra.Co cmd.Flags().BoolVar(&opts.All, "all", false, "Update all skills without prompting") cmd.Flags().BoolVar(&opts.Force, "force", false, "Re-download even if already up to date") cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Report available updates without modifying files") + cmd.Flags().BoolVar(&opts.Unpin, "unpin", false, "Clear pinned version and include pinned skills in update") cmd.Flags().StringVar(&opts.Dir, "dir", "", "Scan a custom directory for installed skills") return cmd @@ -150,8 +155,8 @@ func updateRun(opts *updateOptions) error { } hostname, _ := cfg.Authentication().DefaultHost() - gitRoot := resolveGitRoot(opts.GitClient) - homeDir := resolveHomeDir() + gitRoot := installer.ResolveGitRoot(opts.GitClient) + homeDir := installer.ResolveHomeDir() // Scan for installed skills var installed []installedSkill @@ -162,7 +167,7 @@ func updateRun(opts *updateOptions) error { } installed = skills } else { - installed = scanAllHosts(gitRoot, homeDir) + installed = scanAllAgents(gitRoot, homeDir) } if len(installed) == 0 { @@ -238,7 +243,7 @@ func updateRun(opts *updateOptions) error { if s.owner == "" || s.repo == "" { continue } - if s.pinned != "" { + if s.pinned != "" && !opts.Unpin { pinned = append(pinned, s) continue } @@ -315,7 +320,7 @@ func updateRun(opts *updateOptions) error { } for _, s := range pinned { - fmt.Fprintf(opts.IO.ErrOut, "%s %s is pinned to %s (skipped)\n", cs.Gray("⊘"), s.name, s.pinned) + fmt.Fprintf(opts.IO.ErrOut, "%s %s is pinned to %s (skipped)\n", cs.Muted("⊘"), s.name, s.pinned) } for _, name := range noMeta { fmt.Fprintf(opts.IO.ErrOut, "%s %s has no GitHub metadata — reinstall to enable updates\n", cs.WarningIcon(), name) @@ -339,7 +344,7 @@ func updateRun(opts *updateOptions) error { } else { fmt.Fprintf(opts.IO.Out, " %s %s (%s/%s) %s → %s [%s]\n", cs.Cyan("•"), u.local.name, u.local.owner, u.local.repo, - cs.Gray(git.ShortSHA(u.local.treeSHA)), git.ShortSHA(u.newSHA), + cs.Muted(git.ShortSHA(u.local.treeSHA)), git.ShortSHA(u.newSHA), u.resolved.Ref) } } @@ -359,7 +364,7 @@ func updateRun(opts *updateOptions) error { } if !confirmed { fmt.Fprintf(opts.IO.ErrOut, "Update cancelled.\n") - return nil + return cmdutil.CancelError } } @@ -409,9 +414,9 @@ func updateRun(opts *updateOptions) error { return nil } -// scanAllHosts walks every known host directory (project + user scope) and +// scanAllAgents walks every registered agent's skill directory (project + user scope) and // collects installed skills. Skills are deduplicated by directory path. -func scanAllHosts(gitRoot, homeDir string) []installedSkill { +func scanAllAgents(gitRoot, homeDir string) []installedSkill { seen := make(map[string]bool) var all []installedSkill @@ -533,28 +538,3 @@ func promptForSkillOrigin(p prompter.Prompter, skillName string) (owner, repo, r } return r.RepoOwner(), r.RepoName(), "", true, nil } - -func resolveGitRoot(gc *git.Client) string { - if gc == nil { - if cwd, err := os.Getwd(); err == nil { - return cwd - } - return "" - } - root, err := gc.ToplevelDir(context.Background()) - if err != nil { - if cwd, err := os.Getwd(); err == nil { - return cwd - } - return "" - } - return root -} - -func resolveHomeDir() string { - home, err := os.UserHomeDir() - if err != nil { - return "" - } - return home -} diff --git a/pkg/cmd/skills/update/update_test.go b/pkg/cmd/skills/update/update_test.go index 735536b0d..81fc87efe 100644 --- a/pkg/cmd/skills/update/update_test.go +++ b/pkg/cmd/skills/update/update_test.go @@ -7,6 +7,7 @@ import ( "path/filepath" "testing" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" @@ -43,14 +44,14 @@ func TestNewCmdUpdate_Flags(t *testing.T) { f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{}} cmd := NewCmdUpdate(f, func(_ *updateOptions) error { return nil }) - flags := []string{"all", "force", "dry-run", "dir"} + flags := []string{"all", "force", "dry-run", "dir", "unpin"} for _, name := range flags { assert.NotNil(t, cmd.Flags().Lookup(name), "missing flag: --%s", name) } } func TestNewCmdUpdate_ArgsPassedToOptions(t *testing.T) { - ios, _, _, _ := iostreams.Test() + ios, _, stdout, stderr := iostreams.Test() f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{}} var gotOpts *updateOptions @@ -61,8 +62,8 @@ func TestNewCmdUpdate_ArgsPassedToOptions(t *testing.T) { args, _ := shlex.Split("mcp-cli git-commit --all --force") cmd.SetArgs(args) - cmd.SetOut(os.Stdout) - cmd.SetErr(os.Stderr) + cmd.SetOut(stdout) + cmd.SetErr(stderr) err := cmd.Execute() require.NoError(t, err) assert.Equal(t, []string{"mcp-cli", "git-commit"}, gotOpts.Skills) @@ -71,321 +72,1072 @@ func TestNewCmdUpdate_ArgsPassedToOptions(t *testing.T) { } func TestScanInstalledSkills(t *testing.T) { - dir := t.TempDir() + tests := []struct { + name string + setup func(t *testing.T, dir string) + verify func(t *testing.T, skills []installedSkill, err error) + }{ + { + name: "happy path with metadata, no metadata, and pinned skills", + setup: func(t *testing.T, dir string) { + t.Helper() - skillDir := filepath.Join(dir, "git-commit") - require.NoError(t, os.MkdirAll(skillDir, 0o755)) - content := "---\nname: git-commit\ndescription: Git commit helper\nmetadata:\n github-owner: github\n github-repo: awesome-copilot\n github-tree-sha: abc123\n github-path: skills/git-commit\n---\nBody content\n" - require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) + // Skill with full metadata + skillDir := filepath.Join(dir, "git-commit") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + content := heredoc.Doc(` + --- + name: git-commit + description: Git commit helper + metadata: + github-owner: monalisa + github-repo: awesome-copilot + github-tree-sha: abc123 + github-path: skills/git-commit + --- + Body content + `) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) - noMetaDir := filepath.Join(dir, "unknown-skill") - require.NoError(t, os.MkdirAll(noMetaDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(noMetaDir, "SKILL.md"), []byte("---\nname: unknown-skill\n---\nNo metadata here\n"), 0o644)) + // Skill without metadata + noMetaDir := filepath.Join(dir, "unknown-skill") + require.NoError(t, os.MkdirAll(noMetaDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(noMetaDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: unknown-skill + --- + No metadata here + `)), 0o644)) - pinnedDir := filepath.Join(dir, "pinned-skill") - require.NoError(t, os.MkdirAll(pinnedDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(pinnedDir, "SKILL.md"), []byte("---\nname: pinned-skill\nmetadata:\n github-owner: octo\n github-repo: skills\n github-tree-sha: def456\n github-pinned: v1.0.0\n---\nPinned content\n"), 0o644)) + // Pinned skill + pinnedDir := filepath.Join(dir, "pinned-skill") + require.NoError(t, os.MkdirAll(pinnedDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(pinnedDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: pinned-skill + metadata: + github-owner: octocat + github-repo: hubot-skills + github-tree-sha: def456 + github-pinned: v1.0.0 + --- + Pinned content + `)), 0o644)) + }, + verify: func(t *testing.T, skills []installedSkill, err error) { + t.Helper() + require.NoError(t, err) + assert.Len(t, skills, 3) - skills, err := scanInstalledSkills(dir, nil, "") - require.NoError(t, err) - assert.Len(t, skills, 3) + byName := make(map[string]installedSkill) + for _, s := range skills { + byName[s.name] = s + } - byName := make(map[string]installedSkill) - for _, s := range skills { - byName[s.name] = s - } + gc := byName["git-commit"] + assert.Equal(t, "monalisa", gc.owner) + assert.Equal(t, "awesome-copilot", gc.repo) + assert.Equal(t, "abc123", gc.treeSHA) + assert.Equal(t, "skills/git-commit", gc.sourcePath) + assert.Empty(t, gc.pinned) - gc := byName["git-commit"] - assert.Equal(t, "github", gc.owner) - assert.Equal(t, "awesome-copilot", gc.repo) - assert.Equal(t, "abc123", gc.treeSHA) - assert.Equal(t, "skills/git-commit", gc.sourcePath) - assert.Empty(t, gc.pinned) + us := byName["unknown-skill"] + assert.Empty(t, us.owner) + assert.Empty(t, us.repo) - us := byName["unknown-skill"] - assert.Empty(t, us.owner) - assert.Empty(t, us.repo) - - ps := byName["pinned-skill"] - assert.Equal(t, "v1.0.0", ps.pinned) -} - -func TestScanInstalledSkills_NonExistentDir(t *testing.T) { - skills, err := scanInstalledSkills("/nonexistent/path", nil, "") - require.NoError(t, err) - assert.Nil(t, skills) -} - -func TestScanInstalledSkills_CorruptedYAML(t *testing.T) { - dir := t.TempDir() - skillDir := filepath.Join(dir, "corrupt") - require.NoError(t, os.MkdirAll(skillDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nnot: valid: yaml: [broken\n---\nbody\n"), 0o644)) - - skills, err := scanInstalledSkills(dir, nil, "") - require.NoError(t, err) - assert.Len(t, skills, 0) -} - -func TestPromptForSkillOrigin_Valid(t *testing.T) { - pm := &prompter.PrompterMock{ - InputFunc: func(prompt string, defaultValue string) (string, error) { - return "github/awesome-copilot", nil + ps := byName["pinned-skill"] + assert.Equal(t, "v1.0.0", ps.pinned) + }, + }, + { + name: "non-existent directory returns nil", + // no setup — dir does not exist + verify: func(t *testing.T, skills []installedSkill, err error) { + t.Helper() + require.NoError(t, err) + assert.Nil(t, skills) + }, + }, + { + name: "corrupted YAML is skipped gracefully", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "corrupt") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + not: valid: yaml: [broken + --- + body + `)), 0o644)) + }, + verify: func(t *testing.T, skills []installedSkill, err error) { + t.Helper() + require.NoError(t, err) + assert.Len(t, skills, 0) + }, }, } - owner, repo, _, ok, err := promptForSkillOrigin(pm, "test-skill") - require.NoError(t, err) - assert.True(t, ok) - assert.Equal(t, "github", owner) - assert.Equal(t, "awesome-copilot", repo) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // For the non-existent directory case, pass a path that doesn't exist + dir := filepath.Join(t.TempDir(), "skills") + if tt.setup != nil { + require.NoError(t, os.MkdirAll(dir, 0o755)) + tt.setup(t, dir) + } + + skills, err := scanInstalledSkills(dir, nil, "") + tt.verify(t, skills, err) + }) + } } -func TestPromptForSkillOrigin_Empty(t *testing.T) { - pm := &prompter.PrompterMock{ - InputFunc: func(prompt string, defaultValue string) (string, error) { - return "", nil +func TestPromptForSkillOrigin(t *testing.T) { + tests := []struct { + name string + input string + wantOK bool + wantOwner string + wantRepo string + wantReason string + }{ + { + name: "valid owner/repo", + input: "monalisa/awesome-copilot", + wantOK: true, + wantOwner: "monalisa", + wantRepo: "awesome-copilot", + }, + { + name: "empty input skips", + input: "", + wantOK: false, + }, + { + name: "invalid format returns reason", + input: "just-a-name", + wantOK: false, + wantReason: "invalid repository", }, } - _, _, _, ok, err := promptForSkillOrigin(pm, "test-skill") - require.NoError(t, err) - assert.False(t, ok) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pm := &prompter.PrompterMock{ + InputFunc: func(prompt string, defaultValue string) (string, error) { + return tt.input, nil + }, + } + + owner, repo, reason, ok, err := promptForSkillOrigin(pm, "test-skill") + require.NoError(t, err) + assert.Equal(t, tt.wantOK, ok) + assert.Equal(t, tt.wantOwner, owner) + assert.Equal(t, tt.wantRepo, repo) + if tt.wantReason != "" { + assert.Contains(t, reason, tt.wantReason) + } + }) + } } -func TestPromptForSkillOrigin_Invalid(t *testing.T) { - pm := &prompter.PrompterMock{ - InputFunc: func(prompt string, defaultValue string) (string, error) { - return "just-a-name", nil +func TestUpdateRun(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T, dir string) + stubs func(reg *httpmock.Registry) + opts func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions + verify func(t *testing.T, dir string) + wantErr string + wantStderr string + wantStdout string + }{ + { + name: "scans all agents when no --dir is set", + setup: func(t *testing.T, dir string) { + t.Helper() + t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) + skillDir := filepath.Join(dir, ".github", "skills", "code-review") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: code-review + metadata: + github-owner: monalisa + github-repo: octocat-skills + github-tree-sha: currentsha + github-path: skills/code-review + --- + Installed content + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "commit1", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/commit1"), + httpmock.StringResponse(`{"sha": "commit1", "tree": [{"path": "skills/code-review", "type": "tree", "sha": "currentsha"}, {"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "blob1"}, {"path": "skills", "type": "tree", "sha": "treeshaX"}], "truncated": false}`)) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(false) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + } + }, + wantStderr: "All skills are up to date.", + }, + { + name: "no installed skills", + stubs: func(reg *httpmock.Registry) {}, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(false) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + }, + wantStderr: "No installed skills found.", + }, + { + name: "specific skill not installed", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "octocat-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: octocat-skill + metadata: + github-owner: octocat + github-repo: hubot-skills + github-tree-sha: abc + --- + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) {}, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(false) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + Skills: []string{"nonexistent"}, + } + }, + wantErr: "none of the specified skills are installed", + }, + { + name: "pinned skills are skipped", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "pinned-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: pinned-skill + metadata: + github-owner: octocat + github-repo: hubot-skills + github-tree-sha: abc123 + github-pinned: v1.0.0 + --- + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) {}, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Prompter: &prompter.PrompterMock{}, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + }, + wantStderr: "pinned", + }, + { + name: "no metadata skips in non-interactive mode", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "manual-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: manual-skill + --- + No metadata + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) {}, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(false) + ios.SetStdinTTY(false) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + }, + wantStderr: "no GitHub metadata", + }, + { + name: "all up to date", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "monalisa-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: monalisa-skill + metadata: + github-owner: monalisa + github-repo: octocat-skills + github-tree-sha: abc123def456 + github-path: skills/monalisa-skill + --- + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "commitsha123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/commitsha123"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "commitsha123", "tree": [{"path": "skills/monalisa-skill/SKILL.md", "type": "blob", "sha": "blobsha1"}, {"path": "skills/monalisa-skill", "type": "tree", "sha": "abc123def456"}, {"path": "skills", "type": "tree", "sha": "treeshaX"}], "truncated": false}`)), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(false) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + }, + wantStderr: "All skills are up to date.", + }, + { + name: "dry run reports available updates", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "hubot-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: hubot-skill + metadata: + github-owner: hubot + github-repo: octocat-skills + github-tree-sha: oldsha123 + github-path: skills/hubot-skill + --- + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/hubot/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v2.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/hubot/octocat-skills/git/ref/tags/v2.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit456", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/hubot/octocat-skills/git/trees/newcommit456"), + httpmock.StringResponse(`{"sha": "newcommit456", "tree": [{"path": "skills/hubot-skill/SKILL.md", "type": "blob", "sha": "blobsha2"}, {"path": "skills/hubot-skill", "type": "tree", "sha": "newsha456"}, {"path": "skills", "type": "tree", "sha": "treeshaY"}], "truncated": false}`), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Prompter: &prompter.PrompterMock{}, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + DryRun: true, + } + }, + wantStderr: "1 update(s) available:", + wantStdout: "hubot-skill", + }, + { + name: "non-interactive without --all errors", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "hubot-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: hubot-skill + metadata: + github-owner: hubot + github-repo: octocat-skills + github-tree-sha: oldsha123 + github-path: skills/hubot-skill + --- + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/hubot/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v2.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/hubot/octocat-skills/git/ref/tags/v2.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit456", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/hubot/octocat-skills/git/trees/newcommit456"), + httpmock.StringResponse(`{"sha": "newcommit456", "tree": [{"path": "skills/hubot-skill/SKILL.md", "type": "blob", "sha": "blobsha2"}, {"path": "skills/hubot-skill", "type": "tree", "sha": "newsha456"}, {"path": "skills", "type": "tree", "sha": "treeshaY"}], "truncated": false}`), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(false) + ios.SetStdinTTY(false) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + }, + wantErr: "updates available; re-run with --all to apply, or run interactively to confirm", + }, + { + name: "force update rewrites SKILL.md on disk", + setup: func(t *testing.T, dir string) { + t.Helper() + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + skillDir := filepath.Join(dir, "code-review") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: code-review + metadata: + github-owner: monalisa + github-repo: octocat-skills + github-tree-sha: oldsha000 + github-path: skills/code-review + --- + Old content + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v3.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v3.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newcommit789"), + httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/code-review", "type": "tree", "sha": "newsha999"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newsha999"), + httpmock.StringResponse(`{"sha": "newsha999", "tree": [{"path": "SKILL.md", "type": "blob", "sha": "newblob1", "size": 20}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/newblob1"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "newblob1", "encoding": "base64", "content": "%s"}`, + "IyBDb2RlIFJldmlldyBVcGRhdGVk"))) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(false) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + All: true, + Force: true, + } + }, + verify: func(t *testing.T, dir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(dir, "code-review", "SKILL.md")) + require.NoError(t, err) + assert.Contains(t, string(content), "github-owner: monalisa") + assert.NotContains(t, string(content), "Old content") + }, + wantStdout: "Updated code-review", + }, + { + name: "namespaced skill with --dir resolves install base correctly", + setup: func(t *testing.T, dir string) { + t.Helper() + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + skillDir := filepath.Join(dir, "monalisa", "code-review") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: code-review + metadata: + github-owner: monalisa + github-repo: octocat-skills + github-tree-sha: oldsha000 + github-path: skills/monalisa/code-review + --- + Old namespaced content + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v3.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v3.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newcommit789"), + httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/monalisa/code-review/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/monalisa/code-review", "type": "tree", "sha": "newsha999"}, {"path": "skills/monalisa", "type": "tree", "sha": "nstresha"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newsha999"), + httpmock.StringResponse(`{"sha": "newsha999", "tree": [{"path": "SKILL.md", "type": "blob", "sha": "newblob1", "size": 20}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/newblob1"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "newblob1", "encoding": "base64", "content": "%s"}`, + "IyBOYW1lc3BhY2VkIFNraWxsIFVwZGF0ZWQ="))) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(false) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + All: true, + Force: true, + } + }, + verify: func(t *testing.T, dir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(dir, "monalisa", "code-review", "SKILL.md")) + require.NoError(t, err) + assert.Contains(t, string(content), "github-owner: monalisa") + assert.NotContains(t, string(content), "Old namespaced content") + }, + wantStdout: "Updated monalisa/code-review", + }, + { + name: "install failure during update reports error and continues", + setup: func(t *testing.T, dir string) { + t.Helper() + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + skillDir := filepath.Join(dir, "code-review") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: code-review + metadata: + github-owner: monalisa + github-repo: octocat-skills + github-tree-sha: oldsha000 + github-path: skills/code-review + --- + Original content + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v3.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v3.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newcommit789"), + httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/code-review", "type": "tree", "sha": "newsha999"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newsha999"), + httpmock.StringResponse(`{"sha": "newsha999", "tree": [{"path": "SKILL.md", "type": "blob", "sha": "newblob1", "size": 20}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/newblob1"), + httpmock.StatusStringResponse(500, "server error")) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(false) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + All: true, + } + }, + verify: func(t *testing.T, dir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(dir, "code-review", "SKILL.md")) + require.NoError(t, err) + assert.Contains(t, string(content), "Original content", "file should not be modified on failure") + }, + wantStderr: "Failed to update code-review", + wantErr: "SilentError", + }, + { + name: "interactive confirm applies update", + setup: func(t *testing.T, dir string) { + t.Helper() + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + skillDir := filepath.Join(dir, "code-review") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: code-review + metadata: + github-owner: monalisa + github-repo: octocat-skills + github-tree-sha: oldsha000 + github-path: skills/code-review + --- + Old content + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v3.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v3.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newcommit789"), + httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/code-review", "type": "tree", "sha": "newsha999"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newsha999"), + httpmock.StringResponse(`{"sha": "newsha999", "tree": [{"path": "SKILL.md", "type": "blob", "sha": "newblob1", "size": 20}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/newblob1"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "newblob1", "encoding": "base64", "content": "%s"}`, + "IyBDb2RlIFJldmlldyBVcGRhdGVk"))) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Prompter: &prompter.PrompterMock{ + ConfirmFunc: func(msg string, defaultVal bool) (bool, error) { + return true, nil + }, + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + }, + verify: func(t *testing.T, dir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(dir, "code-review", "SKILL.md")) + require.NoError(t, err) + assert.NotContains(t, string(content), "Old content") + }, + wantStdout: "Updated code-review", + }, + { + name: "interactive confirm cancelled", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "code-review") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: code-review + metadata: + github-owner: monalisa + github-repo: octocat-skills + github-tree-sha: oldsha000 + github-path: skills/code-review + --- + Old content + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v3.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v3.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newcommit789"), + httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/code-review", "type": "tree", "sha": "newsha999"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Prompter: &prompter.PrompterMock{ + ConfirmFunc: func(msg string, defaultVal bool) (bool, error) { + return false, nil + }, + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + }, + wantErr: "CancelError", + wantStderr: "Update cancelled", + }, + { + name: "no-metadata skill prompted interactively and skipped", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "manual-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: manual-skill + --- + No metadata + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) {}, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Prompter: &prompter.PrompterMock{ + InputFunc: func(prompt string, defaultValue string) (string, error) { + return "", nil + }, + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + }, + wantStderr: "no GitHub metadata", + }, + { + name: "no-metadata skill enriched via prompt then updated", + setup: func(t *testing.T, dir string) { + t.Helper() + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + skillDir := filepath.Join(dir, "manual-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: manual-skill + --- + Old manual content + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "commit123", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/commit123"), + httpmock.StringResponse(`{"sha": "commit123", "tree": [{"path": "skills/manual-skill/SKILL.md", "type": "blob", "sha": "blob1"}, {"path": "skills/manual-skill", "type": "tree", "sha": "newtree1"}, {"path": "skills", "type": "tree", "sha": "treeshaX"}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newtree1"), + httpmock.StringResponse(`{"sha": "newtree1", "tree": [{"path": "SKILL.md", "type": "blob", "sha": "blob1", "size": 20}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blob1"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "blob1", "encoding": "base64", "content": "%s"}`, + "IyBNYW51YWwgU2tpbGwgVXBkYXRlZA=="))) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Prompter: &prompter.PrompterMock{ + InputFunc: func(prompt string, defaultValue string) (string, error) { + return "monalisa/octocat-skills", nil + }, + ConfirmFunc: func(msg string, defaultVal bool) (bool, error) { + return true, nil + }, + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + }, + verify: func(t *testing.T, dir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(dir, "manual-skill", "SKILL.md")) + require.NoError(t, err) + assert.NotContains(t, string(content), "Old manual content") + assert.Contains(t, string(content), "github-owner: monalisa") + }, + wantStdout: "Updated manual-skill", + }, + { + name: "unpin clears pin and applies update", + setup: func(t *testing.T, dir string) { + t.Helper() + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + skillDir := filepath.Join(dir, "pinned-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: pinned-skill + metadata: + github-owner: octocat + github-repo: hubot-skills + github-tree-sha: oldsha000 + github-pinned: v1.0.0 + github-path: skills/pinned-skill + --- + Pinned content + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/octocat/hubot-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v2.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/octocat/hubot-skills/git/ref/tags/v2.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/octocat/hubot-skills/git/trees/newcommit789"), + httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/pinned-skill/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/pinned-skill", "type": "tree", "sha": "newsha999"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/octocat/hubot-skills/git/trees/newsha999"), + httpmock.StringResponse(`{"sha": "newsha999", "tree": [{"path": "SKILL.md", "type": "blob", "sha": "newblob1", "size": 20}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/octocat/hubot-skills/git/blobs/newblob1"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "newblob1", "encoding": "base64", "content": "%s"}`, + "IyBVbnBpbm5lZCBhbmQgVXBkYXRlZA=="))) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(false) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + All: true, + Unpin: true, + } + }, + verify: func(t *testing.T, dir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(dir, "pinned-skill", "SKILL.md")) + require.NoError(t, err) + assert.NotContains(t, string(content), "Pinned content") + assert.NotContains(t, string(content), "github-pinned") + }, + wantStdout: "Updated pinned-skill", + }, + { + name: "pinned skills still skipped without --unpin", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "pinned-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: pinned-skill + metadata: + github-owner: octocat + github-repo: hubot-skills + github-tree-sha: abc123 + github-pinned: v1.0.0 + --- + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) {}, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Prompter: &prompter.PrompterMock{}, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + Unpin: false, + } + }, + wantStderr: "pinned", + }, + { + name: "unpin with dry-run reports update without modifying files", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "pinned-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: pinned-skill + metadata: + github-owner: octocat + github-repo: hubot-skills + github-tree-sha: oldsha000 + github-pinned: v1.0.0 + github-path: skills/pinned-skill + --- + Pinned content + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/octocat/hubot-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v2.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/octocat/hubot-skills/git/ref/tags/v2.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/octocat/hubot-skills/git/trees/newcommit789"), + httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/pinned-skill/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/pinned-skill", "type": "tree", "sha": "newsha999"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Prompter: &prompter.PrompterMock{}, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + DryRun: true, + Unpin: true, + } + }, + verify: func(t *testing.T, dir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(dir, "pinned-skill", "SKILL.md")) + require.NoError(t, err) + assert.Contains(t, string(content), "github-pinned: v1.0.0", "dry-run should not modify files") + }, + wantStderr: "1 update(s) available:", + wantStdout: "pinned-skill", }, } - _, _, reason, ok, err := promptForSkillOrigin(pm, "test-skill") - require.NoError(t, err) - assert.False(t, ok) - assert.Contains(t, reason, "invalid repository") -} -func TestUpdateRun_NoInstalledSkills(t *testing.T) { - ios, _, _, stderr := iostreams.Test() - ios.SetStdoutTTY(false) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, stdout, stderr := iostreams.Test() - dir := t.TempDir() + dir := t.TempDir() + if tt.setup != nil { + tt.setup(t, dir) + } - reg := &httpmock.Registry{} - opts := &updateOptions{ - IO: ios, - Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - }, - GitClient: &git.Client{RepoDir: dir}, - Dir: dir, + reg := &httpmock.Registry{} + defer reg.Verify(t) + if tt.stubs != nil { + tt.stubs(reg) + } + + opts := tt.opts(ios, dir, reg) + err := updateRun(opts) + + if tt.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) + return + } + require.NoError(t, err) + if tt.wantStderr != "" { + assert.Contains(t, stderr.String(), tt.wantStderr) + } + if tt.wantStdout != "" { + assert.Contains(t, stdout.String(), tt.wantStdout) + } + if tt.verify != nil { + tt.verify(t, dir) + } + }) } - - defer reg.Verify(t) - err := updateRun(opts) - require.NoError(t, err) - assert.Contains(t, stderr.String(), "No installed skills found.") -} - -func TestUpdateRun_SpecificSkillNotInstalled(t *testing.T) { - ios, _, _, _ := iostreams.Test() - ios.SetStdoutTTY(false) - - dir := t.TempDir() - skillDir := filepath.Join(dir, "existing-skill") - require.NoError(t, os.MkdirAll(skillDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: existing-skill\nmetadata:\n github-owner: owner\n github-repo: repo\n github-tree-sha: abc\n---\n"), 0o644)) - - reg := &httpmock.Registry{} - opts := &updateOptions{ - IO: ios, - Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - }, - GitClient: &git.Client{RepoDir: dir}, - Dir: dir, - Skills: []string{"nonexistent"}, - } - - defer reg.Verify(t) - err := updateRun(opts) - assert.EqualError(t, err, "none of the specified skills are installed") -} - -func TestUpdateRun_PinnedSkillsSkipped(t *testing.T) { - ios, _, _, stderr := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStderrTTY(true) - - dir := t.TempDir() - skillDir := filepath.Join(dir, "pinned-skill") - require.NoError(t, os.MkdirAll(skillDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: pinned-skill\nmetadata:\n github-owner: owner\n github-repo: repo\n github-tree-sha: abc123\n github-pinned: v1.0.0\n---\n"), 0o644)) - - reg := &httpmock.Registry{} - opts := &updateOptions{ - IO: ios, - Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - }, - Prompter: &prompter.PrompterMock{}, - GitClient: &git.Client{RepoDir: dir}, - Dir: dir, - } - - defer reg.Verify(t) - err := updateRun(opts) - require.NoError(t, err) - assert.Contains(t, stderr.String(), "pinned-skill is pinned to v1.0.0 (skipped)") - assert.Contains(t, stderr.String(), "All skills are up to date.") -} - -func TestUpdateRun_NoMetaSkipsNonInteractive(t *testing.T) { - ios, _, _, stderr := iostreams.Test() - ios.SetStdoutTTY(false) - ios.SetStdinTTY(false) - - dir := t.TempDir() - skillDir := filepath.Join(dir, "manual-skill") - require.NoError(t, os.MkdirAll(skillDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: manual-skill\n---\nNo metadata\n"), 0o644)) - - reg := &httpmock.Registry{} - opts := &updateOptions{ - IO: ios, - Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - }, - GitClient: &git.Client{RepoDir: dir}, - Dir: dir, - } - - defer reg.Verify(t) - err := updateRun(opts) - require.NoError(t, err) - assert.Contains(t, stderr.String(), "manual-skill has no GitHub metadata") -} - -func TestUpdateRun_AllUpToDate(t *testing.T) { - ios, _, _, stderr := iostreams.Test() - ios.SetStdoutTTY(false) - - dir := t.TempDir() - skillDir := filepath.Join(dir, "my-skill") - require.NoError(t, os.MkdirAll(skillDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: my-skill\nmetadata:\n github-owner: octo\n github-repo: skills\n github-tree-sha: abc123def456\n github-path: skills/my-skill\n---\n"), 0o644)) - - reg := &httpmock.Registry{} - reg.Register( - httpmock.REST("GET", "repos/octo/skills/releases/latest"), - httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), - ) - reg.Register( - httpmock.REST("GET", "repos/octo/skills/git/ref/tags/v1.0.0"), - httpmock.StringResponse(`{"object": {"sha": "commitsha123", "type": "commit"}}`), - ) - reg.Register( - httpmock.REST("GET", fmt.Sprintf("repos/octo/skills/git/trees/commitsha123")), - httpmock.StringResponse(`{"sha": "commitsha123", "tree": [{"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blobsha1"}, {"path": "skills/my-skill", "type": "tree", "sha": "abc123def456"}, {"path": "skills", "type": "tree", "sha": "treeshaX"}], "truncated": false}`), - ) - - opts := &updateOptions{ - IO: ios, - Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - }, - GitClient: &git.Client{RepoDir: dir}, - Dir: dir, - } - - defer reg.Verify(t) - err := updateRun(opts) - require.NoError(t, err) - assert.Contains(t, stderr.String(), "All skills are up to date.") -} - -func TestUpdateRun_DryRun(t *testing.T) { - ios, _, stdout, stderr := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStderrTTY(true) - - dir := t.TempDir() - skillDir := filepath.Join(dir, "my-skill") - require.NoError(t, os.MkdirAll(skillDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: my-skill\nmetadata:\n github-owner: octo\n github-repo: skills\n github-tree-sha: oldsha123\n github-path: skills/my-skill\n---\n"), 0o644)) - - reg := &httpmock.Registry{} - reg.Register( - httpmock.REST("GET", "repos/octo/skills/releases/latest"), - httpmock.StringResponse(`{"tag_name": "v2.0.0"}`), - ) - reg.Register( - httpmock.REST("GET", "repos/octo/skills/git/ref/tags/v2.0.0"), - httpmock.StringResponse(`{"object": {"sha": "newcommit456", "type": "commit"}}`), - ) - reg.Register( - httpmock.REST("GET", "repos/octo/skills/git/trees/newcommit456"), - httpmock.StringResponse(`{"sha": "newcommit456", "tree": [{"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blobsha2"}, {"path": "skills/my-skill", "type": "tree", "sha": "newsha456"}, {"path": "skills", "type": "tree", "sha": "treeshaY"}], "truncated": false}`), - ) - - opts := &updateOptions{ - IO: ios, - Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - }, - Prompter: &prompter.PrompterMock{}, - GitClient: &git.Client{RepoDir: dir}, - Dir: dir, - DryRun: true, - } - - defer reg.Verify(t) - err := updateRun(opts) - require.NoError(t, err) - assert.Contains(t, stderr.String(), "1 update(s) available:") - assert.Contains(t, stdout.String(), "my-skill") - assert.Contains(t, stdout.String(), "octo/skills") -} - -func TestUpdateRun_NonInteractiveNoAll(t *testing.T) { - ios, _, _, _ := iostreams.Test() - ios.SetStdoutTTY(false) - ios.SetStdinTTY(false) - - dir := t.TempDir() - skillDir := filepath.Join(dir, "my-skill") - require.NoError(t, os.MkdirAll(skillDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: my-skill\nmetadata:\n github-owner: octo\n github-repo: skills\n github-tree-sha: oldsha123\n github-path: skills/my-skill\n---\n"), 0o644)) - - reg := &httpmock.Registry{} - reg.Register( - httpmock.REST("GET", "repos/octo/skills/releases/latest"), - httpmock.StringResponse(`{"tag_name": "v2.0.0"}`), - ) - reg.Register( - httpmock.REST("GET", "repos/octo/skills/git/ref/tags/v2.0.0"), - httpmock.StringResponse(`{"object": {"sha": "newcommit456", "type": "commit"}}`), - ) - reg.Register( - httpmock.REST("GET", "repos/octo/skills/git/trees/newcommit456"), - httpmock.StringResponse(`{"sha": "newcommit456", "tree": [{"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blobsha2"}, {"path": "skills/my-skill", "type": "tree", "sha": "newsha456"}, {"path": "skills", "type": "tree", "sha": "treeshaY"}], "truncated": false}`), - ) - - opts := &updateOptions{ - IO: ios, - Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - }, - GitClient: &git.Client{RepoDir: dir}, - Dir: dir, - } - - defer reg.Verify(t) - err := updateRun(opts) - assert.EqualError(t, err, "updates available; re-run with --all to apply, or run interactively to confirm") }