feat: route root-level commands to plugins in ~/.picoclaw/plugins

- Added root args validator to picoclaw command to route unknown
  commands to plugins (e.g., picoclaw service → picoclaw-service)
- Removed built-in 'plugins' subcommand - now handled as plugin
- Added prefix matching to support 'picoclaw-X' → 'X' shorthand
- Added exported functions (FindPlugin, ExecPlugin, ListPlugins)
- Updated tests for new command structure

Example usage:
  picoclaw service status    → runs picoclaw-service
  picoclaw launcher          → runs picoclaw-launcher
  picoclaw plugins-list      → runs picoclaw-plugins-list

💘 Generated with Crush

Assisted-by: MiniMax-M2.5 via Crush <crush@charm.land>
This commit is contained in:
Keith Patrick 2026-03-05 04:04:00 +00:00
parent 5afa5cf7ff
commit 2e6a51168f
3 changed files with 118 additions and 6 deletions

View file

@ -5,6 +5,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@ -127,6 +128,37 @@ func findPlugin(name string) (string, error) {
} }
} }
// Try prefix match - if user types "X", match "X-*" or "*-X"
entries, err := os.ReadDir(pluginsDir)
if err == nil {
var matches []string
for _, entry := range entries {
if entry.IsDir() {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
if info.Mode()&0111 == 0 {
continue
}
pluginName := entry.Name()
// Check if name is a prefix or suffix of plugin name
if strings.HasPrefix(pluginName, name+"-") ||
strings.HasPrefix(pluginName, name) ||
strings.HasSuffix(pluginName, "-"+name) {
matches = append(matches, pluginName)
}
}
if len(matches) == 1 {
return filepath.Join(pluginsDir, matches[0]), nil
}
if len(matches) > 1 {
return "", fmt.Errorf("multiple plugins match %q: %v", name, matches)
}
}
return "", fmt.Errorf("plugin %q not found", name) return "", fmt.Errorf("plugin %q not found", name)
} }
@ -194,3 +226,18 @@ func newListCommand() *cobra.Command {
}, },
} }
} }
// FindPlugin returns the path to a plugin by name, or an error if not found
func FindPlugin(name string) (string, error) {
return findPlugin(name)
}
// ExecPlugin executes a plugin with the given arguments
func ExecPlugin(pluginPath string, args []string) {
execPlugin(pluginPath, args)
}
// ListPlugins returns a list of available plugin names
func ListPlugins() ([]string, error) {
return listPlugins()
}

View file

@ -29,9 +29,14 @@ func NewPicoclawCommand() *cobra.Command {
short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, internal.GetVersion()) short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, internal.GetVersion())
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "picoclaw", Use: "picoclaw",
Short: short, Short: short,
Example: "picoclaw list", Example: "picoclaw list",
Args: rootArgsValidator,
ValidArgsFunction: rootCompleteArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
} }
cmd.AddCommand( cmd.AddCommand(
@ -43,7 +48,6 @@ func NewPicoclawCommand() *cobra.Command {
cron.NewCronCommand(), cron.NewCronCommand(),
migrate.NewMigrateCommand(), migrate.NewMigrateCommand(),
skills.NewSkillsCommand(), skills.NewSkillsCommand(),
plugins.NewPluginsCommand(),
version.NewVersionCommand(), version.NewVersionCommand(),
) )
@ -63,6 +67,67 @@ const (
"\033[0m\r\n" "\033[0m\r\n"
) )
// rootArgsValidator checks if args should be routed to a plugin
func rootArgsValidator(cmd *cobra.Command, args []string) error {
// No args - let cobra show help
if len(args) == 0 {
return nil
}
// Check if it's a known subcommand
knownCommands := map[string]bool{
"onboard": true,
"agent": true,
"auth": true,
"gateway": true,
"status": true,
"cron": true,
"migrate": true,
"skills": true,
"version": true,
"help": true,
"-h": true,
"--help": true,
}
if knownCommands[args[0]] {
return nil
}
// Try to find a plugin with this name
pluginPath, err := plugins.FindPlugin(args[0])
if err != nil {
// Not a known command and not a plugin - print error and exit
fmt.Fprintf(os.Stderr, "picoclaw: %q is not a picoclaw command. See 'picoclaw --help'.\n", args[0])
os.Exit(1)
}
// Execute the plugin and exit
plugins.ExecPlugin(pluginPath, args[1:])
// Should not reach here
return nil
}
// rootCompleteArgs provides completion for plugin names
func rootCompleteArgs(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) != 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
// Get list of plugins for completion
pluginList, err := plugins.ListPlugins()
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
// Also include built-in commands
builtIn := []string{"onboard", "agent", "auth", "gateway", "status", "cron", "migrate", "skills", "plugins", "version"}
// Combine both lists
result := append(builtIn, pluginList...)
return result, cobra.ShellCompDirectiveNoFileComp
}
func main() { func main() {
fmt.Printf("%s", banner) fmt.Printf("%s", banner)
cmd := NewPicoclawCommand() cmd := NewPicoclawCommand()

View file

@ -26,8 +26,8 @@ func TestNewPicoclawCommand(t *testing.T) {
assert.False(t, cmd.HasFlags()) assert.False(t, cmd.HasFlags())
assert.Nil(t, cmd.Run) // RunE is set to handle plugin execution
assert.Nil(t, cmd.RunE) assert.NotNil(t, cmd.RunE)
assert.Nil(t, cmd.PersistentPreRun) assert.Nil(t, cmd.PersistentPreRun)
assert.Nil(t, cmd.PersistentPostRun) assert.Nil(t, cmd.PersistentPostRun)