diff --git a/cmd/skill.go b/cmd/skill.go new file mode 100644 index 0000000..720bf0c --- /dev/null +++ b/cmd/skill.go @@ -0,0 +1,12 @@ +package cmd + +import "github.com/spf13/cobra" + +var skillCmd = &cobra.Command{ + Use: "skill", + Short: "Manage the Loops CLI skill for AI agents", +} + +func init() { + rootCmd.AddCommand(skillCmd) +} diff --git a/cmd/skill_install.go b/cmd/skill_install.go new file mode 100644 index 0000000..44d7b3a --- /dev/null +++ b/cmd/skill_install.go @@ -0,0 +1,83 @@ +package cmd + +import ( + "fmt" + "io" + "os" + "os/exec" + "strings" + + "github.com/spf13/cobra" +) + +const ( + skillsRepoURL = "https://github.com/loops-so/skills" + skillName = "loops-cli" +) + +var ( + skillInstallGlobal bool + skillInstallYes bool + skillInstallAll bool +) + +func skillInstallArgs(global, yes, all bool) []string { + args := []string{} + // --yes for npx (--all implies non-interactive intent) + if yes || all { + args = append(args, "--yes") + } + args = append(args, "skills", "add", skillsRepoURL) + if all { + args = append(args, "--all") + } else { + args = append(args, "--skill", skillName) + } + if global { + args = append(args, "--global") + } + // --yes for skills (--all already implies -y to skills) + if yes && !all { + args = append(args, "--yes") + } + return args +} + +func runSkillInstall(stderr io.Writer, global, yes, all bool) error { + if _, err := exec.LookPath("npx"); err != nil { + return fmt.Errorf("npx not found on PATH") + } + + args := skillInstallArgs(global, yes, all) + fmt.Fprintf(stderr, "Running: npx %s\n", strings.Join(args, " ")) + + c := exec.Command("npx", args...) + c.Stdin = os.Stdin + c.Stdout = stderr + c.Stderr = stderr + if err := c.Run(); err != nil { + return fmt.Errorf("npx: %w", err) + } + return nil +} + +var skillInstallCmd = &cobra.Command{ + Use: "install", + Short: "Install the Loops CLI skill via 'skills add'", + RunE: func(cmd *cobra.Command, args []string) error { + if err := runSkillInstall(os.Stderr, skillInstallGlobal, skillInstallYes, skillInstallAll); err != nil { + return err + } + if isJSONOutput() { + return printJSON(cmd.OutOrStdout(), Result{Success: true, Message: "skill installed"}) + } + return nil + }, +} + +func init() { + skillInstallCmd.Flags().BoolVarP(&skillInstallGlobal, "global", "g", false, "Install the skill globally") + skillInstallCmd.Flags().BoolVarP(&skillInstallYes, "yes", "y", false, "Skip confirmation prompts") + skillInstallCmd.Flags().BoolVarP(&skillInstallAll, "all", "a", false, "Install all Loops skills (not just the CLI skill)") + skillCmd.AddCommand(skillInstallCmd) +} diff --git a/cmd/skill_install_test.go b/cmd/skill_install_test.go new file mode 100644 index 0000000..5359566 --- /dev/null +++ b/cmd/skill_install_test.go @@ -0,0 +1,76 @@ +package cmd + +import ( + "bytes" + "reflect" + "strings" + "testing" +) + +func TestSkillInstallArgs(t *testing.T) { + tests := []struct { + name string + global bool + yes bool + all bool + want []string + }{ + { + name: "no flags", + want: []string{"skills", "add", "https://github.com/loops-so/skills", "--skill", "loops-cli"}, + }, + { + name: "global", + global: true, + want: []string{"skills", "add", "https://github.com/loops-so/skills", "--skill", "loops-cli", "--global"}, + }, + { + name: "yes", + yes: true, + want: []string{"--yes", "skills", "add", "https://github.com/loops-so/skills", "--skill", "loops-cli", "--yes"}, + }, + { + name: "global and yes", + global: true, + yes: true, + want: []string{"--yes", "skills", "add", "https://github.com/loops-so/skills", "--skill", "loops-cli", "--global", "--yes"}, + }, + { + name: "all", + all: true, + want: []string{"--yes", "skills", "add", "https://github.com/loops-so/skills", "--all"}, + }, + { + name: "all and global", + all: true, + global: true, + want: []string{"--yes", "skills", "add", "https://github.com/loops-so/skills", "--all", "--global"}, + }, + { + name: "all and yes", + all: true, + yes: true, + want: []string{"--yes", "skills", "add", "https://github.com/loops-so/skills", "--all"}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := skillInstallArgs(tc.global, tc.yes, tc.all) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("got %v, want %v", got, tc.want) + } + }) + } +} + +func TestRunSkillInstallErrorsWhenNpxMissing(t *testing.T) { + t.Setenv("PATH", "") + var buf bytes.Buffer + err := runSkillInstall(&buf, false, false, false) + if err == nil { + t.Fatal("expected error when npx is missing, got nil") + } + if !strings.Contains(err.Error(), "npx not found") { + t.Errorf("expected error to mention 'npx not found', got %v", err) + } +}