add skills command scaffold
This commit is contained in:
parent
f79cc02bdd
commit
e57fb436fa
11 changed files with 2206 additions and 0 deletions
175
internal/skills/hosts/hosts.go
Normal file
175
internal/skills/hosts/hosts.go
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
package hosts
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/cli/cli/v2/git"
|
||||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
)
|
||||
|
||||
// Host represents an AI agent that can use skills.
|
||||
type Host struct {
|
||||
// ID is the canonical identifier for this host.
|
||||
ID string
|
||||
// Name is the human-readable display name.
|
||||
Name string
|
||||
// ProjectDir is the relative path within a project for skills.
|
||||
ProjectDir string
|
||||
// UserDir is the relative path within the user's home directory for skills.
|
||||
UserDir string
|
||||
}
|
||||
|
||||
// Scope determines where skills are installed.
|
||||
type Scope string
|
||||
|
||||
const (
|
||||
ScopeProject Scope = "project"
|
||||
ScopeUser Scope = "user"
|
||||
)
|
||||
|
||||
// Registry contains all known agent hosts.
|
||||
var Registry = []Host{
|
||||
{
|
||||
ID: "github-copilot",
|
||||
Name: "GitHub Copilot",
|
||||
ProjectDir: ".github/skills",
|
||||
UserDir: ".copilot/skills",
|
||||
},
|
||||
{
|
||||
ID: "claude-code",
|
||||
Name: "Claude Code",
|
||||
ProjectDir: ".claude/skills",
|
||||
UserDir: ".claude/skills",
|
||||
},
|
||||
{
|
||||
ID: "cursor",
|
||||
Name: "Cursor",
|
||||
ProjectDir: ".cursor/skills",
|
||||
UserDir: ".cursor/skills",
|
||||
},
|
||||
{
|
||||
ID: "codex",
|
||||
Name: "Codex",
|
||||
ProjectDir: ".agents/skills",
|
||||
UserDir: ".codex/skills",
|
||||
},
|
||||
{
|
||||
ID: "gemini",
|
||||
Name: "Gemini CLI",
|
||||
ProjectDir: ".agent/skills",
|
||||
UserDir: ".gemini/skills",
|
||||
},
|
||||
{
|
||||
ID: "antigravity",
|
||||
Name: "Antigravity",
|
||||
ProjectDir: ".agent/skills",
|
||||
UserDir: ".gemini/antigravity/skills",
|
||||
},
|
||||
}
|
||||
|
||||
// FindByID returns the host with the given ID, or an error if not found.
|
||||
func FindByID(id string) (*Host, error) {
|
||||
for i := range Registry {
|
||||
if Registry[i].ID == id {
|
||||
return &Registry[i], nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("unknown host %q, valid hosts: %s", id, ValidHostIDs())
|
||||
}
|
||||
|
||||
// ValidHostIDs returns a comma-separated list of valid host IDs.
|
||||
func ValidHostIDs() string {
|
||||
ids := ""
|
||||
for i, h := range Registry {
|
||||
if i > 0 {
|
||||
ids += ", "
|
||||
}
|
||||
ids += h.ID
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// HostIDs returns the IDs of all known hosts as a slice.
|
||||
func HostIDs() []string {
|
||||
ids := make([]string, len(Registry))
|
||||
for i, h := range Registry {
|
||||
ids[i] = h.ID
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// HostNames returns the display names of all hosts for prompting.
|
||||
func HostNames() []string {
|
||||
names := make([]string, len(Registry))
|
||||
for i, h := range Registry {
|
||||
names[i] = h.Name
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// UniqueProjectDirs returns the deduplicated set of project-scope skill
|
||||
// directories from the Registry, preserving insertion order.
|
||||
func UniqueProjectDirs() []string {
|
||||
seen := map[string]bool{}
|
||||
var dirs []string
|
||||
for _, h := range Registry {
|
||||
if !seen[h.ProjectDir] {
|
||||
seen[h.ProjectDir] = true
|
||||
dirs = append(dirs, h.ProjectDir)
|
||||
}
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
|
||||
// InstallDir resolves the absolute installation directory for a host and scope.
|
||||
// For project scope, it uses the provided git root directory so that skills are
|
||||
// installed at the top level regardless of which subdirectory the user is in.
|
||||
// Returns an error when gitRoot is empty (not in a git repository).
|
||||
// For user scope, it uses the home directory.
|
||||
func (h *Host) InstallDir(scope Scope, gitRoot, homeDir string) (string, error) {
|
||||
switch scope {
|
||||
case ScopeProject:
|
||||
if gitRoot == "" {
|
||||
return "", fmt.Errorf("could not determine project root directory")
|
||||
}
|
||||
return filepath.Join(gitRoot, h.ProjectDir), nil
|
||||
case ScopeUser:
|
||||
if homeDir == "" {
|
||||
return "", fmt.Errorf("could not determine home directory")
|
||||
}
|
||||
return filepath.Join(homeDir, h.UserDir), nil
|
||||
default:
|
||||
return "", fmt.Errorf("invalid scope %q", scope)
|
||||
}
|
||||
}
|
||||
|
||||
// ScopeLabels returns the display labels for the scope selection prompt.
|
||||
// If repoName is non-empty, it is included in the project-scope label
|
||||
// for additional context.
|
||||
func ScopeLabels(repoName string) []string {
|
||||
projectLabel := "Project — install in current repository (recommended)"
|
||||
if repoName != "" {
|
||||
projectLabel = fmt.Sprintf("Project — %s (recommended)", repoName)
|
||||
}
|
||||
return []string{
|
||||
projectLabel,
|
||||
"Global — install in home directory (available everywhere)",
|
||||
}
|
||||
}
|
||||
|
||||
// RepoNameFromRemote extracts "owner/repo" from a git remote URL.
|
||||
func RepoNameFromRemote(remote string) string {
|
||||
if remote == "" {
|
||||
return ""
|
||||
}
|
||||
u, err := git.ParseURL(remote)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
repo, err := ghrepo.FromURL(u)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return ghrepo.FullName(repo)
|
||||
}
|
||||
113
internal/skills/hosts/hosts_test.go
Normal file
113
internal/skills/hosts/hosts_test.go
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
package hosts
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFindByID(t *testing.T) {
|
||||
host, err := FindByID("github-copilot")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "GitHub Copilot", host.Name)
|
||||
assert.Equal(t, ".github/skills", host.ProjectDir)
|
||||
}
|
||||
|
||||
func TestFindByID_Invalid(t *testing.T) {
|
||||
_, err := FindByID("nonexistent")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unknown host")
|
||||
}
|
||||
|
||||
func TestValidHostIDs(t *testing.T) {
|
||||
ids := ValidHostIDs()
|
||||
assert.Contains(t, ids, "github-copilot")
|
||||
assert.Contains(t, ids, "claude-code")
|
||||
assert.Contains(t, ids, "cursor")
|
||||
}
|
||||
|
||||
func TestHostNames(t *testing.T) {
|
||||
names := HostNames()
|
||||
assert.Contains(t, names, "GitHub Copilot")
|
||||
assert.Contains(t, names, "Claude Code")
|
||||
}
|
||||
|
||||
func TestInstallDir_Project(t *testing.T) {
|
||||
host, _ := FindByID("github-copilot")
|
||||
dir, err := host.InstallDir(ScopeProject, "/tmp/myrepo", "/home/user")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, filepath.Join("/tmp/myrepo", ".github", "skills"), dir)
|
||||
}
|
||||
|
||||
func TestInstallDir_User(t *testing.T) {
|
||||
host, _ := FindByID("github-copilot")
|
||||
dir, err := host.InstallDir(ScopeUser, "/tmp/myrepo", "/home/user")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, filepath.Join("/home/user", ".copilot", "skills"), dir)
|
||||
}
|
||||
|
||||
func TestInstallDir_NoGitRoot(t *testing.T) {
|
||||
host, _ := FindByID("github-copilot")
|
||||
_, err := host.InstallDir(ScopeProject, "", "/home/user")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestRepoNameFromRemote(t *testing.T) {
|
||||
tests := []struct {
|
||||
remote string
|
||||
want string
|
||||
}{
|
||||
{"https://github.com/owner/repo.git", "owner/repo"},
|
||||
{"https://github.com/owner/repo", "owner/repo"},
|
||||
{"git@github.com:owner/repo.git", "owner/repo"},
|
||||
{"git@github.com:owner/repo", "owner/repo"},
|
||||
{"ssh://git@github.com/owner/repo.git", "owner/repo"},
|
||||
{"ssh://git@github.com/owner/repo", "owner/repo"},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.remote, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, RepoNameFromRemote(tt.remote))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUniqueProjectDirs(t *testing.T) {
|
||||
dirs := UniqueProjectDirs()
|
||||
|
||||
// Should contain all known project dirs
|
||||
assert.Contains(t, dirs, ".github/skills")
|
||||
assert.Contains(t, dirs, ".claude/skills")
|
||||
assert.Contains(t, dirs, ".cursor/skills")
|
||||
assert.Contains(t, dirs, ".agents/skills")
|
||||
assert.Contains(t, dirs, ".agent/skills")
|
||||
|
||||
// Should deduplicate — gemini and antigravity share .agent/skills
|
||||
seen := map[string]int{}
|
||||
for _, d := range dirs {
|
||||
seen[d]++
|
||||
}
|
||||
for dir, count := range seen {
|
||||
assert.Equalf(t, 1, count, "directory %q appears %d times, expected 1", dir, count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeLabels(t *testing.T) {
|
||||
t.Run("without repo name", func(t *testing.T) {
|
||||
labels := ScopeLabels("")
|
||||
require.Len(t, labels, 2)
|
||||
assert.Contains(t, labels[0], "Project")
|
||||
assert.Contains(t, labels[0], "recommended")
|
||||
assert.Contains(t, labels[1], "Global")
|
||||
})
|
||||
|
||||
t.Run("with repo name", func(t *testing.T) {
|
||||
labels := ScopeLabels("owner/repo")
|
||||
require.Len(t, labels, 2)
|
||||
assert.Contains(t, labels[0], "owner/repo")
|
||||
assert.Contains(t, labels[0], "recommended")
|
||||
assert.Contains(t, labels[1], "Global")
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue