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/gateway"
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/migrate"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/migrate"
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/onboard"
|
"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/skills"
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/status"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/status"
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/version"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/version"
|
||||||
|
|
@ -42,6 +43,7 @@ func NewPicoclawCommand() *cobra.Command {
|
||||||
cron.NewCronCommand(),
|
cron.NewCronCommand(),
|
||||||
migrate.NewMigrateCommand(),
|
migrate.NewMigrateCommand(),
|
||||||
skills.NewSkillsCommand(),
|
skills.NewSkillsCommand(),
|
||||||
|
plugins.NewPluginsCommand(),
|
||||||
version.NewVersionCommand(),
|
version.NewVersionCommand(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue