From d3eb13c3bec2204f0107e0732ab76928d9ce862a Mon Sep 17 00:00:00 2001 From: xj Date: Sat, 28 Feb 2026 17:38:58 -0800 Subject: [PATCH] feat(cli): add plugin list and lint commands --- cmd/picoclaw/internal/plugin/command.go | 19 ++++ cmd/picoclaw/internal/plugin/command_test.go | 44 ++++++++ cmd/picoclaw/internal/plugin/lint.go | 41 +++++++ cmd/picoclaw/internal/plugin/lint_test.go | 73 +++++++++++++ cmd/picoclaw/internal/plugin/list.go | 106 +++++++++++++++++++ cmd/picoclaw/internal/plugin/list_test.go | 61 +++++++++++ cmd/picoclaw/main.go | 2 + cmd/picoclaw/main_test.go | 1 + 8 files changed, 347 insertions(+) create mode 100644 cmd/picoclaw/internal/plugin/command.go create mode 100644 cmd/picoclaw/internal/plugin/command_test.go create mode 100644 cmd/picoclaw/internal/plugin/lint.go create mode 100644 cmd/picoclaw/internal/plugin/lint_test.go create mode 100644 cmd/picoclaw/internal/plugin/list.go create mode 100644 cmd/picoclaw/internal/plugin/list_test.go diff --git a/cmd/picoclaw/internal/plugin/command.go b/cmd/picoclaw/internal/plugin/command.go new file mode 100644 index 000000000..b156aa101 --- /dev/null +++ b/cmd/picoclaw/internal/plugin/command.go @@ -0,0 +1,19 @@ +package plugin + +import "github.com/spf13/cobra" + +func NewPluginCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "plugin", + Short: "Inspect and validate plugins", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Help() + }, + } + + cmd.AddCommand(newListCommand()) + cmd.AddCommand(newLintSubcommand()) + + return cmd +} diff --git a/cmd/picoclaw/internal/plugin/command_test.go b/cmd/picoclaw/internal/plugin/command_test.go new file mode 100644 index 000000000..b457ad847 --- /dev/null +++ b/cmd/picoclaw/internal/plugin/command_test.go @@ -0,0 +1,44 @@ +package plugin + +import ( + "slices" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewPluginCommand(t *testing.T) { + cmd := NewPluginCommand() + + require.NotNil(t, cmd) + + assert.Equal(t, "plugin", cmd.Use) + assert.Equal(t, "Inspect and validate plugins", cmd.Short) + + assert.True(t, cmd.HasSubCommands()) + assert.True(t, cmd.HasAvailableSubCommands()) + + assert.False(t, cmd.HasFlags()) + + assert.Nil(t, cmd.Run) + assert.NotNil(t, cmd.RunE) + + assert.Nil(t, cmd.PersistentPreRun) + assert.Nil(t, cmd.PersistentPostRun) + + allowedCommands := []string{ + "list", + "lint", + } + + subcommands := cmd.Commands() + assert.Len(t, subcommands, len(allowedCommands)) + + for _, subcmd := range subcommands { + found := slices.Contains(allowedCommands, subcmd.Name()) + assert.True(t, found, "unexpected subcommand %q", subcmd.Name()) + + assert.False(t, subcmd.Hidden) + } +} diff --git a/cmd/picoclaw/internal/plugin/lint.go b/cmd/picoclaw/internal/plugin/lint.go new file mode 100644 index 000000000..c31b87d47 --- /dev/null +++ b/cmd/picoclaw/internal/plugin/lint.go @@ -0,0 +1,41 @@ +package plugin + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/cmd/picoclaw/internal/pluginruntime" + "github.com/sipeed/picoclaw/pkg/config" +) + +func newLintSubcommand() *cobra.Command { + configPath := internal.GetConfigPath() + + cmd := &cobra.Command{ + Use: "lint", + Short: "Lint plugin configuration", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, err := config.LoadConfig(configPath) + if err != nil { + return fmt.Errorf("error loading config: %w", err) + } + + if _, _, err := pluginruntime.ResolveConfiguredPlugins(cfg); err != nil { + return fmt.Errorf("invalid plugin config: %w", err) + } + + if _, err := fmt.Fprintln(cmd.OutOrStdout(), "plugin config lint: ok"); err != nil { + return err + } + + return nil + }, + } + + cmd.Flags().StringVar(&configPath, "config", internal.GetConfigPath(), "Path to config file") + + return cmd +} diff --git a/cmd/picoclaw/internal/plugin/lint_test.go b/cmd/picoclaw/internal/plugin/lint_test.go new file mode 100644 index 000000000..0e007983e --- /dev/null +++ b/cmd/picoclaw/internal/plugin/lint_test.go @@ -0,0 +1,73 @@ +package plugin + +import ( + "bytes" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestNewLintSubcommand(t *testing.T) { + cmd := newLintSubcommand() + + require.NotNil(t, cmd) + + assert.Equal(t, "lint", cmd.Use) + assert.Equal(t, "Lint plugin configuration", cmd.Short) + + assert.Nil(t, cmd.Run) + assert.NotNil(t, cmd.RunE) + + assert.False(t, cmd.HasSubCommands()) + assert.True(t, cmd.HasFlags()) + + configFlag := cmd.Flags().Lookup("config") + require.NotNil(t, configFlag) + assert.Equal(t, internal.GetConfigPath(), configFlag.DefValue) +} + +func TestPluginLint_UnknownEnabledExitNonZero(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + cfg := config.DefaultConfig() + cfg.Plugins = config.PluginsConfig{ + DefaultEnabled: false, + Enabled: []string{"missing-plugin"}, + Disabled: []string{}, + } + require.NoError(t, config.SaveConfig(configPath, cfg)) + + cmd := NewPluginCommand() + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"lint", "--config", configPath}) + + err := cmd.Execute() + require.Error(t, err) + assert.ErrorContains(t, err, "missing-plugin") +} + +func TestPluginLint_ValidConfigExitZero(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + cfg := config.DefaultConfig() + cfg.Plugins = config.PluginsConfig{ + DefaultEnabled: false, + Enabled: []string{}, + Disabled: []string{}, + } + require.NoError(t, config.SaveConfig(configPath, cfg)) + + out := &bytes.Buffer{} + cmd := NewPluginCommand() + cmd.SetOut(out) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"lint", "--config", configPath}) + + err := cmd.Execute() + require.NoError(t, err) + assert.Contains(t, out.String(), "plugin config lint: ok") +} diff --git a/cmd/picoclaw/internal/plugin/list.go b/cmd/picoclaw/internal/plugin/list.go new file mode 100644 index 000000000..e19873ab4 --- /dev/null +++ b/cmd/picoclaw/internal/plugin/list.go @@ -0,0 +1,106 @@ +package plugin + +import ( + "encoding/json" + "fmt" + "io" + "sort" + + "github.com/spf13/cobra" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/cmd/picoclaw/internal/pluginruntime" +) + +const ( + formatText = "text" + formatJSON = "json" +) + +type pluginStatus struct { + Name string `json:"name"` + Status string `json:"status"` +} + +func newListCommand() *cobra.Command { + var format string + + cmd := &cobra.Command{ + Use: "list", + Short: "List configured plugin status", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if format != formatText && format != formatJSON { + return fmt.Errorf("invalid value for --format: %q (allowed: %s, %s)", format, formatText, formatJSON) + } + + cfg, err := internal.LoadConfig() + if err != nil { + return fmt.Errorf("error loading config: %w", err) + } + + _, summary, err := pluginruntime.ResolveConfiguredPlugins(cfg) + statuses := buildPluginStatuses(summary) + + if outputErr := renderPluginStatuses(cmd.OutOrStdout(), format, statuses); outputErr != nil { + return outputErr + } + if err != nil { + return fmt.Errorf("error resolving configured plugins: %w", err) + } + + return nil + }, + } + + cmd.Flags().StringVar(&format, "format", formatText, "Output format (text|json)") + + return cmd +} + +func buildPluginStatuses(summary pluginruntime.Summary) []pluginStatus { + statuses := make([]pluginStatus, 0, len(summary.Enabled)+len(summary.Disabled)+len(summary.UnknownEnabled)+len(summary.UnknownDisabled)) + + for _, name := range summary.Enabled { + statuses = append(statuses, pluginStatus{Name: name, Status: "enabled"}) + } + for _, name := range summary.Disabled { + statuses = append(statuses, pluginStatus{Name: name, Status: "disabled"}) + } + for _, name := range summary.UnknownEnabled { + statuses = append(statuses, pluginStatus{Name: name, Status: "unknown-enabled"}) + } + for _, name := range summary.UnknownDisabled { + statuses = append(statuses, pluginStatus{Name: name, Status: "unknown-disabled"}) + } + + sort.Slice(statuses, func(i, j int) bool { + if statuses[i].Name == statuses[j].Name { + return statuses[i].Status < statuses[j].Status + } + return statuses[i].Name < statuses[j].Name + }) + + return statuses +} + +func renderPluginStatuses(w io.Writer, format string, statuses []pluginStatus) error { + switch format { + case formatText: + if _, err := fmt.Fprintln(w, "NAME\tSTATUS"); err != nil { + return err + } + for _, status := range statuses { + if _, err := fmt.Fprintf(w, "%s\t%s\n", status.Name, status.Status); err != nil { + return err + } + } + return nil + case formatJSON: + encoder := json.NewEncoder(w) + encoder.SetIndent("", " ") + return encoder.Encode(statuses) + default: + return fmt.Errorf("invalid value for --format: %q (allowed: %s, %s)", format, formatText, formatJSON) + } +} diff --git a/cmd/picoclaw/internal/plugin/list_test.go b/cmd/picoclaw/internal/plugin/list_test.go new file mode 100644 index 000000000..d541d81cd --- /dev/null +++ b/cmd/picoclaw/internal/plugin/list_test.go @@ -0,0 +1,61 @@ +package plugin + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal/pluginruntime" +) + +func TestNewListSubcommand(t *testing.T) { + cmd := newListCommand() + + require.NotNil(t, cmd) + + assert.Equal(t, "list", cmd.Use) + assert.Equal(t, "List configured plugin status", cmd.Short) + + assert.Nil(t, cmd.Run) + assert.NotNil(t, cmd.RunE) + + assert.False(t, cmd.HasSubCommands()) + assert.True(t, cmd.HasFlags()) + + assert.Len(t, cmd.Aliases, 0) + + formatFlag := cmd.Flags().Lookup("format") + require.NotNil(t, formatFlag) + assert.Equal(t, formatText, formatFlag.DefValue) +} + +func TestNewListSubcommand_RejectsUnknownFormat(t *testing.T) { + cmd := newListCommand() + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"--format", "yaml"}) + + err := cmd.Execute() + require.Error(t, err) + assert.ErrorContains(t, err, `invalid value for --format: "yaml"`) +} + +func TestBuildPluginStatuses_DeterministicOrder(t *testing.T) { + summary := pluginruntime.Summary{ + Enabled: []string{"beta"}, + Disabled: []string{"alpha"}, + UnknownEnabled: []string{"zeta"}, + UnknownDisabled: []string{"eta"}, + } + + got := buildPluginStatuses(summary) + + assert.Equal(t, []pluginStatus{ + {Name: "alpha", Status: "disabled"}, + {Name: "beta", Status: "enabled"}, + {Name: "eta", Status: "unknown-disabled"}, + {Name: "zeta", Status: "unknown-enabled"}, + }, got) +} diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 6db69c990..4db560309 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -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/plugin" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/skills" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/status" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/version" @@ -41,6 +42,7 @@ func NewPicoclawCommand() *cobra.Command { status.NewStatusCommand(), cron.NewCronCommand(), migrate.NewMigrateCommand(), + plugin.NewPluginCommand(), skills.NewSkillsCommand(), version.NewVersionCommand(), ) diff --git a/cmd/picoclaw/main_test.go b/cmd/picoclaw/main_test.go index 3740ba358..2d9a84efe 100644 --- a/cmd/picoclaw/main_test.go +++ b/cmd/picoclaw/main_test.go @@ -39,6 +39,7 @@ func TestNewPicoclawCommand(t *testing.T) { "gateway", "migrate", "onboard", + "plugin", "skills", "status", "version",