add skills command scaffold

This commit is contained in:
tommaso-moro 2026-03-30 17:26:41 +01:00 committed by Sam Morrow
parent f79cc02bdd
commit e57fb436fa
No known key found for this signature in database
11 changed files with 2206 additions and 0 deletions

View 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)
}

View 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")
})
}