From 8ea84d0dee4f247a82912fc16e74eab29f9da091 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 3 Apr 2026 12:37:47 -0600 Subject: [PATCH] Expand test coverage and fix invariants/bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover the three primary discovery entry points with httpmock-based tests. DiscoverSkills: happy path, truncated tree, no skills, API error, dedup. DiscoverSkillByPath: path resolution, namespaces, invalid name, missing directory, missing SKILL.md. DiscoverLocalSkills: convention matching, root skill, no skills, nonexistent directory. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Test InstallLocal public API instead of private installLocalSkill Replace tests that called installLocalSkill directly with tests through InstallLocal. Adds coverage for AgentHost+Scope resolution path, multiple skills, and missing Dir/AgentHost error. Fixes symlink test to require.NoError on os.Symlink. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Test partial failure in concurrent Install Add test where one of two skills fails (500 on tree fetch). Asserts that result.Installed contains the successful skill and err wraps the failed skill name. Fixes test loop to not clear Dir for partial failure cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Refactor update tests to table-driven pattern Consolidate 16 individual test functions into 3 standalone + 3 table tests matching cli/cli conventions. Fix ArgsPassedToOptions to use iostreams.Test() instead of os.Stdout/os.Stderr. Use GitHub-branded test data. No coverage lost. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Add update execution test that verifies SKILL.md is rewritten All prior update tests used DryRun or hit early exits. New test exercises the full fetch-and-rewrite path: stale treeSHA triggers re-download, SKILL.md is overwritten with new content and metadata. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Use heredoc.Doc for multiline SKILL.md strings in update tests Replace escaped newline strings with heredoc.Doc backtick literals for readability, matching cli/cli conventions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Add interactive update path tests Cover confirm-and-apply, confirm-cancelled, and no-metadata prompt paths in TestUpdateRun. These interactive branches were previously untested since all prior tests used non-TTY or DryRun. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Test no-metadata prompt enrichment through full update path Add test where a skill with no GitHub metadata is prompted for origin, user provides owner/repo, skill gets enriched and proceeds through version resolution and file rewrite. Covers lines 222-224 in update.go. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Replace deprecated cs.Gray with cs.Muted Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Test namespaced skill update with --dir base resolution Cover the filepath.Dir double-up path for namespaced skills (name contains '/') when using --dir. Verifies the install base is resolved correctly so the update writes to the right directory. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Test install failure during update reports error and preserves file Cover the path where version resolution succeeds but blob fetch fails during the actual install. Verifies stderr error message, SilentError return, and that the original SKILL.md is not modified. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Dedupe resolveGitRoot/resolveHomeDir into installer, rename scanAllHosts Move ResolveGitRoot and ResolveHomeDir to the installer package to eliminate duplication between install and update commands. Fix ResolveGitRoot to check RepoDir before calling ToplevelDir. Rename scanAllHosts to scanAllAgents to match registry naming. Add test exercising scanAllAgents via updateRun without --dir. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Use heredoc.Doc for multiline YAML strings across all test files Convert 13 escaped-newline frontmatter strings to heredoc.Doc for readability. Applies to discovery, frontmatter, install, update, publish, and preview test files. Preserves edge-case test strings and fmt.Sprintf interpolations as-is. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Use git.Client.Copy() instead of struct copy to avoid mutex copy Fixes go vet 'copies lock value' warnings in publish command where *git.Client was copied by value to set a different RepoDir. Rename terse variable names (bc/ic/dc) to branchGit/ignoreGit/dirGit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Rewrite publish tests: table-driven through publishRun Consolidate 35 test functions into 2: TestNewCmdPublish (4 cases for CLI arg parsing) and TestPublishRun (22 cases exercising all behavior through the command's run function). No individual helper function tests — every codepath tested through publishRun scenarios. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Remove .gitkeep from acceptance/testdata/skills Delete the placeholder .gitkeep file from acceptance/testdata/skills. The directory no longer needs a placeholder file to be tracked in the repository. Rename testPublishGitClient to newTestGitClient Rename the test helper function testPublishGitClient to newTestGitClient in pkg/cmd/skills/publish/publish_test.go and update all call sites accordingly. This is a purely refactor/name-change with no behavioral changes to tests. Fix Windows CI: set USERPROFILE alongside HOME in tests os.UserHomeDir() uses USERPROFILE on Windows, not HOME. All tests that redirect HOME for lockfile isolation now also set USERPROFILE to the same temp directory. Co-Authored-By: Copilot <223556219+Copilot@users.noreply.github.com> Use range-over-int in acquireLock retry loop Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Test lock acquisition edge cases through RecordInstall Make lockRetries and lockRetryInterval configurable (package-level vars) so tests can avoid the 3s retry wait. Add two RecordInstall cases: - Stale lock (>30s old) is broken and install succeeds - Fresh lock exhausts retries, proceeds best-effort without lock Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Rename test helpers for lockfile tests Rename setupHome to setupTestHome and readLockfile to readTestLockfile in internal/skills/lockfile tests, and update all call sites and comments accordingly. This is a refactor-only change to clarify test helper names with no behavior change. Co-Authored-By: Copilot <223556219+Copilot@users.noreply.github.com> Test read() degradation through RecordInstall, delete TestRead Move corrupt JSON and wrong version cases into TestRecordInstall table. RecordInstall calls read() internally, so these exercise the same degradation paths through the public API. Verifies the lockfile is rewritten with correct version and new data after recovery. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Fix InstalledAt preservation test to actually prove preservation Move the update-preserves-InstalledAt case out of the table into a standalone subtest that reads InstalledAt between two RecordInstall calls and asserts exact equality. The table version only checked NotEmpty which couldn't detect if InstalledAt was overwritten. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Merge duplicate plugin test into TestMatchSkillConventions table The standalone TestDuplicatePluginSkills_DifferentAuthors re-implemented dedup logic that belongs in DiscoverSkills. Replace with a table case that tests convention matching only. Dedup is already covered by TestDiscoverSkills. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Fix broken validateName max-length test case Replace make([]byte, N) (which produces null bytes) with strings.Repeat to actually test the 64-character boundary. Add positive test for valid 64-char name. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Replace name-matching hack with createDir field in TestDiscoverLocalSkills Use a struct field instead of comparing tt.name to control whether the test directory is created. Prevents silent breakage if someone renames the test case. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Improve collisions tests: table-driven FormatCollisions, exercise DisplayName Convert TestFormatCollisions to table test with nil-input case. Update single collision case to use different conventions (plugins vs skills) so DisplayName() logic is actually exercised in the assertion. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Add tests for MatchesSkillPath, DiscoverSkillFiles, ListSkillFiles, FetchDescriptionsConcurrent Also cover previously untested branches: root convention matching, annotated tag dereference failure, empty tag_name/default_branch fallbacks, recursive walkTree with subtrees, and skill directory deduplication. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Test full GitHub key stripping in InjectLocalMetadata Add all 7 github-* keys to the input metadata and assert all are absent after injection. Previously only tested github-owner and github-repo removal. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Test Serialize trailing-newline addition for body without newline Add case where body doesn't end in newline and assert the output has one appended. Previously this branch was uncovered. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Test InjectGitHubMetadata with no existing frontmatter Add case where content has no --- delimiters, exercising the RawYAML == nil branch that creates frontmatter from scratch. Also fix test data to use GitHub-branded names. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Convert TestInjectLocalMetadata to table-driven with no-metadata case Add case for content with no frontmatter, exercising the meta == nil branch. Aligns with table-driven pattern used throughout. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Replace name-matching hack with useAgentHost field in TestInstallLocal Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Add tests for ResolveGitRoot Cover RepoDir shortcut, nil client fallback, and empty RepoDir fallback. Skip ResolveHomeDir — it's a thin os.UserHomeDir wrapper with no logic to test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Test OnProgress callback in both single and multi-skill Install paths Cover the progress reporting branches in Install for both the single-skill fast path (len==1) and the concurrent multi-skill path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Cover missing InstallDir error branches and malformed URL in registry Add user-scope-without-homeDir and invalid-scope cases to TestInstallDir. Add malformed URL case to TestRepoNameFromRemote. Coverage 80.5% → 87.8%. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Rewrite install tests: table-driven through installRun and runLocalInstall Consolidate 48 individual test functions into 6: TestNewCmdInstall (10 cases for CLI parsing), TestInstallRun (21 cases for remote install flow), TestRunLocalInstall (10 cases for local install flow), plus TestIsLocalPath, TestIsSkillPath, TestFriendlyDir for pure input classification. Delete zero-value Help test. All behavior tested through public functions instead of calling internal helpers directly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Fix data race in OnProgress test with atomic counter The OnProgress callback was appending to a shared slice from concurrent goroutines. Replace with sync/atomic counter to avoid the race. Co-Authored-By: Copilot <223556219+Copilot@users.noreply.github.com> Add interactive install tests for skill selection, scope, host, and overwrite Exercise the interactive TTY paths in installRun: MultiSelectWithSearch for skill selection, Select for scope prompt, MultiSelect for host selection, and Confirm for overwrite declined. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Exercise skillSearchFunc fully through interactive mock Update the interactive skill selection test to use 31 skills (exceeding maxSearchResults cap), include a skill without a description, and have the mock call searchFunc with both empty and filtered queries. Verifies the MoreResults count, label formatting, and truncation branches. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Fill remaining install coverage gaps Add local path detection cases to TestNewCmdInstall. Add interactive repo prompt, user scope selection, overwrite without metadata, and single exact match cases to TestInstallRun. Add bare tilde expansion to TestRunLocalInstall. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Move HOME/USERPROFILE setenv to test loops, remove per-case duplication Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Add isTTY field to install test tables, centralize TTY setup Move TTY configuration from individual opts funcs into the test loops. Each table case declares isTTY: true/false and the loop sets all three streams accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Remove INSTALL_TARGET env var hack from install test Metadata injection is already proven by installer package tests. This test only needs to verify installRun orchestrates correctly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Add ScopeChanged: true to all install tests with explicit Scope Ensures tests simulate the same state cobra produces when --scope is explicitly provided, preventing silent codepath divergence if the default scope behavior changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Fix assert.Error → require.Error in TestNewCmdSearch Prevents nil panic on err.Error() if the command unexpectedly returns nil. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Improve preview test quality and coverage - Fix assert.Error/assert.NoError → require.Error/require.NoError to prevent nil panics in TestNewCmdPreview and TestPreviewRun - Add renderAllFiles edge case tests: maxFiles cap (20 files), maxBytes cap (512KB), and FetchBlob error fallback message - Replace custom discardWriter with io.Discard - Use GitHub-branded names (monalisa) in new tests Co-Authored-By: Copilot <223556219+Copilot@users.noreply.github.com> Add search test coverage: rate limits, owner scope, blob enrichment - Add HTTP 429 and 403+Retry-After rate limit test cases - Add owner-scoped no-results test (exercises noResultsMessage branch) - Add blob description enrichment test (exercises fetchDescriptions path) - Replace custom splitOnSpaces with strings.Fields - Replace custom discardWriter with io.Discard Co-Authored-By: Copilot <223556219+Copilot@users.noreply.github.com> Remove low-value alias test for preview command The test only asserts a string literal matches another string literal. Alias presence is already visible in the command definition. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Replace local pluralize with text.Pluralize The internal/text package already provides this function via go-gh. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Inline collapseWhitespace — just strings.Fields + Join Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Doc: suggest using go-humanize for star formatting Co-Authored-By: Copilot <223556219+Copilot@users.noreply.github.com> Return cmdutil.CancelError on user cancellation in publish and update Both commands returned nil (success exit) when the user declined confirmation. The core CLI pattern is to return CancelError so the process exits with a non-zero status. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Add interactive publish prompt tests and isTTY field Cover all prompt branches in runPublishRelease: - Topic confirm + semver tag selection + final confirm (happy path) - Custom tag input path (select idx=1) - Final confirm declined (CancelError) - Immutable releases prompt (enable via PATCH) Add isTTY field to test table struct for centralized TTY setup, matching the pattern used in install tests. Add auto-confirm prompters to existing TTY tests that now need them. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Remove duplicate giturl import alias in publish The git package was imported twice — once as 'git' and again as 'giturl'. Use git.ParseURL directly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Fix data race in search enrichment fetchDescriptions and fetchRepoStars run concurrently but both wrote to fields of the same skillResult slice elements, triggering the race detector. Refactor both functions to return index-keyed maps instead of mutating the slice directly. enrichSkills merges the maps into the slice after both goroutines complete. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> refactor: remove Claude plugin branding, align with Open Plugin Spec Replace all 'Claude plugin' references with generic 'plugin' terminology to align with the vendor-neutral Open Plugin Spec (https://github.com/vercel-labs/open-plugin-spec). Changes: - Rename .claude-plugin/ to .plugin/ (spec §5.1 vendor-neutral manifest) - Rename claudePluginJSON/claudeAuthor types to pluginJSON/pluginAuthor - Rename claudeMarketplaceJSON to marketplaceJSON - Rename generateClaudePlugin to generatePlugin - Remove 'Claude Code' from plugin-related comments, help text, and flags - Update install.go plugins/ convention message Factual host references (Claude Code as an agent name, .claude/skills directories) are intentionally preserved — those are product names, not plugin branding. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Remove --plugins flag from publish command Remove the --plugins flag and all associated plugin generation code from the publish flow. This was scope creep — the publish command should focus on validating and publishing skills, not generating plugin manifests. Removed: - --plugins flag and Plugins option field - generatePlugin, generateMarketplace, buildPluginDescription functions - pluginJSON, marketplaceJSON, marketplacePlugin types - Related tests and help text The install command's ability to discover and pluck skills from plugin- structured repositories (plugins/ convention) is preserved. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> don't fall back on default branch if you can't fetch latest release improve search algo by using square rot instead of log for stars, and reduce weight for exact name match add support for --unpin flag when updating a skill --- acceptance/testdata/skills/.gitkeep | 0 internal/skills/discovery/collisions_test.go | 34 +- internal/skills/discovery/discovery_test.go | 654 ++++- .../skills/frontmatter/frontmatter_test.go | 114 +- internal/skills/installer/installer.go | 28 + internal/skills/installer/installer_test.go | 206 +- internal/skills/lockfile/lockfile.go | 9 +- internal/skills/lockfile/lockfile_test.go | 219 +- internal/skills/registry/registry_test.go | 15 + pkg/cmd/skills/install/install.go | 44 +- pkg/cmd/skills/install/install_test.go | 2487 +++++++++++------ pkg/cmd/skills/preview/preview.go | 12 +- pkg/cmd/skills/preview/preview_test.go | 221 +- pkg/cmd/skills/publish/publish.go | 236 +- pkg/cmd/skills/publish/publish_test.go | 2248 ++++++++------- pkg/cmd/skills/search/search.go | 71 +- pkg/cmd/skills/search/search_test.go | 111 +- pkg/cmd/skills/update/update.go | 52 +- pkg/cmd/skills/update/update_test.go | 1352 +++++++-- 19 files changed, 5464 insertions(+), 2649 deletions(-) delete mode 100644 acceptance/testdata/skills/.gitkeep 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") }