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:
parent
5afa5cf7ff
commit
2e6a51168f
3 changed files with 118 additions and 6 deletions
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue