diff --git a/cmd/skill/skill.go b/cmd/skill/skill.go new file mode 100644 index 00000000..b3594637 --- /dev/null +++ b/cmd/skill/skill.go @@ -0,0 +1,423 @@ +package skill + +import ( + "archive/zip" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +const ( + defaultRepo = "buildkite/skills" + defaultBranch = "main" +) + +type AddCmd struct { + Name string `arg:"" help:"Name of the Buildkite skill to install (for example, buildkite-api)."` + Agent string `help:"Agent/harness to install for (claude or cursor). Auto-detected from .claude or .cursor by default." optional:""` + Global bool `help:"Install globally in your home directory instead of the current project."` + Path string `help:"Custom skills directory to install into, for agents such as Amp or Pi." type:"path" optional:"" aliases:"to,location"` + Force bool `help:"Overwrite an existing installed skill."` + Repo string `help:"GitHub repository to install skills from." default:"${skill_repo}" hidden:""` + Branch string `help:"Git branch to install skills from." default:"${skill_branch}" hidden:""` +} + +func (c *AddCmd) Help() string { + return `Install a Buildkite skill from github.com/buildkite/skills. + +By default, the target is auto-detected from a project .claude or .cursor +folder. Use --agent to choose a target explicitly, --global to install to all +existing global agent directories (~/.claude and/or ~/.cursor), or --path for +another agent's skills directory. + +Examples: + # Install buildkite-api into the detected project agent + $ bk skill add buildkite-api + + # Install for Claude Code in this project + $ bk skill add buildkite-api --agent claude + + # Install globally for Cursor + $ bk skill add buildkite-api --agent cursor --global + + # Install into a custom skills directory, such as Amp or Pi + $ bk skill add buildkite-api --path ~/.amp/skills +` +} + +func (c *AddCmd) Run() error { + if err := validateSkillName(c.Name); err != nil { + return err + } + return installSkill(c.Name, c.Agent, c.Global, c.Path, c.Force, c.Repo, c.Branch) +} + +type UpdateCmd struct { + Name string `arg:"" help:"Name of the installed Buildkite skill to update. If omitted, all installed skills are updated." optional:""` + Agent string `help:"Agent/harness to update for (claude or cursor). Auto-detected from .claude or .cursor by default." optional:""` + Global bool `help:"Update the globally installed skill instead of the current project."` + Path string `help:"Custom skills directory to update, for agents such as Amp or Pi." type:"path" optional:""` + Repo string `help:"GitHub repository to install skills from." default:"${skill_repo}" hidden:""` + Branch string `help:"Git branch to install skills from." default:"${skill_branch}" hidden:""` +} + +func (c *UpdateCmd) Help() string { + return `Update installed Buildkite skills from github.com/buildkite/skills. + +If no skill name is provided, all currently installed skills for the target agent +are updated. + +Examples: + $ bk skill update + $ bk skill update buildkite-api + $ bk skill update buildkite-api --agent claude --global + $ bk skill update --path ~/.amp/skills +` +} + +func (c *UpdateCmd) Run() error { + if c.Name != "" { + if err := validateSkillName(c.Name); err != nil { + return err + } + } + + targets, err := resolveTargets(c.Agent, c.Global, c.Path) + if err != nil { + return err + } + + if c.Name != "" { + var installedTargets []target + for _, target := range targets { + if info, err := os.Stat(filepath.Join(target.SkillsDir(), c.Name)); err == nil && info.IsDir() { + installedTargets = append(installedTargets, target) + } else if err != nil && !os.IsNotExist(err) { + return err + } + } + if len(installedTargets) == 0 { + return fmt.Errorf("skill %q is not installed for any selected target", c.Name) + } + return installSkillToTargets(c.Name, installedTargets, true, c.Repo, c.Branch) + } + + plan := map[string][]target{} + for _, target := range targets { + entries, err := os.ReadDir(target.SkillsDir()) + if err != nil { + if os.IsNotExist(err) { + continue + } + return err + } + for _, entry := range entries { + if entry.IsDir() { + plan[entry.Name()] = append(plan[entry.Name()], target) + } + } + } + if len(plan) == 0 { + return fmt.Errorf("no skills are installed for any selected target") + } + + return installSkillsToTargets(plan, true, c.Repo, c.Branch) +} + +type DeleteCmd struct { + Name string `arg:"" help:"Name of the installed Buildkite skill to delete."` + Agent string `help:"Agent/harness to delete from (claude or cursor). Auto-detected from .claude or .cursor by default." optional:""` + Global bool `help:"Delete the globally installed skill instead of the current project."` + Path string `help:"Custom skills directory to delete from, for agents such as Amp or Pi." type:"path" optional:""` +} + +func (c *DeleteCmd) Help() string { + return `Delete an installed Buildkite skill. + +Examples: + $ bk skill delete buildkite-api + $ bk skill delete buildkite-api --agent cursor --global + $ bk skill delete buildkite-api --path ~/.amp/skills +` +} + +func (c *DeleteCmd) Run() error { + if err := validateSkillName(c.Name); err != nil { + return err + } + targets, err := resolveTargets(c.Agent, c.Global, c.Path) + if err != nil { + return err + } + + var installedTargets []target + for _, target := range targets { + dest := filepath.Join(target.SkillsDir(), c.Name) + if info, err := os.Stat(dest); err == nil && info.IsDir() { + installedTargets = append(installedTargets, target) + } else if err != nil && !os.IsNotExist(err) { + return err + } + } + if len(installedTargets) == 0 { + return fmt.Errorf("skill %q is not installed for any selected target", c.Name) + } + + for _, target := range installedTargets { + dest := filepath.Join(target.SkillsDir(), c.Name) + if err := os.RemoveAll(dest); err != nil { + return fmt.Errorf("deleting skill %q: %w", c.Name, err) + } + fmt.Printf("Deleted %s skill %q from %s\n", target.agent, c.Name, dest) + } + return nil +} + +type target struct { + agent string + root string + skillsDir string +} + +func (t target) SkillsDir() string { + if t.skillsDir != "" { + return t.skillsDir + } + return filepath.Join(t.root, "skills") +} + +func resolveTarget(agent string, global bool, customPath string) (target, error) { + targets, err := resolveTargets(agent, global, customPath) + if err != nil { + return target{}, err + } + return targets[0], nil +} + +func resolveTargets(agent string, global bool, customPath string) ([]target, error) { + if global && customPath != "" { + return nil, fmt.Errorf("--global and --path cannot be used together") + } + if customPath == "" && agent != "" && agent != "claude" && agent != "cursor" { + return nil, fmt.Errorf("unsupported --agent %q (expected claude or cursor, or use --path for a custom agent)", agent) + } + + if customPath != "" { + abs, err := filepath.Abs(customPath) + if err != nil { + return nil, err + } + if agent == "" { + agent = "custom" + } + return []target{{agent: agent, skillsDir: abs}}, nil + } + + if global { + home, err := os.UserHomeDir() + if err != nil { + return nil, err + } + if agent != "" { + root := filepath.Join(home, "."+agent) + if !dirExists(root) { + return nil, fmt.Errorf("global %s directory does not exist at %s", agent, root) + } + return []target{{agent: agent, root: root}}, nil + } + + var targets []target + for _, candidate := range []string{"claude", "cursor"} { + root := filepath.Join(home, "."+candidate) + if dirExists(root) { + targets = append(targets, target{agent: candidate, root: root}) + } + } + if len(targets) == 0 { + return nil, fmt.Errorf("no global agent directories found at ~/.claude or ~/.cursor") + } + return targets, nil + } + + wd, err := os.Getwd() + if err != nil { + return nil, err + } + if agent == "" { + if dirExists(filepath.Join(wd, ".claude")) { + agent = "claude" + } else if dirExists(filepath.Join(wd, ".cursor")) { + agent = "cursor" + } else { + return nil, fmt.Errorf("could not detect an agent target: create .claude or .cursor, or pass --agent claude|cursor") + } + } + return []target{{agent: agent, root: filepath.Join(wd, "."+agent)}}, nil +} + +func dirExists(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} + +func installSkill(name, agent string, global bool, customPath string, force bool, repo, branch string) error { + targets, err := resolveTargets(agent, global, customPath) + if err != nil { + return err + } + return installSkillToTargets(name, targets, force, repo, branch) +} + +func installSkillToTargets(name string, targets []target, force bool, repo, branch string) error { + return installSkillsToTargets(map[string][]target{name: targets}, force, repo, branch) +} + +func installSkillsToTargets(plan map[string][]target, force bool, repo, branch string) error { + for name, targets := range plan { + if err := validateSkillName(name); err != nil { + return err + } + for _, target := range targets { + dest := filepath.Join(target.SkillsDir(), name) + if !force { + if _, err := os.Stat(dest); err == nil { + return fmt.Errorf("skill %q is already installed at %s (use --force or bk skill update)", name, dest) + } else if !os.IsNotExist(err) { + return err + } + } + } + } + + tmpDir, err := os.MkdirTemp("", "bk-skill-*") + if err != nil { + return err + } + defer os.RemoveAll(tmpDir) + + archive := filepath.Join(tmpDir, "skills.zip") + if err := downloadRepoArchive(repo, branch, archive); err != nil { + return err + } + + counter := 0 + for name, targets := range plan { + for _, target := range targets { + dest := filepath.Join(target.SkillsDir(), name) + extracted := filepath.Join(tmpDir, fmt.Sprintf("%s-%d", name, counter)) + counter++ + if err := extractSkill(archive, name, extracted); err != nil { + return err + } + if err := os.MkdirAll(target.SkillsDir(), 0o755); err != nil { + return err + } + if err := os.RemoveAll(dest); err != nil { + return err + } + if err := os.Rename(extracted, dest); err != nil { + return err + } + fmt.Printf("Installed %s skill %q to %s\n", target.agent, name, dest) + } + } + return nil +} + +func validateSkillName(name string) error { + if name == "" || name == "." || name == ".." { + return fmt.Errorf("invalid skill name %q", name) + } + for _, r := range name { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' { + continue + } + return fmt.Errorf("invalid skill name %q: use a literal skill name, not a path, URL, or pattern", name) + } + return nil +} + +func downloadRepoArchive(repo, branch, dest string) error { + url := fmt.Sprintf("https://codeload.github.com/%s/zip/refs/heads/%s", repo, branch) + client := &http.Client{Timeout: 60 * time.Second} + resp, err := client.Get(url) + if err != nil { + return fmt.Errorf("downloading %s: %w", url, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("downloading %s: unexpected HTTP status %s", url, resp.Status) + } + out, err := os.Create(dest) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, resp.Body) + return err +} + +func extractSkill(archive, skillName, dest string) error { + r, err := zip.OpenReader(archive) + if err != nil { + return fmt.Errorf("opening downloaded skills archive: %w", err) + } + defer r.Close() + + found := false + for _, f := range r.File { + parts := strings.SplitN(f.Name, "/", 4) + var rel string + switch { + case len(parts) >= 3 && parts[1] == skillName: + rel = parts[2] + case len(parts) >= 4 && parts[1] == "skills" && parts[2] == skillName: + rel = parts[3] + default: + continue + } + if rel == "" { + continue + } + found = true + path := filepath.Join(dest, filepath.FromSlash(rel)) + if !strings.HasPrefix(path, filepath.Clean(dest)+string(os.PathSeparator)) { + return fmt.Errorf("archive contains invalid path %q", f.Name) + } + if f.FileInfo().IsDir() { + if err := os.MkdirAll(path, 0o755); err != nil { + return err + } + continue + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + rc, err := f.Open() + if err != nil { + return err + } + out, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) + if err != nil { + rc.Close() + return err + } + _, copyErr := io.Copy(out, rc) + closeErr := out.Close() + rc.Close() + if copyErr != nil { + return copyErr + } + if closeErr != nil { + return closeErr + } + } + if !found { + return fmt.Errorf("skill %q not found in github.com/%s", skillName, defaultRepo) + } + return nil +} diff --git a/cmd/skill/skill_test.go b/cmd/skill/skill_test.go new file mode 100644 index 00000000..3bc7863e --- /dev/null +++ b/cmd/skill/skill_test.go @@ -0,0 +1,173 @@ +package skill + +import ( + "archive/zip" + "os" + "path/filepath" + "testing" +) + +func TestResolveTargetDetectsProjectAgent(t *testing.T) { + dir := t.TempDir() + oldWD, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer os.Chdir(oldWD) + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + wd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Mkdir(".cursor", 0o755); err != nil { + t.Fatal(err) + } + + target, err := resolveTarget("", false, "") + if err != nil { + t.Fatal(err) + } + if target.agent != "cursor" { + t.Fatalf("agent = %q, want cursor", target.agent) + } + if want := filepath.Join(wd, ".cursor", "skills"); target.SkillsDir() != want { + t.Fatalf("skills dir = %q, want %q", target.SkillsDir(), want) + } +} + +func TestResolveTargetErrorsWithoutProjectAgent(t *testing.T) { + dir := t.TempDir() + oldWD, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer os.Chdir(oldWD) + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + + if _, err := resolveTarget("", false, ""); err == nil { + t.Fatal("expected error") + } +} + +func TestResolveTargetUsesCustomPath(t *testing.T) { + dir := filepath.Join(t.TempDir(), "amp-skills") + target, err := resolveTarget("", false, dir) + if err != nil { + t.Fatal(err) + } + if target.agent != "custom" { + t.Fatalf("agent = %q, want custom", target.agent) + } + want, err := filepath.Abs(dir) + if err != nil { + t.Fatal(err) + } + if target.SkillsDir() != want { + t.Fatalf("skills dir = %q, want %q", target.SkillsDir(), want) + } +} + +func TestResolveTargetsGlobalUsesAllExistingAgentDirs(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + if err := os.Mkdir(filepath.Join(home, ".claude"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(home, ".cursor"), 0o755); err != nil { + t.Fatal(err) + } + + targets, err := resolveTargets("", true, "") + if err != nil { + t.Fatal(err) + } + if len(targets) != 2 { + t.Fatalf("got %d targets, want 2", len(targets)) + } + if targets[0].agent != "claude" || targets[1].agent != "cursor" { + t.Fatalf("targets = %#v, want claude then cursor", targets) + } +} + +func TestResolveTargetsGlobalDoesNotCreateAgentDirs(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + if _, err := resolveTargets("", true, ""); err == nil { + t.Fatal("expected error") + } + if dirExists(filepath.Join(home, ".claude")) || dirExists(filepath.Join(home, ".cursor")) { + t.Fatal("global detection created agent directories") + } +} + +func TestValidateSkillNameRejectsPathsURLsAndPatterns(t *testing.T) { + for _, name := range []string{"../skill", "skills/buildkite-api", "https://example.com/skill", "buildkite-*", ""} { + if err := validateSkillName(name); err == nil { + t.Fatalf("validateSkillName(%q) succeeded, want error", name) + } + } +} + +func TestDeleteErrorsWhenSkillIsNotInstalled(t *testing.T) { + dir := t.TempDir() + cmd := DeleteCmd{Name: "missing", Path: dir} + if err := cmd.Run(); err == nil { + t.Fatal("expected error") + } +} + +func TestExtractSkill(t *testing.T) { + archive := filepath.Join(t.TempDir(), "skills.zip") + createZip(t, archive, map[string]string{ + "skills-main/skills/buildkite-api/SKILL.md": "# Buildkite API", + "skills-main/skills/buildkite-api/docs/ref.md": "reference", + "skills-main/skills/other/SKILL.md": "# Other", + }) + + dest := filepath.Join(t.TempDir(), "buildkite-api") + if err := extractSkill(archive, "buildkite-api", dest); err != nil { + t.Fatal(err) + } + + got, err := os.ReadFile(filepath.Join(dest, "SKILL.md")) + if err != nil { + t.Fatal(err) + } + if string(got) != "# Buildkite API" { + t.Fatalf("SKILL.md = %q", got) + } + if _, err := os.Stat(filepath.Join(dest, "docs", "ref.md")); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(dest, "..", "other")); err == nil { + t.Fatal("extracted another skill") + } +} + +func createZip(t *testing.T, path string, files map[string]string) { + t.Helper() + out, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + defer out.Close() + + zw := zip.NewWriter(out) + for name, content := range files { + w, err := zw.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write([]byte(content)); err != nil { + t.Fatal(err) + } + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } +} diff --git a/main.go b/main.go index 5bb530f1..f6c3462d 100644 --- a/main.go +++ b/main.go @@ -26,6 +26,7 @@ import ( "github.com/buildkite/cli/v3/cmd/preflight" "github.com/buildkite/cli/v3/cmd/queue" "github.com/buildkite/cli/v3/cmd/secret" + "github.com/buildkite/cli/v3/cmd/skill" "github.com/buildkite/cli/v3/cmd/use" "github.com/buildkite/cli/v3/cmd/user" versionPkg "github.com/buildkite/cli/v3/cmd/version" @@ -53,6 +54,7 @@ type CLI struct { Maintainer MaintainerCmd `cmd:"" help:"Manage cluster maintainers"` Queue QueueCmd `cmd:"" help:"Manage cluster queues"` Secret SecretCmd `cmd:"" help:"Manage cluster secrets"` + Skill SkillCmd `cmd:"" help:"Manage Buildkite skills for AI coding agents"` Config bkConfig.ConfigCmd `cmd:"" help:"Manage CLI configuration"` Configure ConfigureCmd `cmd:"" help:"Configure Buildkite API token" hidden:""` Init bkInit.InitCmd `cmd:"" help:"Initialize a pipeline.yaml file"` @@ -128,6 +130,11 @@ type ( Update secret.UpdateCmd `cmd:"" help:"Update a cluster secret."` Delete secret.DeleteCmd `cmd:"" help:"Delete a cluster secret." aliases:"rm"` } + SkillCmd struct { + Add skill.AddCmd `cmd:"" help:"Install a Buildkite skill."` + Update skill.UpdateCmd `cmd:"" help:"Update an installed Buildkite skill."` + Delete skill.DeleteCmd `cmd:"" help:"Delete an installed Buildkite skill." aliases:"rm"` + } JobCmd struct { Cancel job.CancelCmd `cmd:"" help:"Cancel a job."` List job.ListCmd `cmd:"" help:"List jobs." aliases:"ls"` @@ -180,6 +187,8 @@ func newKongParser(cli *CLI, options ...kong.Option) (*kong.Kong, error) { kong.Vars{ // Empty default allows commands to fall back to config value "output_default_format": "", + "skill_repo": "buildkite/skills", + "skill_branch": "main", }, } baseOptions = append(baseOptions, options...)