refactor(cli): migrate to Cobra-based command structure (#429)
* refactor(cli): migrate to Cobra-based command structure Refactor CLI to use Cobra instead of manual os.Args parsing. - Introduce root command and structured subcommands under cmd/picoclaw/internal - Convert agent, auth, cron, gateway, migrate, onboard, skills, status and version to Cobra commands - Replace manual flag parsing with Cobra flags - Remove direct os.Args usage from command handlers - Keep existing command behavior and output semantics This change focuses on CLI structure and maintainability. No business logic changes intended. * chore(cli): remove version2 alias and make cobra a direct dependency * test(cli): add basic command tests - Add tests for CLI command tree and flag parsing - Align LDFLAGS injection path for version info - Remove unused manual help function * test: migrate command tests to testify assertions Replace standard library testing error checks (t.Error*, t.Fatalf) with assert/require from stretchr/testify across all cobra command tests for improved readability and consistency. * fix(cli): make linter happy * test: avoid duplication in windows config path test * test: simplify allowed command checks using slices.Contains * fix(skills): register subcommands during command construction - Move subcommand registration out of PersistentPreRunE - Ensure `picoclaw skills <subcommand>` resolves correctly - Minor install command and test cleanups * refactor(cli): address review feedback and improve command clarity * fix(authLogoutCmd): rm os.Exit
This commit is contained in:
parent
ec6da7a530
commit
73f27803d4
68 changed files with 1978 additions and 797 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -10,7 +10,7 @@ build/
|
|||
*.out
|
||||
/picoclaw
|
||||
/picoclaw-test
|
||||
cmd/picoclaw/workspace
|
||||
cmd/**/workspace
|
||||
|
||||
# Picoclaw specific
|
||||
|
||||
|
|
|
|||
|
|
@ -15,10 +15,10 @@ builds:
|
|||
- stdjson
|
||||
ldflags:
|
||||
- -s -w
|
||||
- -X main.version={{ .Version }}
|
||||
- -X main.gitCommit={{ .ShortCommit }}
|
||||
- -X main.buildTime={{ .Date }}
|
||||
- -X main.goVersion={{ .Env.GOVERSION }}
|
||||
- -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.version={{ .Version }}
|
||||
- -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.gitCommit={{ .ShortCommit }}
|
||||
- -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.buildTime={{ .Date }}
|
||||
- -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.goVersion={{ .Env.GOVERSION }}
|
||||
goos:
|
||||
- linux
|
||||
- windows
|
||||
|
|
|
|||
3
Makefile
3
Makefile
|
|
@ -11,7 +11,8 @@ VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
|||
GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev")
|
||||
BUILD_TIME=$(shell date +%FT%T%z)
|
||||
GO_VERSION=$(shell $(GO) version | awk '{print $$3}')
|
||||
LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.gitCommit=$(GIT_COMMIT) -X main.buildTime=$(BUILD_TIME) -X main.goVersion=$(GO_VERSION) -s -w"
|
||||
INTERNAL=github.com/sipeed/picoclaw/cmd/picoclaw/internal
|
||||
LDFLAGS=-ldflags "-X $(INTERNAL).version=$(VERSION) -X $(INTERNAL).gitCommit=$(GIT_COMMIT) -X $(INTERNAL).buildTime=$(BUILD_TIME) -X $(INTERNAL).goVersion=$(GO_VERSION) -s -w"
|
||||
|
||||
# Go variables
|
||||
GO?=CGO_ENABLED=0 go
|
||||
|
|
|
|||
|
|
@ -1,227 +0,0 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/cron"
|
||||
)
|
||||
|
||||
func cronCmd() {
|
||||
if len(os.Args) < 3 {
|
||||
cronHelp()
|
||||
return
|
||||
}
|
||||
|
||||
subcommand := os.Args[2]
|
||||
|
||||
// Load config to get workspace path
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
fmt.Printf("Error loading config: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
cronStorePath := filepath.Join(cfg.WorkspacePath(), "cron", "jobs.json")
|
||||
|
||||
switch subcommand {
|
||||
case "list":
|
||||
cronListCmd(cronStorePath)
|
||||
case "add":
|
||||
cronAddCmd(cronStorePath)
|
||||
case "remove":
|
||||
if len(os.Args) < 4 {
|
||||
fmt.Println("Usage: picoclaw cron remove <job_id>")
|
||||
return
|
||||
}
|
||||
cronRemoveCmd(cronStorePath, os.Args[3])
|
||||
case "enable":
|
||||
cronEnableCmd(cronStorePath, false)
|
||||
case "disable":
|
||||
cronEnableCmd(cronStorePath, true)
|
||||
default:
|
||||
fmt.Printf("Unknown cron command: %s\n", subcommand)
|
||||
cronHelp()
|
||||
}
|
||||
}
|
||||
|
||||
func cronHelp() {
|
||||
fmt.Println("\nCron commands:")
|
||||
fmt.Println(" list List all scheduled jobs")
|
||||
fmt.Println(" add Add a new scheduled job")
|
||||
fmt.Println(" remove <id> Remove a job by ID")
|
||||
fmt.Println(" enable <id> Enable a job")
|
||||
fmt.Println(" disable <id> Disable a job")
|
||||
fmt.Println()
|
||||
fmt.Println("Add options:")
|
||||
fmt.Println(" -n, --name Job name")
|
||||
fmt.Println(" -m, --message Message for agent")
|
||||
fmt.Println(" -e, --every Run every N seconds")
|
||||
fmt.Println(" -c, --cron Cron expression (e.g. '0 9 * * *')")
|
||||
fmt.Println(" -d, --deliver Deliver response to channel")
|
||||
fmt.Println(" --to Recipient for delivery")
|
||||
fmt.Println(" --channel Channel for delivery")
|
||||
}
|
||||
|
||||
func cronListCmd(storePath string) {
|
||||
cs := cron.NewCronService(storePath, nil)
|
||||
jobs := cs.ListJobs(true) // Show all jobs, including disabled
|
||||
|
||||
if len(jobs) == 0 {
|
||||
fmt.Println("No scheduled jobs.")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("\nScheduled Jobs:")
|
||||
fmt.Println("----------------")
|
||||
for _, job := range jobs {
|
||||
var schedule string
|
||||
if job.Schedule.Kind == "every" && job.Schedule.EveryMS != nil {
|
||||
schedule = fmt.Sprintf("every %ds", *job.Schedule.EveryMS/1000)
|
||||
} else if job.Schedule.Kind == "cron" {
|
||||
schedule = job.Schedule.Expr
|
||||
} else {
|
||||
schedule = "one-time"
|
||||
}
|
||||
|
||||
nextRun := "scheduled"
|
||||
if job.State.NextRunAtMS != nil {
|
||||
nextTime := time.UnixMilli(*job.State.NextRunAtMS)
|
||||
nextRun = nextTime.Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
status := "enabled"
|
||||
if !job.Enabled {
|
||||
status = "disabled"
|
||||
}
|
||||
|
||||
fmt.Printf(" %s (%s)\n", job.Name, job.ID)
|
||||
fmt.Printf(" Schedule: %s\n", schedule)
|
||||
fmt.Printf(" Status: %s\n", status)
|
||||
fmt.Printf(" Next run: %s\n", nextRun)
|
||||
}
|
||||
}
|
||||
|
||||
func cronAddCmd(storePath string) {
|
||||
name := ""
|
||||
message := ""
|
||||
var everySec *int64
|
||||
cronExpr := ""
|
||||
deliver := false
|
||||
channel := ""
|
||||
to := ""
|
||||
|
||||
args := os.Args[3:]
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "-n", "--name":
|
||||
if i+1 < len(args) {
|
||||
name = args[i+1]
|
||||
i++
|
||||
}
|
||||
case "-m", "--message":
|
||||
if i+1 < len(args) {
|
||||
message = args[i+1]
|
||||
i++
|
||||
}
|
||||
case "-e", "--every":
|
||||
if i+1 < len(args) {
|
||||
var sec int64
|
||||
fmt.Sscanf(args[i+1], "%d", &sec)
|
||||
everySec = &sec
|
||||
i++
|
||||
}
|
||||
case "-c", "--cron":
|
||||
if i+1 < len(args) {
|
||||
cronExpr = args[i+1]
|
||||
i++
|
||||
}
|
||||
case "-d", "--deliver":
|
||||
deliver = true
|
||||
case "--to":
|
||||
if i+1 < len(args) {
|
||||
to = args[i+1]
|
||||
i++
|
||||
}
|
||||
case "--channel":
|
||||
if i+1 < len(args) {
|
||||
channel = args[i+1]
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
fmt.Println("Error: --name is required")
|
||||
return
|
||||
}
|
||||
|
||||
if message == "" {
|
||||
fmt.Println("Error: --message is required")
|
||||
return
|
||||
}
|
||||
|
||||
if everySec == nil && cronExpr == "" {
|
||||
fmt.Println("Error: Either --every or --cron must be specified")
|
||||
return
|
||||
}
|
||||
|
||||
var schedule cron.CronSchedule
|
||||
if everySec != nil {
|
||||
everyMS := *everySec * 1000
|
||||
schedule = cron.CronSchedule{
|
||||
Kind: "every",
|
||||
EveryMS: &everyMS,
|
||||
}
|
||||
} else {
|
||||
schedule = cron.CronSchedule{
|
||||
Kind: "cron",
|
||||
Expr: cronExpr,
|
||||
}
|
||||
}
|
||||
|
||||
cs := cron.NewCronService(storePath, nil)
|
||||
job, err := cs.AddJob(name, schedule, message, deliver, channel, to)
|
||||
if err != nil {
|
||||
fmt.Printf("Error adding job: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Added job '%s' (%s)\n", job.Name, job.ID)
|
||||
}
|
||||
|
||||
func cronRemoveCmd(storePath, jobID string) {
|
||||
cs := cron.NewCronService(storePath, nil)
|
||||
if cs.RemoveJob(jobID) {
|
||||
fmt.Printf("✓ Removed job %s\n", jobID)
|
||||
} else {
|
||||
fmt.Printf("✗ Job %s not found\n", jobID)
|
||||
}
|
||||
}
|
||||
|
||||
func cronEnableCmd(storePath string, disable bool) {
|
||||
if len(os.Args) < 4 {
|
||||
fmt.Println("Usage: picoclaw cron enable/disable <job_id>")
|
||||
return
|
||||
}
|
||||
|
||||
jobID := os.Args[3]
|
||||
cs := cron.NewCronService(storePath, nil)
|
||||
enabled := !disable
|
||||
|
||||
job := cs.EnableJob(jobID, enabled)
|
||||
if job != nil {
|
||||
status := "enabled"
|
||||
if disable {
|
||||
status = "disabled"
|
||||
}
|
||||
fmt.Printf("✓ Job '%s' %s\n", job.Name, status)
|
||||
} else {
|
||||
fmt.Printf("✗ Job %s not found\n", jobID)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/migrate"
|
||||
)
|
||||
|
||||
func migrateCmd() {
|
||||
if len(os.Args) > 2 && (os.Args[2] == "--help" || os.Args[2] == "-h") {
|
||||
migrateHelp()
|
||||
return
|
||||
}
|
||||
|
||||
opts := migrate.Options{}
|
||||
|
||||
args := os.Args[2:]
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--dry-run":
|
||||
opts.DryRun = true
|
||||
case "--config-only":
|
||||
opts.ConfigOnly = true
|
||||
case "--workspace-only":
|
||||
opts.WorkspaceOnly = true
|
||||
case "--force":
|
||||
opts.Force = true
|
||||
case "--refresh":
|
||||
opts.Refresh = true
|
||||
case "--openclaw-home":
|
||||
if i+1 < len(args) {
|
||||
opts.OpenClawHome = args[i+1]
|
||||
i++
|
||||
}
|
||||
case "--picoclaw-home":
|
||||
if i+1 < len(args) {
|
||||
opts.PicoClawHome = args[i+1]
|
||||
i++
|
||||
}
|
||||
default:
|
||||
fmt.Printf("Unknown flag: %s\n", args[i])
|
||||
migrateHelp()
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
result, err := migrate.Run(opts)
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if !opts.DryRun {
|
||||
migrate.PrintSummary(result)
|
||||
}
|
||||
}
|
||||
|
||||
func migrateHelp() {
|
||||
fmt.Println("\nMigrate from OpenClaw to PicoClaw")
|
||||
fmt.Println()
|
||||
fmt.Println("Usage: picoclaw migrate [options]")
|
||||
fmt.Println()
|
||||
fmt.Println("Options:")
|
||||
fmt.Println(" --dry-run Show what would be migrated without making changes")
|
||||
fmt.Println(" --refresh Re-sync workspace files from OpenClaw (repeatable)")
|
||||
fmt.Println(" --config-only Only migrate config, skip workspace files")
|
||||
fmt.Println(" --workspace-only Only migrate workspace files, skip config")
|
||||
fmt.Println(" --force Skip confirmation prompts")
|
||||
fmt.Println(" --openclaw-home Override OpenClaw home directory (default: ~/.openclaw)")
|
||||
fmt.Println(" --picoclaw-home Override PicoClaw home directory (default: ~/.picoclaw)")
|
||||
fmt.Println()
|
||||
fmt.Println("Examples:")
|
||||
fmt.Println(" picoclaw migrate Detect and migrate from OpenClaw")
|
||||
fmt.Println(" picoclaw migrate --dry-run Show what would be migrated")
|
||||
fmt.Println(" picoclaw migrate --refresh Re-sync workspace files")
|
||||
fmt.Println(" picoclaw migrate --force Migrate without confirmation")
|
||||
}
|
||||
30
cmd/picoclaw/internal/agent/command.go
Normal file
30
cmd/picoclaw/internal/agent/command.go
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewAgentCommand() *cobra.Command {
|
||||
var (
|
||||
message string
|
||||
sessionKey string
|
||||
model string
|
||||
debug bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "agent",
|
||||
Short: "Interact with the agent directly",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return agentCmd(message, sessionKey, model, debug)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
|
||||
cmd.Flags().StringVarP(&message, "message", "m", "", "Send a single message (non-interactive mode)")
|
||||
cmd.Flags().StringVarP(&sessionKey, "session", "s", "cli:default", "Session key")
|
||||
cmd.Flags().StringVarP(&model, "model", "", "", "Model to use")
|
||||
|
||||
return cmd
|
||||
}
|
||||
33
cmd/picoclaw/internal/agent/command_test.go
Normal file
33
cmd/picoclaw/internal/agent/command_test.go
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewAgentCommand(t *testing.T) {
|
||||
cmd := NewAgentCommand()
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "agent", cmd.Use)
|
||||
assert.Equal(t, "Interact with the agent directly", cmd.Short)
|
||||
|
||||
assert.Len(t, cmd.Aliases, 0)
|
||||
assert.False(t, cmd.HasSubCommands())
|
||||
|
||||
assert.Nil(t, cmd.Run)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
|
||||
assert.Nil(t, cmd.PersistentPreRun)
|
||||
assert.Nil(t, cmd.PersistentPostRun)
|
||||
|
||||
assert.True(t, cmd.HasFlags())
|
||||
|
||||
assert.NotNil(t, cmd.Flags().Lookup("debug"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("message"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("session"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("model"))
|
||||
}
|
||||
|
|
@ -1,7 +1,4 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
|
||||
package main
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
|
|
@ -14,56 +11,37 @@ import (
|
|||
|
||||
"github.com/chzyer/readline"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/pkg/agent"
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
func agentCmd() {
|
||||
message := ""
|
||||
sessionKey := "cli:default"
|
||||
modelOverride := ""
|
||||
|
||||
args := os.Args[2:]
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--debug", "-d":
|
||||
logger.SetLevel(logger.DEBUG)
|
||||
fmt.Println("🔍 Debug mode enabled")
|
||||
case "-m", "--message":
|
||||
if i+1 < len(args) {
|
||||
message = args[i+1]
|
||||
i++
|
||||
}
|
||||
case "-s", "--session":
|
||||
if i+1 < len(args) {
|
||||
sessionKey = args[i+1]
|
||||
i++
|
||||
}
|
||||
case "--model", "-model":
|
||||
if i+1 < len(args) {
|
||||
modelOverride = args[i+1]
|
||||
i++
|
||||
}
|
||||
}
|
||||
func agentCmd(message, sessionKey, model string, debug bool) error {
|
||||
if sessionKey == "" {
|
||||
sessionKey = "cli:default"
|
||||
}
|
||||
|
||||
cfg, err := loadConfig()
|
||||
if debug {
|
||||
logger.SetLevel(logger.DEBUG)
|
||||
fmt.Println("🔍 Debug mode enabled")
|
||||
}
|
||||
|
||||
cfg, err := internal.LoadConfig()
|
||||
if err != nil {
|
||||
fmt.Printf("Error loading config: %v\n", err)
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("error loading config: %w", err)
|
||||
}
|
||||
|
||||
if modelOverride != "" {
|
||||
cfg.Agents.Defaults.ModelName = modelOverride
|
||||
if model != "" {
|
||||
cfg.Agents.Defaults.ModelName = model
|
||||
}
|
||||
|
||||
provider, modelID, err := providers.CreateProvider(cfg)
|
||||
if err != nil {
|
||||
fmt.Printf("Error creating provider: %v\n", err)
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("error creating provider: %w", err)
|
||||
}
|
||||
|
||||
// Use the resolved model ID from provider creation
|
||||
if modelID != "" {
|
||||
cfg.Agents.Defaults.ModelName = modelID
|
||||
|
|
@ -85,18 +63,20 @@ func agentCmd() {
|
|||
ctx := context.Background()
|
||||
response, err := agentLoop.ProcessDirect(ctx, message, sessionKey)
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("error processing message: %w", err)
|
||||
}
|
||||
fmt.Printf("\n%s %s\n", logo, response)
|
||||
} else {
|
||||
fmt.Printf("%s Interactive mode (Ctrl+C to exit)\n\n", logo)
|
||||
interactiveMode(agentLoop, sessionKey)
|
||||
fmt.Printf("\n%s %s\n", internal.Logo, response)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("%s Interactive mode (Ctrl+C to exit)\n\n", internal.Logo)
|
||||
interactiveMode(agentLoop, sessionKey)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
||||
prompt := fmt.Sprintf("%s You: ", logo)
|
||||
prompt := fmt.Sprintf("%s You: ", internal.Logo)
|
||||
|
||||
rl, err := readline.NewEx(&readline.Config{
|
||||
Prompt: prompt,
|
||||
|
|
@ -141,14 +121,14 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
|||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("\n%s %s\n\n", logo, response)
|
||||
fmt.Printf("\n%s %s\n\n", internal.Logo, response)
|
||||
}
|
||||
}
|
||||
|
||||
func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
for {
|
||||
fmt.Printf("%s You: ", logo)
|
||||
fmt.Print(fmt.Sprintf("%s You: ", internal.Logo))
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
|
|
@ -176,6 +156,6 @@ func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
|||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("\n%s %s\n\n", logo, response)
|
||||
fmt.Printf("\n%s %s\n\n", internal.Logo, response)
|
||||
}
|
||||
}
|
||||
22
cmd/picoclaw/internal/auth/command.go
Normal file
22
cmd/picoclaw/internal/auth/command.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package auth
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
func NewAuthCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "auth",
|
||||
Short: "Manage authentication (login, logout, status)",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
cmd.AddCommand(
|
||||
newLoginCommand(),
|
||||
newLogoutCommand(),
|
||||
newStatusCommand(),
|
||||
newModelsCommand(),
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
55
cmd/picoclaw/internal/auth/command_test.go
Normal file
55
cmd/picoclaw/internal/auth/command_test.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewAuthCommand(t *testing.T) {
|
||||
cmd := NewAuthCommand()
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "auth", cmd.Use)
|
||||
assert.Equal(t, "Manage authentication (login, logout, status)", cmd.Short)
|
||||
|
||||
assert.Len(t, cmd.Aliases, 0)
|
||||
|
||||
assert.Nil(t, cmd.Run)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
|
||||
assert.Nil(t, cmd.PersistentPreRun)
|
||||
assert.Nil(t, cmd.PersistentPostRun)
|
||||
|
||||
assert.False(t, cmd.HasFlags())
|
||||
assert.True(t, cmd.HasSubCommands())
|
||||
|
||||
allowedCommands := []string{
|
||||
"login",
|
||||
"logout",
|
||||
"status",
|
||||
"models",
|
||||
}
|
||||
|
||||
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.Len(t, subcmd.Aliases, 0)
|
||||
assert.False(t, subcmd.Hidden)
|
||||
|
||||
assert.False(t, subcmd.HasSubCommands())
|
||||
|
||||
assert.Nil(t, subcmd.Run)
|
||||
assert.NotNil(t, subcmd.RunE)
|
||||
|
||||
assert.Nil(t, subcmd.PersistentPreRun)
|
||||
assert.Nil(t, subcmd.PersistentPostRun)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,4 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
|
||||
package main
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
|
@ -12,92 +9,28 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/pkg/auth"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
const supportedProvidersMsg = "Supported providers: openai, anthropic, google-antigravity"
|
||||
|
||||
func authCmd() {
|
||||
if len(os.Args) < 3 {
|
||||
authHelp()
|
||||
return
|
||||
}
|
||||
|
||||
switch os.Args[2] {
|
||||
case "login":
|
||||
authLoginCmd()
|
||||
case "logout":
|
||||
authLogoutCmd()
|
||||
case "status":
|
||||
authStatusCmd()
|
||||
case "models":
|
||||
authModelsCmd()
|
||||
default:
|
||||
fmt.Printf("Unknown auth command: %s\n", os.Args[2])
|
||||
authHelp()
|
||||
}
|
||||
}
|
||||
|
||||
func authHelp() {
|
||||
fmt.Println("\nAuth commands:")
|
||||
fmt.Println(" login Login via OAuth or paste token")
|
||||
fmt.Println(" logout Remove stored credentials")
|
||||
fmt.Println(" status Show current auth status")
|
||||
fmt.Println(" models List available Antigravity models")
|
||||
fmt.Println()
|
||||
fmt.Println("Login options:")
|
||||
fmt.Println(" --provider <name> Provider to login with (openai, anthropic, google-antigravity)")
|
||||
fmt.Println(" --device-code Use device code flow (for headless environments)")
|
||||
fmt.Println()
|
||||
fmt.Println("Examples:")
|
||||
fmt.Println(" picoclaw auth login --provider openai")
|
||||
fmt.Println(" picoclaw auth login --provider openai --device-code")
|
||||
fmt.Println(" picoclaw auth login --provider anthropic")
|
||||
fmt.Println(" picoclaw auth login --provider google-antigravity")
|
||||
fmt.Println(" picoclaw auth models")
|
||||
fmt.Println(" picoclaw auth logout --provider openai")
|
||||
fmt.Println(" picoclaw auth status")
|
||||
}
|
||||
|
||||
func authLoginCmd() {
|
||||
provider := ""
|
||||
useDeviceCode := false
|
||||
|
||||
args := os.Args[3:]
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--provider", "-p":
|
||||
if i+1 < len(args) {
|
||||
provider = args[i+1]
|
||||
i++
|
||||
}
|
||||
case "--device-code":
|
||||
useDeviceCode = true
|
||||
}
|
||||
}
|
||||
|
||||
if provider == "" {
|
||||
fmt.Println("Error: --provider is required")
|
||||
fmt.Println(supportedProvidersMsg)
|
||||
return
|
||||
}
|
||||
const supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity"
|
||||
|
||||
func authLoginCmd(provider string, useDeviceCode bool) error {
|
||||
switch provider {
|
||||
case "openai":
|
||||
authLoginOpenAI(useDeviceCode)
|
||||
return authLoginOpenAI(useDeviceCode)
|
||||
case "anthropic":
|
||||
authLoginPasteToken(provider)
|
||||
return authLoginPasteToken(provider)
|
||||
case "google-antigravity", "antigravity":
|
||||
authLoginGoogleAntigravity()
|
||||
return authLoginGoogleAntigravity()
|
||||
default:
|
||||
fmt.Printf("Unsupported provider: %s\n", provider)
|
||||
fmt.Println(supportedProvidersMsg)
|
||||
return fmt.Errorf("unsupported provider: %s (%s)", provider, supportedProvidersMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func authLoginOpenAI(useDeviceCode bool) {
|
||||
func authLoginOpenAI(useDeviceCode bool) error {
|
||||
cfg := auth.OpenAIOAuthConfig()
|
||||
|
||||
var cred *auth.AuthCredential
|
||||
|
|
@ -110,16 +43,14 @@ func authLoginOpenAI(useDeviceCode bool) {
|
|||
}
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("Login failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("login failed: %w", err)
|
||||
}
|
||||
|
||||
if err = auth.SetCredential("openai", cred); err != nil {
|
||||
fmt.Printf("Failed to save credentials: %v\n", err)
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("failed to save credentials: %w", err)
|
||||
}
|
||||
|
||||
appCfg, err := loadConfig()
|
||||
appCfg, err := internal.LoadConfig()
|
||||
if err == nil {
|
||||
// Update Providers (legacy format)
|
||||
appCfg.Providers.OpenAI.AuthMethod = "oauth"
|
||||
|
|
@ -146,8 +77,8 @@ func authLoginOpenAI(useDeviceCode bool) {
|
|||
// Update default model to use OpenAI
|
||||
appCfg.Agents.Defaults.ModelName = "gpt-5.2"
|
||||
|
||||
if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
|
||||
fmt.Printf("Warning: could not update config: %v\n", err)
|
||||
if err = config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
|
||||
return fmt.Errorf("could not update config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -156,15 +87,16 @@ func authLoginOpenAI(useDeviceCode bool) {
|
|||
fmt.Printf("Account: %s\n", cred.AccountID)
|
||||
}
|
||||
fmt.Println("Default model set to: gpt-5.2")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func authLoginGoogleAntigravity() {
|
||||
func authLoginGoogleAntigravity() error {
|
||||
cfg := auth.GoogleAntigravityOAuthConfig()
|
||||
|
||||
cred, err := auth.LoginBrowser(cfg)
|
||||
if err != nil {
|
||||
fmt.Printf("Login failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("login failed: %w", err)
|
||||
}
|
||||
|
||||
cred.Provider = "google-antigravity"
|
||||
|
|
@ -189,11 +121,10 @@ func authLoginGoogleAntigravity() {
|
|||
}
|
||||
|
||||
if err = auth.SetCredential("google-antigravity", cred); err != nil {
|
||||
fmt.Printf("Failed to save credentials: %v\n", err)
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("failed to save credentials: %w", err)
|
||||
}
|
||||
|
||||
appCfg, err := loadConfig()
|
||||
appCfg, err := internal.LoadConfig()
|
||||
if err == nil {
|
||||
// Update Providers (legacy format, for backward compatibility)
|
||||
appCfg.Providers.Antigravity.AuthMethod = "oauth"
|
||||
|
|
@ -220,7 +151,7 @@ func authLoginGoogleAntigravity() {
|
|||
// Update default model
|
||||
appCfg.Agents.Defaults.ModelName = "gemini-flash"
|
||||
|
||||
if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
|
||||
if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
|
||||
fmt.Printf("Warning: could not update config: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -228,6 +159,8 @@ func authLoginGoogleAntigravity() {
|
|||
fmt.Println("\n✓ Google Antigravity login successful!")
|
||||
fmt.Println("Default model set to: gemini-flash")
|
||||
fmt.Println("Try it: picoclaw agent -m \"Hello world\"")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func fetchGoogleUserEmail(accessToken string) (string, error) {
|
||||
|
|
@ -258,19 +191,17 @@ func fetchGoogleUserEmail(accessToken string) (string, error) {
|
|||
return userInfo.Email, nil
|
||||
}
|
||||
|
||||
func authLoginPasteToken(provider string) {
|
||||
func authLoginPasteToken(provider string) error {
|
||||
cred, err := auth.LoginPasteToken(provider, os.Stdin)
|
||||
if err != nil {
|
||||
fmt.Printf("Login failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("login failed: %w", err)
|
||||
}
|
||||
|
||||
if err = auth.SetCredential(provider, cred); err != nil {
|
||||
fmt.Printf("Failed to save credentials: %v\n", err)
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("failed to save credentials: %w", err)
|
||||
}
|
||||
|
||||
appCfg, err := loadConfig()
|
||||
appCfg, err := internal.LoadConfig()
|
||||
if err == nil {
|
||||
switch provider {
|
||||
case "anthropic":
|
||||
|
|
@ -314,36 +245,27 @@ func authLoginPasteToken(provider string) {
|
|||
// Update default model
|
||||
appCfg.Agents.Defaults.ModelName = "gpt-5.2"
|
||||
}
|
||||
if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
|
||||
fmt.Printf("Warning: could not update config: %v\n", err)
|
||||
if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
|
||||
return fmt.Errorf("could not update config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Token saved for %s!\n", provider)
|
||||
fmt.Printf("Default model set to: %s\n", appCfg.Agents.Defaults.GetModelName())
|
||||
}
|
||||
|
||||
func authLogoutCmd() {
|
||||
provider := ""
|
||||
|
||||
args := os.Args[3:]
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--provider", "-p":
|
||||
if i+1 < len(args) {
|
||||
provider = args[i+1]
|
||||
i++
|
||||
}
|
||||
}
|
||||
if appCfg != nil {
|
||||
fmt.Printf("Default model set to: %s\n", appCfg.Agents.Defaults.GetModelName())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func authLogoutCmd(provider string) error {
|
||||
if provider != "" {
|
||||
if err := auth.DeleteCredential(provider); err != nil {
|
||||
fmt.Printf("Failed to remove credentials: %v\n", err)
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("failed to remove credentials: %w", err)
|
||||
}
|
||||
|
||||
appCfg, err := loadConfig()
|
||||
appCfg, err := internal.LoadConfig()
|
||||
if err == nil {
|
||||
// Clear AuthMethod in ModelList
|
||||
for i := range appCfg.ModelList {
|
||||
|
|
@ -371,44 +293,46 @@ func authLogoutCmd() {
|
|||
case "google-antigravity", "antigravity":
|
||||
appCfg.Providers.Antigravity.AuthMethod = ""
|
||||
}
|
||||
config.SaveConfig(getConfigPath(), appCfg)
|
||||
config.SaveConfig(internal.GetConfigPath(), appCfg)
|
||||
}
|
||||
|
||||
fmt.Printf("Logged out from %s\n", provider)
|
||||
} else {
|
||||
if err := auth.DeleteAllCredentials(); err != nil {
|
||||
fmt.Printf("Failed to remove credentials: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
appCfg, err := loadConfig()
|
||||
if err == nil {
|
||||
// Clear all AuthMethods in ModelList
|
||||
for i := range appCfg.ModelList {
|
||||
appCfg.ModelList[i].AuthMethod = ""
|
||||
}
|
||||
// Clear all AuthMethods in Providers (legacy)
|
||||
appCfg.Providers.OpenAI.AuthMethod = ""
|
||||
appCfg.Providers.Anthropic.AuthMethod = ""
|
||||
appCfg.Providers.Antigravity.AuthMethod = ""
|
||||
config.SaveConfig(getConfigPath(), appCfg)
|
||||
}
|
||||
|
||||
fmt.Println("Logged out from all providers")
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := auth.DeleteAllCredentials(); err != nil {
|
||||
return fmt.Errorf("failed to remove credentials: %w", err)
|
||||
}
|
||||
|
||||
appCfg, err := internal.LoadConfig()
|
||||
if err == nil {
|
||||
// Clear all AuthMethods in ModelList
|
||||
for i := range appCfg.ModelList {
|
||||
appCfg.ModelList[i].AuthMethod = ""
|
||||
}
|
||||
// Clear all AuthMethods in Providers (legacy)
|
||||
appCfg.Providers.OpenAI.AuthMethod = ""
|
||||
appCfg.Providers.Anthropic.AuthMethod = ""
|
||||
appCfg.Providers.Antigravity.AuthMethod = ""
|
||||
config.SaveConfig(internal.GetConfigPath(), appCfg)
|
||||
}
|
||||
|
||||
fmt.Println("Logged out from all providers")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func authStatusCmd() {
|
||||
func authStatusCmd() error {
|
||||
store, err := auth.LoadStore()
|
||||
if err != nil {
|
||||
fmt.Printf("Error loading auth store: %v\n", err)
|
||||
return
|
||||
return fmt.Errorf("failed to load auth store: %w", err)
|
||||
}
|
||||
|
||||
if len(store.Credentials) == 0 {
|
||||
fmt.Println("No authenticated providers.")
|
||||
fmt.Println("Run: picoclaw auth login --provider <name>")
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println("\nAuthenticated Providers:")
|
||||
|
|
@ -437,14 +361,16 @@ func authStatusCmd() {
|
|||
fmt.Printf(" Expires: %s\n", cred.ExpiresAt.Format("2006-01-02 15:04"))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func authModelsCmd() {
|
||||
func authModelsCmd() error {
|
||||
cred, err := auth.GetCredential("google-antigravity")
|
||||
if err != nil || cred == nil {
|
||||
fmt.Println("Not logged in to Google Antigravity.")
|
||||
fmt.Println("Run: picoclaw auth login --provider google-antigravity")
|
||||
return
|
||||
return fmt.Errorf(
|
||||
"not logged in to Google Antigravity.\nrun: picoclaw auth login --provider google-antigravity",
|
||||
)
|
||||
}
|
||||
|
||||
// Refresh token if needed
|
||||
|
|
@ -459,21 +385,18 @@ func authModelsCmd() {
|
|||
|
||||
projectID := cred.ProjectID
|
||||
if projectID == "" {
|
||||
fmt.Println("No project ID stored. Try logging in again.")
|
||||
return
|
||||
return fmt.Errorf("no project id stored. Try logging in again")
|
||||
}
|
||||
|
||||
fmt.Printf("Fetching models for project: %s\n\n", projectID)
|
||||
|
||||
models, err := providers.FetchAntigravityModels(cred.AccessToken, projectID)
|
||||
if err != nil {
|
||||
fmt.Printf("Error fetching models: %v\n", err)
|
||||
return
|
||||
return fmt.Errorf("error fetching models: %w", err)
|
||||
}
|
||||
|
||||
if len(models) == 0 {
|
||||
fmt.Println("No models available.")
|
||||
return
|
||||
return fmt.Errorf("no models available")
|
||||
}
|
||||
|
||||
fmt.Println("Available Antigravity Models:")
|
||||
|
|
@ -489,6 +412,8 @@ func authModelsCmd() {
|
|||
}
|
||||
fmt.Printf(" %s %s\n", status, name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isAntigravityModel checks if a model string belongs to antigravity provider
|
||||
25
cmd/picoclaw/internal/auth/login.go
Normal file
25
cmd/picoclaw/internal/auth/login.go
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
package auth
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
func newLoginCommand() *cobra.Command {
|
||||
var (
|
||||
provider string
|
||||
useDeviceCode bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "login",
|
||||
Short: "Login via OAuth or paste token",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return authLoginCmd(provider, useDeviceCode)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&provider, "provider", "p", "", "Provider to login with (openai, anthropic)")
|
||||
cmd.Flags().BoolVar(&useDeviceCode, "device-code", false, "Use device code flow (for headless environments)")
|
||||
_ = cmd.MarkFlagRequired("provider")
|
||||
|
||||
return cmd
|
||||
}
|
||||
29
cmd/picoclaw/internal/auth/login_test.go
Normal file
29
cmd/picoclaw/internal/auth/login_test.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewLoginSubCommand(t *testing.T) {
|
||||
cmd := newLoginCommand()
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "Login via OAuth or paste token", cmd.Short)
|
||||
|
||||
assert.True(t, cmd.HasFlags())
|
||||
|
||||
assert.NotNil(t, cmd.Flags().Lookup("device-code"))
|
||||
|
||||
providerFlag := cmd.Flags().Lookup("provider")
|
||||
require.NotNil(t, providerFlag)
|
||||
|
||||
val, found := providerFlag.Annotations[cobra.BashCompOneRequiredFlag]
|
||||
require.True(t, found)
|
||||
require.NotEmpty(t, val)
|
||||
assert.Equal(t, "true", val[0])
|
||||
}
|
||||
20
cmd/picoclaw/internal/auth/logout.go
Normal file
20
cmd/picoclaw/internal/auth/logout.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
package auth
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
func newLogoutCommand() *cobra.Command {
|
||||
var provider string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "logout",
|
||||
Short: "Remove stored credentials",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return authLogoutCmd(provider)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&provider, "provider", "p", "", "Provider to logout from (openai, anthropic); empty = all")
|
||||
|
||||
return cmd
|
||||
}
|
||||
20
cmd/picoclaw/internal/auth/logout_test.go
Normal file
20
cmd/picoclaw/internal/auth/logout_test.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewLogoutSubcommand(t *testing.T) {
|
||||
cmd := newLogoutCommand()
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "Remove stored credentials", cmd.Short)
|
||||
|
||||
assert.True(t, cmd.HasFlags())
|
||||
|
||||
assert.NotNil(t, cmd.Flags().Lookup("provider"))
|
||||
}
|
||||
15
cmd/picoclaw/internal/auth/models.go
Normal file
15
cmd/picoclaw/internal/auth/models.go
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
package auth
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
func newModelsCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "models",
|
||||
Short: "Show available models",
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
return authModelsCmd()
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
19
cmd/picoclaw/internal/auth/models_test.go
Normal file
19
cmd/picoclaw/internal/auth/models_test.go
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewModelsCommand(t *testing.T) {
|
||||
cmd := newModelsCommand()
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "models", cmd.Use)
|
||||
assert.Equal(t, "Show available models", cmd.Short)
|
||||
|
||||
assert.False(t, cmd.HasFlags())
|
||||
}
|
||||
16
cmd/picoclaw/internal/auth/status.go
Normal file
16
cmd/picoclaw/internal/auth/status.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
package auth
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
func newStatusCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Show current auth status",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return authStatusCmd()
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
18
cmd/picoclaw/internal/auth/status_test.go
Normal file
18
cmd/picoclaw/internal/auth/status_test.go
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewStatusSubcommand(t *testing.T) {
|
||||
cmd := newStatusCommand()
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "Show current auth status", cmd.Short)
|
||||
|
||||
assert.False(t, cmd.HasFlags())
|
||||
}
|
||||
64
cmd/picoclaw/internal/cron/add.go
Normal file
64
cmd/picoclaw/internal/cron/add.go
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
package cron
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/cron"
|
||||
)
|
||||
|
||||
func newAddCommand(storePath func() string) *cobra.Command {
|
||||
var (
|
||||
name string
|
||||
message string
|
||||
every int64
|
||||
cronExp string
|
||||
deliver bool
|
||||
channel string
|
||||
to string
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "add",
|
||||
Short: "Add a new scheduled job",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
if every <= 0 && cronExp == "" {
|
||||
return fmt.Errorf("either --every or --cron must be specified")
|
||||
}
|
||||
|
||||
var schedule cron.CronSchedule
|
||||
if every > 0 {
|
||||
everyMS := every * 1000
|
||||
schedule = cron.CronSchedule{Kind: "every", EveryMS: &everyMS}
|
||||
} else {
|
||||
schedule = cron.CronSchedule{Kind: "cron", Expr: cronExp}
|
||||
}
|
||||
|
||||
cs := cron.NewCronService(storePath(), nil)
|
||||
job, err := cs.AddJob(name, schedule, message, deliver, channel, to)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error adding job: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Added job '%s' (%s)\n", job.Name, job.ID)
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&name, "name", "n", "", "Job name")
|
||||
cmd.Flags().StringVarP(&message, "message", "m", "", "Message for agent")
|
||||
cmd.Flags().Int64VarP(&every, "every", "e", 0, "Run every N seconds")
|
||||
cmd.Flags().StringVarP(&cronExp, "cron", "c", "", "Cron expression (e.g. '0 9 * * *')")
|
||||
cmd.Flags().BoolVarP(&deliver, "deliver", "d", false, "Deliver response to channel")
|
||||
cmd.Flags().StringVar(&to, "to", "", "Recipient for delivery")
|
||||
cmd.Flags().StringVar(&channel, "channel", "", "Channel for delivery")
|
||||
|
||||
_ = cmd.MarkFlagRequired("name")
|
||||
_ = cmd.MarkFlagRequired("message")
|
||||
cmd.MarkFlagsMutuallyExclusive("every", "cron")
|
||||
|
||||
return cmd
|
||||
}
|
||||
57
cmd/picoclaw/internal/cron/add_test.go
Normal file
57
cmd/picoclaw/internal/cron/add_test.go
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
package cron
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewAddSubcommand(t *testing.T) {
|
||||
fn := func() string { return "" }
|
||||
cmd := newAddCommand(fn)
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "add", cmd.Use)
|
||||
assert.Equal(t, "Add a new scheduled job", cmd.Short)
|
||||
|
||||
assert.True(t, cmd.HasFlags())
|
||||
|
||||
assert.NotNil(t, cmd.Flags().Lookup("every"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("cron"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("deliver"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("to"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("channel"))
|
||||
|
||||
nameFlag := cmd.Flags().Lookup("name")
|
||||
require.NotNil(t, nameFlag)
|
||||
|
||||
messageFlag := cmd.Flags().Lookup("message")
|
||||
require.NotNil(t, messageFlag)
|
||||
|
||||
val, found := nameFlag.Annotations[cobra.BashCompOneRequiredFlag]
|
||||
require.True(t, found)
|
||||
require.NotEmpty(t, val)
|
||||
assert.Equal(t, "true", val[0])
|
||||
|
||||
val, found = messageFlag.Annotations[cobra.BashCompOneRequiredFlag]
|
||||
require.True(t, found)
|
||||
require.NotEmpty(t, val)
|
||||
assert.Equal(t, "true", val[0])
|
||||
}
|
||||
|
||||
func TestNewAddCommandEveryAndCronMutuallyExclusive(t *testing.T) {
|
||||
cmd := newAddCommand(func() string { return "testing" })
|
||||
|
||||
cmd.SetArgs([]string{
|
||||
"--name", "job",
|
||||
"--message", "hello",
|
||||
"--every", "10",
|
||||
"--cron", "0 9 * * *",
|
||||
})
|
||||
|
||||
err := cmd.Execute()
|
||||
require.Error(t, err)
|
||||
}
|
||||
44
cmd/picoclaw/internal/cron/command.go
Normal file
44
cmd/picoclaw/internal/cron/command.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
package cron
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
)
|
||||
|
||||
func NewCronCommand() *cobra.Command {
|
||||
var storePath string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "cron",
|
||||
Aliases: []string{"c"},
|
||||
Short: "Manage scheduled tasks",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
// Resolve storePath at execution time so it reflects the current config
|
||||
// and is shared across all subcommands.
|
||||
PersistentPreRunE: func(_ *cobra.Command, _ []string) error {
|
||||
cfg, err := internal.LoadConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error loading config: %w", err)
|
||||
}
|
||||
storePath = filepath.Join(cfg.WorkspacePath(), "cron", "jobs.json")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.AddCommand(
|
||||
newListCommand(func() string { return storePath }),
|
||||
newAddCommand(func() string { return storePath }),
|
||||
newRemoveCommand(func() string { return storePath }),
|
||||
newEnableCommand(func() string { return storePath }),
|
||||
newDisableCommand(func() string { return storePath }),
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
58
cmd/picoclaw/internal/cron/command_test.go
Normal file
58
cmd/picoclaw/internal/cron/command_test.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
package cron
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewCronCommand(t *testing.T) {
|
||||
cmd := NewCronCommand()
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "Manage scheduled tasks", cmd.Short)
|
||||
|
||||
assert.Len(t, cmd.Aliases, 1)
|
||||
assert.True(t, cmd.HasAlias("c"))
|
||||
|
||||
assert.False(t, cmd.HasFlags())
|
||||
|
||||
assert.Nil(t, cmd.Run)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
|
||||
assert.NotNil(t, cmd.PersistentPreRunE)
|
||||
assert.Nil(t, cmd.PersistentPreRun)
|
||||
assert.Nil(t, cmd.PersistentPostRun)
|
||||
|
||||
assert.True(t, cmd.HasSubCommands())
|
||||
|
||||
allowedCommands := []string{
|
||||
"list",
|
||||
"add",
|
||||
"remove",
|
||||
"enable",
|
||||
"disable",
|
||||
}
|
||||
|
||||
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.Len(t, subcmd.Aliases, 0)
|
||||
assert.False(t, subcmd.Hidden)
|
||||
|
||||
assert.False(t, subcmd.HasSubCommands())
|
||||
|
||||
assert.Nil(t, subcmd.Run)
|
||||
assert.NotNil(t, subcmd.RunE)
|
||||
|
||||
assert.Nil(t, subcmd.PersistentPreRun)
|
||||
assert.Nil(t, subcmd.PersistentPostRun)
|
||||
}
|
||||
}
|
||||
16
cmd/picoclaw/internal/cron/disable.go
Normal file
16
cmd/picoclaw/internal/cron/disable.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
package cron
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
func newDisableCommand(storePath func() string) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "disable",
|
||||
Short: "Disable a job",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Example: `picoclaw cron disable 1`,
|
||||
RunE: func(_ *cobra.Command, args []string) error {
|
||||
cronSetJobEnabled(storePath(), args[0], false)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
20
cmd/picoclaw/internal/cron/disable_test.go
Normal file
20
cmd/picoclaw/internal/cron/disable_test.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
package cron
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDisableSubcommand(t *testing.T) {
|
||||
fn := func() string { return "" }
|
||||
cmd := newDisableCommand(fn)
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "disable", cmd.Use)
|
||||
assert.Equal(t, "Disable a job", cmd.Short)
|
||||
|
||||
assert.True(t, cmd.HasExample())
|
||||
}
|
||||
16
cmd/picoclaw/internal/cron/enable.go
Normal file
16
cmd/picoclaw/internal/cron/enable.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
package cron
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
func newEnableCommand(storePath func() string) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "enable",
|
||||
Short: "Enable a job",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Example: `picoclaw cron enable 1`,
|
||||
RunE: func(_ *cobra.Command, args []string) error {
|
||||
cronSetJobEnabled(storePath(), args[0], true)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
20
cmd/picoclaw/internal/cron/enable_test.go
Normal file
20
cmd/picoclaw/internal/cron/enable_test.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
package cron
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEnableSubcommand(t *testing.T) {
|
||||
fn := func() string { return "" }
|
||||
cmd := newEnableCommand(fn)
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "enable", cmd.Use)
|
||||
assert.Equal(t, "Enable a job", cmd.Short)
|
||||
|
||||
assert.True(t, cmd.HasExample())
|
||||
}
|
||||
66
cmd/picoclaw/internal/cron/helpers.go
Normal file
66
cmd/picoclaw/internal/cron/helpers.go
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
package cron
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/cron"
|
||||
)
|
||||
|
||||
func cronListCmd(storePath string) {
|
||||
cs := cron.NewCronService(storePath, nil)
|
||||
jobs := cs.ListJobs(true) // Show all jobs, including disabled
|
||||
|
||||
if len(jobs) == 0 {
|
||||
fmt.Println("No scheduled jobs.")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("\nScheduled Jobs:")
|
||||
fmt.Println("----------------")
|
||||
for _, job := range jobs {
|
||||
var schedule string
|
||||
if job.Schedule.Kind == "every" && job.Schedule.EveryMS != nil {
|
||||
schedule = fmt.Sprintf("every %ds", *job.Schedule.EveryMS/1000)
|
||||
} else if job.Schedule.Kind == "cron" {
|
||||
schedule = job.Schedule.Expr
|
||||
} else {
|
||||
schedule = "one-time"
|
||||
}
|
||||
|
||||
nextRun := "scheduled"
|
||||
if job.State.NextRunAtMS != nil {
|
||||
nextTime := time.UnixMilli(*job.State.NextRunAtMS)
|
||||
nextRun = nextTime.Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
status := "enabled"
|
||||
if !job.Enabled {
|
||||
status = "disabled"
|
||||
}
|
||||
|
||||
fmt.Printf(" %s (%s)\n", job.Name, job.ID)
|
||||
fmt.Printf(" Schedule: %s\n", schedule)
|
||||
fmt.Printf(" Status: %s\n", status)
|
||||
fmt.Printf(" Next run: %s\n", nextRun)
|
||||
}
|
||||
}
|
||||
|
||||
func cronRemoveCmd(storePath, jobID string) {
|
||||
cs := cron.NewCronService(storePath, nil)
|
||||
if cs.RemoveJob(jobID) {
|
||||
fmt.Printf("✓ Removed job %s\n", jobID)
|
||||
} else {
|
||||
fmt.Printf("✗ Job %s not found\n", jobID)
|
||||
}
|
||||
}
|
||||
|
||||
func cronSetJobEnabled(storePath, jobID string, enabled bool) {
|
||||
cs := cron.NewCronService(storePath, nil)
|
||||
job := cs.EnableJob(jobID, enabled)
|
||||
if job != nil {
|
||||
fmt.Printf("✓ Job '%s' enabled\n", job.Name)
|
||||
} else {
|
||||
fmt.Printf("✗ Job %s not found\n", jobID)
|
||||
}
|
||||
}
|
||||
17
cmd/picoclaw/internal/cron/list.go
Normal file
17
cmd/picoclaw/internal/cron/list.go
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
package cron
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
func newListCommand(storePath func() string) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all scheduled jobs",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
cronListCmd(storePath())
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
17
cmd/picoclaw/internal/cron/list_test.go
Normal file
17
cmd/picoclaw/internal/cron/list_test.go
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
package cron
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewListSubcommand(t *testing.T) {
|
||||
fn := func() string { return "" }
|
||||
cmd := newListCommand(fn)
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "List all scheduled jobs", cmd.Short)
|
||||
}
|
||||
18
cmd/picoclaw/internal/cron/remove.go
Normal file
18
cmd/picoclaw/internal/cron/remove.go
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
package cron
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
func newRemoveCommand(storePath func() string) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "remove",
|
||||
Short: "Remove a job by ID",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Example: `picoclaw cron remove 1`,
|
||||
RunE: func(_ *cobra.Command, args []string) error {
|
||||
cronRemoveCmd(storePath(), args[0])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
19
cmd/picoclaw/internal/cron/remove_test.go
Normal file
19
cmd/picoclaw/internal/cron/remove_test.go
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
package cron
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewRemoveSubcommand(t *testing.T) {
|
||||
fn := func() string { return "" }
|
||||
cmd := newRemoveCommand(fn)
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "Remove a job by ID", cmd.Short)
|
||||
|
||||
assert.True(t, cmd.HasExample())
|
||||
}
|
||||
23
cmd/picoclaw/internal/gateway/command.go
Normal file
23
cmd/picoclaw/internal/gateway/command.go
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
package gateway
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewGatewayCommand() *cobra.Command {
|
||||
var debug bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "gateway",
|
||||
Aliases: []string{"g"},
|
||||
Short: "Start picoclaw gateway",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
return gatewayCmd(debug)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
|
||||
|
||||
return cmd
|
||||
}
|
||||
31
cmd/picoclaw/internal/gateway/command_test.go
Normal file
31
cmd/picoclaw/internal/gateway/command_test.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
package gateway
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewGatewayCommand(t *testing.T) {
|
||||
cmd := NewGatewayCommand()
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "gateway", cmd.Use)
|
||||
assert.Equal(t, "Start picoclaw gateway", cmd.Short)
|
||||
|
||||
assert.Len(t, cmd.Aliases, 1)
|
||||
assert.True(t, cmd.HasAlias("g"))
|
||||
|
||||
assert.Nil(t, cmd.Run)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
|
||||
assert.Nil(t, cmd.PersistentPreRun)
|
||||
assert.Nil(t, cmd.PersistentPostRun)
|
||||
|
||||
assert.False(t, cmd.HasSubCommands())
|
||||
|
||||
assert.True(t, cmd.HasFlags())
|
||||
assert.NotNil(t, cmd.Flags().Lookup("debug"))
|
||||
}
|
||||
|
|
@ -1,10 +1,8 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
|
||||
package main
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
|
|
@ -13,6 +11,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/pkg/agent"
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
|
|
@ -28,28 +27,22 @@ import (
|
|||
"github.com/sipeed/picoclaw/pkg/voice"
|
||||
)
|
||||
|
||||
func gatewayCmd() {
|
||||
// Check for --debug flag
|
||||
args := os.Args[2:]
|
||||
for _, arg := range args {
|
||||
if arg == "--debug" || arg == "-d" {
|
||||
logger.SetLevel(logger.DEBUG)
|
||||
fmt.Println("🔍 Debug mode enabled")
|
||||
break
|
||||
}
|
||||
func gatewayCmd(debug bool) error {
|
||||
if debug {
|
||||
logger.SetLevel(logger.DEBUG)
|
||||
fmt.Println("🔍 Debug mode enabled")
|
||||
}
|
||||
|
||||
cfg, err := loadConfig()
|
||||
cfg, err := internal.LoadConfig()
|
||||
if err != nil {
|
||||
fmt.Printf("Error loading config: %v\n", err)
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("error loading config: %w", err)
|
||||
}
|
||||
|
||||
provider, modelID, err := providers.CreateProvider(cfg)
|
||||
if err != nil {
|
||||
fmt.Printf("Error creating provider: %v\n", err)
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("error creating provider: %w", err)
|
||||
}
|
||||
|
||||
// Use the resolved model ID from provider creation
|
||||
if modelID != "" {
|
||||
cfg.Agents.Defaults.ModelName = modelID
|
||||
|
|
@ -114,8 +107,7 @@ func gatewayCmd() {
|
|||
|
||||
channelManager, err := channels.NewManager(cfg, msgBus)
|
||||
if err != nil {
|
||||
fmt.Printf("Error creating channel manager: %v\n", err)
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("error creating channel manager: %w", err)
|
||||
}
|
||||
|
||||
// Inject channel manager into agent loop for command handling
|
||||
|
|
@ -198,7 +190,7 @@ func gatewayCmd() {
|
|||
|
||||
healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
|
||||
go func() {
|
||||
if err := healthServer.Start(); err != nil && err != http.ErrServerClosed {
|
||||
if err := healthServer.Start(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
logger.ErrorCF("health", "Health server error", map[string]any{"error": err.Error()})
|
||||
}
|
||||
}()
|
||||
|
|
@ -222,6 +214,8 @@ func gatewayCmd() {
|
|||
agentLoop.Stop()
|
||||
channelManager.StopAll(ctx)
|
||||
fmt.Println("✓ Gateway stopped")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func setupCronTool(
|
||||
54
cmd/picoclaw/internal/helpers.go
Normal file
54
cmd/picoclaw/internal/helpers.go
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
package internal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
const Logo = "🦞"
|
||||
|
||||
var (
|
||||
version = "dev"
|
||||
gitCommit string
|
||||
buildTime string
|
||||
goVersion string
|
||||
)
|
||||
|
||||
func GetConfigPath() string {
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".picoclaw", "config.json")
|
||||
}
|
||||
|
||||
func LoadConfig() (*config.Config, error) {
|
||||
return config.LoadConfig(GetConfigPath())
|
||||
}
|
||||
|
||||
// FormatVersion returns the version string with optional git commit
|
||||
func FormatVersion() string {
|
||||
v := version
|
||||
if gitCommit != "" {
|
||||
v += fmt.Sprintf(" (git: %s)", gitCommit)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// FormatBuildInfo returns build time and go version info
|
||||
func FormatBuildInfo() (build string, goVer string) {
|
||||
if buildTime != "" {
|
||||
build = buildTime
|
||||
}
|
||||
goVer = goVersion
|
||||
if goVer == "" {
|
||||
goVer = runtime.Version()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// GetVersion returns the version string
|
||||
func GetVersion() string {
|
||||
return version
|
||||
}
|
||||
97
cmd/picoclaw/internal/helpers_test.go
Normal file
97
cmd/picoclaw/internal/helpers_test.go
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
package internal
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetConfigPath(t *testing.T) {
|
||||
t.Setenv("HOME", "/tmp/home")
|
||||
|
||||
got := GetConfigPath()
|
||||
want := filepath.Join("/tmp/home", ".picoclaw", "config.json")
|
||||
|
||||
assert.Equal(t, want, got)
|
||||
}
|
||||
|
||||
func TestFormatVersion_NoGitCommit(t *testing.T) {
|
||||
oldVersion, oldGit := version, gitCommit
|
||||
t.Cleanup(func() { version, gitCommit = oldVersion, oldGit })
|
||||
|
||||
version = "1.2.3"
|
||||
gitCommit = ""
|
||||
|
||||
assert.Equal(t, "1.2.3", FormatVersion())
|
||||
}
|
||||
|
||||
func TestFormatVersion_WithGitCommit(t *testing.T) {
|
||||
oldVersion, oldGit := version, gitCommit
|
||||
t.Cleanup(func() { version, gitCommit = oldVersion, oldGit })
|
||||
|
||||
version = "1.2.3"
|
||||
gitCommit = "abc123"
|
||||
|
||||
assert.Equal(t, "1.2.3 (git: abc123)", FormatVersion())
|
||||
}
|
||||
|
||||
func TestFormatBuildInfo_UsesBuildTimeAndGoVersion_WhenSet(t *testing.T) {
|
||||
oldBuildTime, oldGoVersion := buildTime, goVersion
|
||||
t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion })
|
||||
|
||||
buildTime = "2026-02-20T00:00:00Z"
|
||||
goVersion = "go1.23.0"
|
||||
|
||||
build, goVer := FormatBuildInfo()
|
||||
|
||||
assert.Equal(t, buildTime, build)
|
||||
assert.Equal(t, goVersion, goVer)
|
||||
}
|
||||
|
||||
func TestFormatBuildInfo_EmptyBuildTime_ReturnsEmptyBuild(t *testing.T) {
|
||||
oldBuildTime, oldGoVersion := buildTime, goVersion
|
||||
t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion })
|
||||
|
||||
buildTime = ""
|
||||
goVersion = "go1.23.0"
|
||||
|
||||
build, goVer := FormatBuildInfo()
|
||||
|
||||
assert.Empty(t, build)
|
||||
assert.Equal(t, goVersion, goVer)
|
||||
}
|
||||
|
||||
func TestFormatBuildInfo_EmptyGoVersion_FallsBackToRuntimeVersion(t *testing.T) {
|
||||
oldBuildTime, oldGoVersion := buildTime, goVersion
|
||||
t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion })
|
||||
|
||||
buildTime = "x"
|
||||
goVersion = ""
|
||||
|
||||
build, goVer := FormatBuildInfo()
|
||||
|
||||
assert.Equal(t, "x", build)
|
||||
assert.Equal(t, runtime.Version(), goVer)
|
||||
}
|
||||
|
||||
func TestGetConfigPath_Windows(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("windows-specific HOME behavior varies; run on windows")
|
||||
}
|
||||
|
||||
testUserProfilePath := `C:\Users\Test`
|
||||
t.Setenv("USERPROFILE", testUserProfilePath)
|
||||
|
||||
got := GetConfigPath()
|
||||
want := filepath.Join(testUserProfilePath, ".picoclaw", "config.json")
|
||||
|
||||
require.True(t, strings.EqualFold(got, want), "GetConfigPath() = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
func TestGetVersion(t *testing.T) {
|
||||
assert.Equal(t, "dev", GetVersion())
|
||||
}
|
||||
48
cmd/picoclaw/internal/migrate/command.go
Normal file
48
cmd/picoclaw/internal/migrate/command.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
package migrate
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/migrate"
|
||||
)
|
||||
|
||||
func NewMigrateCommand() *cobra.Command {
|
||||
var opts migrate.Options
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "migrate",
|
||||
Short: "Migrate from OpenClaw to PicoClaw",
|
||||
Args: cobra.NoArgs,
|
||||
Example: ` picoclaw migrate
|
||||
picoclaw migrate --dry-run
|
||||
picoclaw migrate --refresh
|
||||
picoclaw migrate --force`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
result, err := migrate.Run(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !opts.DryRun {
|
||||
migrate.PrintSummary(result)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false,
|
||||
"Show what would be migrated without making changes")
|
||||
cmd.Flags().BoolVar(&opts.Refresh, "refresh", false,
|
||||
"Re-sync workspace files from OpenClaw (repeatable)")
|
||||
cmd.Flags().BoolVar(&opts.ConfigOnly, "config-only", false,
|
||||
"Only migrate config, skip workspace files")
|
||||
cmd.Flags().BoolVar(&opts.WorkspaceOnly, "workspace-only", false,
|
||||
"Only migrate workspace files, skip config")
|
||||
cmd.Flags().BoolVar(&opts.Force, "force", false,
|
||||
"Skip confirmation prompts")
|
||||
cmd.Flags().StringVar(&opts.OpenClawHome, "openclaw-home", "",
|
||||
"Override OpenClaw home directory (default: ~/.openclaw)")
|
||||
cmd.Flags().StringVar(&opts.PicoClawHome, "picoclaw-home", "",
|
||||
"Override PicoClaw home directory (default: ~/.picoclaw)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
38
cmd/picoclaw/internal/migrate/command_test.go
Normal file
38
cmd/picoclaw/internal/migrate/command_test.go
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
package migrate
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewMigrateCommand(t *testing.T) {
|
||||
cmd := NewMigrateCommand()
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "migrate", cmd.Use)
|
||||
assert.Equal(t, "Migrate from OpenClaw to PicoClaw", cmd.Short)
|
||||
|
||||
assert.Len(t, cmd.Aliases, 0)
|
||||
|
||||
assert.True(t, cmd.HasExample())
|
||||
assert.False(t, cmd.HasSubCommands())
|
||||
|
||||
assert.Nil(t, cmd.Run)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
|
||||
assert.Nil(t, cmd.PersistentPreRun)
|
||||
assert.Nil(t, cmd.PersistentPostRun)
|
||||
|
||||
assert.True(t, cmd.HasFlags())
|
||||
|
||||
assert.NotNil(t, cmd.Flags().Lookup("dry-run"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("refresh"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("config-only"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("workspace-only"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("force"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("openclaw-home"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("picoclaw-home"))
|
||||
}
|
||||
24
cmd/picoclaw/internal/onboard/command.go
Normal file
24
cmd/picoclaw/internal/onboard/command.go
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
package onboard
|
||||
|
||||
import (
|
||||
"embed"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
//go:generate cp -r ../../../../workspace .
|
||||
//go:embed workspace
|
||||
var embeddedFiles embed.FS
|
||||
|
||||
func NewOnboardCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "onboard",
|
||||
Aliases: []string{"o"},
|
||||
Short: "Initialize picoclaw configuration and workspace",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
onboard()
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
29
cmd/picoclaw/internal/onboard/command_test.go
Normal file
29
cmd/picoclaw/internal/onboard/command_test.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package onboard
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewOnboardCommand(t *testing.T) {
|
||||
cmd := NewOnboardCommand()
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "onboard", cmd.Use)
|
||||
assert.Equal(t, "Initialize picoclaw configuration and workspace", cmd.Short)
|
||||
|
||||
assert.Len(t, cmd.Aliases, 1)
|
||||
assert.True(t, cmd.HasAlias("o"))
|
||||
|
||||
assert.NotNil(t, cmd.Run)
|
||||
assert.Nil(t, cmd.RunE)
|
||||
|
||||
assert.Nil(t, cmd.PersistentPreRun)
|
||||
assert.Nil(t, cmd.PersistentPostRun)
|
||||
|
||||
assert.False(t, cmd.HasFlags())
|
||||
assert.False(t, cmd.HasSubCommands())
|
||||
}
|
||||
|
|
@ -1,24 +1,17 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
|
||||
package main
|
||||
package onboard
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
//go:generate cp -r ../../workspace .
|
||||
//go:embed workspace
|
||||
var embeddedFiles embed.FS
|
||||
|
||||
func onboard() {
|
||||
configPath := getConfigPath()
|
||||
configPath := internal.GetConfigPath()
|
||||
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
fmt.Printf("Config already exists at %s\n", configPath)
|
||||
|
|
@ -40,7 +33,7 @@ func onboard() {
|
|||
workspace := cfg.WorkspacePath()
|
||||
createWorkspaceTemplates(workspace)
|
||||
|
||||
fmt.Printf("%s picoclaw is ready!\n", logo)
|
||||
fmt.Printf("%s picoclaw is ready!\n", internal.Logo)
|
||||
fmt.Println("\nNext steps:")
|
||||
fmt.Println(" 1. Add your API key to", configPath)
|
||||
fmt.Println("")
|
||||
|
|
@ -53,6 +46,13 @@ func onboard() {
|
|||
fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"")
|
||||
}
|
||||
|
||||
func createWorkspaceTemplates(workspace string) {
|
||||
err := copyEmbeddedToTarget(workspace)
|
||||
if err != nil {
|
||||
fmt.Printf("Error copying workspace templates: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func copyEmbeddedToTarget(targetDir string) error {
|
||||
// Ensure target directory exists
|
||||
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
||||
|
|
@ -99,10 +99,3 @@ func copyEmbeddedToTarget(targetDir string) error {
|
|||
|
||||
return err
|
||||
}
|
||||
|
||||
func createWorkspaceTemplates(workspace string) {
|
||||
err := copyEmbeddedToTarget(workspace)
|
||||
if err != nil {
|
||||
fmt.Printf("Error copying workspace templates: %v\n", err)
|
||||
}
|
||||
}
|
||||
79
cmd/picoclaw/internal/skills/command.go
Normal file
79
cmd/picoclaw/internal/skills/command.go
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
package skills
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/pkg/skills"
|
||||
)
|
||||
|
||||
type deps struct {
|
||||
workspace string
|
||||
installer *skills.SkillInstaller
|
||||
skillsLoader *skills.SkillsLoader
|
||||
}
|
||||
|
||||
func NewSkillsCommand() *cobra.Command {
|
||||
var d deps
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "skills",
|
||||
Short: "Manage skills",
|
||||
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
|
||||
cfg, err := internal.LoadConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error loading config: %w", err)
|
||||
}
|
||||
|
||||
d.workspace = cfg.WorkspacePath()
|
||||
d.installer = skills.NewSkillInstaller(d.workspace)
|
||||
|
||||
// get global config directory and builtin skills directory
|
||||
globalDir := filepath.Dir(internal.GetConfigPath())
|
||||
globalSkillsDir := filepath.Join(globalDir, "skills")
|
||||
builtinSkillsDir := filepath.Join(globalDir, "picoclaw", "skills")
|
||||
d.skillsLoader = skills.NewSkillsLoader(d.workspace, globalSkillsDir, builtinSkillsDir)
|
||||
|
||||
return nil
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
installerFn := func() (*skills.SkillInstaller, error) {
|
||||
if d.installer == nil {
|
||||
return nil, fmt.Errorf("skills installer is not initialized")
|
||||
}
|
||||
return d.installer, nil
|
||||
}
|
||||
|
||||
loaderFn := func() (*skills.SkillsLoader, error) {
|
||||
if d.skillsLoader == nil {
|
||||
return nil, fmt.Errorf("skills loader is not initialized")
|
||||
}
|
||||
return d.skillsLoader, nil
|
||||
}
|
||||
|
||||
workspaceFn := func() (string, error) {
|
||||
if d.workspace == "" {
|
||||
return "", fmt.Errorf("workspace is not initialized")
|
||||
}
|
||||
return d.workspace, nil
|
||||
}
|
||||
|
||||
cmd.AddCommand(
|
||||
newListCommand(loaderFn),
|
||||
newInstallCommand(installerFn),
|
||||
newInstallBuiltinCommand(workspaceFn),
|
||||
newListBuiltinCommand(),
|
||||
newRemoveCommand(installerFn),
|
||||
newSearchCommand(installerFn),
|
||||
newShowCommand(loaderFn),
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
28
cmd/picoclaw/internal/skills/command_test.go
Normal file
28
cmd/picoclaw/internal/skills/command_test.go
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
package skills
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewSkillsCommand(t *testing.T) {
|
||||
cmd := NewSkillsCommand()
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "skills", cmd.Use)
|
||||
assert.Equal(t, "Manage skills", cmd.Short)
|
||||
|
||||
assert.Len(t, cmd.Aliases, 0)
|
||||
|
||||
assert.False(t, cmd.HasFlags())
|
||||
|
||||
assert.Nil(t, cmd.Run)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
|
||||
assert.NotNil(t, cmd.PersistentPreRunE)
|
||||
assert.Nil(t, cmd.PersistentPreRun)
|
||||
assert.Nil(t, cmd.PersistentPostRun)
|
||||
}
|
||||
|
|
@ -1,40 +1,20 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
|
||||
package main
|
||||
package skills
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/skills"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
)
|
||||
|
||||
func skillsHelp() {
|
||||
fmt.Println("\nSkills commands:")
|
||||
fmt.Println(" list List installed skills")
|
||||
fmt.Println(" install <repo> Install skill from GitHub")
|
||||
fmt.Println(" install-builtin Install all builtin skills to workspace")
|
||||
fmt.Println(" list-builtin List available builtin skills")
|
||||
fmt.Println(" remove <name> Remove installed skill")
|
||||
fmt.Println(" search Search available skills")
|
||||
fmt.Println(" show <name> Show skill details")
|
||||
fmt.Println()
|
||||
fmt.Println("Examples:")
|
||||
fmt.Println(" picoclaw skills list")
|
||||
fmt.Println(" picoclaw skills install sipeed/picoclaw-skills/weather")
|
||||
fmt.Println(" picoclaw skills install-builtin")
|
||||
fmt.Println(" picoclaw skills list-builtin")
|
||||
fmt.Println(" picoclaw skills remove weather")
|
||||
fmt.Println(" picoclaw skills install --registry clawhub github")
|
||||
}
|
||||
|
||||
func skillsListCmd(loader *skills.SkillsLoader) {
|
||||
allSkills := loader.ListSkills()
|
||||
|
||||
|
|
@ -53,53 +33,31 @@ func skillsListCmd(loader *skills.SkillsLoader) {
|
|||
}
|
||||
}
|
||||
|
||||
func skillsInstallCmd(installer *skills.SkillInstaller, cfg *config.Config) {
|
||||
if len(os.Args) < 4 {
|
||||
fmt.Println("Usage: picoclaw skills install <github-repo>")
|
||||
fmt.Println(" picoclaw skills install --registry <name> <slug>")
|
||||
return
|
||||
}
|
||||
|
||||
// Check for --registry flag.
|
||||
if os.Args[3] == "--registry" {
|
||||
if len(os.Args) < 6 {
|
||||
fmt.Println("Usage: picoclaw skills install --registry <name> <slug>")
|
||||
fmt.Println("Example: picoclaw skills install --registry clawhub github")
|
||||
return
|
||||
}
|
||||
registryName := os.Args[4]
|
||||
slug := os.Args[5]
|
||||
skillsInstallFromRegistry(cfg, registryName, slug)
|
||||
return
|
||||
}
|
||||
|
||||
// Default: install from GitHub (backward compatible).
|
||||
repo := os.Args[3]
|
||||
func skillsInstallCmd(installer *skills.SkillInstaller, repo string) error {
|
||||
fmt.Printf("Installing skill from %s...\n", repo)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := installer.InstallFromGitHub(ctx, repo); err != nil {
|
||||
fmt.Printf("\u2717 Failed to install skill: %v\n", err)
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("failed to install skill: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\u2713 Skill '%s' installed successfully!\n", filepath.Base(repo))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// skillsInstallFromRegistry installs a skill from a named registry (e.g. clawhub).
|
||||
func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) {
|
||||
func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) error {
|
||||
err := utils.ValidateSkillIdentifier(registryName)
|
||||
if err != nil {
|
||||
fmt.Printf("\u2717 Invalid registry name: %v\n", err)
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("✗ invalid registry name: %w", err)
|
||||
}
|
||||
|
||||
err = utils.ValidateSkillIdentifier(slug)
|
||||
if err != nil {
|
||||
fmt.Printf("\u2717 Invalid slug: %v\n", err)
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("✗ invalid slug: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Installing skill '%s' from %s registry...\n", slug, registryName)
|
||||
|
|
@ -111,24 +69,21 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) {
|
|||
|
||||
registry := registryMgr.GetRegistry(registryName)
|
||||
if registry == nil {
|
||||
fmt.Printf("\u2717 Registry '%s' not found or not enabled. Check your config.json.\n", registryName)
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("✗ registry '%s' not found or not enabled. check your config.json.", registryName)
|
||||
}
|
||||
|
||||
workspace := cfg.WorkspacePath()
|
||||
targetDir := filepath.Join(workspace, "skills", slug)
|
||||
|
||||
if _, err = os.Stat(targetDir); err == nil {
|
||||
fmt.Printf("\u2717 Skill '%s' already installed at %s\n", slug, targetDir)
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("\u2717 skill '%s' already installed at %s", slug, targetDir)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err = os.MkdirAll(filepath.Join(workspace, "skills"), 0o755); err != nil {
|
||||
fmt.Printf("\u2717 Failed to create skills directory: %v\n", err)
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("\u2717 failed to create skills directory: %v", err)
|
||||
}
|
||||
|
||||
result, err := registry.DownloadAndInstall(ctx, slug, "", targetDir)
|
||||
|
|
@ -137,8 +92,7 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) {
|
|||
if rmErr != nil {
|
||||
fmt.Printf("\u2717 Failed to remove partial install: %v\n", rmErr)
|
||||
}
|
||||
fmt.Printf("\u2717 Failed to install skill: %v\n", err)
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("✗ failed to install skill: %w", err)
|
||||
}
|
||||
|
||||
if result.IsMalwareBlocked {
|
||||
|
|
@ -146,8 +100,8 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) {
|
|||
if rmErr != nil {
|
||||
fmt.Printf("\u2717 Failed to remove partial install: %v\n", rmErr)
|
||||
}
|
||||
fmt.Printf("\u2717 Skill '%s' is flagged as malicious and cannot be installed.\n", slug)
|
||||
os.Exit(1)
|
||||
|
||||
return fmt.Errorf("\u2717 Skill '%s' is flagged as malicious and cannot be installed.\n", slug)
|
||||
}
|
||||
|
||||
if result.IsSuspicious {
|
||||
|
|
@ -158,6 +112,8 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) {
|
|||
if result.Summary != "" {
|
||||
fmt.Printf(" %s\n", result.Summary)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func skillsRemoveCmd(installer *skills.SkillInstaller, skillName string) {
|
||||
|
|
@ -208,7 +164,7 @@ func skillsInstallBuiltinCmd(workspace string) {
|
|||
}
|
||||
|
||||
func skillsListBuiltinCmd() {
|
||||
cfg, err := loadConfig()
|
||||
cfg, err := internal.LoadConfig()
|
||||
if err != nil {
|
||||
fmt.Printf("Error loading config: %v\n", err)
|
||||
return
|
||||
|
|
@ -303,3 +259,37 @@ func skillsShowCmd(loader *skills.SkillsLoader, skillName string) {
|
|||
fmt.Println("----------------------")
|
||||
fmt.Println(content)
|
||||
}
|
||||
|
||||
func copyDirectory(src, dst string) error {
|
||||
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
relPath, err := filepath.Rel(src, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dstPath := filepath.Join(dst, relPath)
|
||||
|
||||
if info.IsDir() {
|
||||
return os.MkdirAll(dstPath, info.Mode())
|
||||
}
|
||||
|
||||
srcFile, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
dstFile, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
_, err = io.Copy(dstFile, srcFile)
|
||||
return err
|
||||
})
|
||||
}
|
||||
58
cmd/picoclaw/internal/skills/install.go
Normal file
58
cmd/picoclaw/internal/skills/install.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
package skills
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/pkg/skills"
|
||||
)
|
||||
|
||||
func newInstallCommand(installerFn func() (*skills.SkillInstaller, error)) *cobra.Command {
|
||||
var registry string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "install",
|
||||
Short: "Install skill from GitHub",
|
||||
Example: `
|
||||
picoclaw skills install sipeed/picoclaw-skills/weather
|
||||
picoclaw skills install --registry clawhub github
|
||||
`,
|
||||
Args: func(cmd *cobra.Command, args []string) error {
|
||||
if registry != "" {
|
||||
if len(args) != 2 {
|
||||
return fmt.Errorf("when --registry is set, exactly 2 arguments are required: <name> <slug>")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(args) != 1 {
|
||||
return fmt.Errorf("exactly 1 argument is required: <github>")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
RunE: func(_ *cobra.Command, args []string) error {
|
||||
installer, err := installerFn()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if registry != "" {
|
||||
cfg, err := internal.LoadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return skillsInstallFromRegistry(cfg, args[0], args[1])
|
||||
}
|
||||
|
||||
return skillsInstallCmd(installer, args[0])
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(®istry, "registry", "", "Install from registry: --registry <name> <slug>")
|
||||
|
||||
return cmd
|
||||
}
|
||||
28
cmd/picoclaw/internal/skills/install_test.go
Normal file
28
cmd/picoclaw/internal/skills/install_test.go
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
package skills
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewInstallSubcommand(t *testing.T) {
|
||||
cmd := newInstallCommand(nil)
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "install", cmd.Use)
|
||||
assert.Equal(t, "Install skill from GitHub", cmd.Short)
|
||||
|
||||
assert.Nil(t, cmd.Run)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
|
||||
assert.True(t, cmd.HasExample())
|
||||
assert.False(t, cmd.HasSubCommands())
|
||||
|
||||
assert.True(t, cmd.HasFlags())
|
||||
assert.NotNil(t, cmd.Flags().Lookup("registry"))
|
||||
|
||||
assert.Len(t, cmd.Aliases, 0)
|
||||
}
|
||||
21
cmd/picoclaw/internal/skills/installbuiltin.go
Normal file
21
cmd/picoclaw/internal/skills/installbuiltin.go
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
package skills
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
func newInstallBuiltinCommand(workspaceFn func() (string, error)) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "install-builtin",
|
||||
Short: "Install all builtin skills to workspace",
|
||||
Example: `picoclaw skills install-builtin`,
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
workspace, err := workspaceFn()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
skillsInstallBuiltinCmd(workspace)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
27
cmd/picoclaw/internal/skills/installbuiltin_test.go
Normal file
27
cmd/picoclaw/internal/skills/installbuiltin_test.go
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
package skills
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewInstallbuiltinSubcommand(t *testing.T) {
|
||||
cmd := newInstallBuiltinCommand(nil)
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "install-builtin", cmd.Use)
|
||||
assert.Equal(t, "Install all builtin skills to workspace", cmd.Short)
|
||||
|
||||
assert.Nil(t, cmd.Run)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
|
||||
assert.True(t, cmd.HasExample())
|
||||
assert.False(t, cmd.HasSubCommands())
|
||||
|
||||
assert.False(t, cmd.HasFlags())
|
||||
|
||||
assert.Len(t, cmd.Aliases, 0)
|
||||
}
|
||||
25
cmd/picoclaw/internal/skills/list.go
Normal file
25
cmd/picoclaw/internal/skills/list.go
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
package skills
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/skills"
|
||||
)
|
||||
|
||||
func newListCommand(loaderFn func() (*skills.SkillsLoader, error)) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List installed skills",
|
||||
Example: `picoclaw skills list`,
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
loader, err := loaderFn()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
skillsListCmd(loader)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
27
cmd/picoclaw/internal/skills/list_test.go
Normal file
27
cmd/picoclaw/internal/skills/list_test.go
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
package skills
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewListSubcommand(t *testing.T) {
|
||||
cmd := newListCommand(nil)
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "list", cmd.Use)
|
||||
assert.Equal(t, "List installed skills", cmd.Short)
|
||||
|
||||
assert.Nil(t, cmd.Run)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
|
||||
assert.True(t, cmd.HasExample())
|
||||
assert.False(t, cmd.HasSubCommands())
|
||||
|
||||
assert.False(t, cmd.HasFlags())
|
||||
|
||||
assert.Len(t, cmd.Aliases, 0)
|
||||
}
|
||||
16
cmd/picoclaw/internal/skills/listbuiltin.go
Normal file
16
cmd/picoclaw/internal/skills/listbuiltin.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
package skills
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
func newListBuiltinCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list-builtin",
|
||||
Short: "List available builtin skills",
|
||||
Example: `picoclaw skills list-builtin`,
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
skillsListBuiltinCmd()
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
26
cmd/picoclaw/internal/skills/listbuiltin_test.go
Normal file
26
cmd/picoclaw/internal/skills/listbuiltin_test.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
package skills
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewListbuiltinSubcommand(t *testing.T) {
|
||||
cmd := newListBuiltinCommand()
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "list-builtin", cmd.Use)
|
||||
assert.Equal(t, "List available builtin skills", cmd.Short)
|
||||
|
||||
assert.NotNil(t, cmd.Run)
|
||||
|
||||
assert.True(t, cmd.HasExample())
|
||||
assert.False(t, cmd.HasSubCommands())
|
||||
|
||||
assert.False(t, cmd.HasFlags())
|
||||
|
||||
assert.Len(t, cmd.Aliases, 0)
|
||||
}
|
||||
27
cmd/picoclaw/internal/skills/remove.go
Normal file
27
cmd/picoclaw/internal/skills/remove.go
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
package skills
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/skills"
|
||||
)
|
||||
|
||||
func newRemoveCommand(installerFn func() (*skills.SkillInstaller, error)) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "remove",
|
||||
Aliases: []string{"rm", "uninstall"},
|
||||
Short: "Remove installed skill",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Example: `picoclaw skills remove weather`,
|
||||
RunE: func(_ *cobra.Command, args []string) error {
|
||||
installer, err := installerFn()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
skillsRemoveCmd(installer, args[0])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
29
cmd/picoclaw/internal/skills/remove_test.go
Normal file
29
cmd/picoclaw/internal/skills/remove_test.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package skills
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewRemoveSubcommand(t *testing.T) {
|
||||
cmd := newRemoveCommand(nil)
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "remove", cmd.Use)
|
||||
assert.Equal(t, "Remove installed skill", cmd.Short)
|
||||
|
||||
assert.Nil(t, cmd.Run)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
|
||||
assert.True(t, cmd.HasExample())
|
||||
assert.False(t, cmd.HasSubCommands())
|
||||
|
||||
assert.False(t, cmd.HasFlags())
|
||||
|
||||
assert.Len(t, cmd.Aliases, 2)
|
||||
assert.True(t, cmd.HasAlias("rm"))
|
||||
assert.True(t, cmd.HasAlias("uninstall"))
|
||||
}
|
||||
24
cmd/picoclaw/internal/skills/search.go
Normal file
24
cmd/picoclaw/internal/skills/search.go
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
package skills
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/skills"
|
||||
)
|
||||
|
||||
func newSearchCommand(installerFn func() (*skills.SkillInstaller, error)) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "search",
|
||||
Short: "Search available skills",
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
installer, err := installerFn()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
skillsSearchCmd(installer)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
25
cmd/picoclaw/internal/skills/search_test.go
Normal file
25
cmd/picoclaw/internal/skills/search_test.go
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
package skills
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewSearchSubcommand(t *testing.T) {
|
||||
cmd := newSearchCommand(nil)
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "search", cmd.Use)
|
||||
assert.Equal(t, "Search available skills", cmd.Short)
|
||||
|
||||
assert.Nil(t, cmd.Run)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
|
||||
assert.False(t, cmd.HasSubCommands())
|
||||
assert.False(t, cmd.HasFlags())
|
||||
|
||||
assert.Len(t, cmd.Aliases, 0)
|
||||
}
|
||||
26
cmd/picoclaw/internal/skills/show.go
Normal file
26
cmd/picoclaw/internal/skills/show.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
package skills
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/skills"
|
||||
)
|
||||
|
||||
func newShowCommand(loaderFn func() (*skills.SkillsLoader, error)) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "show",
|
||||
Short: "Show skill details",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Example: `picoclaw skills show weather`,
|
||||
RunE: func(_ *cobra.Command, args []string) error {
|
||||
loader, err := loaderFn()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
skillsShowCmd(loader, args[0])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
27
cmd/picoclaw/internal/skills/show_test.go
Normal file
27
cmd/picoclaw/internal/skills/show_test.go
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
package skills
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewShowSubcommand(t *testing.T) {
|
||||
cmd := newShowCommand(nil)
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "show", cmd.Use)
|
||||
assert.Equal(t, "Show skill details", cmd.Short)
|
||||
|
||||
assert.Nil(t, cmd.Run)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
|
||||
assert.True(t, cmd.HasExample())
|
||||
assert.False(t, cmd.HasSubCommands())
|
||||
|
||||
assert.False(t, cmd.HasFlags())
|
||||
|
||||
assert.Len(t, cmd.Aliases, 0)
|
||||
}
|
||||
18
cmd/picoclaw/internal/status/command.go
Normal file
18
cmd/picoclaw/internal/status/command.go
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
package status
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewStatusCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "status",
|
||||
Aliases: []string{"s"},
|
||||
Short: "Show picoclaw status",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
statusCmd()
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
29
cmd/picoclaw/internal/status/command_test.go
Normal file
29
cmd/picoclaw/internal/status/command_test.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package status
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewStatusCommand(t *testing.T) {
|
||||
cmd := NewStatusCommand()
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "status", cmd.Use)
|
||||
|
||||
assert.Len(t, cmd.Aliases, 1)
|
||||
assert.True(t, cmd.HasAlias("s"))
|
||||
|
||||
assert.Equal(t, "Show picoclaw status", cmd.Short)
|
||||
|
||||
assert.False(t, cmd.HasSubCommands())
|
||||
|
||||
assert.NotNil(t, cmd.Run)
|
||||
assert.Nil(t, cmd.RunE)
|
||||
|
||||
assert.Nil(t, cmd.PersistentPreRun)
|
||||
assert.Nil(t, cmd.PersistentPostRun)
|
||||
}
|
||||
|
|
@ -1,27 +1,25 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
|
||||
package main
|
||||
package status
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/pkg/auth"
|
||||
)
|
||||
|
||||
func statusCmd() {
|
||||
cfg, err := loadConfig()
|
||||
cfg, err := internal.LoadConfig()
|
||||
if err != nil {
|
||||
fmt.Printf("Error loading config: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
configPath := getConfigPath()
|
||||
configPath := internal.GetConfigPath()
|
||||
|
||||
fmt.Printf("%s picoclaw Status\n", logo)
|
||||
fmt.Printf("Version: %s\n", formatVersion())
|
||||
build, _ := formatBuildInfo()
|
||||
fmt.Printf("%s picoclaw Status\n", internal.Logo)
|
||||
fmt.Printf("Version: %s\n", internal.FormatVersion())
|
||||
build, _ := internal.FormatBuildInfo()
|
||||
if build != "" {
|
||||
fmt.Printf("Build: %s\n", build)
|
||||
}
|
||||
33
cmd/picoclaw/internal/version/command.go
Normal file
33
cmd/picoclaw/internal/version/command.go
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
package version
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
)
|
||||
|
||||
func NewVersionCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "version",
|
||||
Aliases: []string{"v"},
|
||||
Short: "Show version information",
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
printVersion()
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func printVersion() {
|
||||
fmt.Printf("%s picoclaw %s\n", internal.Logo, internal.FormatVersion())
|
||||
build, goVer := internal.FormatBuildInfo()
|
||||
if build != "" {
|
||||
fmt.Printf(" Build: %s\n", build)
|
||||
}
|
||||
if goVer != "" {
|
||||
fmt.Printf(" Go: %s\n", goVer)
|
||||
}
|
||||
}
|
||||
31
cmd/picoclaw/internal/version/command_test.go
Normal file
31
cmd/picoclaw/internal/version/command_test.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
package version
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewVersionCommand(t *testing.T) {
|
||||
cmd := NewVersionCommand()
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
assert.Equal(t, "version", cmd.Use)
|
||||
|
||||
assert.Len(t, cmd.Aliases, 1)
|
||||
assert.True(t, cmd.HasAlias("v"))
|
||||
|
||||
assert.False(t, cmd.HasFlags())
|
||||
|
||||
assert.Equal(t, "Show version information", cmd.Short)
|
||||
|
||||
assert.False(t, cmd.HasSubCommands())
|
||||
|
||||
assert.NotNil(t, cmd.Run)
|
||||
assert.Nil(t, cmd.RunE)
|
||||
|
||||
assert.Nil(t, cmd.PersistentPreRun)
|
||||
assert.Nil(t, cmd.PersistentPostRun)
|
||||
}
|
||||
|
|
@ -8,192 +8,49 @@ package main
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/skills"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/agent"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/auth"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron"
|
||||
"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/skills"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/status"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/version"
|
||||
)
|
||||
|
||||
var (
|
||||
version = "dev"
|
||||
gitCommit string
|
||||
buildTime string
|
||||
goVersion string
|
||||
)
|
||||
func NewPicoclawCommand() *cobra.Command {
|
||||
short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, internal.GetVersion())
|
||||
|
||||
const logo = "🦞"
|
||||
|
||||
// formatVersion returns the version string with optional git commit
|
||||
func formatVersion() string {
|
||||
v := version
|
||||
if gitCommit != "" {
|
||||
v += fmt.Sprintf(" (git: %s)", gitCommit)
|
||||
cmd := &cobra.Command{
|
||||
Use: "picoclaw",
|
||||
Short: short,
|
||||
Example: "picoclaw list",
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// formatBuildInfo returns build time and go version info
|
||||
func formatBuildInfo() (build string, goVer string) {
|
||||
if buildTime != "" {
|
||||
build = buildTime
|
||||
}
|
||||
goVer = goVersion
|
||||
if goVer == "" {
|
||||
goVer = runtime.Version()
|
||||
}
|
||||
return
|
||||
}
|
||||
cmd.AddCommand(
|
||||
onboard.NewOnboardCommand(),
|
||||
agent.NewAgentCommand(),
|
||||
auth.NewAuthCommand(),
|
||||
gateway.NewGatewayCommand(),
|
||||
status.NewStatusCommand(),
|
||||
cron.NewCronCommand(),
|
||||
migrate.NewMigrateCommand(),
|
||||
skills.NewSkillsCommand(),
|
||||
version.NewVersionCommand(),
|
||||
)
|
||||
|
||||
func printVersion() {
|
||||
fmt.Printf("%s picoclaw %s\n", logo, formatVersion())
|
||||
build, goVer := formatBuildInfo()
|
||||
if build != "" {
|
||||
fmt.Printf(" Build: %s\n", build)
|
||||
}
|
||||
if goVer != "" {
|
||||
fmt.Printf(" Go: %s\n", goVer)
|
||||
}
|
||||
}
|
||||
|
||||
func copyDirectory(src, dst string) error {
|
||||
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
relPath, err := filepath.Rel(src, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dstPath := filepath.Join(dst, relPath)
|
||||
|
||||
if info.IsDir() {
|
||||
return os.MkdirAll(dstPath, info.Mode())
|
||||
}
|
||||
|
||||
srcFile, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
dstFile, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
_, err = io.Copy(dstFile, srcFile)
|
||||
return err
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
printHelp()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
command := os.Args[1]
|
||||
|
||||
switch command {
|
||||
case "onboard":
|
||||
onboard()
|
||||
case "agent":
|
||||
agentCmd()
|
||||
case "gateway":
|
||||
gatewayCmd()
|
||||
case "status":
|
||||
statusCmd()
|
||||
case "migrate":
|
||||
migrateCmd()
|
||||
case "auth":
|
||||
authCmd()
|
||||
case "cron":
|
||||
cronCmd()
|
||||
case "skills":
|
||||
if len(os.Args) < 3 {
|
||||
skillsHelp()
|
||||
return
|
||||
}
|
||||
|
||||
subcommand := os.Args[2]
|
||||
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
fmt.Printf("Error loading config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
workspace := cfg.WorkspacePath()
|
||||
installer := skills.NewSkillInstaller(workspace)
|
||||
// get global config directory and builtin skills directory
|
||||
globalDir := filepath.Dir(getConfigPath())
|
||||
globalSkillsDir := filepath.Join(globalDir, "skills")
|
||||
builtinSkillsDir := filepath.Join(globalDir, "picoclaw", "skills")
|
||||
skillsLoader := skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir)
|
||||
|
||||
switch subcommand {
|
||||
case "list":
|
||||
skillsListCmd(skillsLoader)
|
||||
case "install":
|
||||
skillsInstallCmd(installer, cfg)
|
||||
case "remove", "uninstall":
|
||||
if len(os.Args) < 4 {
|
||||
fmt.Println("Usage: picoclaw skills remove <skill-name>")
|
||||
return
|
||||
}
|
||||
skillsRemoveCmd(installer, os.Args[3])
|
||||
case "install-builtin":
|
||||
skillsInstallBuiltinCmd(workspace)
|
||||
case "list-builtin":
|
||||
skillsListBuiltinCmd()
|
||||
case "search":
|
||||
skillsSearchCmd(installer)
|
||||
case "show":
|
||||
if len(os.Args) < 4 {
|
||||
fmt.Println("Usage: picoclaw skills show <skill-name>")
|
||||
return
|
||||
}
|
||||
skillsShowCmd(skillsLoader, os.Args[3])
|
||||
default:
|
||||
fmt.Printf("Unknown skills command: %s\n", subcommand)
|
||||
skillsHelp()
|
||||
}
|
||||
case "version", "--version", "-v":
|
||||
printVersion()
|
||||
default:
|
||||
fmt.Printf("Unknown command: %s\n", command)
|
||||
printHelp()
|
||||
cmd := NewPicoclawCommand()
|
||||
if err := cmd.Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func printHelp() {
|
||||
fmt.Printf("%s picoclaw - Personal AI Assistant v%s\n\n", logo, version)
|
||||
fmt.Println("Usage: picoclaw <command>")
|
||||
fmt.Println()
|
||||
fmt.Println("Commands:")
|
||||
fmt.Println(" onboard Initialize picoclaw configuration and workspace")
|
||||
fmt.Println(" agent Interact with the agent directly")
|
||||
fmt.Println(" auth Manage authentication (login, logout, status)")
|
||||
fmt.Println(" gateway Start picoclaw gateway")
|
||||
fmt.Println(" status Show picoclaw status")
|
||||
fmt.Println(" cron Manage scheduled tasks")
|
||||
fmt.Println(" migrate Migrate from OpenClaw to PicoClaw")
|
||||
fmt.Println(" skills Manage skills (install, list, remove)")
|
||||
fmt.Println(" version Show version information")
|
||||
}
|
||||
|
||||
func getConfigPath() string {
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".picoclaw", "config.json")
|
||||
}
|
||||
|
||||
func loadConfig() (*config.Config, error) {
|
||||
return config.LoadConfig(getConfigPath())
|
||||
}
|
||||
|
|
|
|||
56
cmd/picoclaw/main_test.go
Normal file
56
cmd/picoclaw/main_test.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
)
|
||||
|
||||
func TestNewPicoclawCommand(t *testing.T) {
|
||||
cmd := NewPicoclawCommand()
|
||||
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, internal.GetVersion())
|
||||
|
||||
assert.Equal(t, "picoclaw", cmd.Use)
|
||||
assert.Equal(t, short, cmd.Short)
|
||||
|
||||
assert.True(t, cmd.HasSubCommands())
|
||||
assert.True(t, cmd.HasAvailableSubCommands())
|
||||
|
||||
assert.False(t, cmd.HasFlags())
|
||||
|
||||
assert.Nil(t, cmd.Run)
|
||||
assert.Nil(t, cmd.RunE)
|
||||
|
||||
assert.Nil(t, cmd.PersistentPreRun)
|
||||
assert.Nil(t, cmd.PersistentPostRun)
|
||||
|
||||
allowedCommands := []string{
|
||||
"agent",
|
||||
"auth",
|
||||
"cron",
|
||||
"gateway",
|
||||
"migrate",
|
||||
"onboard",
|
||||
"skills",
|
||||
"status",
|
||||
"version",
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
3
go.mod
3
go.mod
|
|
@ -15,6 +15,7 @@ require (
|
|||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
||||
github.com/openai/openai-go/v3 v3.22.0
|
||||
github.com/slack-go/slack v0.17.3
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/tencent-connect/botgo v0.2.1
|
||||
golang.org/x/oauth2 v0.35.0
|
||||
|
|
@ -22,7 +23,9 @@ require (
|
|||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
|
|
|
|||
10
go.sum
10
go.sum
|
|
@ -25,6 +25,7 @@ github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
|
|||
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
|
|
@ -72,6 +73,8 @@ github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad
|
|||
github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc=
|
||||
github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
|
|
@ -108,8 +111,14 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
|
|||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g=
|
||||
github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
|
|
@ -151,6 +160,7 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec
|
|||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
|
||||
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue