Add support for installation in multiple agent hosts in gh skills install (#13209)

* add support for installation in multiple agent host

* print correct dir in warning

* remove dir as it depends on user vs project installation scope

* Move comment closer to assertion in registry test

Move the explanatory comment from above the map initialization to
directly above the assertions it describes, per review feedback.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* List supported agent names and IDs in help text

Replace the self-referencing "run --help" sentence with an inline list
of all supported --agent values showing Name (id) pairs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Use heredoc.Docf for Kiro CLI post-install hint

Replace individual fmt.Fprintln calls with a single heredoc.Docf block
for the Kiro CLI post-install guidance, per review feedback. Also
shorten the --agent flag usage line by overriding the auto-generated
enum list with a reference to the supported values in the help text.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Kynan Ware <47394200+BagToad@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Tommaso Moro 2026-04-18 22:22:09 +01:00 committed by GitHub
parent 998b6212b3
commit 082f15a8fd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 434 additions and 21 deletions

View file

@ -34,7 +34,16 @@ const (
)
// Agents contains all known agent hosts.
//
// The slice is ordered so that the most widely used agents appear first,
// followed by the rest in alphabetical order. This order is used for
// interactive selection, help output, and flag enum suggestions.
//
// Agents sharing a ProjectDir (such as the shared .agents/skills directory)
// install skills to the same project-scope location, so selecting multiple
// such agents writes each skill only once.
var Agents = []AgentHost{
// Popular agents, listed first for discoverability.
{
ID: "github-copilot",
Name: "GitHub Copilot",
@ -60,7 +69,7 @@ var Agents = []AgentHost{
UserDir: ".codex/skills",
},
{
ID: "gemini",
ID: "gemini-cli",
Name: "Gemini CLI",
ProjectDir: sharedProjectSkillsDir,
UserDir: ".gemini/skills",
@ -71,6 +80,242 @@ var Agents = []AgentHost{
ProjectDir: sharedProjectSkillsDir,
UserDir: ".gemini/antigravity/skills",
},
// All other supported agents, alphabetical by ID.
{
ID: "adal",
Name: "AdaL",
ProjectDir: ".adal/skills",
UserDir: ".adal/skills",
},
{
ID: "amp",
Name: "Amp",
ProjectDir: sharedProjectSkillsDir,
UserDir: ".config/agents/skills",
},
{
ID: "augment",
Name: "Augment",
ProjectDir: ".augment/skills",
UserDir: ".augment/skills",
},
{
ID: "bob",
Name: "IBM Bob",
ProjectDir: ".bob/skills",
UserDir: ".bob/skills",
},
{
ID: "cline",
Name: "Cline",
ProjectDir: sharedProjectSkillsDir,
UserDir: ".agents/skills",
},
{
ID: "codebuddy",
Name: "CodeBuddy",
ProjectDir: ".codebuddy/skills",
UserDir: ".codebuddy/skills",
},
{
ID: "command-code",
Name: "Command Code",
ProjectDir: ".commandcode/skills",
UserDir: ".commandcode/skills",
},
{
ID: "continue",
Name: "Continue",
ProjectDir: ".continue/skills",
UserDir: ".continue/skills",
},
{
ID: "cortex",
Name: "Cortex Code",
ProjectDir: ".cortex/skills",
UserDir: ".snowflake/cortex/skills",
},
{
ID: "crush",
Name: "Crush",
ProjectDir: ".crush/skills",
UserDir: ".config/crush/skills",
},
{
ID: "deepagents",
Name: "Deep Agents",
ProjectDir: sharedProjectSkillsDir,
UserDir: ".deepagents/agent/skills",
},
{
ID: "droid",
Name: "Droid",
ProjectDir: ".factory/skills",
UserDir: ".factory/skills",
},
{
ID: "firebender",
Name: "Firebender",
ProjectDir: sharedProjectSkillsDir,
UserDir: ".firebender/skills",
},
{
ID: "goose",
Name: "Goose",
ProjectDir: ".goose/skills",
UserDir: ".config/goose/skills",
},
{
ID: "iflow-cli",
Name: "iFlow CLI",
ProjectDir: ".iflow/skills",
UserDir: ".iflow/skills",
},
{
ID: "junie",
Name: "Junie",
ProjectDir: ".junie/skills",
UserDir: ".junie/skills",
},
{
ID: "kilo",
Name: "Kilo Code",
ProjectDir: ".kilocode/skills",
UserDir: ".kilocode/skills",
},
{
ID: "kimi-cli",
Name: "Kimi Code CLI",
ProjectDir: sharedProjectSkillsDir,
UserDir: ".config/agents/skills",
},
{
ID: "kiro-cli",
Name: "Kiro CLI",
ProjectDir: ".kiro/skills",
UserDir: ".kiro/skills",
},
{
ID: "kode",
Name: "Kode",
ProjectDir: ".kode/skills",
UserDir: ".kode/skills",
},
{
ID: "mcpjam",
Name: "MCPJam",
ProjectDir: ".mcpjam/skills",
UserDir: ".mcpjam/skills",
},
{
ID: "mistral-vibe",
Name: "Mistral Vibe",
ProjectDir: ".vibe/skills",
UserDir: ".vibe/skills",
},
{
ID: "mux",
Name: "Mux",
ProjectDir: ".mux/skills",
UserDir: ".mux/skills",
},
{
ID: "neovate",
Name: "Neovate",
ProjectDir: ".neovate/skills",
UserDir: ".neovate/skills",
},
{
ID: "openclaw",
Name: "OpenClaw",
ProjectDir: "skills",
UserDir: ".openclaw/skills",
},
{
ID: "opencode",
Name: "OpenCode",
ProjectDir: sharedProjectSkillsDir,
UserDir: ".config/opencode/skills",
},
{
ID: "openhands",
Name: "OpenHands",
ProjectDir: ".openhands/skills",
UserDir: ".openhands/skills",
},
{
ID: "pi",
Name: "Pi",
ProjectDir: ".pi/skills",
UserDir: ".pi/agent/skills",
},
{
ID: "pochi",
Name: "Pochi",
ProjectDir: ".pochi/skills",
UserDir: ".pochi/skills",
},
{
ID: "qoder",
Name: "Qoder",
ProjectDir: ".qoder/skills",
UserDir: ".qoder/skills",
},
{
ID: "qwen-code",
Name: "Qwen Code",
ProjectDir: ".qwen/skills",
UserDir: ".qwen/skills",
},
{
ID: "replit",
Name: "Replit",
ProjectDir: sharedProjectSkillsDir,
UserDir: ".config/agents/skills",
},
{
ID: "roo",
Name: "Roo Code",
ProjectDir: ".roo/skills",
UserDir: ".roo/skills",
},
{
ID: "trae",
Name: "Trae",
ProjectDir: ".trae/skills",
UserDir: ".trae/skills",
},
{
ID: "trae-cn",
Name: "Trae CN",
ProjectDir: ".trae/skills",
UserDir: ".trae-cn/skills",
},
{
ID: "universal",
Name: "Universal",
ProjectDir: sharedProjectSkillsDir,
UserDir: ".config/agents/skills",
},
{
ID: "warp",
Name: "Warp",
ProjectDir: sharedProjectSkillsDir,
UserDir: ".agents/skills",
},
{
ID: "windsurf",
Name: "Windsurf",
ProjectDir: ".windsurf/skills",
UserDir: ".codeium/windsurf/skills",
},
{
ID: "zencoder",
Name: "Zencoder",
ProjectDir: ".zencoder/skills",
UserDir: ".zencoder/skills",
},
}
// FindByID returns the agent host with the given ID, or an error if not found.
@ -97,6 +342,15 @@ func AgentIDs() []string {
return ids
}
// AgentHelpList returns a newline-separated bulleted list of agents for help text.
func AgentHelpList() string {
lines := make([]string, len(Agents))
for i, h := range Agents {
lines[i] = fmt.Sprintf(" - %s (%s)", h.Name, h.ID)
}
return strings.Join(lines, "\n")
}
// AgentNames returns the display names of all agents for prompting.
func AgentNames() []string {
names := make([]string, len(Agents))

View file

@ -19,7 +19,7 @@ func TestFindByID(t *testing.T) {
{name: "claude-code", id: "claude-code", wantName: "Claude Code"},
{name: "cursor", id: "cursor", wantName: "Cursor"},
{name: "codex", id: "codex", wantName: "Codex"},
{name: "gemini", id: "gemini", wantName: "Gemini CLI"},
{name: "gemini-cli", id: "gemini-cli", wantName: "Gemini CLI"},
{name: "antigravity", id: "antigravity", wantName: "Antigravity"},
{name: "unknown agent", id: "nonexistent", wantErr: "unknown agent"},
}
@ -89,7 +89,7 @@ func TestInstallDir(t *testing.T) {
},
{
name: "gemini project scope",
hostID: "gemini",
hostID: "gemini-cli",
scope: ScopeProject,
gitRoot: "/tmp/monalisa-repo",
homeDir: "/home/monalisa",
@ -167,7 +167,18 @@ func TestRepoNameFromRemote(t *testing.T) {
func TestUniqueProjectDirs(t *testing.T) {
dirs := UniqueProjectDirs()
assert.Equal(t, []string{".agents/skills", ".claude/skills"}, dirs)
seen := map[string]int{}
for _, d := range dirs {
seen[d]++
}
// The shared .agents/skills dir and .claude/skills must both be present
// and listed exactly once each.
assert.Equal(t, 1, seen[".agents/skills"], "expected .agents/skills exactly once")
assert.Equal(t, 1, seen[".claude/skills"], "expected .claude/skills exactly once")
// No project dir should appear more than once.
for d, n := range seen {
assert.LessOrEqualf(t, n, 1, "project dir %q appears %d times", d, n)
}
}
func TestScopeLabels(t *testing.T) {

View file

@ -80,24 +80,24 @@ func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru
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). Supported hosts
and their storage directories are (project, user):
scope (in your home directory, available everywhere).
- GitHub Copilot (%[1]s.agents/skills%[1]s, %[1]s~/.copilot/skills%[1]s)
- Claude Code (%[1]s.claude/skills%[1]s, %[1]s~/.claude/skills%[1]s)
- Cursor (%[1]s.agents/skills%[1]s, %[1]s~/.cursor/skills%[1]s)
- Codex (%[1]s.agents/skills%[1]s, %[1]s~/.codex/skills%[1]s)
- Gemini CLI (%[1]s.agents/skills%[1]s, %[1]s~/.gemini/skills%[1]s)
- Antigravity (%[1]s.agents/skills%[1]s, %[1]s~/.gemini/antigravity/skills%[1]s)
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, GitHub Copilot, Cursor, Codex, Gemini CLI, and
Antigravity all use the shared %[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.
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.
@ -133,7 +133,7 @@ func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru
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
@ -198,7 +198,8 @@ func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru
},
}
cmdutil.StringEnumFlag(cmd, &opts.Agent, "agent", "", "", registry.AgentIDs(), "Target agent")
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)")
@ -336,6 +337,7 @@ func installRun(opts *InstallOptions) error {
printFileTree(opts.IO.ErrOut, cs, result.Dir, result.Installed)
printReviewHint(opts.IO.ErrOut, cs, repoSource, resolved.SHA, result.Installed)
printHostHints(opts.IO.ErrOut, cs, plan.hosts, result.Installed, result.Dir, gitRoot)
}
if err != nil {
@ -474,6 +476,7 @@ func runLocalInstall(opts *InstallOptions) error {
printFileTree(opts.IO.ErrOut, cs, result.Dir, result.Installed)
printReviewHint(opts.IO.ErrOut, cs, "", "", result.Installed)
printHostHints(opts.IO.ErrOut, cs, plan.hosts, result.Installed, result.Dir, gitRoot)
}
return nil
@ -789,8 +792,18 @@ func resolveHosts(opts *InstallOptions, canPrompt bool) ([]*registry.AgentHost,
}
fmt.Fprintln(opts.IO.ErrOut)
names := registry.AgentNames()
indices, err := opts.Prompter.MultiSelect("Select target agent(s):", []string{names[0]}, names)
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
}
@ -1058,3 +1071,43 @@ func printReviewHint(w io.Writer, cs *iostreams.ColorScheme, repo, sha string, s
}
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)
}

View file

@ -16,6 +16,7 @@ import (
"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/registry"
"github.com/cli/cli/v2/internal/telemetry"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/httpmock"
@ -1403,7 +1404,15 @@ func TestInstallRun_DeduplicatesSharedProjectDirAcrossHosts(t *testing.T) {
pm := &prompter.PrompterMock{
MultiSelectFunc: func(prompt string, defaults []string, options []string) ([]int, error) {
return []int{0, 2}, nil // GitHub Copilot + Cursor share .agents/skills
// Select two agents that share the .agents/skills project dir
// (GitHub Copilot and Cursor) to exercise deduplication.
var indices []int
for i, label := range options {
if label == "GitHub Copilot" || label == "Cursor" {
indices = append(indices, i)
}
}
return indices, nil
},
SelectFunc: func(prompt, defaultValue string, options []string) (int, error) {
return 0, nil // project scope
@ -1947,6 +1956,87 @@ func Test_printReviewHint(t *testing.T) {
}
}
func Test_printHostHints(t *testing.T) {
kiro := &registry.AgentHost{ID: "kiro-cli", Name: "Kiro CLI", ProjectDir: ".kiro/skills", UserDir: ".kiro/skills"}
copilot := &registry.AgentHost{ID: "copilot-cli", Name: "GitHub Copilot CLI", ProjectDir: ".github/skills"}
tests := []struct {
name string
hosts []*registry.AgentHost
installed []string
installDir string
gitRoot string
wantSub []string
wantNot []string
}{
{
name: "no installs produces no output",
hosts: []*registry.AgentHost{kiro},
installed: nil,
installDir: "/repo/.kiro/skills",
gitRoot: "/repo",
wantNot: []string{"Kiro CLI"},
},
{
name: "non-kiro host produces no output",
hosts: []*registry.AgentHost{copilot},
installed: []string{"s1"},
installDir: "/repo/.github/skills",
gitRoot: "/repo",
wantNot: []string{"Kiro CLI"},
},
{
name: "kiro project scope uses relative path",
hosts: []*registry.AgentHost{kiro},
installed: []string{"s1"},
installDir: filepath.Join("/repo", ".kiro", "skills"),
gitRoot: "/repo",
wantSub: []string{"Kiro CLI", `"skill://.kiro/skills/**/SKILL.md"`},
},
{
name: "kiro user scope uses absolute install dir",
hosts: []*registry.AgentHost{kiro},
installed: []string{"s1"},
installDir: "/home/user/.kiro/skills",
gitRoot: "/repo",
wantSub: []string{`"skill:///home/user/.kiro/skills/**/SKILL.md"`},
wantNot: []string{`skill://.kiro/skills`},
},
{
name: "kiro custom dir outside git root uses absolute path",
hosts: []*registry.AgentHost{kiro},
installed: []string{"s1"},
installDir: "/tmp/my-skills",
gitRoot: "/repo",
wantSub: []string{`"skill:///tmp/my-skills/**/SKILL.md"`},
},
{
name: "kiro without git root falls back to install dir",
hosts: []*registry.AgentHost{kiro},
installed: []string{"s1"},
installDir: "/home/user/.kiro/skills",
gitRoot: "",
wantSub: []string{`"skill:///home/user/.kiro/skills/**/SKILL.md"`},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ios, _, _, _ := iostreams.Test()
cs := ios.ColorScheme()
var buf strings.Builder
printHostHints(&buf, cs, tt.hosts, tt.installed, tt.installDir, tt.gitRoot)
got := buf.String()
for _, s := range tt.wantSub {
assert.Contains(t, got, s)
}
for _, s := range tt.wantNot {
assert.NotContains(t, got, s)
}
})
}
}
func Test_printPreInstallDisclaimer(t *testing.T) {
ios, _, _, _ := iostreams.Test()
cs := ios.ColorScheme()

View file

@ -859,6 +859,11 @@ func checkInstalledSkillDirs(gitClient *git.Client, repoDir string) []publishDia
var diagnostics []publishDiagnostic
for _, relPath := range registry.UniqueProjectDirs() {
// Skip non-hidden project dirs (such as "skills") to avoid
// flagging the canonical authoring layout used when publishing.
if !strings.HasPrefix(relPath, ".") {
continue
}
absPath := filepath.Join(repoDir, relPath)
if _, err := os.Stat(absPath); os.IsNotExist(err) {
continue