feat: add plugins command for running executables from ~/.picoclaw/plugins

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 <crush@charm.land>
This commit is contained in:
Keith Patrick 2026-03-05 03:08:26 +00:00
parent 651cb2ebda
commit 5afa5cf7ff
3 changed files with 316 additions and 0 deletions

View file

@ -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
},
}
}

View file

@ -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)
}
}

View file

@ -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(),
)