Skip to content
19 changes: 19 additions & 0 deletions cmd/picoclaw/internal/agent/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/chzyer/readline"

"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/pluginruntime"
"github.com/sipeed/picoclaw/pkg/agent"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/logger"
Expand Down Expand Up @@ -51,6 +52,24 @@ func agentCmd(message, sessionKey, model string, debug bool) error {
defer msgBus.Close()
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)

pluginsToEnable, pluginSummary, err := pluginruntime.ResolveConfiguredPlugins(cfg)
if err != nil {
return fmt.Errorf("error resolving configured plugins: %w", err)
}
if len(pluginsToEnable) > 0 {
if err := agentLoop.EnablePlugins(pluginsToEnable...); err != nil {
return fmt.Errorf("error enabling plugins: %w", err)
}
}
logger.InfoCF("agent", "Plugin selection resolved",
map[string]any{
"plugins_enabled": pluginSummary.Enabled,
"plugins_disabled": pluginSummary.Disabled,
"plugins_unknown_enabled": pluginSummary.UnknownEnabled,
"plugins_unknown_disabled": pluginSummary.UnknownDisabled,
"plugins_warnings": pluginSummary.Warnings,
})

// Print agent startup info (only for interactive mode)
startupInfo := agentLoop.GetStartupInfo()
logger.InfoCF("agent", "Agent initialized",
Expand Down
18 changes: 18 additions & 0 deletions cmd/picoclaw/internal/gateway/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"time"

"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/pluginruntime"
"github.com/sipeed/picoclaw/pkg/agent"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
Expand Down Expand Up @@ -61,6 +62,23 @@ func gatewayCmd(debug bool) error {

msgBus := bus.NewMessageBus()
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
pluginsToEnable, pluginSummary, err := pluginruntime.ResolveConfiguredPlugins(cfg)
if err != nil {
return fmt.Errorf("error resolving configured plugins: %w", err)
}
if len(pluginsToEnable) > 0 {
if enableErr := agentLoop.EnablePlugins(pluginsToEnable...); enableErr != nil {
return fmt.Errorf("error enabling plugins: %w", enableErr)
}
}
logger.InfoCF("agent", "Plugin selection resolved",
map[string]any{
"plugins_enabled": pluginSummary.Enabled,
"plugins_disabled": pluginSummary.Disabled,
"plugins_unknown_enabled": pluginSummary.UnknownEnabled,
"plugins_unknown_disabled": pluginSummary.UnknownDisabled,
"plugins_warnings": pluginSummary.Warnings,
})

// Print agent startup info
fmt.Println("\nπŸ“¦ Agent Status:")
Expand Down
19 changes: 19 additions & 0 deletions cmd/picoclaw/internal/plugin/command.go
Original file line number Diff line number Diff line change
@@ -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 changes: 44 additions & 0 deletions cmd/picoclaw/internal/plugin/command_test.go
Original file line number Diff line number Diff line change
@@ -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 changes: 41 additions & 0 deletions cmd/picoclaw/internal/plugin/lint.go
Original file line number Diff line number Diff line change
@@ -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 changes: 73 additions & 0 deletions cmd/picoclaw/internal/plugin/lint_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
110 changes: 110 additions & 0 deletions cmd/picoclaw/internal/plugin/list.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
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 {
total := len(summary.Enabled) +
len(summary.Disabled) +
len(summary.UnknownEnabled) +
len(summary.UnknownDisabled)
statuses := make([]pluginStatus, 0, total)

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