From 5afa5cf7ff3c50576dc39fee2d345a58db5a17e4 Mon Sep 17 00:00:00 2001 From: Keith Patrick Date: Thu, 5 Mar 2026 03:08:26 +0000 Subject: [PATCH] feat: add plugins command for running executables from ~/.picoclaw/plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new 'plugins' subcommand that routes commands to executable plugins in ~/.picoclaw/plugins, providing feature parity with the picoclaw-manager shell script. Example usage: picoclaw plugins list # List available plugins picoclaw plugins service restart # Run 'service' plugin 💘 Generated with Crush Assisted-by: MiniMax-M2.5 via Crush --- cmd/picoclaw/internal/plugins/command.go | 196 ++++++++++++++++++ cmd/picoclaw/internal/plugins/command_test.go | 118 +++++++++++ cmd/picoclaw/main.go | 2 + 3 files changed, 316 insertions(+) create mode 100644 cmd/picoclaw/internal/plugins/command.go create mode 100644 cmd/picoclaw/internal/plugins/command_test.go diff --git a/cmd/picoclaw/internal/plugins/command.go b/cmd/picoclaw/internal/plugins/command.go new file mode 100644 index 000000000..4801dd047 --- /dev/null +++ b/cmd/picoclaw/internal/plugins/command.go @@ -0,0 +1,196 @@ +package plugins + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal" +) + +const pluginsDirName = "plugins" + +// pluginArgsValidator validates args and runs plugins for unknown subcommands +func pluginArgsValidator(cmd *cobra.Command, args []string) error { + // If no args, let cobra handle it (show help) + if len(args) == 0 { + return nil + } + + // Check if it's a known subcommand (handled by cobra) + knownSubcommands := map[string]bool{ + "list": true, + } + + if knownSubcommands[args[0]] { + return nil + } + + // Try to find and run the plugin + pluginPath, err := findPlugin(args[0]) + if err != nil { + // Plugin not found - return error to show "unknown command" + return fmt.Errorf("unknown command %q", args[0]) + } + + // Run the plugin directly (this will exit) + execPlugin(pluginPath, args[1:]) + // Should not reach here + return nil +} + +func NewPluginsCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "plugins", + Short: "Manage and run plugins", + Long: pluginsLongHelp, + Args: pluginArgsValidator, + ValidArgsFunction: completePluginArgs, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + // If we get here with args (after Args validation passed), show help + return cmd.Help() + }, + } + + cmd.AddCommand( + newListCommand(), + ) + + return cmd +} + +const pluginsLongHelp = `Manage and run plugins from ~/.picoclaw/plugins + +Routes commands to executable plugins in the plugins directory. +Each plugin is an executable file in ~/.picoclaw/plugins. + +Examples: + picoclaw plugins list # List available plugins + picoclaw plugins service restart # Run 'service' plugin with 'restart' + picoclaw plugins service status # Run 'service' plugin with 'status'` + +func getPluginsDir() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".picoclaw", pluginsDirName) +} + +func listPlugins() ([]string, error) { + pluginsDir := getPluginsDir() + + entries, err := os.ReadDir(pluginsDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + + var plugins []string + for _, entry := range entries { + if entry.IsDir() { + continue + } + + info, err := entry.Info() + if err != nil { + continue + } + + // Check if executable by anyone + if info.Mode()&0111 != 0 { + plugins = append(plugins, entry.Name()) + } + } + + return plugins, nil +} + +func findPlugin(name string) (string, error) { + pluginsDir := getPluginsDir() + + // Try exact match first + pluginPath := filepath.Join(pluginsDir, name) + if info, err := os.Stat(pluginPath); err == nil && !info.IsDir() && info.Mode()&0111 != 0 { + return pluginPath, nil + } + + // Try common extensions + extensions := []string{".sh", ".bash", ".py", ".go", ""} + for _, ext := range extensions { + extPath := pluginPath + ext + if info, err := os.Stat(extPath); err == nil && !info.IsDir() && info.Mode()&0111 != 0 { + return extPath, nil + } + } + + return "", fmt.Errorf("plugin %q not found", name) +} + +func execPlugin(pluginPath string, args []string) { + execCmd := exec.Command(pluginPath, args...) + execCmd.Stdin = os.Stdin + execCmd.Stdout = os.Stdout + execCmd.Stderr = os.Stderr + execCmd.Env = os.Environ() + + if err := execCmd.Run(); err != nil { + // Check if it's an exit error + if exitErr, ok := err.(*exec.ExitError); ok { + os.Exit(exitErr.ExitCode()) + } + // Otherwise it's some other error + fmt.Fprintf(os.Stderr, "Error running plugin: %v\n", err) + os.Exit(1) + } + os.Exit(0) +} + +func completePluginArgs(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + plugins, err := listPlugins() + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + return plugins, cobra.ShellCompDirectiveNoFileComp +} + +func newListCommand() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List available plugins", + RunE: func(cmd *cobra.Command, _ []string) error { + plugins, err := listPlugins() + if err != nil { + return fmt.Errorf("failed to list plugins: %w", err) + } + + cmd.Println("Available plugins in ", internal.GetConfigPath()) + cmd.Println("") + + // Show the plugins directory + pluginsDir := getPluginsDir() + cmd.Printf("Plugins directory: %s\n", pluginsDir) + cmd.Println("") + + if len(plugins) == 0 { + cmd.Println(" (no executable plugins found)") + return nil + } + + cmd.Println("Plugins:") + for _, plugin := range plugins { + cmd.Printf(" %s\n", plugin) + } + + return nil + }, + } +} diff --git a/cmd/picoclaw/internal/plugins/command_test.go b/cmd/picoclaw/internal/plugins/command_test.go new file mode 100644 index 000000000..78885250a --- /dev/null +++ b/cmd/picoclaw/internal/plugins/command_test.go @@ -0,0 +1,118 @@ +package plugins + +import ( + "os" + "path/filepath" + "testing" + + "github.com/spf13/cobra" +) + +func TestGetPluginsDir(t *testing.T) { + dir := getPluginsDir() + expected := filepath.Join(os.Getenv("HOME"), ".picoclaw", "plugins") + if dir != expected { + t.Errorf("expected %s, got %s", expected, dir) + } +} + +func TestListPlugins(t *testing.T) { + // Test when plugins directory doesn't exist + // We can't easily test this since ~/.picoclaw/plugins may already exist + // Just verify it doesn't error + plugins, err := listPlugins() + if err != nil { + t.Errorf("unexpected error: %v", err) + } + // plugins may be nil or empty or contain existing plugins + _ = plugins +} + +func TestFindPlugin(t *testing.T) { + // Test with non-existent plugin + _, err := findPlugin("nonexistent-plugin-12345") + if err == nil { + t.Error("expected error for non-existent plugin") + } +} + +func TestNewPluginsCommand(t *testing.T) { + cmd := NewPluginsCommand() + if cmd == nil { + t.Fatal("NewPluginsCommand returned nil") + } + + if cmd.Use != "plugins" { + t.Errorf("expected use 'plugins', got %s", cmd.Use) + } + + // Check subcommands + subcommands := cmd.Commands() + if len(subcommands) != 1 { + t.Errorf("expected 1 subcommand, got %d", len(subcommands)) + } + + // Find list command + var listCmd *cobra.Command + for _, sub := range subcommands { + if sub.Use == "list" { + listCmd = sub + break + } + } + if listCmd == nil { + t.Error("list subcommand not found") + } +} + +func TestRunPluginNotFound(t *testing.T) { + _, err := findPlugin("nonexistent-plugin-xyz") + if err == nil { + t.Error("expected error for non-existent plugin") + } +} + +func TestListPluginsIntegration(t *testing.T) { + // This is an integration test that checks the actual plugins directory + pluginsDir := getPluginsDir() + + // Create a temporary test plugin + tmpDir := pluginsDir + if err := os.MkdirAll(tmpDir, 0755); err != nil { + // Directory creation failed, skip integration test + t.Skip("cannot create plugins directory") + } + + // Create a temporary test script + testPlugin := filepath.Join(tmpDir, "test-plugin-temp") + if err := os.WriteFile(testPlugin, []byte("#!/bin/bash\necho hello"), 0755); err != nil { + t.Skip("cannot write test plugin") + } + defer os.Remove(testPlugin) + + // Test listing + plugins, err := listPlugins() + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + found := false + for _, p := range plugins { + if p == "test-plugin-temp" { + found = true + break + } + } + if !found { + t.Error("test plugin not found in listing") + } + + // Test finding (verifies plugin is found - execution tested via CLI) + foundPath, err := findPlugin("test-plugin-temp") + if err != nil { + t.Errorf("unexpected error finding plugin: %v", err) + } + if foundPath != testPlugin { + t.Errorf("expected %s, got %s", testPlugin, foundPath) + } +} diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index d9263462e..0649eaebf 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -19,6 +19,7 @@ import ( "github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/migrate" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/onboard" + "github.com/sipeed/picoclaw/cmd/picoclaw/internal/plugins" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/skills" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/status" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/version" @@ -42,6 +43,7 @@ func NewPicoclawCommand() *cobra.Command { cron.NewCronCommand(), migrate.NewMigrateCommand(), skills.NewSkillsCommand(), + plugins.NewPluginsCommand(), version.NewVersionCommand(), )