1982 lines
68 KiB
Go
1982 lines
68 KiB
Go
package install
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"net/http"
|
|
"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/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,
|
|
},
|
|
}
|
|
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, 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)
|
|
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 <repository> [<skill[@version]>] [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, 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 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 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 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)
|
|
// Extra 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)))
|
|
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()
|
|
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(),
|
|
}
|
|
},
|
|
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-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: "supports only github.com",
|
|
},
|
|
{
|
|
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 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) {
|
|
return []int{0, 2}, nil // GitHub Copilot + Cursor share .agents/skills
|
|
},
|
|
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,
|
|
})
|
|
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 install to separate directories",
|
|
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()},
|
|
}
|
|
},
|
|
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: true,
|
|
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()
|
|
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()},
|
|
}
|
|
},
|
|
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",
|
|
},
|
|
}
|
|
|
|
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_printReviewHint(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
repo string
|
|
sha string
|
|
skillNames []string
|
|
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: "",
|
|
},
|
|
}
|
|
|
|
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)
|
|
if tt.wantOutput == "" {
|
|
assert.Empty(t, buf.String())
|
|
} else {
|
|
assert.Contains(t, buf.String(), tt.wantOutput)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|