From 082f15a8fd1419d95d9296161d3203842f3c9794 Mon Sep 17 00:00:00 2001 From: Tommaso Moro <37270480+tommaso-moro@users.noreply.github.com> Date: Sat, 18 Apr 2026 22:22:09 +0100 Subject: [PATCH] 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> --- internal/skills/registry/registry.go | 256 +++++++++++++++++++++- internal/skills/registry/registry_test.go | 17 +- pkg/cmd/skills/install/install.go | 85 +++++-- pkg/cmd/skills/install/install_test.go | 92 +++++++- pkg/cmd/skills/publish/publish.go | 5 + 5 files changed, 434 insertions(+), 21 deletions(-) diff --git a/internal/skills/registry/registry.go b/internal/skills/registry/registry.go index b112d361a..a5e018176 100644 --- a/internal/skills/registry/registry.go +++ b/internal/skills/registry/registry.go @@ -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)) diff --git a/internal/skills/registry/registry_test.go b/internal/skills/registry/registry_test.go index 003a28afa..bd0c44709 100644 --- a/internal/skills/registry/registry_test.go +++ b/internal/skills/registry/registry_test.go @@ -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) { diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index d4a05440e..5f715ff7e 100644 --- a/pkg/cmd/skills/install/install.go +++ b/pkg/cmd/skills/install/install.go @@ -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/.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) +} diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go index 0560625f7..120738fd0 100644 --- a/pkg/cmd/skills/install/install_test.go +++ b/pkg/cmd/skills/install/install_test.go @@ -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 := ®istry.AgentHost{ID: "kiro-cli", Name: "Kiro CLI", ProjectDir: ".kiro/skills", UserDir: ".kiro/skills"} + copilot := ®istry.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() diff --git a/pkg/cmd/skills/publish/publish.go b/pkg/cmd/skills/publish/publish.go index d82781876..636484684 100644 --- a/pkg/cmd/skills/publish/publish.go +++ b/pkg/cmd/skills/publish/publish.go @@ -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