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/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(),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ func TestNewPicoclawCommand(t *testing.T) {
|
||||||
"gateway",
|
"gateway",
|
||||||
"migrate",
|
"migrate",
|
||||||
"onboard",
|
"onboard",
|
||||||
|
"plugin",
|
||||||
"skills",
|
"skills",
|
||||||
"status",
|
"status",
|
||||||
"version",
|
"version",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue