feat(cli): add plugin list and lint commands

This commit is contained in:
xj 2026-02-28 17:38:58 -08:00
parent 3cceb99d22
commit d3eb13c3be
8 changed files with 347 additions and 0 deletions

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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")
}

View file

@ -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)
}
}

View file

@ -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)
}

View file

@ -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/plugin"
"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"
@ -41,6 +42,7 @@ func NewPicoclawCommand() *cobra.Command {
status.NewStatusCommand(), status.NewStatusCommand(),
cron.NewCronCommand(), cron.NewCronCommand(),
migrate.NewMigrateCommand(), migrate.NewMigrateCommand(),
plugin.NewPluginCommand(),
skills.NewSkillsCommand(), skills.NewSkillsCommand(),
version.NewVersionCommand(), version.NewVersionCommand(),
) )

View file

@ -39,6 +39,7 @@ func TestNewPicoclawCommand(t *testing.T) {
"gateway", "gateway",
"migrate", "migrate",
"onboard", "onboard",
"plugin",
"skills", "skills",
"status", "status",
"version", "version",