1298 lines
41 KiB
Go
1298 lines
41 KiB
Go
package install
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/MakeNowJust/heredoc"
|
|
"github.com/cli/cli/v2/api"
|
|
ghContext "github.com/cli/cli/v2/context"
|
|
"github.com/cli/cli/v2/git"
|
|
"github.com/cli/cli/v2/internal/gh/ghtelemetry"
|
|
"github.com/cli/cli/v2/internal/ghinstance"
|
|
"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/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/text"
|
|
"github.com/cli/cli/v2/pkg/cmdutil"
|
|
"github.com/cli/cli/v2/pkg/iostreams"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
const (
|
|
// allSkillsKey is the persistent option label for selecting all skills.
|
|
allSkillsKey = "(all skills)"
|
|
|
|
// maxSearchResults caps how many skills are shown per search page in
|
|
// interactive selection, keeping the prompt readable.
|
|
maxSearchResults = 30
|
|
)
|
|
|
|
// InstallOptions holds all dependencies and user-provided flags for the install command.
|
|
type InstallOptions struct {
|
|
IO *iostreams.IOStreams
|
|
Telemetry ghtelemetry.EventRecorder
|
|
HttpClient func() (*http.Client, error)
|
|
Prompter prompter.Prompter
|
|
GitClient *git.Client
|
|
Remotes func() (ghContext.Remotes, error)
|
|
|
|
SkillSource string // owner/repo or local path (when --from-local is set)
|
|
SkillName string // possibly with @version suffix
|
|
Agent string
|
|
Scope string
|
|
ScopeChanged bool // true when --scope was explicitly set
|
|
Pin string
|
|
Dir string // overrides --agent and --scope
|
|
Force bool
|
|
FromLocal bool // treat SkillSource as a local directory path
|
|
AllowHiddenDirs bool // include skills in dot-prefixed directories
|
|
Upstream bool // install from upstream when re-published skill detected
|
|
|
|
repo ghrepo.Interface // set when SkillSource is a GitHub repository
|
|
localPath string // set when FromLocal is true
|
|
version string // parsed from SkillName@version
|
|
}
|
|
|
|
// NewCmdInstall creates the "skills install" command.
|
|
func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, runF func(*InstallOptions) error) *cobra.Command {
|
|
opts := &InstallOptions{
|
|
IO: f.IOStreams,
|
|
Telemetry: telemetry,
|
|
Prompter: f.Prompter,
|
|
GitClient: f.GitClient,
|
|
Remotes: f.Remotes,
|
|
HttpClient: f.HttpClient,
|
|
}
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "install <repository> [<skill[@version]>] [flags]",
|
|
Short: "Install agent skills from a GitHub repository (preview)",
|
|
Long: heredoc.Docf(`
|
|
Install agent skills from a GitHub repository or local directory into
|
|
your local environment. Skills are placed in a host-specific directory
|
|
at either project scope (inside the current git repository) or user
|
|
scope (in your home directory, available everywhere).
|
|
|
|
A wide range of AI coding agents are supported, including GitHub
|
|
Copilot, Claude Code, Cursor, Codex, Gemini CLI, Antigravity, Amp,
|
|
Goose, Junie, OpenCode, Windsurf, and many more.
|
|
|
|
Supported %[1]s--agent%[1]s values:
|
|
|
|
%[2]s
|
|
|
|
Use %[1]s--agent%[1]s and %[1]s--scope%[1]s to control placement, or %[1]s--dir%[1]s for a
|
|
custom directory. The default scope is %[1]sproject%[1]s, and the default
|
|
agent is %[1]sgithub-copilot%[1]s (when running non-interactively).
|
|
|
|
At project scope, several agents (including GitHub Copilot, Cursor,
|
|
Codex, Gemini CLI, Antigravity, Amp, Cline, OpenCode, and Warp) share
|
|
the %[1]s.agents/skills%[1]s directory. If you select multiple hosts that
|
|
resolve to the same destination, each skill is installed there only once.
|
|
|
|
The first argument is a GitHub repository in %[1]sOWNER/REPO%[1]s format.
|
|
Use %[1]s--from-local%[1]s to install from a local directory instead.
|
|
Local skills are auto-discovered using the same conventions as remote
|
|
repositories, and files are copied (not symlinked) with local-path
|
|
tracking metadata injected into frontmatter.
|
|
|
|
Skills are discovered automatically using the %[1]sskills/*/SKILL.md%[1]s convention
|
|
defined by the Agent Skills specification, including when the %[1]sskills/%[1]s
|
|
directory is nested under a prefix (e.g. %[1]sterraform/code-generation/skills/...%[1]s).
|
|
For more information on the specification,
|
|
see: https://agentskills.io/specification
|
|
|
|
The skill argument can be a name, a namespaced name (%[1]sauthor/skill%[1]s),
|
|
or an exact path within the repository (%[1]sskills/author/skill%[1]s or
|
|
%[1]sskills/author/skill/SKILL.md%[1]s).
|
|
|
|
Performance tip: when installing from a large repository with many
|
|
skills, providing an exact path instead of a skill name avoids a
|
|
full tree traversal of the repository, making the install significantly faster.
|
|
|
|
When a skill name is provided without a version, the CLI resolves the
|
|
version in this order:
|
|
|
|
1. Latest tagged release in the repository
|
|
2. Default branch HEAD
|
|
|
|
To pin to a specific version, either append %[1]s@VERSION%[1]s to the skill
|
|
name or use the %[1]s--pin%[1]s flag. The version is resolved as a git tag or commit SHA.
|
|
|
|
Installed skills have source tracking metadata injected into their
|
|
frontmatter. This metadata identifies the source repository and
|
|
enables %[1]sgh skill update%[1]s to detect changes.
|
|
|
|
When run interactively, the command prompts for any missing arguments.
|
|
When run non-interactively, %[1]srepository%[1]s and a skill name are
|
|
required.
|
|
`, "`", registry.AgentHelpList()),
|
|
Example: heredoc.Doc(`
|
|
# Interactive: choose repo, skill, and agent
|
|
$ gh skill install
|
|
|
|
# Choose a skill from the repo interactively
|
|
$ gh skill install github/awesome-copilot
|
|
|
|
# Install a specific skill
|
|
$ gh skill install github/awesome-copilot git-commit
|
|
|
|
# Install a specific version
|
|
$ gh skill install github/awesome-copilot git-commit@v1.2.0
|
|
|
|
# Install from a large namespaced repo by path (efficient, skips full discovery)
|
|
$ gh skill install github/awesome-copilot skills/monalisa/code-review
|
|
|
|
# Install from a local directory
|
|
$ gh skill install ./my-skills-repo --from-local
|
|
|
|
# Install a specific local skill
|
|
$ gh skill install ./my-skills-repo git-commit --from-local
|
|
|
|
# Install for Claude Code at user scope
|
|
$ gh skill install github/awesome-copilot git-commit --agent claude-code --scope user
|
|
|
|
# Pin to a specific git ref
|
|
$ gh skill install github/awesome-copilot git-commit --pin v2.0.0
|
|
|
|
# Install skills from hidden directories (e.g. .claude/skills/)
|
|
$ gh skill install owner/repo --allow-hidden-dirs
|
|
`),
|
|
Aliases: []string{"add"},
|
|
Args: cobra.MaximumNArgs(2),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
if len(args) >= 1 {
|
|
opts.SkillSource = args[0]
|
|
}
|
|
if len(args) >= 2 {
|
|
opts.SkillName = args[1]
|
|
}
|
|
opts.ScopeChanged = cmd.Flags().Changed("scope")
|
|
|
|
// Resolve the source type early so installRun can branch directly.
|
|
if opts.FromLocal {
|
|
if opts.SkillSource == "" {
|
|
return cmdutil.FlagErrorf("--from-local requires a directory path argument")
|
|
}
|
|
opts.localPath = opts.SkillSource
|
|
} else if len(args) == 0 && !opts.IO.CanPrompt() {
|
|
return cmdutil.FlagErrorf("must specify a repository to install from")
|
|
}
|
|
|
|
if err := cmdutil.MutuallyExclusive("--from-local and --pin cannot be used together", opts.FromLocal, opts.Pin != ""); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := cmdutil.MutuallyExclusive("--from-local and --upstream cannot be used together", opts.FromLocal, opts.Upstream); err != nil {
|
|
return err
|
|
}
|
|
|
|
if opts.Pin != "" && opts.SkillName != "" && strings.Contains(opts.SkillName, "@") {
|
|
return cmdutil.FlagErrorf("cannot use --pin with an inline @version in the skill name")
|
|
}
|
|
|
|
if runF != nil {
|
|
return runF(opts)
|
|
}
|
|
return installRun(opts)
|
|
},
|
|
}
|
|
|
|
agentFlag := cmdutil.StringEnumFlag(cmd, &opts.Agent, "agent", "", "", registry.AgentIDs(), "Target agent")
|
|
agentFlag.Usage = "Target agent (see supported values above)"
|
|
cmdutil.StringEnumFlag(cmd, &opts.Scope, "scope", "", "project", []string{"project", "user"}, "Installation scope")
|
|
cmd.Flags().StringVar(&opts.Pin, "pin", "", "Pin to a specific git tag or commit SHA")
|
|
cmd.Flags().StringVar(&opts.Dir, "dir", "", "Install to a custom directory (overrides --agent and --scope)")
|
|
cmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "Overwrite existing skills without prompting")
|
|
cmd.Flags().BoolVar(&opts.FromLocal, "from-local", false, "Treat the argument as a local directory path instead of a repository")
|
|
cmd.Flags().BoolVar(&opts.AllowHiddenDirs, "allow-hidden-dirs", false, "Include skills in hidden directories (e.g. .claude/skills/, .agents/skills/)")
|
|
cmd.Flags().BoolVar(&opts.Upstream, "upstream", false, "Install from the upstream source when a re-published skill is detected")
|
|
cmdutil.DisableAuthCheckFlag(cmd.Flags().Lookup("from-local"))
|
|
|
|
return cmd
|
|
}
|
|
|
|
func installRun(opts *InstallOptions) error {
|
|
cs := opts.IO.ColorScheme()
|
|
canPrompt := opts.IO.CanPrompt()
|
|
|
|
if opts.localPath != "" {
|
|
return runLocalInstall(opts)
|
|
}
|
|
|
|
repo, repoSource, err := resolveRepoArg(opts.SkillSource, canPrompt, opts.Prompter)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
opts.repo = repo
|
|
opts.SkillSource = repoSource
|
|
|
|
parseSkillFromOpts(opts)
|
|
|
|
httpClient, err := opts.HttpClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
apiClient := api.NewClientFromHTTP(httpClient)
|
|
|
|
hostname := opts.repo.RepoHost()
|
|
if err := source.ValidateSupportedHost(hostname); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Kick off the visibility fetch in parallel with the install work so
|
|
// the extra API roundtrip doesn't add latency on the critical path.
|
|
// The result is consumed when the telemetry event is emitted below.
|
|
// Capture repo fields now to avoid a data race if opts.repo is
|
|
// swapped during an upstream redirect.
|
|
type visResult struct {
|
|
vis discovery.RepoVisibility
|
|
err error
|
|
}
|
|
visCh := make(chan visResult, 1)
|
|
visOwner := opts.repo.RepoOwner()
|
|
visRepo := opts.repo.RepoName()
|
|
go func() {
|
|
vis, err := discovery.FetchRepoVisibility(apiClient, hostname, visOwner, visRepo)
|
|
visCh <- visResult{vis: vis, err: err}
|
|
}()
|
|
|
|
resolved, err := resolveVersion(opts, apiClient, hostname)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var selectedSkills []discovery.Skill
|
|
|
|
if isSkillPath(opts.SkillName) {
|
|
opts.IO.StartProgressIndicatorWithLabel("Looking up skill")
|
|
skill, err := discovery.DiscoverSkillByPath(apiClient, hostname, opts.repo.RepoOwner(), opts.repo.RepoName(), resolved.SHA, opts.SkillName)
|
|
opts.IO.StopProgressIndicator()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
selectedSkills = []discovery.Skill{*skill}
|
|
} else {
|
|
skills, err := discoverSkills(opts, apiClient, hostname, resolved)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
selectedSkills, err = selectSkillsWithSelector(opts, skills, canPrompt, skillSelector{
|
|
matchByName: matchSkillByName,
|
|
sourceHint: ghrepo.FullName(opts.repo),
|
|
fetchDescriptions: func() {
|
|
opts.IO.StartProgressIndicatorWithLabel("Fetching skill info")
|
|
discovery.FetchDescriptionsConcurrent(apiClient, hostname, opts.repo.RepoOwner(), opts.repo.RepoName(), skills, nil)
|
|
opts.IO.StopProgressIndicator()
|
|
},
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Track upstream provenance detection result for telemetry.
|
|
upstreamSource := "none"
|
|
|
|
// Check if the selected skill was re-published from an upstream source.
|
|
// The re-publisher's SKILL.md will have github-repo metadata pointing
|
|
// to the original source repo. If detected, offer to install directly
|
|
// from upstream instead.
|
|
if len(selectedSkills) == 1 && selectedSkills[0].BlobSHA != "" {
|
|
upstreamRepo, detected, err := checkUpstreamProvenance(opts, apiClient, hostname, selectedSkills[0], resolved.SHA)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if upstreamRepo != nil {
|
|
redirectDims := map[string]string{}
|
|
select {
|
|
case r := <-visCh:
|
|
if r.err == nil && r.vis == discovery.RepoVisibilityPublic {
|
|
redirectDims["from_owner"] = visOwner
|
|
redirectDims["from_repo"] = visRepo
|
|
}
|
|
case <-time.After(visibilityWaitTimeout):
|
|
}
|
|
opts.Telemetry.Record(ghtelemetry.Event{
|
|
Type: "skill_upstream_redirect",
|
|
Dimensions: redirectDims,
|
|
})
|
|
opts.repo = upstreamRepo
|
|
opts.SkillSource = ghrepo.FullName(upstreamRepo)
|
|
opts.version = ""
|
|
opts.Pin = ""
|
|
return installRun(opts)
|
|
}
|
|
if detected {
|
|
upstreamSource = "republisher"
|
|
}
|
|
}
|
|
|
|
printPreInstallDisclaimer(opts.IO.ErrOut, cs)
|
|
|
|
selectedHosts, err := resolveHosts(opts, canPrompt)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
scope, err := resolveScope(opts, canPrompt)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
gitRoot := installer.ResolveGitRoot(opts.GitClient)
|
|
homeDir := installer.ResolveHomeDir()
|
|
repoSource = ghrepo.FullName(opts.repo)
|
|
|
|
plans, err := buildInstallPlans(opts, selectedSkills, selectedHosts, scope, gitRoot, homeDir, canPrompt)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, plan := range plans {
|
|
if len(plans) > 1 {
|
|
fmt.Fprintf(opts.IO.ErrOut, "\nInstalling to %s for %s...\n", friendlyDir(plan.dir), formatPlanHosts(plan.hosts))
|
|
}
|
|
|
|
result, err := installer.Install(&installer.Options{
|
|
Host: hostname,
|
|
Owner: opts.repo.RepoOwner(),
|
|
Repo: opts.repo.RepoName(),
|
|
Ref: resolved.Ref,
|
|
SHA: resolved.SHA,
|
|
PinnedRef: opts.Pin,
|
|
Skills: plan.skills,
|
|
Dir: plan.dir,
|
|
Client: apiClient,
|
|
OnProgress: installProgress(opts.IO, len(plan.skills)),
|
|
})
|
|
|
|
if result != nil {
|
|
for _, w := range result.Warnings {
|
|
fmt.Fprintf(opts.IO.ErrOut, "%s %s\n", cs.WarningIcon(), w)
|
|
}
|
|
|
|
for _, name := range result.Installed {
|
|
fmt.Fprintf(opts.IO.Out, "%s Installed %s (from %s@%s) in %s\n",
|
|
cs.SuccessIcon(), name, repoSource, discovery.ShortRef(resolved.Ref), friendlyDir(result.Dir))
|
|
}
|
|
|
|
printFileTree(opts.IO.ErrOut, cs, result.Dir, result.Installed)
|
|
printReviewHint(opts.IO.ErrOut, cs, repoSource, resolved.SHA, result.Installed, opts.AllowHiddenDirs)
|
|
printHostHints(opts.IO.ErrOut, cs, plan.hosts, result.Installed, result.Dir, gitRoot)
|
|
}
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
dims := map[string]string{
|
|
"agent_hosts": mapAgentHostsToIDs(selectedHosts),
|
|
"skill_host_type": ghinstance.CategorizeHost(opts.repo.RepoHost()),
|
|
"upstream_source": upstreamSource,
|
|
}
|
|
select {
|
|
case r := <-visCh:
|
|
if r.err == nil {
|
|
dims["repo_visibility"] = string(r.vis)
|
|
if r.vis == discovery.RepoVisibilityPublic {
|
|
dims["skill_owner"] = opts.repo.RepoOwner()
|
|
dims["skill_repo"] = opts.repo.RepoName()
|
|
dims["skill_names"] = mapSkillsToNames(selectedSkills)
|
|
}
|
|
} else {
|
|
dims["repo_visibility"] = "unknown"
|
|
}
|
|
case <-time.After(visibilityWaitTimeout):
|
|
dims["repo_visibility"] = "unknown"
|
|
}
|
|
opts.Telemetry.Record(ghtelemetry.Event{
|
|
Type: "skill_install",
|
|
Dimensions: dims,
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
// visibilityWaitTimeout is how long to wait at telemetry-emit time for
|
|
// the in-flight repo visibility fetch before giving up and emitting
|
|
// repo_visibility="unknown". By this point the command has already done
|
|
// several serial API calls and (for install) a git sparse-checkout, so
|
|
// the fetch has almost always completed; this budget is a short safety
|
|
// net for the case where that single REST call has stalled.
|
|
const visibilityWaitTimeout = 200 * time.Millisecond
|
|
|
|
func mapSkillsToNames(skills []discovery.Skill) string {
|
|
names := make([]string, len(skills))
|
|
for i, s := range skills {
|
|
names[i] = s.DisplayName()
|
|
}
|
|
return strings.Join(names, ",")
|
|
}
|
|
|
|
func mapAgentHostsToIDs(hosts []*registry.AgentHost) string {
|
|
agentHostIDs := make([]string, len(hosts))
|
|
for i, h := range hosts {
|
|
agentHostIDs[i] = h.ID
|
|
}
|
|
return strings.Join(agentHostIDs, ",")
|
|
}
|
|
|
|
// runLocalInstall handles installation from a local directory path.
|
|
func runLocalInstall(opts *InstallOptions) error {
|
|
cs := opts.IO.ColorScheme()
|
|
canPrompt := opts.IO.CanPrompt()
|
|
sourcePath := opts.localPath
|
|
if sourcePath == "~" {
|
|
if home, err := os.UserHomeDir(); err == nil {
|
|
sourcePath = home
|
|
}
|
|
} else if after, ok := strings.CutPrefix(sourcePath, "~/"); ok {
|
|
if home, err := os.UserHomeDir(); err == nil {
|
|
sourcePath = filepath.Join(home, after)
|
|
}
|
|
}
|
|
|
|
absSource, err := filepath.Abs(sourcePath)
|
|
if err != nil {
|
|
return fmt.Errorf("could not resolve path: %w", err)
|
|
}
|
|
|
|
opts.IO.StartProgressIndicatorWithLabel("Discovering skills")
|
|
allSkills, err := discovery.DiscoverLocalSkillsWithOptions(absSource, discovery.DiscoverOptions{})
|
|
opts.IO.StopProgressIndicator()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
skills, err := filterHiddenDirSkills(opts, allSkills)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if canPrompt {
|
|
fmt.Fprintf(opts.IO.ErrOut, "Found %d skill(s)\n", len(skills))
|
|
}
|
|
|
|
selectedSkills, err := selectSkillsWithSelector(opts, skills, canPrompt, skillSelector{
|
|
matchByName: matchLocalSkillByName,
|
|
sourceHint: absSource,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
printPreInstallDisclaimer(opts.IO.ErrOut, cs)
|
|
|
|
selectedHosts, err := resolveHosts(opts, canPrompt)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
scope, err := resolveScope(opts, canPrompt)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
gitRoot := installer.ResolveGitRoot(opts.GitClient)
|
|
homeDir := installer.ResolveHomeDir()
|
|
|
|
plans, err := buildInstallPlans(opts, selectedSkills, selectedHosts, scope, gitRoot, homeDir, canPrompt)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, plan := range plans {
|
|
if len(plans) > 1 {
|
|
fmt.Fprintf(opts.IO.ErrOut, "\nInstalling to %s for %s...\n", friendlyDir(plan.dir), formatPlanHosts(plan.hosts))
|
|
}
|
|
|
|
result, err := installer.InstallLocal(&installer.LocalOptions{
|
|
SourceDir: absSource,
|
|
Skills: plan.skills,
|
|
Dir: plan.dir,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, name := range result.Installed {
|
|
fmt.Fprintf(opts.IO.Out, "Installed %s (from %s) in %s\n",
|
|
name, opts.SkillSource, friendlyDir(result.Dir))
|
|
}
|
|
|
|
printFileTree(opts.IO.ErrOut, cs, result.Dir, result.Installed)
|
|
printReviewHint(opts.IO.ErrOut, cs, "", "", result.Installed, false)
|
|
printHostHints(opts.IO.ErrOut, cs, plan.hosts, result.Installed, result.Dir, gitRoot)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// isSkillPath returns true if the argument looks like a repo-relative path
|
|
// rather than a simple skill name.
|
|
func isSkillPath(name string) bool {
|
|
if name == "" {
|
|
return false
|
|
}
|
|
if name == "SKILL.md" || strings.HasSuffix(name, "/SKILL.md") {
|
|
return true
|
|
}
|
|
if strings.HasPrefix(name, "skills/") || strings.HasPrefix(name, "plugins/") {
|
|
return true
|
|
}
|
|
if strings.Contains(name, "/skills/") || strings.Contains(name, "/plugins/") {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func resolveRepoArg(skillSource string, canPrompt bool, p prompter.Prompter) (ghrepo.Interface, string, error) {
|
|
if skillSource == "" {
|
|
if !canPrompt {
|
|
return nil, "", cmdutil.FlagErrorf("must specify a repository to install from")
|
|
}
|
|
repoInput, err := p.Input("Repository (owner/repo):", "")
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
skillSource = strings.TrimSpace(repoInput)
|
|
if skillSource == "" {
|
|
return nil, "", fmt.Errorf("must specify a repository to install from")
|
|
}
|
|
}
|
|
repo, err := ghrepo.FromFullName(skillSource)
|
|
if err != nil {
|
|
return nil, "", cmdutil.FlagErrorf("invalid repository reference %q: expected OWNER/REPO, HOST/OWNER/REPO, or a full URL", skillSource)
|
|
}
|
|
return repo, skillSource, nil
|
|
}
|
|
|
|
func parseSkillFromOpts(opts *InstallOptions) {
|
|
if opts.SkillName != "" {
|
|
if name, version, ok := cutLast(opts.SkillName, "@"); ok && name != "" {
|
|
opts.version = version
|
|
opts.SkillName = name
|
|
return
|
|
}
|
|
}
|
|
if opts.Pin != "" {
|
|
opts.version = opts.Pin
|
|
}
|
|
}
|
|
|
|
// cutLast splits s around the last occurrence of sep,
|
|
// returning the text before and after sep, and whether sep was found.
|
|
func cutLast(s, sep string) (before, after string, found bool) {
|
|
if i := strings.LastIndex(s, sep); i >= 0 {
|
|
return s[:i], s[i+len(sep):], true
|
|
}
|
|
return s, "", false
|
|
}
|
|
|
|
func resolveVersion(opts *InstallOptions, client *api.Client, hostname string) (*discovery.ResolvedRef, error) {
|
|
opts.IO.StartProgressIndicatorWithLabel("Resolving version")
|
|
resolved, err := discovery.ResolveRef(client, hostname, opts.repo.RepoOwner(), opts.repo.RepoName(), opts.version)
|
|
opts.IO.StopProgressIndicator()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not resolve version: %w", err)
|
|
}
|
|
fmt.Fprintf(opts.IO.ErrOut, "Using ref %s (%s)\n", discovery.ShortRef(resolved.Ref), git.ShortSHA(resolved.SHA))
|
|
return resolved, nil
|
|
}
|
|
|
|
func discoverSkills(opts *InstallOptions, client *api.Client, hostname string, resolved *discovery.ResolvedRef) ([]discovery.Skill, error) {
|
|
opts.IO.StartProgressIndicatorWithLabel("Discovering skills")
|
|
allSkills, err := discovery.DiscoverSkillsWithOptions(client, hostname, opts.repo.RepoOwner(), opts.repo.RepoName(), resolved.SHA, discovery.DiscoverOptions{})
|
|
opts.IO.StopProgressIndicator()
|
|
if err != nil {
|
|
var treeTooLarge *discovery.TreeTooLargeError
|
|
if errors.As(err, &treeTooLarge) {
|
|
fmt.Fprintf(opts.IO.ErrOut, "%s\n Use path-based install instead: gh skill install %s/%s skills/<skill-name>\n",
|
|
err, treeTooLarge.Owner, treeTooLarge.Repo)
|
|
return nil, err
|
|
}
|
|
return nil, err
|
|
}
|
|
skills, filterErr := filterHiddenDirSkills(opts, allSkills)
|
|
if filterErr != nil {
|
|
return nil, filterErr
|
|
}
|
|
logConventions(opts.IO, skills)
|
|
for _, s := range skills {
|
|
if !discovery.IsSpecCompliant(s.Name) {
|
|
fmt.Fprintf(opts.IO.ErrOut, "Warning: skill %q does not follow the agentskills.io naming convention\n", s.DisplayName())
|
|
}
|
|
}
|
|
return skills, nil
|
|
}
|
|
|
|
func logConventions(io *iostreams.IOStreams, skills []discovery.Skill) {
|
|
conventions := make(map[string]int)
|
|
for _, s := range skills {
|
|
conventions[s.Convention]++
|
|
}
|
|
if n, ok := conventions["skills-namespaced"]; ok {
|
|
fmt.Fprintf(io.ErrOut, "Note: found %d namespaced skill(s) in skills/{author}/ directories\n", n)
|
|
}
|
|
if n, ok := conventions["plugins"]; ok {
|
|
fmt.Fprintf(io.ErrOut, "Note: found %d skill(s) using the plugins/ convention\n", n)
|
|
}
|
|
if n, ok := conventions["root"]; ok {
|
|
fmt.Fprintf(io.ErrOut, "Note: found %d skill(s) at the repository root\n", n)
|
|
}
|
|
}
|
|
|
|
// skillSelector holds the callbacks that differ between remote and local skill selection.
|
|
type skillSelector struct {
|
|
// matchByName resolves a skill name to matching skills.
|
|
matchByName func(opts *InstallOptions, skills []discovery.Skill) ([]discovery.Skill, error)
|
|
// sourceHint is shown in collision error guidance (e.g. "owner/repo" or "/path/to/skills").
|
|
sourceHint string
|
|
// fetchDescriptions, if non-nil, is called before prompting to pre-populate descriptions.
|
|
fetchDescriptions func()
|
|
}
|
|
|
|
type installPlan struct {
|
|
dir string
|
|
hosts []*registry.AgentHost
|
|
skills []discovery.Skill
|
|
}
|
|
|
|
func selectSkillsWithSelector(opts *InstallOptions, skills []discovery.Skill, canPrompt bool, sel skillSelector) ([]discovery.Skill, error) {
|
|
checkCollisions := func(ss []discovery.Skill) error {
|
|
if err := collisionError(ss); err != nil {
|
|
fmt.Fprintf(opts.IO.ErrOut, "Hint: install individually using the full name: gh skill install %s namespace/skill-name\n", sel.sourceHint)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if opts.SkillName != "" {
|
|
return sel.matchByName(opts, skills)
|
|
}
|
|
|
|
if !canPrompt {
|
|
return nil, cmdutil.FlagErrorf("must specify a skill name when not running interactively")
|
|
}
|
|
|
|
if sel.fetchDescriptions != nil {
|
|
sel.fetchDescriptions()
|
|
}
|
|
|
|
tw := opts.IO.TerminalWidth()
|
|
descWidth := tw - 35
|
|
if descWidth < 20 {
|
|
descWidth = 20
|
|
}
|
|
|
|
selected, err := opts.Prompter.MultiSelectWithSearch(
|
|
"Select skill(s) to install:",
|
|
"Filter skills",
|
|
nil,
|
|
[]string{allSkillsKey},
|
|
skillSearchFunc(skills, descWidth),
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(selected) == 0 {
|
|
return nil, fmt.Errorf("must select at least one skill")
|
|
}
|
|
|
|
for _, s := range selected {
|
|
if s == allSkillsKey {
|
|
if err := checkCollisions(skills); err != nil {
|
|
return nil, err
|
|
}
|
|
return skills, nil
|
|
}
|
|
}
|
|
|
|
result, err := matchSelectedSkills(skills, selected)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return result, checkCollisions(result)
|
|
}
|
|
|
|
func matchSkillByName(opts *InstallOptions, skills []discovery.Skill) ([]discovery.Skill, error) {
|
|
for _, s := range skills {
|
|
if s.DisplayName() == opts.SkillName {
|
|
return []discovery.Skill{s}, nil
|
|
}
|
|
}
|
|
|
|
var matches []discovery.Skill
|
|
for _, s := range skills {
|
|
if s.Name == opts.SkillName {
|
|
matches = append(matches, s)
|
|
}
|
|
}
|
|
|
|
switch len(matches) {
|
|
case 0:
|
|
return nil, fmt.Errorf("skill %q not found in %s", opts.SkillName, ghrepo.FullName(opts.repo))
|
|
case 1:
|
|
return matches, nil
|
|
default:
|
|
names := make([]string, len(matches))
|
|
for i, m := range matches {
|
|
names[i] = m.DisplayName()
|
|
}
|
|
return nil, fmt.Errorf(
|
|
"skill name %q is ambiguous, multiple matches found:\n %s\n Specify the full name (e.g. %s) to disambiguate",
|
|
opts.SkillName, strings.Join(names, "\n "), names[0],
|
|
)
|
|
}
|
|
}
|
|
|
|
func matchLocalSkillByName(opts *InstallOptions, skills []discovery.Skill) ([]discovery.Skill, error) {
|
|
for _, s := range skills {
|
|
if s.DisplayName() == opts.SkillName || s.Name == opts.SkillName {
|
|
return []discovery.Skill{s}, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("skill %q not found in local directory", opts.SkillName)
|
|
}
|
|
|
|
// skillSearchFunc returns a search function for MultiSelectWithSearch that
|
|
// filters skills by case-insensitive substring match on name and description.
|
|
func skillSearchFunc(skills []discovery.Skill, descWidth int) func(string) prompter.MultiSelectSearchResult {
|
|
return func(query string) prompter.MultiSelectSearchResult {
|
|
var matched []discovery.Skill
|
|
if query == "" {
|
|
matched = skills
|
|
} else {
|
|
q := strings.ToLower(query)
|
|
for _, s := range skills {
|
|
if strings.Contains(strings.ToLower(s.DisplayName()), q) ||
|
|
strings.Contains(strings.ToLower(s.Description), q) {
|
|
matched = append(matched, s)
|
|
}
|
|
}
|
|
}
|
|
|
|
more := 0
|
|
if len(matched) > maxSearchResults {
|
|
more = len(matched) - maxSearchResults
|
|
matched = matched[:maxSearchResults]
|
|
}
|
|
|
|
keys := make([]string, len(matched))
|
|
labels := make([]string, len(matched))
|
|
for i, s := range matched {
|
|
keys[i] = s.DisplayName()
|
|
if s.Description != "" {
|
|
labels[i] = fmt.Sprintf("%s - %s", s.DisplayName(), truncateDescription(s.Description, descWidth))
|
|
} else {
|
|
labels[i] = s.DisplayName()
|
|
}
|
|
}
|
|
|
|
return prompter.MultiSelectSearchResult{
|
|
Keys: keys,
|
|
Labels: labels,
|
|
MoreResults: more,
|
|
}
|
|
}
|
|
}
|
|
|
|
// matchSelectedSkills maps display names back to skill structs.
|
|
func matchSelectedSkills(skills []discovery.Skill, selected []string) ([]discovery.Skill, error) {
|
|
nameSet := make(map[string]struct{}, len(selected))
|
|
for _, name := range selected {
|
|
nameSet[name] = struct{}{}
|
|
}
|
|
|
|
var result []discovery.Skill
|
|
for _, s := range skills {
|
|
if _, ok := nameSet[s.DisplayName()]; ok {
|
|
result = append(result, s)
|
|
}
|
|
}
|
|
if len(result) == 0 {
|
|
return nil, fmt.Errorf("no matching skills found")
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// collisionError checks for name collisions among the selected skills.
|
|
func collisionError(ss []discovery.Skill) error {
|
|
collisions := discovery.FindNameCollisions(ss)
|
|
if len(collisions) == 0 {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("cannot install skills with conflicting names; they would overwrite each other:\n %s",
|
|
discovery.FormatCollisions(collisions))
|
|
}
|
|
|
|
func resolveHosts(opts *InstallOptions, canPrompt bool) ([]*registry.AgentHost, error) {
|
|
if opts.Agent != "" {
|
|
h, err := registry.FindByID(opts.Agent)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return []*registry.AgentHost{h}, nil
|
|
}
|
|
|
|
if !canPrompt {
|
|
h, err := registry.FindByID(registry.DefaultAgentID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return []*registry.AgentHost{h}, nil
|
|
}
|
|
|
|
fmt.Fprintln(opts.IO.ErrOut)
|
|
labels := make([]string, len(registry.Agents))
|
|
defaultLabel := ""
|
|
for i, h := range registry.Agents {
|
|
labels[i] = h.Name
|
|
if h.ID == registry.DefaultAgentID {
|
|
defaultLabel = labels[i]
|
|
}
|
|
}
|
|
if defaultLabel == "" {
|
|
defaultLabel = labels[0]
|
|
}
|
|
indices, err := opts.Prompter.MultiSelect("Select target agent(s):", []string{defaultLabel}, labels)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(indices) == 0 {
|
|
return nil, fmt.Errorf("must select at least one target agent")
|
|
}
|
|
|
|
selected := make([]*registry.AgentHost, len(indices))
|
|
for i, idx := range indices {
|
|
selected[i] = ®istry.Agents[idx]
|
|
}
|
|
return selected, nil
|
|
}
|
|
|
|
func resolveScope(opts *InstallOptions, canPrompt bool) (registry.Scope, error) {
|
|
if opts.Dir != "" {
|
|
return registry.Scope(opts.Scope), nil
|
|
}
|
|
|
|
if opts.ScopeChanged || !canPrompt {
|
|
return registry.Scope(opts.Scope), nil
|
|
}
|
|
|
|
var repoName string
|
|
if opts.Remotes != nil {
|
|
if remotes, err := opts.Remotes(); err == nil && len(remotes) > 0 {
|
|
repoName = ghrepo.FullName(remotes[0].Repo)
|
|
}
|
|
}
|
|
idx, err := opts.Prompter.Select("Installation scope:", "", registry.ScopeLabels(repoName))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if idx == 0 {
|
|
return registry.ScopeProject, nil
|
|
}
|
|
return registry.ScopeUser, nil
|
|
}
|
|
|
|
func buildInstallPlans(opts *InstallOptions, selectedSkills []discovery.Skill, selectedHosts []*registry.AgentHost, scope registry.Scope, gitRoot, homeDir string, canPrompt bool) ([]installPlan, error) {
|
|
byDir := make(map[string]*installPlan)
|
|
orderedDirs := make([]string, 0, len(selectedHosts))
|
|
|
|
for _, host := range selectedHosts {
|
|
targetDir, err := resolveInstallDir(opts, host, scope, gitRoot, homeDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
plan, ok := byDir[targetDir]
|
|
if !ok {
|
|
plan = &installPlan{dir: targetDir}
|
|
byDir[targetDir] = plan
|
|
orderedDirs = append(orderedDirs, targetDir)
|
|
}
|
|
plan.hosts = append(plan.hosts, host)
|
|
}
|
|
|
|
plans := make([]installPlan, 0, len(orderedDirs))
|
|
for _, dir := range orderedDirs {
|
|
plan := byDir[dir]
|
|
installSkills, err := checkOverwrite(opts, selectedSkills, plan.dir, canPrompt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(installSkills) == 0 {
|
|
fmt.Fprintf(opts.IO.ErrOut, "No skills to install in %s for %s.\n", friendlyDir(plan.dir), formatPlanHosts(plan.hosts))
|
|
continue
|
|
}
|
|
plan.skills = installSkills
|
|
plans = append(plans, *plan)
|
|
}
|
|
|
|
return plans, nil
|
|
}
|
|
|
|
func resolveInstallDir(opts *InstallOptions, host *registry.AgentHost, scope registry.Scope, gitRoot, homeDir string) (string, error) {
|
|
if opts.Dir != "" {
|
|
return opts.Dir, nil
|
|
}
|
|
return host.InstallDir(scope, gitRoot, homeDir)
|
|
}
|
|
|
|
func formatPlanHosts(hosts []*registry.AgentHost) string {
|
|
names := make([]string, len(hosts))
|
|
for i, host := range hosts {
|
|
names[i] = host.Name
|
|
}
|
|
return strings.Join(names, ", ")
|
|
}
|
|
|
|
func truncateDescription(s string, maxWidth int) string {
|
|
return text.Truncate(maxWidth, text.RemoveExcessiveWhitespace(s))
|
|
}
|
|
|
|
func checkOverwrite(opts *InstallOptions, skills []discovery.Skill, targetDir string, canPrompt bool) ([]discovery.Skill, error) {
|
|
var existing, fresh []discovery.Skill
|
|
for _, s := range skills {
|
|
dir := filepath.Join(targetDir, s.Name)
|
|
if _, err := os.Stat(dir); err == nil {
|
|
existing = append(existing, s)
|
|
} else {
|
|
fresh = append(fresh, s)
|
|
}
|
|
}
|
|
|
|
if len(existing) == 0 {
|
|
return skills, nil
|
|
}
|
|
|
|
if opts.Force {
|
|
return skills, nil
|
|
}
|
|
|
|
if !canPrompt {
|
|
names := make([]string, len(existing))
|
|
for i, s := range existing {
|
|
names[i] = s.DisplayName()
|
|
}
|
|
return nil, fmt.Errorf("skills already installed: %s (use --force to overwrite)", strings.Join(names, ", "))
|
|
}
|
|
|
|
var confirmed []discovery.Skill
|
|
for _, s := range existing {
|
|
prompt := existingSkillPrompt(targetDir, s)
|
|
ok, err := opts.Prompter.Confirm(prompt, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if ok {
|
|
confirmed = append(confirmed, s)
|
|
} else {
|
|
fmt.Fprintf(opts.IO.ErrOut, "Skipping %s\n", s.DisplayName())
|
|
}
|
|
}
|
|
|
|
return append(fresh, confirmed...), nil
|
|
}
|
|
|
|
func existingSkillPrompt(targetDir string, incoming discovery.Skill) string {
|
|
skillFile := filepath.Join(targetDir, incoming.Name, "SKILL.md")
|
|
data, err := os.ReadFile(skillFile)
|
|
if err != nil {
|
|
return fmt.Sprintf("Skill %q already exists. Overwrite?", incoming.DisplayName())
|
|
}
|
|
|
|
result, err := frontmatter.Parse(string(data))
|
|
if err != nil || result.Metadata.Meta == nil {
|
|
return fmt.Sprintf("Skill %q already exists. Overwrite?", incoming.DisplayName())
|
|
}
|
|
|
|
repoInfo, _, err := source.ParseMetadataRepo(result.Metadata.Meta)
|
|
ref, _ := result.Metadata.Meta["github-ref"].(string)
|
|
if err != nil {
|
|
return fmt.Sprintf("Skill %q already exists. Overwrite?", incoming.DisplayName())
|
|
}
|
|
|
|
if repoInfo != nil {
|
|
sourceName := ghrepo.FullName(repoInfo)
|
|
if ref != "" {
|
|
sourceName += "@" + ref
|
|
}
|
|
return fmt.Sprintf("Skill %q already installed from %s. Overwrite?", incoming.DisplayName(), sourceName)
|
|
}
|
|
|
|
return fmt.Sprintf("Skill %q already exists. Overwrite?", incoming.DisplayName())
|
|
}
|
|
|
|
const installProgressLabel = "Downloading skill files"
|
|
|
|
func installProgress(io *iostreams.IOStreams, total int) func(done, total int) {
|
|
if total <= 0 {
|
|
return nil
|
|
}
|
|
return func(done, total int) {
|
|
if done == 0 {
|
|
io.StartProgressIndicatorWithLabel(installProgressLabel)
|
|
} else if done >= total {
|
|
io.StopProgressIndicator()
|
|
}
|
|
}
|
|
}
|
|
|
|
func friendlyDir(dir string) string {
|
|
if cwd, err := os.Getwd(); err == nil {
|
|
if rel, err := filepath.Rel(cwd, dir); err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
|
if rel == "." {
|
|
return filepath.Base(dir)
|
|
}
|
|
return rel
|
|
}
|
|
}
|
|
if home, err := os.UserHomeDir(); err == nil {
|
|
if rel, err := filepath.Rel(home, dir); err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
|
return "~/" + rel
|
|
}
|
|
}
|
|
return dir
|
|
}
|
|
|
|
// printFileTree renders a text tree of the on-disk contents of each skill directory.
|
|
func printFileTree(w io.Writer, cs *iostreams.ColorScheme, dir string, skillNames []string) {
|
|
if len(skillNames) == 0 {
|
|
return
|
|
}
|
|
fmt.Fprintln(w)
|
|
for _, name := range skillNames {
|
|
skillDir := filepath.Join(dir, filepath.FromSlash(name))
|
|
fmt.Fprintf(w, " %s\n", cs.Bold(name+"/"))
|
|
printTreeDir(w, cs, skillDir, " ")
|
|
}
|
|
}
|
|
|
|
func printTreeDir(w io.Writer, cs *iostreams.ColorScheme, dir, indent string) {
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
fmt.Fprintf(w, "%s%s\n", indent, cs.Muted("(could not read directory)"))
|
|
return
|
|
}
|
|
for i, entry := range entries {
|
|
isLast := i == len(entries)-1
|
|
connector := "├── "
|
|
childIndent := "│ "
|
|
if isLast {
|
|
connector = "└── "
|
|
childIndent = " "
|
|
}
|
|
name := entry.Name()
|
|
if entry.IsDir() {
|
|
fmt.Fprintf(w, "%s%s%s\n", indent, cs.Muted(connector), cs.Bold(name+"/"))
|
|
printTreeDir(w, cs, filepath.Join(dir, name), indent+cs.Muted(childIndent))
|
|
} else {
|
|
fmt.Fprintf(w, "%s%s%s\n", indent, cs.Muted(connector), name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// printPreInstallDisclaimer prints a warning that installed skills are unverified
|
|
// and should be inspected before use.
|
|
func printPreInstallDisclaimer(w io.Writer, cs *iostreams.ColorScheme) {
|
|
fmt.Fprintf(w, "\n%s Skills are not verified by GitHub and may contain prompt injections, hidden instructions, or malicious scripts. Always review skill contents before use.\n\n", cs.WarningIcon())
|
|
}
|
|
|
|
// printReviewHint warns the user to review installed skills and suggests preview commands.
|
|
// When sha is non-empty the suggested commands include @SHA so the user previews
|
|
// exactly the version that was installed. When allowHiddenDirs is true, the
|
|
// suggested commands include --allow-hidden-dirs so previewing hidden-dir
|
|
// skills works without an extra manual step.
|
|
func printReviewHint(w io.Writer, cs *iostreams.ColorScheme, repo, sha string, skillNames []string, allowHiddenDirs bool) {
|
|
if len(skillNames) == 0 {
|
|
return
|
|
}
|
|
fmt.Fprintf(w, "\n%s Skills may contain prompt injections or malicious scripts.\n", cs.WarningIcon())
|
|
if repo == "" {
|
|
fmt.Fprintln(w, " Review the installed files before use.")
|
|
return
|
|
}
|
|
fmt.Fprintln(w, " Review installed content before use:")
|
|
fmt.Fprintln(w)
|
|
hiddenFlag := ""
|
|
if allowHiddenDirs {
|
|
hiddenFlag = " --allow-hidden-dirs"
|
|
}
|
|
for _, name := range skillNames {
|
|
if sha != "" {
|
|
fmt.Fprintf(w, " gh skill preview %s %s@%s%s\n", repo, name, sha, hiddenFlag)
|
|
} else {
|
|
fmt.Fprintf(w, " gh skill preview %s %s%s\n", repo, name, hiddenFlag)
|
|
}
|
|
}
|
|
fmt.Fprintln(w)
|
|
}
|
|
|
|
// printHostHints prints any agent-specific post-install guidance for the
|
|
// hosts that were installed to. Most agents need no extra steps; this is
|
|
// currently used for Kiro CLI, which requires skills to be registered as
|
|
// resources on a custom agent. The path in the example is derived from
|
|
// the actual install directory so it matches the chosen scope or --dir.
|
|
func printHostHints(w io.Writer, cs *iostreams.ColorScheme, hosts []*registry.AgentHost, installed []string, installDir, gitRoot string) {
|
|
if len(installed) == 0 {
|
|
return
|
|
}
|
|
for _, h := range hosts {
|
|
if h.ID == "kiro-cli" {
|
|
fmt.Fprintln(w)
|
|
fmt.Fprint(w, heredoc.Docf(`
|
|
%s Kiro CLI: register these skills on a custom agent by adding them to
|
|
.kiro/agents/<agent>.json under "resources", for example:
|
|
|
|
{
|
|
"resources": ["skill://%s/**/SKILL.md"]
|
|
}
|
|
`, cs.WarningIcon(), kiroResourcePath(installDir, gitRoot)))
|
|
fmt.Fprintln(w)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// kiroResourcePath returns a slash-separated path suitable for use in the
|
|
// "resources" field of a Kiro agent config. When the install directory is
|
|
// inside the current git repository the path is made relative to the repo
|
|
// root so the example works for project-scoped agent configs; otherwise
|
|
// the absolute install path is used (e.g. for --scope user or --dir).
|
|
func kiroResourcePath(installDir, gitRoot string) string {
|
|
if gitRoot != "" && installDir != "" {
|
|
if rel, err := filepath.Rel(gitRoot, installDir); err == nil && !strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel) {
|
|
return filepath.ToSlash(rel)
|
|
}
|
|
}
|
|
return filepath.ToSlash(installDir)
|
|
}
|
|
|
|
// filterHiddenDirSkills applies the --allow-hidden-dirs flag logic. When the
|
|
// flag is set, all skills are returned with a warning. Otherwise, hidden-dir
|
|
// skills are excluded with an error if no standard skills remain.
|
|
func filterHiddenDirSkills(opts *InstallOptions, allSkills []discovery.Skill) ([]discovery.Skill, error) {
|
|
cs := opts.IO.ColorScheme()
|
|
|
|
if opts.AllowHiddenDirs {
|
|
if discovery.HasHiddenDirSkills(allSkills) {
|
|
fmt.Fprint(opts.IO.ErrOut, heredoc.Docf(`
|
|
%[1]s Skills in hidden directories (e.g. .claude/, .agents/) may be installed
|
|
copies from another publisher. Verify the skill's origin and check for a
|
|
canonical source.
|
|
`, cs.WarningIcon()))
|
|
}
|
|
return allSkills, nil
|
|
}
|
|
|
|
r := discovery.PartitionHiddenDirSkills(allSkills)
|
|
if len(r.Standard) == 0 && r.HiddenCount > 0 {
|
|
return nil, fmt.Errorf(
|
|
"no standard skills found, but %d skill(s) exist in hidden directories\n"+
|
|
" Use --allow-hidden-dirs to include them",
|
|
r.HiddenCount,
|
|
)
|
|
}
|
|
|
|
return r.Standard, nil
|
|
}
|
|
|
|
// checkUpstreamProvenance fetches the skill's SKILL.md via the contents API
|
|
// to check if it contains github-repo metadata pointing to a different
|
|
// repository, indicating the skill was re-published from an upstream source.
|
|
// In interactive mode, the user is asked whether to install from the
|
|
// re-publisher or redirect to the upstream. Non-interactive mode always
|
|
// installs from the re-publisher.
|
|
// Returns (repo to redirect to, whether upstream was detected, error).
|
|
func checkUpstreamProvenance(opts *InstallOptions, client *api.Client, hostname string, skill discovery.Skill, commitSHA string) (ghrepo.Interface, bool, error) {
|
|
apiPath := fmt.Sprintf("repos/%s/%s/contents/%s?ref=%s",
|
|
opts.repo.RepoOwner(), opts.repo.RepoName(),
|
|
skill.Path+"/SKILL.md", commitSHA)
|
|
var fileResp struct {
|
|
Content string `json:"content"`
|
|
Encoding string `json:"encoding"`
|
|
}
|
|
if err := client.REST(hostname, "GET", apiPath, nil, &fileResp); err != nil {
|
|
return nil, false, nil //nolint:nilerr // best-effort check; failing to fetch is not fatal
|
|
}
|
|
if fileResp.Encoding != "base64" {
|
|
return nil, false, nil
|
|
}
|
|
decoded, decodeErr := io.ReadAll(base64.NewDecoder(base64.StdEncoding, strings.NewReader(fileResp.Content)))
|
|
if decodeErr != nil {
|
|
return nil, false, nil //nolint:nilerr // best-effort; decode failure is not fatal
|
|
}
|
|
content := string(decoded)
|
|
|
|
result, parseErr := frontmatter.Parse(content)
|
|
if parseErr != nil || result.Metadata.Meta == nil {
|
|
//nolint:nilerr // unparseable frontmatter means no upstream to detect
|
|
return nil, false, nil
|
|
}
|
|
|
|
existingRepo, _ := result.Metadata.Meta["github-repo"].(string)
|
|
if existingRepo == "" {
|
|
return nil, false, nil
|
|
}
|
|
|
|
currentRepoURL := source.BuildRepoURL(hostname, opts.repo.RepoOwner(), opts.repo.RepoName())
|
|
if existingRepo == currentRepoURL {
|
|
return nil, false, nil
|
|
}
|
|
|
|
upstreamRepo, parseErr := source.ParseRepoURL(existingRepo)
|
|
if parseErr != nil {
|
|
//nolint:nilerr // invalid repo URL means we can't redirect; install normally
|
|
return nil, false, nil
|
|
}
|
|
|
|
cs := opts.IO.ColorScheme()
|
|
upstreamLabel := ghrepo.FullName(upstreamRepo)
|
|
repoSource := ghrepo.FullName(opts.repo)
|
|
|
|
fmt.Fprintf(opts.IO.ErrOut, "%s This skill was originally published in %s\n", cs.WarningIcon(), upstreamLabel)
|
|
|
|
if opts.Upstream {
|
|
fmt.Fprintf(opts.IO.ErrOut, "Redirecting install to %s...\n", upstreamLabel)
|
|
return upstreamRepo, true, nil
|
|
}
|
|
|
|
if !opts.IO.CanPrompt() {
|
|
fmt.Fprintf(opts.IO.ErrOut, " Installing from %s (use --upstream or interactive mode to choose upstream)\n", repoSource)
|
|
return nil, true, nil
|
|
}
|
|
|
|
choices := []string{
|
|
fmt.Sprintf("%s (re-publisher, recommended)", repoSource),
|
|
fmt.Sprintf("%s (upstream)", upstreamLabel),
|
|
}
|
|
idx, err := opts.Prompter.Select("Install from:", "", choices)
|
|
if err != nil {
|
|
return nil, true, err
|
|
}
|
|
|
|
if idx == 1 {
|
|
fmt.Fprintf(opts.IO.ErrOut, "Redirecting install to %s...\n", upstreamLabel)
|
|
return upstreamRepo, true, nil
|
|
}
|
|
|
|
return nil, true, nil
|
|
}
|