cli/internal/skills/installer/installer.go
2026-04-15 15:43:43 +02:00

296 lines
8.3 KiB
Go

package installer
import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"github.com/cli/cli/v2/internal/skills/discovery"
"github.com/cli/cli/v2/internal/skills/frontmatter"
"github.com/cli/cli/v2/internal/skills/hosts"
"github.com/cli/cli/v2/internal/skills/lockfile"
)
// maxConcurrency limits parallel API requests to avoid rate limiting.
const maxConcurrency = 5
// Options configures an installation.
type Options struct {
Host string // GitHub API hostname
Owner string
Repo string
Ref string // resolved ref name
SHA string // resolved commit SHA
PinnedRef string // user-supplied --pin value (empty if unpinned)
Skills []discovery.Skill
AgentHost *hosts.Host
Scope hosts.Scope
Dir string // explicit target directory (overrides AgentHost+Scope)
GitRoot string // git repository root (for project scope)
HomeDir string // user home directory (for user scope)
Client discovery.RESTClient
OnProgress func(done, total int) // called after each skill is installed
}
// Result tracks what was installed.
type Result struct {
Installed []string
Dir string
Warnings []string
}
type skillResult struct {
name string
err error
}
// Install fetches and writes skills to the target directory.
func Install(opts *Options) (*Result, error) {
targetDir := opts.Dir
if targetDir == "" {
var err error
targetDir, err = opts.AgentHost.InstallDir(opts.Scope, opts.GitRoot, opts.HomeDir)
if err != nil {
return nil, err
}
}
if len(opts.Skills) == 1 {
skill := opts.Skills[0]
if opts.OnProgress != nil {
opts.OnProgress(0, 1)
}
if err := installSkill(opts, skill, targetDir); err != nil {
return nil, fmt.Errorf("failed to install skill %q: %w", skill.InstallName(), err)
}
var warnings []string
if err := lockfile.RecordInstall(skill.InstallName(), opts.Owner, opts.Repo, skill.Path+"/SKILL.md", skill.TreeSHA, opts.PinnedRef); err != nil {
warnings = append(warnings, fmt.Sprintf("could not record install for %s: %v", skill.InstallName(), err))
}
if opts.OnProgress != nil {
opts.OnProgress(1, 1)
}
return &Result{Installed: []string{skill.InstallName()}, Dir: targetDir, Warnings: warnings}, nil
}
total := len(opts.Skills)
if opts.OnProgress != nil {
opts.OnProgress(0, total)
}
sem := make(chan struct{}, maxConcurrency)
results := make([]skillResult, total)
var wg sync.WaitGroup
var mu sync.Mutex
done := 0
for i, skill := range opts.Skills {
wg.Add(1)
go func(idx int, s discovery.Skill) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
err := installSkill(opts, s, targetDir)
results[idx] = skillResult{name: s.InstallName(), err: err}
if opts.OnProgress != nil {
mu.Lock()
done++
d := done
mu.Unlock()
opts.OnProgress(d, total)
}
}(i, skill)
}
wg.Wait()
var installed []string
var warnings []string
var firstErr error
for i, r := range results {
if r.err != nil {
if firstErr == nil {
firstErr = fmt.Errorf("failed to install skill %q: %w", r.name, r.err)
}
continue
}
installed = append(installed, r.name)
skill := opts.Skills[i]
if err := lockfile.RecordInstall(skill.InstallName(), opts.Owner, opts.Repo, skill.Path+"/SKILL.md", skill.TreeSHA, opts.PinnedRef); err != nil {
warnings = append(warnings, fmt.Sprintf("could not record install for %s: %v", skill.InstallName(), err))
}
}
if firstErr != nil {
return &Result{Installed: installed, Dir: targetDir, Warnings: warnings}, firstErr
}
return &Result{Installed: installed, Dir: targetDir, Warnings: warnings}, nil
}
// LocalOptions configures a local directory installation.
type LocalOptions struct {
SourceDir string
Skills []discovery.Skill
AgentHost *hosts.Host
Scope hosts.Scope
Dir string
GitRoot string
HomeDir string
}
// InstallLocal copies skills from a local directory to the target install location.
func InstallLocal(opts *LocalOptions) (*Result, error) {
targetDir := opts.Dir
if targetDir == "" {
var err error
targetDir, err = opts.AgentHost.InstallDir(opts.Scope, opts.GitRoot, opts.HomeDir)
if err != nil {
return nil, err
}
}
var installed []string
for _, skill := range opts.Skills {
if err := installLocalSkill(opts.SourceDir, skill, targetDir); err != nil {
return nil, fmt.Errorf("failed to install skill %q: %w", skill.InstallName(), err)
}
installed = append(installed, skill.InstallName())
}
return &Result{Installed: installed, Dir: targetDir}, nil
}
func installLocalSkill(sourceRoot string, skill discovery.Skill, baseDir string) error {
skillDir := filepath.Join(baseDir, filepath.FromSlash(skill.InstallName()))
if err := os.MkdirAll(skillDir, 0o755); err != nil {
return fmt.Errorf("could not create directory %s: %w", skillDir, err)
}
srcDir := filepath.Join(sourceRoot, filepath.FromSlash(skill.Path))
absSource, err := filepath.Abs(srcDir)
if err != nil {
return fmt.Errorf("could not resolve source path: %w", err)
}
absSkillDir, err := filepath.Abs(skillDir)
if err != nil {
return fmt.Errorf("could not resolve target path: %w", err)
}
return filepath.WalkDir(srcDir, func(p string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.Type()&os.ModeSymlink != 0 {
return nil
}
if d.IsDir() {
return nil
}
relPath, err := filepath.Rel(srcDir, p)
if err != nil {
return err
}
cleaned := filepath.Clean(relPath)
if cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) {
return nil
}
destPath := filepath.Join(skillDir, cleaned)
absDest, err := filepath.Abs(destPath)
if err != nil {
return fmt.Errorf("could not resolve destination path: %w", err)
}
if !strings.HasPrefix(absDest, absSkillDir+string(filepath.Separator)) && absDest != absSkillDir {
return nil
}
if dir := filepath.Dir(destPath); dir != skillDir {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("could not create directory: %w", err)
}
}
content, err := os.ReadFile(p)
if err != nil {
return fmt.Errorf("could not read %s: %w", p, err)
}
if filepath.Base(relPath) == "SKILL.md" {
injected, injectErr := frontmatter.InjectLocalMetadata(string(content), absSource)
if injectErr != nil {
return fmt.Errorf("could not inject metadata: %w", injectErr)
}
content = []byte(injected)
}
return os.WriteFile(destPath, content, 0o644)
})
}
func installSkill(opts *Options, skill discovery.Skill, baseDir string) error {
skillDir := filepath.Join(baseDir, filepath.FromSlash(skill.InstallName()))
if err := os.MkdirAll(skillDir, 0o755); err != nil {
return fmt.Errorf("could not create directory %s: %w", skillDir, err)
}
files, err := discovery.DiscoverSkillFiles(opts.Client, opts.Host, opts.Owner, opts.Repo, skill.TreeSHA, skill.Path)
if err != nil {
return fmt.Errorf("could not list skill files: %w", err)
}
absSkillDir, err := filepath.Abs(skillDir)
if err != nil {
return fmt.Errorf("could not resolve skill directory path: %w", err)
}
for _, file := range files {
content, err := discovery.FetchBlob(opts.Client, opts.Host, opts.Owner, opts.Repo, file.SHA)
if err != nil {
return fmt.Errorf("could not fetch %s: %w", file.Path, err)
}
relPath := strings.TrimPrefix(file.Path, skill.Path+"/")
cleaned := filepath.Clean(relPath)
if cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) {
continue
}
destPath := filepath.Join(skillDir, cleaned)
absDest, err := filepath.Abs(destPath)
if err != nil {
return fmt.Errorf("could not resolve destination path: %w", err)
}
if !strings.HasPrefix(absDest, absSkillDir+string(filepath.Separator)) && absDest != absSkillDir {
continue
}
if dir := filepath.Dir(destPath); dir != skillDir {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("could not create directory: %w", err)
}
}
if filepath.Base(relPath) == "SKILL.md" {
content, err = frontmatter.InjectGitHubMetadata(content, opts.Owner, opts.Repo, opts.Ref, file.SHA, skill.TreeSHA, opts.PinnedRef, skill.Path)
if err != nil {
return fmt.Errorf("could not inject metadata: %w", err)
}
}
if err := os.WriteFile(destPath, []byte(content), 0o644); err != nil {
return fmt.Errorf("could not write %s: %w", destPath, err)
}
}
return nil
}