add skill list command
This commit is contained in:
parent
9b505c3fb8
commit
d9fab039ab
3 changed files with 833 additions and 0 deletions
480
pkg/cmd/skills/list/list.go
Normal file
480
pkg/cmd/skills/list/list.go
Normal file
|
|
@ -0,0 +1,480 @@
|
|||
package list
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/v2/git"
|
||||
"github.com/cli/cli/v2/internal/gh/ghtelemetry"
|
||||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
"github.com/cli/cli/v2/internal/skills/discovery"
|
||||
"github.com/cli/cli/v2/internal/skills/frontmatter"
|
||||
"github.com/cli/cli/v2/internal/skills/installer"
|
||||
"github.com/cli/cli/v2/internal/skills/registry"
|
||||
"github.com/cli/cli/v2/internal/skills/source"
|
||||
"github.com/cli/cli/v2/internal/tableprinter"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var skillListFields = []string{
|
||||
"skillName",
|
||||
"hosts",
|
||||
"scope",
|
||||
"sourceURL",
|
||||
"version",
|
||||
"pinned",
|
||||
"path",
|
||||
}
|
||||
|
||||
// ListOptions holds dependencies and user-provided flags for the list command.
|
||||
type ListOptions struct {
|
||||
IO *iostreams.IOStreams
|
||||
Telemetry ghtelemetry.EventRecorder
|
||||
GitClient *git.Client
|
||||
Exporter cmdutil.Exporter
|
||||
|
||||
Agent string
|
||||
Scope string
|
||||
ScopeChanged bool
|
||||
Dir string
|
||||
}
|
||||
|
||||
type agentInfo struct {
|
||||
id string
|
||||
}
|
||||
|
||||
type scanTarget struct {
|
||||
dir string
|
||||
hosts []agentInfo
|
||||
scope string
|
||||
}
|
||||
|
||||
type listedSkill struct {
|
||||
skillName string
|
||||
hostIDs []string
|
||||
scope string
|
||||
source string
|
||||
sourceURL string
|
||||
version string
|
||||
pinned bool
|
||||
path string
|
||||
}
|
||||
|
||||
// ExportData implements cmdutil.exportable for --json output.
|
||||
func (s listedSkill) ExportData(fields []string) map[string]interface{} {
|
||||
data := map[string]interface{}{}
|
||||
for _, f := range fields {
|
||||
switch f {
|
||||
case "skillName":
|
||||
data[f] = s.skillName
|
||||
case "hosts":
|
||||
data[f] = s.hostIDs
|
||||
case "scope":
|
||||
data[f] = s.scope
|
||||
case "sourceURL":
|
||||
data[f] = s.sourceURL
|
||||
case "version":
|
||||
data[f] = s.version
|
||||
case "pinned":
|
||||
data[f] = s.pinned
|
||||
case "path":
|
||||
data[f] = s.path
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// NewCmdList creates the "skills list" command.
|
||||
func NewCmdList(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, runF func(*ListOptions) error) *cobra.Command {
|
||||
opts := &ListOptions{
|
||||
IO: f.IOStreams,
|
||||
Telemetry: telemetry,
|
||||
GitClient: f.GitClient,
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "list [flags]",
|
||||
Short: "List installed skills (preview)",
|
||||
Aliases: []string{"ls"},
|
||||
Long: heredoc.Docf(`
|
||||
List installed agent skills across known agent host directories.
|
||||
|
||||
By default, scans all supported agent hosts in both project and user scope.
|
||||
Use %[1]s--agent%[1]s to scan one host, %[1]s--scope%[1]s to scan only project or user
|
||||
scope, or %[1]s--dir%[1]s to scan a custom skills directory.
|
||||
|
||||
Project-scope skills are discovered relative to the current git repository
|
||||
root. User-scope skills are discovered relative to your home directory.
|
||||
`, "`"),
|
||||
Example: heredoc.Doc(`
|
||||
# List all installed skills
|
||||
$ gh skill list
|
||||
|
||||
# List skills installed for Claude Code
|
||||
$ gh skill list --agent claude-code
|
||||
|
||||
# List user-scope skills
|
||||
$ gh skill list --scope user
|
||||
|
||||
# List skills as JSON
|
||||
$ gh skill list --json skillName,sourceURL,scope,version,pinned,path
|
||||
`),
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.ScopeChanged = cmd.Flags().Changed("scope")
|
||||
|
||||
if opts.Dir != "" && opts.Agent != "" {
|
||||
return cmdutil.FlagErrorf("--dir and --agent cannot be used together")
|
||||
}
|
||||
if opts.Dir != "" && opts.ScopeChanged {
|
||||
return cmdutil.FlagErrorf("--dir and --scope cannot be used together")
|
||||
}
|
||||
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
return listRun(opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmdutil.StringEnumFlag(cmd, &opts.Agent, "agent", "", "", registry.AgentIDs(), "Filter by target agent")
|
||||
cmdutil.StringEnumFlag(cmd, &opts.Scope, "scope", "", "", []string{string(registry.ScopeProject), string(registry.ScopeUser)}, "Filter by installation scope")
|
||||
cmd.Flags().StringVar(&opts.Dir, "dir", "", "Scan a custom directory for installed skills")
|
||||
cmdutil.AddJSONFlags(cmd, &opts.Exporter, skillListFields)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func listRun(opts *ListOptions) error {
|
||||
skills, err := listInstalledSkills(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sortListedSkills(skills)
|
||||
recordListTelemetry(opts, len(skills))
|
||||
|
||||
if opts.Exporter != nil {
|
||||
return opts.Exporter.Write(opts.IO, skills)
|
||||
}
|
||||
|
||||
if len(skills) == 0 {
|
||||
return cmdutil.NewNoResultsError("no installed skills found")
|
||||
}
|
||||
|
||||
return renderTable(opts.IO, skills)
|
||||
}
|
||||
|
||||
func listInstalledSkills(opts *ListOptions) ([]listedSkill, error) {
|
||||
targets, err := buildScanTargets(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var all []listedSkill
|
||||
for _, target := range targets {
|
||||
skills, scanErr := scanInstalledSkills(target.dir, target.hosts, target.scope)
|
||||
if scanErr != nil {
|
||||
if opts.Dir != "" {
|
||||
return nil, fmt.Errorf("could not scan directory: %w", scanErr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
all = append(all, skills...)
|
||||
}
|
||||
|
||||
return all, nil
|
||||
}
|
||||
|
||||
func buildScanTargets(opts *ListOptions) ([]scanTarget, error) {
|
||||
if opts.Dir != "" {
|
||||
dir, err := filepath.Abs(opts.Dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not resolve path: %w", err)
|
||||
}
|
||||
return []scanTarget{{dir: dir, scope: "custom"}}, nil
|
||||
}
|
||||
|
||||
gitRoot := installer.ResolveGitRoot(opts.GitClient)
|
||||
homeDir := installer.ResolveHomeDir()
|
||||
|
||||
hosts, err := selectedHosts(opts.Agent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scopes := selectedScopes(opts.Scope)
|
||||
|
||||
byDir := map[string]int{}
|
||||
var targets []scanTarget
|
||||
for _, host := range hosts {
|
||||
for _, scope := range scopes {
|
||||
dir, installErr := host.InstallDir(scope, gitRoot, homeDir)
|
||||
if installErr != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if idx, ok := byDir[dir]; ok {
|
||||
targets[idx].hosts = appendHost(targets[idx].hosts, host)
|
||||
continue
|
||||
}
|
||||
|
||||
byDir[dir] = len(targets)
|
||||
targets = append(targets, scanTarget{
|
||||
dir: dir,
|
||||
hosts: []agentInfo{{id: host.ID}},
|
||||
scope: string(scope),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return targets, nil
|
||||
}
|
||||
|
||||
func selectedHosts(agentID string) ([]*registry.AgentHost, error) {
|
||||
if agentID != "" {
|
||||
host, err := registry.FindByID(agentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []*registry.AgentHost{host}, nil
|
||||
}
|
||||
|
||||
hosts := make([]*registry.AgentHost, len(registry.Agents))
|
||||
for i := range registry.Agents {
|
||||
hosts[i] = ®istry.Agents[i]
|
||||
}
|
||||
return hosts, nil
|
||||
}
|
||||
|
||||
func selectedScopes(scope string) []registry.Scope {
|
||||
if scope != "" {
|
||||
return []registry.Scope{registry.Scope(scope)}
|
||||
}
|
||||
return []registry.Scope{registry.ScopeProject, registry.ScopeUser}
|
||||
}
|
||||
|
||||
func appendHost(hosts []agentInfo, host *registry.AgentHost) []agentInfo {
|
||||
for _, existing := range hosts {
|
||||
if existing.id == host.ID {
|
||||
return hosts
|
||||
}
|
||||
}
|
||||
return append(hosts, agentInfo{id: host.ID})
|
||||
}
|
||||
|
||||
func scanInstalledSkills(skillsDir string, hosts []agentInfo, scope string) ([]listedSkill, error) {
|
||||
entries, err := os.ReadDir(skillsDir)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not read skills directory: %w", err)
|
||||
}
|
||||
|
||||
var skills []listedSkill
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Flat layout: {dir}/{name}/SKILL.md.
|
||||
skillDir := filepath.Join(skillsDir, e.Name())
|
||||
skillFile := filepath.Join(skillDir, "SKILL.md")
|
||||
if data, readErr := os.ReadFile(skillFile); readErr == nil {
|
||||
skills = append(skills, parseInstalledSkill(data, e.Name(), skillDir, hosts, scope))
|
||||
continue
|
||||
}
|
||||
|
||||
// Namespaced layout: {dir}/{namespace}/{name}/SKILL.md.
|
||||
subEntries, subErr := os.ReadDir(skillDir)
|
||||
if subErr != nil {
|
||||
continue
|
||||
}
|
||||
for _, sub := range subEntries {
|
||||
if !sub.IsDir() {
|
||||
continue
|
||||
}
|
||||
subSkillDir := filepath.Join(skillDir, sub.Name())
|
||||
subSkillFile := filepath.Join(subSkillDir, "SKILL.md")
|
||||
if data, readErr := os.ReadFile(subSkillFile); readErr == nil {
|
||||
installName := e.Name() + "/" + sub.Name()
|
||||
skills = append(skills, parseInstalledSkill(data, installName, subSkillDir, hosts, scope))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return skills, nil
|
||||
}
|
||||
|
||||
func parseInstalledSkill(data []byte, name, dir string, hosts []agentInfo, scope string) listedSkill {
|
||||
s := listedSkill{
|
||||
skillName: name,
|
||||
hostIDs: hostIDs(hosts),
|
||||
scope: scope,
|
||||
path: dir,
|
||||
}
|
||||
|
||||
result, err := frontmatter.Parse(string(data))
|
||||
if err != nil {
|
||||
return s
|
||||
}
|
||||
|
||||
meta := result.Metadata.Meta
|
||||
if meta == nil {
|
||||
return s
|
||||
}
|
||||
|
||||
if sourcePath, _ := meta["github-path"].(string); sourcePath != "" {
|
||||
if skillName := skillNameFromSourcePath(sourcePath); skillName != "" {
|
||||
s.skillName = skillName
|
||||
}
|
||||
}
|
||||
|
||||
if repoURL, _ := meta["github-repo"].(string); repoURL != "" {
|
||||
s.sourceURL = repoURL
|
||||
s.source = repoURL
|
||||
if repo, parseErr := source.ParseRepoURL(repoURL); parseErr == nil {
|
||||
s.source = ghrepo.FullName(repo)
|
||||
s.sourceURL = source.BuildRepoURL(repo.RepoHost(), repo.RepoOwner(), repo.RepoName())
|
||||
}
|
||||
} else if localPath, _ := meta["local-path"].(string); localPath != "" {
|
||||
s.sourceURL = localPath
|
||||
s.source = localPath
|
||||
}
|
||||
|
||||
if ref, _ := meta["github-ref"].(string); ref != "" {
|
||||
s.version = discovery.ShortRef(ref)
|
||||
}
|
||||
if pinnedRef, _ := meta["github-pinned"].(string); pinnedRef != "" {
|
||||
s.pinned = true
|
||||
if s.version == "" {
|
||||
s.version = pinnedRef
|
||||
}
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func skillNameFromSourcePath(sourcePath string) string {
|
||||
sourcePath = strings.TrimSuffix(sourcePath, "/SKILL.md")
|
||||
sourcePath = strings.Trim(sourcePath, "/")
|
||||
if sourcePath == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
parts := strings.Split(sourcePath, "/")
|
||||
for i := len(parts) - 1; i >= 0; i-- {
|
||||
if parts[i] != "skills" {
|
||||
continue
|
||||
}
|
||||
|
||||
if i >= 2 && parts[i-2] == "plugins" && i+1 < len(parts) {
|
||||
return parts[i-1] + "/" + parts[len(parts)-1]
|
||||
}
|
||||
|
||||
afterSkills := len(parts) - i - 1
|
||||
switch afterSkills {
|
||||
case 0:
|
||||
return ""
|
||||
case 1:
|
||||
return parts[i+1]
|
||||
default:
|
||||
return parts[i+1] + "/" + parts[len(parts)-1]
|
||||
}
|
||||
}
|
||||
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
|
||||
func hostIDs(hosts []agentInfo) []string {
|
||||
ids := make([]string, len(hosts))
|
||||
for i, host := range hosts {
|
||||
ids[i] = host.id
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func sortListedSkills(skills []listedSkill) {
|
||||
sort.Slice(skills, func(i, j int) bool {
|
||||
if skills[i].skillName != skills[j].skillName {
|
||||
return skills[i].skillName < skills[j].skillName
|
||||
}
|
||||
if skills[i].scope != skills[j].scope {
|
||||
return skills[i].scope < skills[j].scope
|
||||
}
|
||||
if formatHosts(skills[i].hostIDs) != formatHosts(skills[j].hostIDs) {
|
||||
return formatHosts(skills[i].hostIDs) < formatHosts(skills[j].hostIDs)
|
||||
}
|
||||
return skills[i].path < skills[j].path
|
||||
})
|
||||
}
|
||||
|
||||
func renderTable(io *iostreams.IOStreams, skills []listedSkill) error {
|
||||
table := tableprinter.New(io, tableprinter.WithHeader("Name", "Agent", "Scope", "Source"))
|
||||
|
||||
for _, skill := range skills {
|
||||
table.AddField(skill.skillName)
|
||||
table.AddField(formatHosts(skill.hostIDs))
|
||||
table.AddField(displayOrDash(skill.scope))
|
||||
table.AddField(displayOrDash(skill.source))
|
||||
table.EndRow()
|
||||
}
|
||||
|
||||
return table.Render()
|
||||
}
|
||||
|
||||
func displayOrDash(value string) string {
|
||||
if value == "" {
|
||||
return "-"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func formatHosts(hosts []string) string {
|
||||
if len(hosts) == 0 {
|
||||
return "-"
|
||||
}
|
||||
return strings.Join(hosts, ",")
|
||||
}
|
||||
|
||||
func recordListTelemetry(opts *ListOptions, skillCount int) {
|
||||
if opts.Telemetry == nil {
|
||||
return
|
||||
}
|
||||
|
||||
agentHosts := opts.Agent
|
||||
if agentHosts == "" {
|
||||
agentHosts = "all"
|
||||
}
|
||||
scope := opts.Scope
|
||||
if scope == "" {
|
||||
scope = "all"
|
||||
}
|
||||
customDir := "false"
|
||||
if opts.Dir != "" {
|
||||
customDir = "true"
|
||||
scope = "custom"
|
||||
}
|
||||
format := "table"
|
||||
if opts.Exporter != nil {
|
||||
format = "json"
|
||||
}
|
||||
|
||||
opts.Telemetry.Record(ghtelemetry.Event{
|
||||
Type: "skill_list",
|
||||
Dimensions: ghtelemetry.Dimensions{
|
||||
"agent_hosts": agentHosts,
|
||||
"custom_dir": customDir,
|
||||
"format": format,
|
||||
"scope": scope,
|
||||
},
|
||||
Measures: ghtelemetry.Measures{
|
||||
"skill_count": int64(skillCount),
|
||||
},
|
||||
})
|
||||
}
|
||||
348
pkg/cmd/skills/list/list_test.go
Normal file
348
pkg/cmd/skills/list/list_test.go
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
package list
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/v2/git"
|
||||
"github.com/cli/cli/v2/internal/telemetry"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/google/shlex"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewCmdList(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cli string
|
||||
wantOpts ListOptions
|
||||
wantJSON bool
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "no flags",
|
||||
cli: "",
|
||||
wantOpts: ListOptions{},
|
||||
},
|
||||
{
|
||||
name: "agent and scope filters",
|
||||
cli: "--agent claude-code --scope user",
|
||||
wantOpts: ListOptions{
|
||||
Agent: "claude-code",
|
||||
Scope: "user",
|
||||
ScopeChanged: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "custom dir",
|
||||
cli: "--dir ./skills",
|
||||
wantOpts: ListOptions{
|
||||
Dir: "./skills",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "json fields",
|
||||
cli: "--json skillName,sourceURL,scope,version,pinned,path",
|
||||
wantJSON: true,
|
||||
},
|
||||
{
|
||||
name: "too many args",
|
||||
cli: "extra",
|
||||
wantErr: "unknown command",
|
||||
},
|
||||
{
|
||||
name: "invalid agent",
|
||||
cli: "--agent unknown",
|
||||
wantErr: "invalid argument",
|
||||
},
|
||||
{
|
||||
name: "invalid scope",
|
||||
cli: "--scope org",
|
||||
wantErr: "invalid argument",
|
||||
},
|
||||
{
|
||||
name: "dir and agent are mutually exclusive",
|
||||
cli: "--dir ./skills --agent claude-code",
|
||||
wantErr: "--dir and --agent cannot be used together",
|
||||
},
|
||||
{
|
||||
name: "dir and scope are mutually exclusive",
|
||||
cli: "--dir ./skills --scope user",
|
||||
wantErr: "--dir and --scope cannot be used together",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ios, _, _, _ := iostreams.Test()
|
||||
f := &cmdutil.Factory{IOStreams: ios, GitClient: &git.Client{}}
|
||||
|
||||
var gotOpts *ListOptions
|
||||
cmd := NewCmdList(f, &telemetry.NoOpService{}, func(opts *ListOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
|
||||
args, err := shlex.Split(tt.cli)
|
||||
require.NoError(t, err)
|
||||
cmd.SetArgs(args)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
|
||||
err = cmd.Execute()
|
||||
if tt.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, gotOpts)
|
||||
assert.Equal(t, tt.wantOpts.Agent, gotOpts.Agent)
|
||||
assert.Equal(t, tt.wantOpts.Scope, gotOpts.Scope)
|
||||
assert.Equal(t, tt.wantOpts.ScopeChanged, gotOpts.ScopeChanged)
|
||||
assert.Equal(t, tt.wantOpts.Dir, gotOpts.Dir)
|
||||
if tt.wantJSON {
|
||||
assert.NotNil(t, gotOpts.Exporter)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCmdList_Metadata(t *testing.T) {
|
||||
ios, _, _, _ := iostreams.Test()
|
||||
f := &cmdutil.Factory{IOStreams: ios, GitClient: &git.Client{}}
|
||||
cmd := NewCmdList(f, &telemetry.NoOpService{}, nil)
|
||||
|
||||
assert.Equal(t, "list [flags]", cmd.Use)
|
||||
assert.NotEmpty(t, cmd.Short)
|
||||
assert.NotEmpty(t, cmd.Long)
|
||||
assert.NotEmpty(t, cmd.Example)
|
||||
assert.Contains(t, cmd.Aliases, "ls")
|
||||
|
||||
for _, flag := range []string{"agent", "scope", "dir", "json"} {
|
||||
assert.NotNil(t, cmd.Flags().Lookup(flag), "missing flag: --%s", flag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRun(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(t *testing.T, repoDir, homeDir string)
|
||||
opts func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions
|
||||
wantStdout string
|
||||
wantJSON string
|
||||
wantErr string
|
||||
verify func(t *testing.T, stdout string, spy *telemetry.CommandRecorderSpy)
|
||||
}{
|
||||
{
|
||||
name: "lists project skill for selected shared agent",
|
||||
setup: func(t *testing.T, repoDir, homeDir string) {
|
||||
writeSkill(t, repoDir, ".agents/skills/git-commit", remoteSkillFrontmatter("git-commit", "skills/git-commit", "refs/tags/v1.0.0", ""))
|
||||
},
|
||||
opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions {
|
||||
return &ListOptions{
|
||||
IO: ios,
|
||||
Telemetry: spy,
|
||||
GitClient: &git.Client{RepoDir: repoDir},
|
||||
Agent: "cursor",
|
||||
Scope: "project",
|
||||
}
|
||||
},
|
||||
wantStdout: "git-commit\tcursor\tproject\tmonalisa/skills-repo\n",
|
||||
verify: func(t *testing.T, stdout string, spy *telemetry.CommandRecorderSpy) {
|
||||
require.Len(t, spy.Events, 1)
|
||||
event := spy.Events[0]
|
||||
assert.Equal(t, "skill_list", event.Type)
|
||||
assert.Equal(t, "cursor", event.Dimensions["agent_hosts"])
|
||||
assert.Equal(t, "project", event.Dimensions["scope"])
|
||||
assert.Equal(t, int64(1), event.Measures["skill_count"])
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "lists user skill as json",
|
||||
setup: func(t *testing.T, repoDir, homeDir string) {
|
||||
writeSkill(t, homeDir, ".claude/skills/code-review", remoteSkillFrontmatter("code-review", "skills/code-review", "refs/tags/v2.0.0", "v2.0.0"))
|
||||
},
|
||||
opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions {
|
||||
exporter := cmdutil.NewJSONExporter()
|
||||
exporter.SetFields([]string{"skillName", "hosts", "scope", "sourceURL", "version", "pinned", "path"})
|
||||
return &ListOptions{
|
||||
IO: ios,
|
||||
Telemetry: spy,
|
||||
GitClient: &git.Client{RepoDir: repoDir},
|
||||
Exporter: exporter,
|
||||
Agent: "claude-code",
|
||||
Scope: "user",
|
||||
}
|
||||
},
|
||||
wantJSON: fmt.Sprintf(`[
|
||||
{
|
||||
"skillName": "code-review",
|
||||
"hosts": ["claude-code"],
|
||||
"scope": "user",
|
||||
"sourceURL": "https://github.com/monalisa/skills-repo",
|
||||
"version": "v2.0.0",
|
||||
"pinned": true,
|
||||
"path": %q
|
||||
}
|
||||
]`, filepath.Join("HOME", ".claude", "skills", "code-review")),
|
||||
verify: func(t *testing.T, stdout string, spy *telemetry.CommandRecorderSpy) {
|
||||
assert.Equal(t, "json", spy.Events[0].Dimensions["format"])
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "custom directory with local metadata",
|
||||
setup: func(t *testing.T, repoDir, homeDir string) {
|
||||
customDir := filepath.Join(repoDir, "custom-skills")
|
||||
writeSkill(t, customDir, "local-helper", heredoc.Doc(`
|
||||
---
|
||||
name: local-helper
|
||||
metadata:
|
||||
local-path: /src/local-helper
|
||||
---
|
||||
Body
|
||||
`))
|
||||
},
|
||||
opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions {
|
||||
return &ListOptions{
|
||||
IO: ios,
|
||||
Telemetry: spy,
|
||||
GitClient: &git.Client{RepoDir: repoDir},
|
||||
Dir: filepath.Join(repoDir, "custom-skills"),
|
||||
}
|
||||
},
|
||||
wantStdout: "local-helper\t-\tcustom\t/src/local-helper\n",
|
||||
},
|
||||
{
|
||||
name: "recovers namespaced skill name from source path",
|
||||
setup: func(t *testing.T, repoDir, homeDir string) {
|
||||
writeSkill(t, repoDir, ".agents/skills/xlsx-pro", remoteSkillFrontmatter("xlsx-pro", "skills/bob/xlsx-pro", "refs/heads/main", ""))
|
||||
},
|
||||
opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions {
|
||||
return &ListOptions{
|
||||
IO: ios,
|
||||
Telemetry: spy,
|
||||
GitClient: &git.Client{RepoDir: repoDir},
|
||||
Agent: "github-copilot",
|
||||
Scope: "project",
|
||||
}
|
||||
},
|
||||
wantStdout: "bob/xlsx-pro\tgithub-copilot\tproject\tmonalisa/skills-repo\n",
|
||||
},
|
||||
{
|
||||
name: "no installed skills returns no results",
|
||||
opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions {
|
||||
return &ListOptions{
|
||||
IO: ios,
|
||||
Telemetry: spy,
|
||||
GitClient: &git.Client{RepoDir: repoDir},
|
||||
Agent: "github-copilot",
|
||||
Scope: "project",
|
||||
}
|
||||
},
|
||||
wantErr: "no installed skills found",
|
||||
},
|
||||
{
|
||||
name: "no installed skills with json returns empty array",
|
||||
opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions {
|
||||
exporter := cmdutil.NewJSONExporter()
|
||||
exporter.SetFields([]string{"skillName"})
|
||||
return &ListOptions{
|
||||
IO: ios,
|
||||
Telemetry: spy,
|
||||
GitClient: &git.Client{RepoDir: repoDir},
|
||||
Exporter: exporter,
|
||||
Agent: "github-copilot",
|
||||
Scope: "project",
|
||||
}
|
||||
},
|
||||
wantJSON: "[]",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
repoDir := t.TempDir()
|
||||
homeDir := t.TempDir()
|
||||
t.Setenv("HOME", homeDir)
|
||||
|
||||
if tt.setup != nil {
|
||||
tt.setup(t, repoDir, homeDir)
|
||||
}
|
||||
|
||||
ios, _, stdout, _ := iostreams.Test()
|
||||
ios.SetStdoutTTY(false)
|
||||
spy := &telemetry.CommandRecorderSpy{}
|
||||
opts := tt.opts(ios, repoDir, homeDir, spy)
|
||||
|
||||
err := listRun(opts)
|
||||
if tt.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
if tt.wantJSON != "" {
|
||||
expected := tt.wantJSON
|
||||
expected = string(bytes.ReplaceAll([]byte(expected), []byte(filepath.Join("HOME")), []byte(homeDir)))
|
||||
assert.JSONEq(t, expected, stdout.String())
|
||||
} else {
|
||||
assert.Equal(t, tt.wantStdout, stdout.String())
|
||||
}
|
||||
if tt.verify != nil {
|
||||
tt.verify(t, stdout.String(), spy)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderTableUsesAgentHeader(t *testing.T) {
|
||||
ios, _, stdout, _ := iostreams.Test()
|
||||
ios.SetStdoutTTY(true)
|
||||
|
||||
err := renderTable(ios, []listedSkill{{
|
||||
skillName: "git-commit",
|
||||
hostIDs: []string{"github-copilot"},
|
||||
scope: "project",
|
||||
source: "monalisa/skills-repo",
|
||||
version: "v1.0.0",
|
||||
}})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, stdout.String(), "AGENT")
|
||||
assert.NotContains(t, stdout.String(), "HOST")
|
||||
}
|
||||
|
||||
func writeSkill(t *testing.T, baseDir, relDir, content string) {
|
||||
t.Helper()
|
||||
skillDir := filepath.Join(baseDir, filepath.FromSlash(relDir))
|
||||
require.NoError(t, os.MkdirAll(skillDir, 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644))
|
||||
}
|
||||
|
||||
func remoteSkillFrontmatter(name, sourcePath, ref, pinned string) string {
|
||||
pinnedLine := ""
|
||||
if pinned != "" {
|
||||
pinnedLine = fmt.Sprintf(" github-pinned: %s\n", pinned)
|
||||
}
|
||||
return fmt.Sprintf(heredoc.Doc(`
|
||||
---
|
||||
name: %s
|
||||
metadata:
|
||||
github-repo: https://github.com/monalisa/skills-repo
|
||||
github-ref: %s
|
||||
github-tree-sha: abc123
|
||||
github-path: %s
|
||||
%s---
|
||||
Body
|
||||
`), name, ref, sourcePath, pinnedLine)
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/v2/internal/gh/ghtelemetry"
|
||||
"github.com/cli/cli/v2/pkg/cmd/skills/install"
|
||||
skilllist "github.com/cli/cli/v2/pkg/cmd/skills/list"
|
||||
"github.com/cli/cli/v2/pkg/cmd/skills/preview"
|
||||
"github.com/cli/cli/v2/pkg/cmd/skills/publish"
|
||||
"github.com/cli/cli/v2/pkg/cmd/skills/search"
|
||||
|
|
@ -32,6 +33,9 @@ func NewCmdSkills(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder) *co
|
|||
# Install a skill
|
||||
$ gh skill install github/awesome-copilot documentation-writer
|
||||
|
||||
# List installed skills
|
||||
$ gh skill list
|
||||
|
||||
# Preview a skill before installing
|
||||
$ gh skill preview github/awesome-copilot documentation-writer
|
||||
|
||||
|
|
@ -48,6 +52,7 @@ func NewCmdSkills(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder) *co
|
|||
}
|
||||
|
||||
cmd.AddCommand(install.NewCmdInstall(f, telemetry, nil))
|
||||
cmd.AddCommand(skilllist.NewCmdList(f, telemetry, nil))
|
||||
cmd.AddCommand(preview.NewCmdPreview(f, telemetry, nil))
|
||||
cmd.AddCommand(publish.NewCmdPublish(f, nil))
|
||||
cmd.AddCommand(search.NewCmdSearch(f, telemetry, nil))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue