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:
parent
651cb2ebda
commit
5afa5cf7ff
3 changed files with 316 additions and 0 deletions
196
cmd/picoclaw/internal/plugins/command.go
Normal file
196
cmd/picoclaw/internal/plugins/command.go
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
118
cmd/picoclaw/internal/plugins/command_test.go
Normal file
118
cmd/picoclaw/internal/plugins/command_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue