package install import ( "bytes" "encoding/base64" "fmt" "net/http" "net/url" "os" "path/filepath" "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/internal/telemetry" "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 TestNewCmdInstall(t *testing.T) { tests := []struct { name string cli string wantOpts InstallOptions wantLocalPath bool wantErr bool }{ { name: "repo argument only", cli: "monalisa/skills-repo", wantOpts: InstallOptions{SkillSource: "monalisa/skills-repo", Scope: "project"}, }, { name: "repo and skill", cli: "monalisa/skills-repo git-commit", wantOpts: InstallOptions{SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Scope: "project"}, }, { 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: "dir flag", 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", 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: "from-local flag sets localPath", cli: "--from-local ./local-dir", wantOpts: InstallOptions{SkillSource: "./local-dir", Scope: "project", FromLocal: true}, wantLocalPath: true, }, { name: "from-local with absolute path", cli: "--from-local /absolute/path", wantOpts: InstallOptions{SkillSource: "/absolute/path", Scope: "project", FromLocal: true}, wantLocalPath: true, }, { name: "from-local with tilde path", cli: "--from-local ~/skills", wantOpts: InstallOptions{SkillSource: "~/skills", Scope: "project", FromLocal: true}, wantLocalPath: true, }, { name: "owner/repo does not set localPath", cli: "monalisa/skills-repo", wantOpts: InstallOptions{SkillSource: "monalisa/skills-repo", Scope: "project"}, }, { name: "local-looking path without --from-local treated as repo", cli: "./local-dir", wantOpts: InstallOptions{SkillSource: "./local-dir", Scope: "project"}, }, { name: "from-local without argument errors", cli: "--from-local", wantErr: true, }, { name: "from-local with --pin is mutually exclusive", cli: "--from-local ./local-dir --pin v1.0.0", wantErr: true, }, { name: "allow-hidden-dirs flag", cli: "monalisa/skills-repo --allow-hidden-dirs", wantOpts: InstallOptions{SkillSource: "monalisa/skills-repo", Scope: "project", AllowHiddenDirs: true}, }, { name: "upstream flag", cli: "monalisa/skills-repo git-commit --upstream", wantOpts: InstallOptions{SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Scope: "project", Upstream: true}, }, { name: "from-local with --upstream is mutually exclusive", cli: "--from-local ./local-dir --upstream", wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ios, _, _, _ := iostreams.Test() f := &cmdutil.Factory{ IOStreams: ios, Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{}, } var gotOpts *InstallOptions cmd := NewCmdInstall(f, &telemetry.NoOpService{}, func(opts *InstallOptions) 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.wantErr { 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) assert.Equal(t, tt.wantOpts.Scope, gotOpts.Scope) assert.Equal(t, tt.wantOpts.Pin, gotOpts.Pin) assert.Equal(t, tt.wantOpts.Dir, gotOpts.Dir) assert.Equal(t, tt.wantOpts.Force, gotOpts.Force) assert.Equal(t, tt.wantOpts.FromLocal, gotOpts.FromLocal) assert.Equal(t, tt.wantOpts.AllowHiddenDirs, gotOpts.AllowHiddenDirs) if tt.wantLocalPath { assert.NotEmpty(t, gotOpts.localPath, "expected localPath to be set") } else { 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, &telemetry.NoOpService{}, nil) assert.Equal(t, "install [] [flags]", 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", "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, url.PathEscape(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, ) } // hiddenDirSkillTreeJSON returns tree entries for a hidden-dir skill under .claude/skills/. func hiddenDirSkillTreeJSON(name, treeSHA, blobSHA string) string { return fmt.Sprintf( `{"path": ".claude/skills/%s", "type": "tree", "sha": %q}, {"path": ".claude/skills/%s/SKILL.md", "type": "blob", "sha": %q}`, name, treeSHA, name, blobSHA, ) } func TestInstallRun(t *testing.T) { tests := []struct { name string 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 when not running interactively", }, { 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 --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/heads/v2.0.0"), httpmock.StatusStringResponse(404, "not found")) 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 shows pre-install disclaimer", 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: "not verified by GitHub", }, { 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: "gh skill preview monalisa/skills-repo git-commit@abc123", }, { 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(), } }, wantStderr: "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/heads/v1.2.0"), httpmock.StatusStringResponse(404, "not found")) 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 by nested 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", "terraform/code-generation/skills/terraform-style-guide", "terraform-style-guide", "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: "terraform/code-generation/skills/terraform-style-guide", Agent: "github-copilot", Scope: "project", ScopeChanged: true, Dir: t.TempDir(), } }, wantStdout: "Installed terraform-style-guide", }, { 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: true, 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() pm := &prompter.PrompterMock{ MultiSelectWithSearchFunc: func(_, _ string, _, _ []string, _ func(string) prompter.MultiSelectSearchResult) ([]string, error) { return []string{allSkillsKey}, 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/skills-repo", Agent: "github-copilot", Scope: "project", ScopeChanged: true, Dir: t.TempDir(), } }, wantErr: "conflicting names", }, { name: "remote install all with namespaced skills detects 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) // Blob stubs consumed by FetchDescriptionsConcurrent during interactive selection. contentA := base64.StdEncoding.EncodeToString([]byte("---\nname: xlsx-pro\ndescription: Alice\n---\n# A\n")) contentB := base64.StdEncoding.EncodeToString([]byte("---\nname: xlsx-pro\ndescription: Bob\n---\n# B\n")) reg.Register( httpmock.REST("GET", "repos/monalisa/skills-repo/git/blobs/blobA"), httpmock.StringResponse(fmt.Sprintf(`{"sha": "blobA", "content": %q, "encoding": "base64"}`, contentA))) reg.Register( httpmock.REST("GET", "repos/monalisa/skills-repo/git/blobs/blobB"), httpmock.StringResponse(fmt.Sprintf(`{"sha": "blobB", "content": %q, "encoding": "base64"}`, contentB))) }, opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() pm := &prompter.PrompterMock{ MultiSelectWithSearchFunc: func(_, _ string, _, _ []string, _ func(string) prompter.MultiSelectSearchResult) ([]string, error) { return []string{allSkillsKey}, 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/skills-repo", Agent: "github-copilot", Scope: "project", ScopeChanged: true, Dir: t.TempDir(), } }, wantErr: "conflicting names", }, { 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-repo: https://github.com/someowner/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: "unsupported host returns error", stubs: func(reg *httpmock.Registry) {}, 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 }, Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "acme.ghes.com/monalisa/octocat-skills", SkillName: "git-commit", } }, wantErr: "does not currently support GitHub Enterprise Server", }, { 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", }, { name: "hidden-dir skills excluded without --allow-hidden-dirs", isTTY: false, stubs: func(reg *httpmock.Registry) { stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", hiddenDirSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) }, opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Agent: "github-copilot", Scope: "project", ScopeChanged: true, } }, wantErr: "no standard skills found, but 1 skill(s) exist in hidden directories", }, { name: "hidden-dir skills included with --allow-hidden-dirs", isTTY: true, stubs: func(reg *httpmock.Registry) { stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", hiddenDirSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) }, opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Agent: "github-copilot", Scope: "project", ScopeChanged: true, Dir: t.TempDir(), AllowHiddenDirs: true, } }, wantStdout: "Installed git-commit", wantStderr: "Skills in hidden directories", }, { name: "mixed tree without --allow-hidden-dirs returns only standard", isTTY: true, stubs: func(reg *httpmock.Registry) { stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")+", "+ hiddenDirSkillTreeJSON("hidden-skill", "treeSHA2", "blobSHA2")) stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) }, opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Agent: "github-copilot", Scope: "project", ScopeChanged: true, Dir: t.TempDir(), } }, wantStdout: "Installed git-commit", }, { name: "mixed tree with --allow-hidden-dirs returns all", isTTY: false, stubs: func(reg *httpmock.Registry) { stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")+", "+ hiddenDirSkillTreeJSON("hidden-skill", "treeSHA2", "blobSHA2")) stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA2", "blobSHA2", gitCommitContent) }, opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/skills-repo", SkillName: "hidden-skill", Agent: "github-copilot", Scope: "project", ScopeChanged: true, Dir: t.TempDir(), AllowHiddenDirs: true, } }, wantStdout: "Installed hidden-skill", wantStderr: "Skills in hidden directories", }, } for _, tt := range tests { 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) if opts.Telemetry == nil { opts.Telemetry = &telemetry.NoOpService{} } 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 TestInstallProgress(t *testing.T) { ios, _, _, _ := iostreams.Test() assert.Nil(t, installProgress(ios, 0)) assert.NotNil(t, installProgress(ios, 1)) assert.NotNil(t, installProgress(ios, 2)) } func TestInstallRun_DeduplicatesSharedProjectDirAcrossHosts(t *testing.T) { homeDir := t.TempDir() t.Setenv("HOME", homeDir) t.Setenv("USERPROFILE", homeDir) reg := &httpmock.Registry{} defer reg.Verify(t) 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) ios, _, stdout, stderr := iostreams.Test() ios.SetStdoutTTY(true) ios.SetStdinTTY(true) ios.SetStderrTTY(true) pm := &prompter.PrompterMock{ MultiSelectFunc: func(prompt string, defaults []string, options []string) ([]int, error) { // Select two agents that share the .agents/skills project dir // (GitHub Copilot and Cursor) to exercise deduplication. var indices []int for i, label := range options { if label == "GitHub Copilot" || label == "Cursor" { indices = append(indices, i) } } return indices, nil }, SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { return 0, nil // project scope }, } err := installRun(&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, Telemetry: &telemetry.NoOpService{}, }) require.NoError(t, err) assert.Equal(t, 1, strings.Count(stdout.String(), "Installed git-commit")) assert.NotContains(t, stderr.String(), "Installing to") } 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, SkillName: "git-commit", 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, SkillName: "direct-skill", Force: true, Agent: "github-copilot", Scope: "project", ScopeChanged: true, Dir: targetDir, GitClient: &git.Client{RepoDir: t.TempDir()}, } }, wantStdout: "Installed direct-skill", }, { name: "namespaced skills with same name collide in flat install", isTTY: true, 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() pm := &prompter.PrompterMock{ MultiSelectWithSearchFunc: func(_, _ string, _, _ []string, _ func(string) prompter.MultiSelectSearchResult) ([]string, error) { return []string{allSkillsKey}, nil }, } return &InstallOptions{ IO: ios, SkillSource: sourceDir, localPath: sourceDir, Prompter: pm, Force: true, Agent: "github-copilot", Scope: "project", ScopeChanged: true, Dir: targetDir, GitClient: &git.Client{RepoDir: t.TempDir()}, } }, wantErr: "conflicting names", }, { name: "local install with --force overwrites namespaced skill flat", isTTY: true, setup: func(t *testing.T, sourceDir, targetDir string) { t.Helper() writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "alice", "xlsx-pro"), "---\nname: xlsx-pro\ndescription: alice xlsx-pro\n---\n# Test\n") require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "xlsx-pro"), 0o755)) require.NoError(t, os.WriteFile(filepath.Join(targetDir, "xlsx-pro", "SKILL.md"), []byte("old"), 0o644)) }, opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { t.Helper() return &InstallOptions{ IO: ios, SkillSource: sourceDir, localPath: sourceDir, SkillName: "xlsx-pro", 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() content, err := os.ReadFile(filepath.Join(targetDir, "xlsx-pro", "SKILL.md")) require.NoError(t, err) assert.Contains(t, string(content), "alice xlsx-pro") }, 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, SkillName: "anything", 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, SkillName: "git-commit", 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, SkillName: "git-commit", 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, SkillName: "git-commit", Force: true, Agent: "github-copilot", Scope: "project", ScopeChanged: true, Dir: targetDir, GitClient: &git.Client{RepoDir: t.TempDir()}, } }, wantStderr: "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: "~/", 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 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: "~", 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 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", }, { name: "local hidden-dir skills excluded without --allow-hidden-dirs", isTTY: false, setup: func(t *testing.T, sourceDir, _ string) { t.Helper() writeLocalTestSkill(t, sourceDir, filepath.Join(".claude", "skills", "code-review"), heredoc.Doc(` --- name: code-review description: Reviews code --- # Code Review `)) }, opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { t.Helper() return &InstallOptions{ IO: ios, SkillSource: sourceDir, localPath: sourceDir, SkillName: "code-review", Agent: "github-copilot", Scope: "project", ScopeChanged: true, Dir: targetDir, GitClient: &git.Client{RepoDir: t.TempDir()}, } }, wantErr: "no standard skills found, but 1 skill(s) exist in hidden directories", }, { name: "local hidden-dir skills included with --allow-hidden-dirs", isTTY: false, setup: func(t *testing.T, sourceDir, _ string) { t.Helper() writeLocalTestSkill(t, sourceDir, filepath.Join(".claude", "skills", "code-review"), heredoc.Doc(` --- name: code-review description: Reviews code --- # Code Review `)) }, opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { t.Helper() return &InstallOptions{ IO: ios, SkillSource: sourceDir, localPath: sourceDir, SkillName: "code-review", Force: true, Agent: "github-copilot", Scope: "project", ScopeChanged: true, Dir: targetDir, AllowHiddenDirs: true, GitClient: &git.Client{RepoDir: t.TempDir()}, } }, wantStdout: "Installed code-review", wantStderr: "Skills in hidden directories", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { homeDir := t.TempDir() t.Setenv("HOME", homeDir) t.Setenv("USERPROFILE", homeDir) sourceDir := t.TempDir() targetDir := t.TempDir() if tt.setup != nil { tt.setup(t, sourceDir, targetDir) } ios, _, stdout, stderr := iostreams.Test() ios.SetStdoutTTY(tt.isTTY) ios.SetStdinTTY(tt.isTTY) ios.SetStderrTTY(tt.isTTY) opts := tt.opts(ios, sourceDir, targetDir) 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, targetDir) } }) } } func Test_isSkillPath(t *testing.T) { tests := []struct { name string path string want bool }{ {name: "empty string", path: "", want: false}, {name: "plain skill name", path: "git-commit", want: false}, {name: "SKILL.md at root", path: "SKILL.md", want: true}, {name: "SKILL.md suffix", path: "skills/code-review/SKILL.md", want: true}, {name: "starts with skills/", path: "skills/code-review", want: true}, {name: "starts with plugins/", path: "plugins/hubot/skills/pr-summary", want: true}, {name: "nested skills/ path", path: "terraform/code-generation/skills/terraform-style-guide", want: true}, {name: "deeply nested skills/ path", path: "a/b/c/skills/my-skill", want: true}, {name: "nested plugins/ path", path: "vendor/plugins/hubot/skills/pr-summary", want: true}, {name: "name containing skills substring", path: "myskills", want: false}, {name: "namespaced path", path: "skills/monalisa/issue-triage", want: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.want, isSkillPath(tt.path)) }) } } func Test_printReviewHint(t *testing.T) { tests := []struct { name string repo string sha string skillNames []string allowHiddenDirs bool wantOutput string }{ { name: "remote install with SHA includes SHA in preview command", repo: "owner/repo", sha: "abc123def456", skillNames: []string{"my-skill"}, wantOutput: "gh skill preview owner/repo my-skill@abc123def456", }, { name: "remote install without SHA omits SHA from preview command", repo: "owner/repo", sha: "", skillNames: []string{"my-skill"}, wantOutput: "gh skill preview owner/repo my-skill\n", }, { name: "multiple skills with SHA", repo: "owner/repo", sha: "deadbeef", skillNames: []string{"skill-a", "skill-b"}, wantOutput: "skill-a@deadbeef", }, { name: "local install shows generic message", repo: "", sha: "", skillNames: []string{"my-skill"}, wantOutput: "Review the installed files before use", }, { name: "no skills produces no output", repo: "owner/repo", sha: "abc123", skillNames: []string{}, wantOutput: "", }, { name: "allow-hidden-dirs appends flag to preview command", repo: "owner/repo", sha: "abc123", skillNames: []string{"hidden-skill"}, allowHiddenDirs: true, wantOutput: "gh skill preview owner/repo hidden-skill@abc123 --allow-hidden-dirs", }, { name: "allow-hidden-dirs without SHA", repo: "owner/repo", sha: "", skillNames: []string{"hidden-skill"}, allowHiddenDirs: true, wantOutput: "gh skill preview owner/repo hidden-skill --allow-hidden-dirs", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ios, _, _, _ := iostreams.Test() cs := ios.ColorScheme() var buf strings.Builder printReviewHint(&buf, cs, tt.repo, tt.sha, tt.skillNames, tt.allowHiddenDirs) if tt.wantOutput == "" { assert.Empty(t, buf.String()) } else { assert.Contains(t, buf.String(), tt.wantOutput) } }) } } func Test_printHostHints(t *testing.T) { kiro := ®istry.AgentHost{ID: "kiro-cli", Name: "Kiro CLI", ProjectDir: ".kiro/skills", UserDir: ".kiro/skills"} copilot := ®istry.AgentHost{ID: "copilot-cli", Name: "GitHub Copilot CLI", ProjectDir: ".github/skills"} tests := []struct { name string hosts []*registry.AgentHost installed []string installDir string gitRoot string wantSub []string wantNot []string }{ { name: "no installs produces no output", hosts: []*registry.AgentHost{kiro}, installed: nil, installDir: "/repo/.kiro/skills", gitRoot: "/repo", wantNot: []string{"Kiro CLI"}, }, { name: "non-kiro host produces no output", hosts: []*registry.AgentHost{copilot}, installed: []string{"s1"}, installDir: "/repo/.github/skills", gitRoot: "/repo", wantNot: []string{"Kiro CLI"}, }, { name: "kiro project scope uses relative path", hosts: []*registry.AgentHost{kiro}, installed: []string{"s1"}, installDir: filepath.Join("/repo", ".kiro", "skills"), gitRoot: "/repo", wantSub: []string{"Kiro CLI", `"skill://.kiro/skills/**/SKILL.md"`}, }, { name: "kiro user scope uses absolute install dir", hosts: []*registry.AgentHost{kiro}, installed: []string{"s1"}, installDir: "/home/user/.kiro/skills", gitRoot: "/repo", wantSub: []string{`"skill:///home/user/.kiro/skills/**/SKILL.md"`}, wantNot: []string{`skill://.kiro/skills`}, }, { name: "kiro custom dir outside git root uses absolute path", hosts: []*registry.AgentHost{kiro}, installed: []string{"s1"}, installDir: "/tmp/my-skills", gitRoot: "/repo", wantSub: []string{`"skill:///tmp/my-skills/**/SKILL.md"`}, }, { name: "kiro without git root falls back to install dir", hosts: []*registry.AgentHost{kiro}, installed: []string{"s1"}, installDir: "/home/user/.kiro/skills", gitRoot: "", wantSub: []string{`"skill:///home/user/.kiro/skills/**/SKILL.md"`}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ios, _, _, _ := iostreams.Test() cs := ios.ColorScheme() var buf strings.Builder printHostHints(&buf, cs, tt.hosts, tt.installed, tt.installDir, tt.gitRoot) got := buf.String() for _, s := range tt.wantSub { assert.Contains(t, got, s) } for _, s := range tt.wantNot { assert.NotContains(t, got, s) } }) } } func Test_printPreInstallDisclaimer(t *testing.T) { ios, _, _, _ := iostreams.Test() cs := ios.ColorScheme() var buf strings.Builder printPreInstallDisclaimer(&buf, cs) output := buf.String() assert.Contains(t, output, "not verified by GitHub") assert.Contains(t, output, "prompt") assert.Contains(t, output, "malicious") } func Test_selectSkillsWithSelector_noDisclaimer(t *testing.T) { ios, _, _, stderr := iostreams.Test() ios.SetStdoutTTY(true) ios.SetStderrTTY(true) skills := []discovery.Skill{ {Name: "git-commit", Convention: "skills", Path: "skills/git-commit/SKILL.md"}, } pm := &prompter.PrompterMock{ MultiSelectWithSearchFunc: func(_, _ string, _, _ []string, _ func(string) prompter.MultiSelectSearchResult) ([]string, error) { return []string{"git-commit"}, nil }, } opts := &InstallOptions{ IO: ios, Prompter: pm, } _, err := selectSkillsWithSelector(opts, skills, true, skillSelector{ matchByName: matchSkillByName, sourceHint: "owner/repo", }) require.NoError(t, err) assert.NotContains(t, stderr.String(), "not verified by GitHub") } func TestInstallRun_TelemetryVisibility(t *testing.T) { tests := []struct { name string visibility string visibilityErr bool wantSkillNames string }{ { name: "public repo includes skill names", visibility: "public", wantSkillNames: "git-commit", }, { name: "private repo excludes skill names", visibility: "private", }, { name: "internal repo excludes skill names", visibility: "internal", }, { name: "API error omits visibility and skill names", visibilityErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { reg := &httpmock.Registry{} defer reg.Verify(t) stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) stubInstallFiles(reg, "monalisa", "octocat-skills", "treeSHA", "blobSHA", gitCommitContent) if tt.visibilityErr { reg.Register( httpmock.REST("GET", "repos/monalisa/octocat-skills"), httpmock.StatusStringResponse(500, "server error"), ) } else { reg.Register( httpmock.REST("GET", "repos/monalisa/octocat-skills"), httpmock.JSONResponse(map[string]interface{}{ "visibility": tt.visibility, }), ) } ios, _, _, _ := iostreams.Test() ios.SetStdoutTTY(true) ios.SetStdinTTY(true) recorder := &telemetry.EventRecorderSpy{} err := installRun(&InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, Prompter: &prompter.PrompterMock{}, SkillSource: "monalisa/octocat-skills", SkillName: "git-commit", Agent: "github-copilot", Scope: "project", ScopeChanged: true, Dir: t.TempDir(), Force: true, Telemetry: recorder, }) require.NoError(t, err) require.Len(t, recorder.Events, 1) event := recorder.Events[0] assert.Equal(t, "skill_install", event.Type) assert.NotEmpty(t, event.Dimensions["agent_hosts"], "agent_hosts should always be present") // skill_host_type is always recorded (categorized, no raw hostname for enterprise/tenancy). assert.Equal(t, "github.com", event.Dimensions["skill_host_type"]) if tt.visibilityErr { assert.Equal(t, "unknown", event.Dimensions["repo_visibility"], "visibility fetch errors should emit repo_visibility=\"unknown\" so the fallback is distinguishable from a successful fetch") } else { assert.Equal(t, tt.visibility, event.Dimensions["repo_visibility"]) } // Owner, repo, and skill names are only included when the repo // is public; for private/internal/unknown they are omitted to // avoid leaking identifiers of non-public repositories. if tt.wantSkillNames != "" { assert.Equal(t, "monalisa", event.Dimensions["skill_owner"]) assert.Equal(t, "octocat-skills", event.Dimensions["skill_repo"]) assert.Equal(t, tt.wantSkillNames, event.Dimensions["skill_names"]) } else { assert.Empty(t, event.Dimensions["skill_owner"]) assert.Empty(t, event.Dimensions["skill_repo"]) assert.Empty(t, event.Dimensions["skill_names"]) } }) } } func TestInstallRun_TelemetryMultipleSkills(t *testing.T) { codeReviewContent := heredoc.Doc(` --- name: code-review description: Reviews code --- # Code Review `) reg := &httpmock.Registry{} defer reg.Verify(t) stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") treeJSON := `{"path": "skills/git-commit", "type": "tree", "sha": "treeGC"}, ` + `{"path": "skills/git-commit/SKILL.md", "type": "blob", "sha": "blobGC"}, ` + `{"path": "skills/code-review", "type": "tree", "sha": "treeCR"}, ` + `{"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "blobCR"}` stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", treeJSON) // Blob stubs for FetchDescriptionsConcurrent during interactive selection encGC := base64.StdEncoding.EncodeToString([]byte(gitCommitContent)) encCR := base64.StdEncoding.EncodeToString([]byte(codeReviewContent)) reg.Register( httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blobGC"), httpmock.StringResponse(fmt.Sprintf(`{"sha": "blobGC", "content": %q, "encoding": "base64"}`, encGC))) reg.Register( httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blobCR"), httpmock.StringResponse(fmt.Sprintf(`{"sha": "blobCR", "content": %q, "encoding": "base64"}`, encCR))) stubInstallFiles(reg, "monalisa", "octocat-skills", "treeGC", "blobGC", gitCommitContent) stubInstallFiles(reg, "monalisa", "octocat-skills", "treeCR", "blobCR", codeReviewContent) reg.Register( httpmock.REST("GET", "repos/monalisa/octocat-skills"), httpmock.JSONResponse(map[string]interface{}{ "visibility": "public", }), ) ios, _, _, _ := iostreams.Test() ios.SetStdoutTTY(true) ios.SetStdinTTY(true) pm := &prompter.PrompterMock{ MultiSelectWithSearchFunc: func(_, _ string, _, _ []string, _ func(string) prompter.MultiSelectSearchResult) ([]string, error) { return []string{allSkillsKey}, nil }, } recorder := &telemetry.EventRecorderSpy{} err := installRun(&InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, Prompter: pm, SkillSource: "monalisa/octocat-skills", Agent: "github-copilot", Scope: "project", ScopeChanged: true, Dir: t.TempDir(), Telemetry: recorder, }) require.NoError(t, err) require.Len(t, recorder.Events, 1) event := recorder.Events[0] assert.Equal(t, "skill_install", event.Type) assert.Equal(t, "public", event.Dimensions["repo_visibility"]) // Verify comma-separated skill names (alphabetical order from DiscoverSkills) names := strings.Split(event.Dimensions["skill_names"], ",") assert.Len(t, names, 2) assert.Contains(t, names, "code-review") assert.Contains(t, names, "git-commit") } var republishedContent = heredoc.Doc(` --- name: git-commit description: Writes commits metadata: github-repo: https://github.com/monalisa/original-skills github-tree-sha: upstreamTreeSHA github-path: skills/git-commit --- # Git Commit `) func stubContentsAPI(reg *httpmock.Registry, owner, repo, path, content string) { encoded := base64.StdEncoding.EncodeToString([]byte(content)) reg.Register( httpmock.REST("GET", fmt.Sprintf("repos/%s/%s/contents/%s", owner, repo, path)), httpmock.StringResponse(fmt.Sprintf(`{"content": %q, "encoding": "base64"}`, encoded)), ) } func TestInstallRun_UpstreamDetection(t *testing.T) { tests := []struct { name string isTTY bool stubs func(*httpmock.Registry) opts func(t *testing.T, ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions wantErr string wantStdout string wantStderr string }{ { name: "detects re-published skill and user picks re-publisher", 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")) stubContentsAPI(reg, "monalisa", "skills-repo", "skills/git-commit/SKILL.md", republishedContent) stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", republishedContent) }, opts: func(t *testing.T, ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, Prompter: &prompter.PrompterMock{ SelectFunc: func(_ string, _ string, choices []string) (int, error) { require.Len(t, choices, 2) assert.Contains(t, choices[0], "monalisa/skills-repo") assert.Contains(t, choices[1], "monalisa/original-skills") return 0, nil }, }, Telemetry: &telemetry.NoOpService{}, SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Agent: "github-copilot", Scope: "project", ScopeChanged: true, Dir: t.TempDir(), } }, wantStderr: "originally published in monalisa/original-skills", wantStdout: "Installed git-commit", }, { name: "detects re-published skill and user picks upstream", 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")) stubContentsAPI(reg, "monalisa", "skills-repo", "skills/git-commit/SKILL.md", republishedContent) stubResolveVersion(reg, "monalisa", "original-skills", "v2.0.0", "upstream456") stubDiscoverTree(reg, "monalisa", "original-skills", "upstream456", singleSkillTreeJSON("git-commit", "upTreeSHA", "upBlobSHA")) stubContentsAPI(reg, "monalisa", "original-skills", "skills/git-commit/SKILL.md", gitCommitContent) stubInstallFiles(reg, "monalisa", "original-skills", "upTreeSHA", "upBlobSHA", gitCommitContent) }, opts: func(t *testing.T, ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, Prompter: &prompter.PrompterMock{ SelectFunc: func(_ string, _ string, choices []string) (int, error) { require.Len(t, choices, 2) assert.Contains(t, choices[0], "monalisa/skills-repo") assert.Contains(t, choices[1], "monalisa/original-skills") return 1, nil }, }, Telemetry: &telemetry.NoOpService{}, SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Agent: "github-copilot", Scope: "project", ScopeChanged: true, Dir: t.TempDir(), } }, wantStderr: "Redirecting install to monalisa/original-skills", wantStdout: "Installed git-commit", }, { name: "non-interactive defaults to re-publisher with notice", 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")) stubContentsAPI(reg, "monalisa", "skills-repo", "skills/git-commit/SKILL.md", republishedContent) stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", republishedContent) }, opts: func(t *testing.T, ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, Telemetry: &telemetry.NoOpService{}, SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Agent: "github-copilot", Scope: "project", ScopeChanged: true, Dir: t.TempDir(), } }, wantStderr: "use --upstream", wantStdout: "Installed git-commit", }, { name: "non-interactive with --upstream redirects to upstream", 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")) stubContentsAPI(reg, "monalisa", "skills-repo", "skills/git-commit/SKILL.md", republishedContent) stubResolveVersion(reg, "monalisa", "original-skills", "v2.0.0", "upstream456") stubDiscoverTree(reg, "monalisa", "original-skills", "upstream456", singleSkillTreeJSON("git-commit", "upTreeSHA", "upBlobSHA")) stubContentsAPI(reg, "monalisa", "original-skills", "skills/git-commit/SKILL.md", gitCommitContent) stubInstallFiles(reg, "monalisa", "original-skills", "upTreeSHA", "upBlobSHA", gitCommitContent) }, opts: func(t *testing.T, ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, Telemetry: &telemetry.NoOpService{}, SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Agent: "github-copilot", Scope: "project", ScopeChanged: true, Dir: t.TempDir(), Upstream: true, } }, wantStderr: "Redirecting install to monalisa/original-skills", wantStdout: "Installed git-commit", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { reg := &httpmock.Registry{} defer reg.Verify(t) tt.stubs(reg) homeDir := t.TempDir() t.Setenv("HOME", homeDir) t.Setenv("USERPROFILE", homeDir) ios, _, stdout, stderr := iostreams.Test() ios.SetStdoutTTY(tt.isTTY) ios.SetStdinTTY(tt.isTTY) ios.SetStderrTTY(tt.isTTY) opts := tt.opts(t, 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) } }) } }