feat(cli): add plugin list and lint commands
This commit is contained in:
parent
3cceb99d22
commit
d3eb13c3be
8 changed files with 347 additions and 0 deletions
19
cmd/picoclaw/internal/plugin/command.go
Normal file
19
cmd/picoclaw/internal/plugin/command.go
Normal 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
|
||||
}
|
||||
44
cmd/picoclaw/internal/plugin/command_test.go
Normal file
44
cmd/picoclaw/internal/plugin/command_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
41
cmd/picoclaw/internal/plugin/lint.go
Normal file
41
cmd/picoclaw/internal/plugin/lint.go
Normal 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
|
||||
}
|
||||
73
cmd/picoclaw/internal/plugin/lint_test.go
Normal file
73
cmd/picoclaw/internal/plugin/lint_test.go
Normal 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")
|
||||
}
|
||||
106
cmd/picoclaw/internal/plugin/list.go
Normal file
106
cmd/picoclaw/internal/plugin/list.go
Normal 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)
|
||||
}
|
||||
}
|
||||
61
cmd/picoclaw/internal/plugin/list_test.go
Normal file
61
cmd/picoclaw/internal/plugin/list_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ func TestNewPicoclawCommand(t *testing.T) {
|
|||
"gateway",
|
||||
"migrate",
|
||||
"onboard",
|
||||
"plugin",
|
||||
"skills",
|
||||
"status",
|
||||
"version",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue