diff --git a/cmd/picoclaw/agent.go b/cmd/picoclaw/agent.go new file mode 100644 index 000000000..320e0c773 --- /dev/null +++ b/cmd/picoclaw/agent.go @@ -0,0 +1,175 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT + +package main + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/chzyer/readline" + "github.com/sipeed/picoclaw/pkg/agent" + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/spf13/cobra" +) + +var agentCmd = &cobra.Command{ + Use: "agent", + Short: "Interact with the agent directly", + Long: `Interact with the AI agent directly via command line or interactive mode.`, + Run: func(cmd *cobra.Command, args []string) { + agentImpl() + }, +} + +var ( + agentDebug bool + agentMessage string + agentSession string +) + +func init() { + agentCmd.Flags().BoolVarP(&agentDebug, "debug", "d", false, "Enable debug mode") + agentCmd.Flags().StringVarP(&agentMessage, "message", "m", "", "Send a single message to the agent") + agentCmd.Flags().StringVarP(&agentSession, "session", "s", "cli:default", "Session key for conversation") +} + +func agentImpl() { + message := agentMessage + sessionKey := agentSession + + if agentDebug { + logger.SetLevel(logger.DEBUG) + fmt.Println("šŸ” Debug mode enabled") + } + + cfg, err := loadConfig() + if err != nil { + fmt.Printf("Error loading config: %v\n", err) + os.Exit(1) + } + + provider, err := providers.CreateProvider(cfg) + if err != nil { + fmt.Printf("Error creating provider: %v\n", err) + os.Exit(1) + } + + msgBus := bus.NewMessageBus() + agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) + + // Print agent startup info (only for interactive mode) + startupInfo := agentLoop.GetStartupInfo() + logger.InfoCF("agent", "Agent initialized", + map[string]interface{}{ + "tools_count": startupInfo["tools"].(map[string]interface{})["count"], + "skills_total": startupInfo["skills"].(map[string]interface{})["total"], + "skills_available": startupInfo["skills"].(map[string]interface{})["available"], + }) + + if message != "" { + ctx := context.Background() + response, err := agentLoop.ProcessDirect(ctx, message, sessionKey) + if err != nil { + fmt.Printf("Error: %v\n", err) + os.Exit(1) + } + fmt.Printf("\n%s %s\n", logo, response) + } else { + fmt.Printf("%s Interactive mode (Ctrl+C to exit)\n\n", logo) + interactiveMode(agentLoop, sessionKey) + } +} + +func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) { + prompt := fmt.Sprintf("%s You: ", logo) + + rl, err := readline.NewEx(&readline.Config{ + Prompt: prompt, + HistoryFile: filepath.Join(os.TempDir(), ".picoclaw_history"), + HistoryLimit: 100, + InterruptPrompt: "^C", + EOFPrompt: "exit", + }) + + if err != nil { + fmt.Printf("Error initializing readline: %v\n", err) + fmt.Println("Falling back to simple input mode...") + simpleInteractiveMode(agentLoop, sessionKey) + return + } + defer rl.Close() + + for { + line, err := rl.Readline() + if err != nil { + if err == readline.ErrInterrupt || err == io.EOF { + fmt.Println("\nGoodbye!") + return + } + fmt.Printf("Error reading input: %v\n", err) + continue + } + + input := strings.TrimSpace(line) + if input == "" { + continue + } + + if input == "exit" || input == "quit" { + fmt.Println("Goodbye!") + return + } + + ctx := context.Background() + response, err := agentLoop.ProcessDirect(ctx, input, sessionKey) + if err != nil { + fmt.Printf("Error: %v\n", err) + continue + } + + fmt.Printf("\n%s %s\n\n", logo, response) + } +} + +func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) { + reader := bufio.NewReader(os.Stdin) + for { + fmt.Print(fmt.Sprintf("%s You: ", logo)) + line, err := reader.ReadString('\n') + if err != nil { + if err == io.EOF { + fmt.Println("\nGoodbye!") + return + } + fmt.Printf("Error reading input: %v\n", err) + continue + } + + input := strings.TrimSpace(line) + if input == "" { + continue + } + + if input == "exit" || input == "quit" { + fmt.Println("Goodbye!") + return + } + + ctx := context.Background() + response, err := agentLoop.ProcessDirect(ctx, input, sessionKey) + if err != nil { + fmt.Printf("Error: %v\n", err) + continue + } + + fmt.Printf("\n%s %s\n\n", logo, response) + } +} diff --git a/cmd/picoclaw/auth.go b/cmd/picoclaw/auth.go new file mode 100644 index 000000000..c2a1b8a13 --- /dev/null +++ b/cmd/picoclaw/auth.go @@ -0,0 +1,44 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT + +package main + +import ( + "fmt" + + "github.com/sipeed/picoclaw/cmd/picoclaw/auth" + "github.com/spf13/cobra" +) + +var authCmd = &cobra.Command{ + Use: "auth", + Short: "Manage authentication", + Long: `Manage authentication for different providers (login, logout, status).`, + Run: func(cmd *cobra.Command, args []string) { + authHelp() + }, +} + +func init() { + authCmd.AddCommand(auth.LoginCmd) + authCmd.AddCommand(auth.LogoutCmd) + authCmd.AddCommand(auth.StatusCmd) +} + +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() + fmt.Println("Login options:") + fmt.Println(" --provider Provider to login with (openai, anthropic)") + 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 logout --provider openai") + fmt.Println(" picoclaw auth status") +} diff --git a/cmd/picoclaw/auth/login.go b/cmd/picoclaw/auth/login.go new file mode 100644 index 000000000..6c9e795ee --- /dev/null +++ b/cmd/picoclaw/auth/login.go @@ -0,0 +1,124 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT + +package auth + +import ( + "fmt" + "os" + + "github.com/sipeed/picoclaw/pkg/auth" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/spf13/cobra" +) + +var LoginCmd = &cobra.Command{ + Use: "login", + Short: "Login via OAuth or paste token", + Long: `Login to a provider using OAuth browser flow or paste a token.`, + PreRunE: func(cmd *cobra.Command, args []string) error { + if loginProvider == "" { + return fmt.Errorf("--provider is required") + } + return nil + }, + Run: func(cmd *cobra.Command, args []string) { + loginImpl() + }, +} + +var ( + loginProvider string + loginDeviceCode bool +) + +func init() { + LoginCmd.Flags().StringVarP(&loginProvider, "provider", "p", "", "Provider to login with (openai, anthropic)") + LoginCmd.Flags().BoolVar(&loginDeviceCode, "device-code", false, "Use device code flow (for headless environments)") +} + +func getConfigPath() string { + home, _ := os.UserHomeDir() + return fmt.Sprintf("%s/.picoclaw/config.json", home) +} + +func loadConfig() (*config.Config, error) { + return config.LoadConfig(getConfigPath()) +} + +func loginImpl() { + switch loginProvider { + case "openai": + loginOpenAI(loginDeviceCode) + case "anthropic": + loginPasteToken(loginProvider) + default: + fmt.Printf("Unsupported provider: %s\n", loginProvider) + fmt.Println("Supported providers: openai, anthropic") + os.Exit(1) + } +} + +func loginOpenAI(useDeviceCode bool) { + cfg := auth.OpenAIOAuthConfig() + + var cred *auth.AuthCredential + var err error + + if useDeviceCode { + cred, err = auth.LoginDeviceCode(cfg) + } else { + cred, err = auth.LoginBrowser(cfg) + } + + if err != nil { + fmt.Printf("Login failed: %v\n", err) + os.Exit(1) + } + + if err := auth.SetCredential("openai", cred); err != nil { + fmt.Printf("Failed to save credentials: %v\n", err) + os.Exit(1) + } + + appCfg, err := loadConfig() + if err == nil { + appCfg.Providers.OpenAI.AuthMethod = "oauth" + if err := config.SaveConfig(getConfigPath(), appCfg); err != nil { + fmt.Printf("Warning: could not update config: %v\n", err) + } + } + + fmt.Println("Login successful!") + if cred.AccountID != "" { + fmt.Printf("Account: %s\n", cred.AccountID) + } +} + +func loginPasteToken(provider string) { + cred, err := auth.LoginPasteToken(provider, os.Stdin) + if err != nil { + fmt.Printf("Login failed: %v\n", err) + os.Exit(1) + } + + if err := auth.SetCredential(provider, cred); err != nil { + fmt.Printf("Failed to save credentials: %v\n", err) + os.Exit(1) + } + + appCfg, err := loadConfig() + if err == nil { + switch provider { + case "anthropic": + appCfg.Providers.Anthropic.AuthMethod = "token" + case "openai": + appCfg.Providers.OpenAI.AuthMethod = "token" + } + if err := config.SaveConfig(getConfigPath(), appCfg); err != nil { + fmt.Printf("Warning: could not update config: %v\n", err) + } + } + + fmt.Printf("Token saved for %s!\n", provider) +} diff --git a/cmd/picoclaw/auth/logout.go b/cmd/picoclaw/auth/logout.go new file mode 100644 index 000000000..fdd0858c7 --- /dev/null +++ b/cmd/picoclaw/auth/logout.go @@ -0,0 +1,64 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT + +package auth + +import ( + "fmt" + "os" + + "github.com/sipeed/picoclaw/pkg/auth" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/spf13/cobra" +) + +var LogoutCmd = &cobra.Command{ + Use: "logout", + Short: "Remove stored credentials", + Long: `Remove stored authentication credentials for a specific provider or all providers.`, + Run: func(cmd *cobra.Command, args []string) { + logoutImpl() + }, +} + +var logoutProvider string + +func init() { + LogoutCmd.Flags().StringVarP(&logoutProvider, "provider", "p", "", "Provider to logout from (openai, anthropic)") +} + +func logoutImpl() { + if logoutProvider != "" { + if err := auth.DeleteCredential(logoutProvider); err != nil { + fmt.Printf("Failed to remove credentials: %v\n", err) + os.Exit(1) + } + + appCfg, err := loadConfig() + if err == nil { + switch logoutProvider { + case "openai": + appCfg.Providers.OpenAI.AuthMethod = "" + case "anthropic": + appCfg.Providers.Anthropic.AuthMethod = "" + } + config.SaveConfig(getConfigPath(), appCfg) + } + + fmt.Printf("Logged out from %s\n", logoutProvider) + } 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 { + appCfg.Providers.OpenAI.AuthMethod = "" + appCfg.Providers.Anthropic.AuthMethod = "" + config.SaveConfig(getConfigPath(), appCfg) + } + + fmt.Println("Logged out from all providers") + } +} diff --git a/cmd/picoclaw/auth/status.go b/cmd/picoclaw/auth/status.go new file mode 100644 index 000000000..a8e799c5d --- /dev/null +++ b/cmd/picoclaw/auth/status.go @@ -0,0 +1,55 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT + +package auth + +import ( + "fmt" + + "github.com/sipeed/picoclaw/pkg/auth" + "github.com/spf13/cobra" +) + +var StatusCmd = &cobra.Command{ + Use: "status", + Short: "Show current auth status", + Long: `Display the current authentication status for all providers.`, + Run: func(cmd *cobra.Command, args []string) { + statusImpl() + }, +} + +func statusImpl() { + store, err := auth.LoadStore() + if err != nil { + fmt.Printf("Error loading auth store: %v\n", err) + return + } + + if len(store.Credentials) == 0 { + fmt.Println("No authenticated providers.") + fmt.Println("Run: picoclaw auth login --provider ") + return + } + + fmt.Println("\nAuthenticated Providers:") + fmt.Println("------------------------") + for provider, cred := range store.Credentials { + status := "active" + if cred.IsExpired() { + status = "expired" + } else if cred.NeedsRefresh() { + status = "needs refresh" + } + + fmt.Printf(" %s:\n", provider) + fmt.Printf(" Method: %s\n", cred.AuthMethod) + fmt.Printf(" Status: %s\n", status) + if cred.AccountID != "" { + fmt.Printf(" Account: %s\n", cred.AccountID) + } + if !cred.ExpiresAt.IsZero() { + fmt.Printf(" Expires: %s\n", cred.ExpiresAt.Format("2006-01-02 15:04")) + } + } +} diff --git a/cmd/picoclaw/cron.go b/cmd/picoclaw/cron.go new file mode 100644 index 000000000..7a7ed74e2 --- /dev/null +++ b/cmd/picoclaw/cron.go @@ -0,0 +1,56 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT + +package main + +import ( + "fmt" + + "github.com/sipeed/picoclaw/cmd/picoclaw/cronpkg" + "github.com/spf13/cobra" +) + +var cronCmd = &cobra.Command{ + Use: "cron", + Short: "Manage scheduled tasks", + Long: `Manage cron jobs and scheduled tasks.`, + Run: func(cmd *cobra.Command, args []string) { + cronHelp() + }, +} + +func init() { + // PreRun to load config and store cronStorePath for subcommands + cronCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { + cfg, err := loadConfig() + if err != nil { + fmt.Printf("Error loading config: %v\n", err) + return + } + cronpkg.SetCronStorePath(cfg.WorkspacePath()) + } + + cronCmd.AddCommand(cronpkg.ListCmd) + cronCmd.AddCommand(cronpkg.AddCmd) + cronCmd.AddCommand(cronpkg.RemoveCmd) + cronCmd.AddCommand(cronpkg.EnableCmd) + cronCmd.AddCommand(cronpkg.DisableCmd) +} + +func cronHelp() { + fmt.Println("\nCron commands:") + fmt.Println(" list List all scheduled jobs") + fmt.Println(" add Add a new scheduled job") + fmt.Println(" remove Remove a job by ID") + fmt.Println(" enable Enable a job") + fmt.Println(" disable 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") +} diff --git a/cmd/picoclaw/cronpkg/add.go b/cmd/picoclaw/cronpkg/add.go new file mode 100644 index 000000000..54ff2066c --- /dev/null +++ b/cmd/picoclaw/cronpkg/add.go @@ -0,0 +1,78 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT + +package cronpkg + +import ( + "fmt" + "os" + + "github.com/sipeed/picoclaw/pkg/cron" + "github.com/spf13/cobra" +) + +var AddCmd = &cobra.Command{ + Use: "add", + Short: "Add a new scheduled job", + Long: `Add a new cron job with specified schedule and message.`, + PreRunE: func(cmd *cobra.Command, args []string) error { + if cronName == "" { + return fmt.Errorf("--name is required") + } + if cronMessage == "" { + return fmt.Errorf("--message is required") + } + if cronEvery == 0 && cronCronExpr == "" { + return fmt.Errorf("Either --every or --cron must be specified") + } + return nil + }, + Run: func(cmd *cobra.Command, args []string) { + addImpl() + }, +} + +var ( + cronName string + cronMessage string + cronEvery int64 + cronCronExpr string + cronDeliver bool + cronTo string + cronChannel string +) + +func init() { + AddCmd.Flags().StringVarP(&cronName, "name", "n", "", "Job name (required)") + AddCmd.Flags().StringVarP(&cronMessage, "message", "m", "", "Message for agent (required)") + AddCmd.Flags().StringVarP(&cronCronExpr, "cron", "c", "", "Cron expression (e.g. '0 9 * * *')") + AddCmd.Flags().StringVarP(&cronTo, "to", "", "", "Recipient for delivery") + AddCmd.Flags().StringVar(&cronChannel, "channel", "", "Channel for delivery") + AddCmd.Flags().Int64VarP(&cronEvery, "every", "e", 0, "Run every N seconds") + AddCmd.Flags().BoolVarP(&cronDeliver, "deliver", "d", false, "Deliver response to channel") +} + +func addImpl() { + var schedule cron.CronSchedule + if cronEvery > 0 { + everyMS := cronEvery * 1000 + schedule = cron.CronSchedule{ + Kind: "every", + EveryMS: &everyMS, + } + } else { + schedule = cron.CronSchedule{ + Kind: "cron", + Expr: cronCronExpr, + } + } + + cs := cron.NewCronService(cronStorePath, nil) + job, err := cs.AddJob(cronName, schedule, cronMessage, cronDeliver, cronChannel, cronTo) + if err != nil { + fmt.Printf("Error adding job: %v\n", err) + os.Exit(1) + } + + fmt.Printf("āœ“ Added job '%s' (%s)\n", job.Name, job.ID) +} diff --git a/cmd/picoclaw/cronpkg/cron.go b/cmd/picoclaw/cronpkg/cron.go new file mode 100644 index 000000000..0b9a426e5 --- /dev/null +++ b/cmd/picoclaw/cronpkg/cron.go @@ -0,0 +1,14 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT + +package cronpkg + +var cronStorePath string + +func SetCronStorePath(workspace string) { + cronStorePath = workspace + "/cron/jobs.json" +} + +func GetCronStorePath() string { + return cronStorePath +} diff --git a/cmd/picoclaw/cronpkg/enable.go b/cmd/picoclaw/cronpkg/enable.go new file mode 100644 index 000000000..afd446e9d --- /dev/null +++ b/cmd/picoclaw/cronpkg/enable.go @@ -0,0 +1,47 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT + +package cronpkg + +import ( + "fmt" + + "github.com/sipeed/picoclaw/pkg/cron" + "github.com/spf13/cobra" +) + +var EnableCmd = &cobra.Command{ + Use: "enable ", + Short: "Enable a job", + Long: `Enable a scheduled job by its ID.`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + enableImpl(args[0], false) + }, +} + +var DisableCmd = &cobra.Command{ + Use: "disable ", + Short: "Disable a job", + Long: `Disable a scheduled job by its ID.`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + enableImpl(args[0], true) + }, +} + +func enableImpl(jobID string, disable bool) { + cs := cron.NewCronService(cronStorePath, 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) + } +} diff --git a/cmd/picoclaw/cronpkg/list.go b/cmd/picoclaw/cronpkg/list.go new file mode 100644 index 000000000..dc06811db --- /dev/null +++ b/cmd/picoclaw/cronpkg/list.go @@ -0,0 +1,60 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT + +package cronpkg + +import ( + "fmt" + "time" + + "github.com/sipeed/picoclaw/pkg/cron" + "github.com/spf13/cobra" +) + +var ListCmd = &cobra.Command{ + Use: "list", + Short: "List all scheduled jobs", + Long: `Display all cron jobs including disabled ones.`, + Run: func(cmd *cobra.Command, args []string) { + listImpl() + }, +} + +func listImpl() { + cs := cron.NewCronService(cronStorePath, 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) + } +} diff --git a/cmd/picoclaw/cronpkg/remove.go b/cmd/picoclaw/cronpkg/remove.go new file mode 100644 index 000000000..96365d03d --- /dev/null +++ b/cmd/picoclaw/cronpkg/remove.go @@ -0,0 +1,30 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT + +package cronpkg + +import ( + "fmt" + + "github.com/sipeed/picoclaw/pkg/cron" + "github.com/spf13/cobra" +) + +var RemoveCmd = &cobra.Command{ + Use: "remove ", + Short: "Remove a job by ID", + Long: `Remove a scheduled job by its ID.`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + removeImpl(args[0]) + }, +} + +func removeImpl(jobID string) { + cs := cron.NewCronService(cronStorePath, nil) + if cs.RemoveJob(jobID) { + fmt.Printf("āœ“ Removed job %s\n", jobID) + } else { + fmt.Printf("āœ— Job %s not found\n", jobID) + } +} diff --git a/cmd/picoclaw/gateway.go b/cmd/picoclaw/gateway.go new file mode 100644 index 000000000..4a4fce4f8 --- /dev/null +++ b/cmd/picoclaw/gateway.go @@ -0,0 +1,213 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT + +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "path/filepath" + + "github.com/sipeed/picoclaw/pkg/agent" + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/cron" + "github.com/sipeed/picoclaw/pkg/devices" + "github.com/sipeed/picoclaw/pkg/heartbeat" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/state" + "github.com/sipeed/picoclaw/pkg/tools" + "github.com/sipeed/picoclaw/pkg/voice" + "github.com/spf13/cobra" +) + +var gatewayCmd = &cobra.Command{ + Use: "gateway", + Short: "Start picoclaw gateway", + Long: `Start the picoclaw gateway service with all channels and services.`, + Run: func(cmd *cobra.Command, args []string) { + gatewayImpl() + }, +} + +var gatewayDebug bool + +func init() { + gatewayCmd.Flags().BoolVarP(&gatewayDebug, "debug", "d", false, "Enable debug mode") +} + +func gatewayImpl() { + if gatewayDebug { + logger.SetLevel(logger.DEBUG) + fmt.Println("šŸ” Debug mode enabled") + } + + cfg, err := loadConfig() + if err != nil { + fmt.Printf("Error loading config: %v\n", err) + os.Exit(1) + } + + provider, err := providers.CreateProvider(cfg) + if err != nil { + fmt.Printf("Error creating provider: %v\n", err) + os.Exit(1) + } + + msgBus := bus.NewMessageBus() + agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) + + // Print agent startup info + fmt.Println("\nšŸ“¦ Agent Status:") + startupInfo := agentLoop.GetStartupInfo() + toolsInfo := startupInfo["tools"].(map[string]interface{}) + skillsInfo := startupInfo["skills"].(map[string]interface{}) + fmt.Printf(" • Tools: %d loaded\n", toolsInfo["count"]) + fmt.Printf(" • Skills: %d/%d available\n", + skillsInfo["available"], + skillsInfo["total"]) + + // Log to file as well + logger.InfoCF("agent", "Agent initialized", + map[string]interface{}{ + "tools_count": toolsInfo["count"], + "skills_total": skillsInfo["total"], + "skills_available": skillsInfo["available"], + }) + + // Setup cron tool and service + cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath()) + + heartbeatService := heartbeat.NewHeartbeatService( + cfg.WorkspacePath(), + cfg.Heartbeat.Interval, + cfg.Heartbeat.Enabled, + ) + heartbeatService.SetBus(msgBus) + heartbeatService.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult { + // Use cli:direct as fallback if no valid channel + if channel == "" || chatID == "" { + channel, chatID = "cli", "direct" + } + // Use ProcessHeartbeat - no session history, each heartbeat is independent + response, err := agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID) + if err != nil { + return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err)) + } + if response == "HEARTBEAT_OK" { + return tools.SilentResult("Heartbeat OK") + } + // For heartbeat, always return silent - the subagent result will be + // sent to user via processSystemMessage when the async task completes + return tools.SilentResult(response) + }) + + channelManager, err := channels.NewManager(cfg, msgBus) + if err != nil { + fmt.Printf("Error creating channel manager: %v\n", err) + os.Exit(1) + } + + var transcriber *voice.GroqTranscriber + if cfg.Providers.Groq.APIKey != "" { + transcriber = voice.NewGroqTranscriber(cfg.Providers.Groq.APIKey) + logger.InfoC("voice", "Groq voice transcription enabled") + } + + if transcriber != nil { + if telegramChannel, ok := channelManager.GetChannel("telegram"); ok { + if tc, ok := telegramChannel.(*channels.TelegramChannel); ok { + tc.SetTranscriber(transcriber) + logger.InfoC("voice", "Groq transcription attached to Telegram channel") + } + } + if discordChannel, ok := channelManager.GetChannel("discord"); ok { + if dc, ok := discordChannel.(*channels.DiscordChannel); ok { + dc.SetTranscriber(transcriber) + logger.InfoC("voice", "Groq transcription attached to Discord channel") + } + } + if slackChannel, ok := channelManager.GetChannel("slack"); ok { + if sc, ok := slackChannel.(*channels.SlackChannel); ok { + sc.SetTranscriber(transcriber) + logger.InfoC("voice", "Groq transcription attached to Slack channel") + } + } + } + + enabledChannels := channelManager.GetEnabledChannels() + if len(enabledChannels) > 0 { + fmt.Printf("āœ“ Channels enabled: %s\n", enabledChannels) + } else { + fmt.Println("⚠ Warning: No channels enabled") + } + + fmt.Printf("āœ“ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port) + fmt.Println("Press Ctrl+C to stop") + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if err := cronService.Start(); err != nil { + fmt.Printf("Error starting cron service: %v\n", err) + } + fmt.Println("āœ“ Cron service started") + + if err := heartbeatService.Start(); err != nil { + fmt.Printf("Error starting heartbeat service: %v\n", err) + } + fmt.Println("āœ“ Heartbeat service started") + + stateManager := state.NewManager(cfg.WorkspacePath()) + deviceService := devices.NewService(devices.Config{ + Enabled: cfg.Devices.Enabled, + MonitorUSB: cfg.Devices.MonitorUSB, + }, stateManager) + deviceService.SetBus(msgBus) + if err := deviceService.Start(ctx); err != nil { + fmt.Printf("Error starting device service: %v\n", err) + } else if cfg.Devices.Enabled { + fmt.Println("āœ“ Device event service started") + } + + if err := channelManager.StartAll(ctx); err != nil { + fmt.Printf("Error starting channels: %v\n", err) + } + + go agentLoop.Run(ctx) + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt) + <-sigChan + + fmt.Println("\nShutting down...") + cancel() + deviceService.Stop() + heartbeatService.Stop() + cronService.Stop() + agentLoop.Stop() + channelManager.StopAll(ctx) + fmt.Println("āœ“ Gateway stopped") +} + +func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string) *cron.CronService { + cronStorePath := filepath.Join(workspace, "cron", "jobs.json") + + // Create cron service + cronService := cron.NewCronService(cronStorePath, nil) + + // Create and register CronTool + cronTool := tools.NewCronTool(cronService, agentLoop, msgBus, workspace) + agentLoop.RegisterTool(cronTool) + + // Set the onJob handler + cronService.SetOnJob(func(job *cron.CronJob) (string, error) { + result := cronTool.ExecuteJob(context.Background(), job) + return result, nil + }) + + return cronService +} diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 36bf2ea83..023f49f78 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -6,1428 +6,8 @@ package main -import ( - "bufio" - "context" - "embed" - "fmt" - "io" - "io/fs" - "net/http" - "os" - "os/signal" - "path/filepath" - "runtime" - "strings" - "time" - - "github.com/chzyer/readline" - "github.com/sipeed/picoclaw/pkg/agent" - "github.com/sipeed/picoclaw/pkg/auth" - "github.com/sipeed/picoclaw/pkg/bus" - "github.com/sipeed/picoclaw/pkg/channels" - "github.com/sipeed/picoclaw/pkg/config" - "github.com/sipeed/picoclaw/pkg/cron" - "github.com/sipeed/picoclaw/pkg/devices" - "github.com/sipeed/picoclaw/pkg/health" - "github.com/sipeed/picoclaw/pkg/heartbeat" - "github.com/sipeed/picoclaw/pkg/logger" - "github.com/sipeed/picoclaw/pkg/migrate" - "github.com/sipeed/picoclaw/pkg/providers" - "github.com/sipeed/picoclaw/pkg/skills" - "github.com/sipeed/picoclaw/pkg/state" - "github.com/sipeed/picoclaw/pkg/tools" - "github.com/sipeed/picoclaw/pkg/voice" -) - //go:generate cp -r ../../workspace . -//go:embed workspace -var embeddedFiles embed.FS - -var ( - version = "dev" - gitCommit string - buildTime string - goVersion string -) - -const logo = "šŸ¦ž" - -// 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 -} - -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 - }) -} 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) - // čŽ·å–å…Øå±€é…ē½®ē›®å½•å’Œå†…ē½® skills 目录 - 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) - case "remove", "uninstall": - if len(os.Args) < 4 { - fmt.Println("Usage: picoclaw skills remove ") - 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 ") - 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() - os.Exit(1) - } -} - -func printHelp() { - fmt.Printf("%s picoclaw - Personal AI Assistant v%s\n\n", logo, version) - fmt.Println("Usage: picoclaw ") - 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 onboard() { - configPath := getConfigPath() - - if _, err := os.Stat(configPath); err == nil { - fmt.Printf("Config already exists at %s\n", configPath) - fmt.Print("Overwrite? (y/n): ") - var response string - fmt.Scanln(&response) - if response != "y" { - fmt.Println("Aborted.") - return - } - } - - cfg := config.DefaultConfig() - if err := config.SaveConfig(configPath, cfg); err != nil { - fmt.Printf("Error saving config: %v\n", err) - os.Exit(1) - } - - workspace := cfg.WorkspacePath() - createWorkspaceTemplates(workspace) - - fmt.Printf("%s picoclaw is ready!\n", logo) - fmt.Println("\nNext steps:") - fmt.Println(" 1. Add your API key to", configPath) - fmt.Println(" Get one at: https://openrouter.ai/keys") - fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"") -} - -func copyEmbeddedToTarget(targetDir string) error { - // Ensure target directory exists - if err := os.MkdirAll(targetDir, 0755); err != nil { - return fmt.Errorf("Failed to create target directory: %w", err) - } - - // Walk through all files in embed.FS - err := fs.WalkDir(embeddedFiles, "workspace", func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - - // Skip directories - if d.IsDir() { - return nil - } - - // Read embedded file - data, err := embeddedFiles.ReadFile(path) - if err != nil { - return fmt.Errorf("Failed to read embedded file %s: %w", path, err) - } - - new_path, err := filepath.Rel("workspace", path) - if err != nil { - return fmt.Errorf("Failed to get relative path for %s: %v\n", path, err) - } - - // Build target file path - targetPath := filepath.Join(targetDir, new_path) - - // Ensure target file's directory exists - if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { - return fmt.Errorf("Failed to create directory %s: %w", filepath.Dir(targetPath), err) - } - - // Write file - if err := os.WriteFile(targetPath, data, 0644); err != nil { - return fmt.Errorf("Failed to write file %s: %w", targetPath, err) - } - - return nil - }) - - return err -} - -func createWorkspaceTemplates(workspace string) { - err := copyEmbeddedToTarget(workspace) - if err != nil { - fmt.Printf("Error copying workspace templates: %v\n", err) - } -} - -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") -} - -func agentCmd() { - message := "" - sessionKey := "cli:default" - - 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++ - } - } - } - - cfg, err := loadConfig() - if err != nil { - fmt.Printf("Error loading config: %v\n", err) - os.Exit(1) - } - - provider, err := providers.CreateProvider(cfg) - if err != nil { - fmt.Printf("Error creating provider: %v\n", err) - os.Exit(1) - } - - msgBus := bus.NewMessageBus() - agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) - - // Print agent startup info (only for interactive mode) - startupInfo := agentLoop.GetStartupInfo() - logger.InfoCF("agent", "Agent initialized", - map[string]interface{}{ - "tools_count": startupInfo["tools"].(map[string]interface{})["count"], - "skills_total": startupInfo["skills"].(map[string]interface{})["total"], - "skills_available": startupInfo["skills"].(map[string]interface{})["available"], - }) - - if message != "" { - ctx := context.Background() - response, err := agentLoop.ProcessDirect(ctx, message, sessionKey) - if err != nil { - fmt.Printf("Error: %v\n", err) - os.Exit(1) - } - fmt.Printf("\n%s %s\n", logo, response) - } else { - fmt.Printf("%s Interactive mode (Ctrl+C to exit)\n\n", logo) - interactiveMode(agentLoop, sessionKey) - } -} - -func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) { - prompt := fmt.Sprintf("%s You: ", logo) - - rl, err := readline.NewEx(&readline.Config{ - Prompt: prompt, - HistoryFile: filepath.Join(os.TempDir(), ".picoclaw_history"), - HistoryLimit: 100, - InterruptPrompt: "^C", - EOFPrompt: "exit", - }) - - if err != nil { - fmt.Printf("Error initializing readline: %v\n", err) - fmt.Println("Falling back to simple input mode...") - simpleInteractiveMode(agentLoop, sessionKey) - return - } - defer rl.Close() - - for { - line, err := rl.Readline() - if err != nil { - if err == readline.ErrInterrupt || err == io.EOF { - fmt.Println("\nGoodbye!") - return - } - fmt.Printf("Error reading input: %v\n", err) - continue - } - - input := strings.TrimSpace(line) - if input == "" { - continue - } - - if input == "exit" || input == "quit" { - fmt.Println("Goodbye!") - return - } - - ctx := context.Background() - response, err := agentLoop.ProcessDirect(ctx, input, sessionKey) - if err != nil { - fmt.Printf("Error: %v\n", err) - continue - } - - fmt.Printf("\n%s %s\n\n", logo, response) - } -} - -func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) { - reader := bufio.NewReader(os.Stdin) - for { - fmt.Print(fmt.Sprintf("%s You: ", logo)) - line, err := reader.ReadString('\n') - if err != nil { - if err == io.EOF { - fmt.Println("\nGoodbye!") - return - } - fmt.Printf("Error reading input: %v\n", err) - continue - } - - input := strings.TrimSpace(line) - if input == "" { - continue - } - - if input == "exit" || input == "quit" { - fmt.Println("Goodbye!") - return - } - - ctx := context.Background() - response, err := agentLoop.ProcessDirect(ctx, input, sessionKey) - if err != nil { - fmt.Printf("Error: %v\n", err) - continue - } - - fmt.Printf("\n%s %s\n\n", logo, response) - } -} - -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 - } - } - - cfg, err := loadConfig() - if err != nil { - fmt.Printf("Error loading config: %v\n", err) - os.Exit(1) - } - - provider, err := providers.CreateProvider(cfg) - if err != nil { - fmt.Printf("Error creating provider: %v\n", err) - os.Exit(1) - } - - msgBus := bus.NewMessageBus() - agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) - - // Print agent startup info - fmt.Println("\nšŸ“¦ Agent Status:") - startupInfo := agentLoop.GetStartupInfo() - toolsInfo := startupInfo["tools"].(map[string]interface{}) - skillsInfo := startupInfo["skills"].(map[string]interface{}) - fmt.Printf(" • Tools: %d loaded\n", toolsInfo["count"]) - fmt.Printf(" • Skills: %d/%d available\n", - skillsInfo["available"], - skillsInfo["total"]) - - // Log to file as well - logger.InfoCF("agent", "Agent initialized", - map[string]interface{}{ - "tools_count": toolsInfo["count"], - "skills_total": skillsInfo["total"], - "skills_available": skillsInfo["available"], - }) - - // Setup cron tool and service - execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute - cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath(), cfg.Agents.Defaults.RestrictToWorkspace, execTimeout, cfg) - - heartbeatService := heartbeat.NewHeartbeatService( - cfg.WorkspacePath(), - cfg.Heartbeat.Interval, - cfg.Heartbeat.Enabled, - ) - heartbeatService.SetBus(msgBus) - heartbeatService.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult { - // Use cli:direct as fallback if no valid channel - if channel == "" || chatID == "" { - channel, chatID = "cli", "direct" - } - // Use ProcessHeartbeat - no session history, each heartbeat is independent - response, err := agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID) - if err != nil { - return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err)) - } - if response == "HEARTBEAT_OK" { - return tools.SilentResult("Heartbeat OK") - } - // For heartbeat, always return silent - the subagent result will be - // sent to user via processSystemMessage when the async task completes - return tools.SilentResult(response) - }) - - channelManager, err := channels.NewManager(cfg, msgBus) - if err != nil { - fmt.Printf("Error creating channel manager: %v\n", err) - os.Exit(1) - } - - // Inject channel manager into agent loop for command handling - agentLoop.SetChannelManager(channelManager) - - var transcriber *voice.GroqTranscriber - if cfg.Providers.Groq.APIKey != "" { - transcriber = voice.NewGroqTranscriber(cfg.Providers.Groq.APIKey) - logger.InfoC("voice", "Groq voice transcription enabled") - } - - if transcriber != nil { - if telegramChannel, ok := channelManager.GetChannel("telegram"); ok { - if tc, ok := telegramChannel.(*channels.TelegramChannel); ok { - tc.SetTranscriber(transcriber) - logger.InfoC("voice", "Groq transcription attached to Telegram channel") - } - } - if discordChannel, ok := channelManager.GetChannel("discord"); ok { - if dc, ok := discordChannel.(*channels.DiscordChannel); ok { - dc.SetTranscriber(transcriber) - logger.InfoC("voice", "Groq transcription attached to Discord channel") - } - } - if slackChannel, ok := channelManager.GetChannel("slack"); ok { - if sc, ok := slackChannel.(*channels.SlackChannel); ok { - sc.SetTranscriber(transcriber) - logger.InfoC("voice", "Groq transcription attached to Slack channel") - } - } - if onebotChannel, ok := channelManager.GetChannel("onebot"); ok { - if oc, ok := onebotChannel.(*channels.OneBotChannel); ok { - oc.SetTranscriber(transcriber) - logger.InfoC("voice", "Groq transcription attached to OneBot channel") - } - } - } - - enabledChannels := channelManager.GetEnabledChannels() - if len(enabledChannels) > 0 { - fmt.Printf("āœ“ Channels enabled: %s\n", enabledChannels) - } else { - fmt.Println("⚠ Warning: No channels enabled") - } - - fmt.Printf("āœ“ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port) - fmt.Println("Press Ctrl+C to stop") - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - if err := cronService.Start(); err != nil { - fmt.Printf("Error starting cron service: %v\n", err) - } - fmt.Println("āœ“ Cron service started") - - if err := heartbeatService.Start(); err != nil { - fmt.Printf("Error starting heartbeat service: %v\n", err) - } - fmt.Println("āœ“ Heartbeat service started") - - stateManager := state.NewManager(cfg.WorkspacePath()) - deviceService := devices.NewService(devices.Config{ - Enabled: cfg.Devices.Enabled, - MonitorUSB: cfg.Devices.MonitorUSB, - }, stateManager) - deviceService.SetBus(msgBus) - if err := deviceService.Start(ctx); err != nil { - fmt.Printf("Error starting device service: %v\n", err) - } else if cfg.Devices.Enabled { - fmt.Println("āœ“ Device event service started") - } - - if err := channelManager.StartAll(ctx); err != nil { - fmt.Printf("Error starting channels: %v\n", err) - } - - healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port) - go func() { - if err := healthServer.Start(); err != nil && err != http.ErrServerClosed { - logger.ErrorCF("health", "Health server error", map[string]interface{}{"error": err.Error()}) - } - }() - fmt.Printf("āœ“ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port) - - go agentLoop.Run(ctx) - - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, os.Interrupt) - <-sigChan - - fmt.Println("\nShutting down...") - cancel() - healthServer.Stop(context.Background()) - deviceService.Stop() - heartbeatService.Stop() - cronService.Stop() - agentLoop.Stop() - channelManager.StopAll(ctx) - fmt.Println("āœ“ Gateway stopped") -} - -func statusCmd() { - cfg, err := loadConfig() - if err != nil { - fmt.Printf("Error loading config: %v\n", err) - return - } - - configPath := getConfigPath() - - fmt.Printf("%s picoclaw Status\n", logo) - fmt.Printf("Version: %s\n", formatVersion()) - build, _ := formatBuildInfo() - if build != "" { - fmt.Printf("Build: %s\n", build) - } - fmt.Println() - - if _, err := os.Stat(configPath); err == nil { - fmt.Println("Config:", configPath, "āœ“") - } else { - fmt.Println("Config:", configPath, "āœ—") - } - - workspace := cfg.WorkspacePath() - if _, err := os.Stat(workspace); err == nil { - fmt.Println("Workspace:", workspace, "āœ“") - } else { - fmt.Println("Workspace:", workspace, "āœ—") - } - - if _, err := os.Stat(configPath); err == nil { - fmt.Printf("Model: %s\n", cfg.Agents.Defaults.Model) - - hasOpenRouter := cfg.Providers.OpenRouter.APIKey != "" - hasAnthropic := cfg.Providers.Anthropic.APIKey != "" - hasOpenAI := cfg.Providers.OpenAI.APIKey != "" - hasGemini := cfg.Providers.Gemini.APIKey != "" - hasZhipu := cfg.Providers.Zhipu.APIKey != "" - hasGroq := cfg.Providers.Groq.APIKey != "" - hasVLLM := cfg.Providers.VLLM.APIBase != "" - - status := func(enabled bool) string { - if enabled { - return "āœ“" - } - return "not set" - } - fmt.Println("OpenRouter API:", status(hasOpenRouter)) - fmt.Println("Anthropic API:", status(hasAnthropic)) - fmt.Println("OpenAI API:", status(hasOpenAI)) - fmt.Println("Gemini API:", status(hasGemini)) - fmt.Println("Zhipu API:", status(hasZhipu)) - fmt.Println("Groq API:", status(hasGroq)) - if hasVLLM { - fmt.Printf("vLLM/Local: āœ“ %s\n", cfg.Providers.VLLM.APIBase) - } else { - fmt.Println("vLLM/Local: not set") - } - - store, _ := auth.LoadStore() - if store != nil && len(store.Credentials) > 0 { - fmt.Println("\nOAuth/Token Auth:") - for provider, cred := range store.Credentials { - status := "authenticated" - if cred.IsExpired() { - status = "expired" - } else if cred.NeedsRefresh() { - status = "needs refresh" - } - fmt.Printf(" %s (%s): %s\n", provider, cred.AuthMethod, status) - } - } - } -} - -func authCmd() { - if len(os.Args) < 3 { - authHelp() - return - } - - switch os.Args[2] { - case "login": - authLoginCmd() - case "logout": - authLogoutCmd() - case "status": - authStatusCmd() - 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() - fmt.Println("Login options:") - fmt.Println(" --provider Provider to login with (openai, anthropic)") - 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 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("Supported providers: openai, anthropic") - return - } - - switch provider { - case "openai": - authLoginOpenAI(useDeviceCode) - case "anthropic": - authLoginPasteToken(provider) - default: - fmt.Printf("Unsupported provider: %s\n", provider) - fmt.Println("Supported providers: openai, anthropic") - } -} - -func authLoginOpenAI(useDeviceCode bool) { - cfg := auth.OpenAIOAuthConfig() - - var cred *auth.AuthCredential - var err error - - if useDeviceCode { - cred, err = auth.LoginDeviceCode(cfg) - } else { - cred, err = auth.LoginBrowser(cfg) - } - - if err != nil { - fmt.Printf("Login failed: %v\n", err) - os.Exit(1) - } - - if err := auth.SetCredential("openai", cred); err != nil { - fmt.Printf("Failed to save credentials: %v\n", err) - os.Exit(1) - } - - appCfg, err := loadConfig() - if err == nil { - appCfg.Providers.OpenAI.AuthMethod = "oauth" - if err := config.SaveConfig(getConfigPath(), appCfg); err != nil { - fmt.Printf("Warning: could not update config: %v\n", err) - } - } - - fmt.Println("Login successful!") - if cred.AccountID != "" { - fmt.Printf("Account: %s\n", cred.AccountID) - } -} - -func authLoginPasteToken(provider string) { - cred, err := auth.LoginPasteToken(provider, os.Stdin) - if err != nil { - fmt.Printf("Login failed: %v\n", err) - os.Exit(1) - } - - if err := auth.SetCredential(provider, cred); err != nil { - fmt.Printf("Failed to save credentials: %v\n", err) - os.Exit(1) - } - - appCfg, err := loadConfig() - if err == nil { - switch provider { - case "anthropic": - appCfg.Providers.Anthropic.AuthMethod = "token" - case "openai": - appCfg.Providers.OpenAI.AuthMethod = "token" - } - if err := config.SaveConfig(getConfigPath(), appCfg); err != nil { - fmt.Printf("Warning: could not update config: %v\n", err) - } - } - - fmt.Printf("Token saved for %s!\n", provider) -} - -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 provider != "" { - if err := auth.DeleteCredential(provider); err != nil { - fmt.Printf("Failed to remove credentials: %v\n", err) - os.Exit(1) - } - - appCfg, err := loadConfig() - if err == nil { - switch provider { - case "openai": - appCfg.Providers.OpenAI.AuthMethod = "" - case "anthropic": - appCfg.Providers.Anthropic.AuthMethod = "" - } - config.SaveConfig(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 { - appCfg.Providers.OpenAI.AuthMethod = "" - appCfg.Providers.Anthropic.AuthMethod = "" - config.SaveConfig(getConfigPath(), appCfg) - } - - fmt.Println("Logged out from all providers") - } -} - -func authStatusCmd() { - store, err := auth.LoadStore() - if err != nil { - fmt.Printf("Error loading auth store: %v\n", err) - return - } - - if len(store.Credentials) == 0 { - fmt.Println("No authenticated providers.") - fmt.Println("Run: picoclaw auth login --provider ") - return - } - - fmt.Println("\nAuthenticated Providers:") - fmt.Println("------------------------") - for provider, cred := range store.Credentials { - status := "active" - if cred.IsExpired() { - status = "expired" - } else if cred.NeedsRefresh() { - status = "needs refresh" - } - - fmt.Printf(" %s:\n", provider) - fmt.Printf(" Method: %s\n", cred.AuthMethod) - fmt.Printf(" Status: %s\n", status) - if cred.AccountID != "" { - fmt.Printf(" Account: %s\n", cred.AccountID) - } - if !cred.ExpiresAt.IsZero() { - fmt.Printf(" Expires: %s\n", cred.ExpiresAt.Format("2006-01-02 15:04")) - } - } -} - -func getConfigPath() string { - home, _ := os.UserHomeDir() - return filepath.Join(home, ".picoclaw", "config.json") -} - -func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration, config *config.Config) *cron.CronService { - cronStorePath := filepath.Join(workspace, "cron", "jobs.json") - - // Create cron service - cronService := cron.NewCronService(cronStorePath, nil) - - // Create and register CronTool - cronTool := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, config) - agentLoop.RegisterTool(cronTool) - - // Set the onJob handler - cronService.SetOnJob(func(job *cron.CronJob) (string, error) { - result := cronTool.ExecuteJob(context.Background(), job) - return result, nil - }) - - return cronService -} - -func loadConfig() (*config.Config, error) { - return config.LoadConfig(getConfigPath()) -} - -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 ") - 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 Remove a job by ID") - fmt.Println(" enable Enable a job") - fmt.Println(" disable 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 ") - 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) - } -} - -func skillsHelp() { - fmt.Println("\nSkills commands:") - fmt.Println(" list List installed skills") - fmt.Println(" install 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 Remove installed skill") - fmt.Println(" search Search available skills") - fmt.Println(" show 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") -} - -func skillsListCmd(loader *skills.SkillsLoader) { - allSkills := loader.ListSkills() - - if len(allSkills) == 0 { - fmt.Println("No skills installed.") - return - } - - fmt.Println("\nInstalled Skills:") - fmt.Println("------------------") - for _, skill := range allSkills { - fmt.Printf(" āœ“ %s (%s)\n", skill.Name, skill.Source) - if skill.Description != "" { - fmt.Printf(" %s\n", skill.Description) - } - } -} - -func skillsInstallCmd(installer *skills.SkillInstaller) { - if len(os.Args) < 4 { - fmt.Println("Usage: picoclaw skills install ") - fmt.Println("Example: picoclaw skills install sipeed/picoclaw-skills/weather") - return - } - - repo := os.Args[3] - 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("āœ— Failed to install skill: %v\n", err) - os.Exit(1) - } - - fmt.Printf("āœ“ Skill '%s' installed successfully!\n", filepath.Base(repo)) -} - -func skillsRemoveCmd(installer *skills.SkillInstaller, skillName string) { - fmt.Printf("Removing skill '%s'...\n", skillName) - - if err := installer.Uninstall(skillName); err != nil { - fmt.Printf("āœ— Failed to remove skill: %v\n", err) - os.Exit(1) - } - - fmt.Printf("āœ“ Skill '%s' removed successfully!\n", skillName) -} - -func skillsInstallBuiltinCmd(workspace string) { - builtinSkillsDir := "./picoclaw/skills" - workspaceSkillsDir := filepath.Join(workspace, "skills") - - fmt.Printf("Copying builtin skills to workspace...\n") - - skillsToInstall := []string{ - "weather", - "news", - "stock", - "calculator", - } - - for _, skillName := range skillsToInstall { - builtinPath := filepath.Join(builtinSkillsDir, skillName) - workspacePath := filepath.Join(workspaceSkillsDir, skillName) - - if _, err := os.Stat(builtinPath); err != nil { - fmt.Printf("⊘ Builtin skill '%s' not found: %v\n", skillName, err) - continue - } - - if err := os.MkdirAll(workspacePath, 0755); err != nil { - fmt.Printf("āœ— Failed to create directory for %s: %v\n", skillName, err) - continue - } - - if err := copyDirectory(builtinPath, workspacePath); err != nil { - fmt.Printf("āœ— Failed to copy %s: %v\n", skillName, err) - } - } - - fmt.Println("\nāœ“ All builtin skills installed!") - fmt.Println("Now you can use them in your workspace.") -} - -func skillsListBuiltinCmd() { - cfg, err := loadConfig() - if err != nil { - fmt.Printf("Error loading config: %v\n", err) - return - } - builtinSkillsDir := filepath.Join(filepath.Dir(cfg.WorkspacePath()), "picoclaw", "skills") - - fmt.Println("\nAvailable Builtin Skills:") - fmt.Println("-----------------------") - - entries, err := os.ReadDir(builtinSkillsDir) - if err != nil { - fmt.Printf("Error reading builtin skills: %v\n", err) - return - } - - if len(entries) == 0 { - fmt.Println("No builtin skills available.") - return - } - - for _, entry := range entries { - if entry.IsDir() { - skillName := entry.Name() - skillFile := filepath.Join(builtinSkillsDir, skillName, "SKILL.md") - - description := "No description" - if _, err := os.Stat(skillFile); err == nil { - data, err := os.ReadFile(skillFile) - if err == nil { - content := string(data) - if idx := strings.Index(content, "\n"); idx > 0 { - firstLine := content[:idx] - if strings.Contains(firstLine, "description:") { - descLine := strings.Index(content[idx:], "\n") - if descLine > 0 { - description = strings.TrimSpace(content[idx+descLine : idx+descLine]) - } - } - } - } - } - status := "āœ“" - fmt.Printf(" %s %s\n", status, entry.Name()) - if description != "" { - fmt.Printf(" %s\n", description) - } - } - } -} - -func skillsSearchCmd(installer *skills.SkillInstaller) { - fmt.Println("Searching for available skills...") - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - availableSkills, err := installer.ListAvailableSkills(ctx) - if err != nil { - fmt.Printf("āœ— Failed to fetch skills list: %v\n", err) - return - } - - if len(availableSkills) == 0 { - fmt.Println("No skills available.") - return - } - - fmt.Printf("\nAvailable Skills (%d):\n", len(availableSkills)) - fmt.Println("--------------------") - for _, skill := range availableSkills { - fmt.Printf(" šŸ“¦ %s\n", skill.Name) - fmt.Printf(" %s\n", skill.Description) - fmt.Printf(" Repo: %s\n", skill.Repository) - if skill.Author != "" { - fmt.Printf(" Author: %s\n", skill.Author) - } - if len(skill.Tags) > 0 { - fmt.Printf(" Tags: %v\n", skill.Tags) - } - fmt.Println() - } -} - -func skillsShowCmd(loader *skills.SkillsLoader, skillName string) { - content, ok := loader.LoadSkill(skillName) - if !ok { - fmt.Printf("āœ— Skill '%s' not found\n", skillName) - return - } - - fmt.Printf("\nšŸ“¦ Skill: %s\n", skillName) - fmt.Println("----------------------") - fmt.Println(content) + rootCmd.Execute() } diff --git a/cmd/picoclaw/migrate.go b/cmd/picoclaw/migrate.go new file mode 100644 index 000000000..19151d821 --- /dev/null +++ b/cmd/picoclaw/migrate.go @@ -0,0 +1,63 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT + +package main + +import ( + "fmt" + "os" + + "github.com/sipeed/picoclaw/pkg/migrate" + "github.com/spf13/cobra" +) + +var migrateCmd = &cobra.Command{ + Use: "migrate", + Short: "Migrate from OpenClaw to PicoClaw", + Long: `Migrate configuration and workspace from OpenClaw to PicoClaw.`, + Run: func(cmd *cobra.Command, args []string) { + migrateImpl() + }, +} + +var ( + migrateDryRun bool + migrateRefresh bool + migrateConfigOnly bool + migrateWorkspaceOnly bool + migrateForce bool + migrateOpenClawHome string + migratePicoClawHome string +) + +func init() { + migrateCmd.Flags().BoolVar(&migrateDryRun, "dry-run", false, "Show what would be migrated without making changes") + migrateCmd.Flags().BoolVar(&migrateRefresh, "refresh", false, "Re-sync workspace files from OpenClaw (repeatable)") + migrateCmd.Flags().BoolVar(&migrateConfigOnly, "config-only", false, "Only migrate config, skip workspace files") + migrateCmd.Flags().BoolVar(&migrateWorkspaceOnly, "workspace-only", false, "Only migrate workspace files, skip config") + migrateCmd.Flags().BoolVar(&migrateForce, "force", false, "Skip confirmation prompts") + migrateCmd.Flags().StringVar(&migrateOpenClawHome, "openclaw-home", "", "Override OpenClaw home directory (default: ~/.openclaw)") + migrateCmd.Flags().StringVar(&migratePicoClawHome, "picoclaw-home", "", "Override PicoClaw home directory (default: ~/.picoclaw)") +} + +func migrateImpl() { + opts := migrate.Options{ + DryRun: migrateDryRun, + Refresh: migrateRefresh, + ConfigOnly: migrateConfigOnly, + WorkspaceOnly: migrateWorkspaceOnly, + Force: migrateForce, + OpenClawHome: migrateOpenClawHome, + PicoClawHome: migratePicoClawHome, + } + + result, err := migrate.Run(opts) + if err != nil { + fmt.Printf("Error: %v\n", err) + os.Exit(1) + } + + if !opts.DryRun { + migrate.PrintSummary(result) + } +} diff --git a/cmd/picoclaw/onboard.go b/cmd/picoclaw/onboard.go new file mode 100644 index 000000000..cae4368f9 --- /dev/null +++ b/cmd/picoclaw/onboard.go @@ -0,0 +1,167 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT + +package main + +import ( + "embed" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/spf13/cobra" +) + +//go:embed workspace +var embeddedFiles embed.FS + +var onboardCmd = &cobra.Command{ + Use: "onboard", + Short: "Initialize picoclaw configuration and workspace", + Long: "Initialize picoclaw configuration and workspace for first-time use.", + Run: func(cmd *cobra.Command, args []string) { + onboard() + }, +} + +func onboard() { + configPath := getConfigPath() + + if _, err := os.Stat(configPath); err == nil { + fmt.Printf("Config already exists at %s\n", configPath) + fmt.Print("Overwrite? (y/n): ") + var response string + fmt.Scanln(&response) + if response != "y" { + fmt.Println("Aborted.") + return + } + } + + cfg := config.DefaultConfig() + if err := config.SaveConfig(configPath, cfg); err != nil { + fmt.Printf("Error saving config: %v\n", err) + os.Exit(1) + } + + workspace := cfg.WorkspacePath() + createWorkspaceTemplates(workspace) + + fmt.Printf("%s picoclaw is ready!\n", logo) + fmt.Println("\nNext steps:") + fmt.Println(" 1. Add your API key to", configPath) + fmt.Println(" Get one at: https://openrouter.ai/keys") + fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"") +} + +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 = fmtCopy(dstFile, srcFile) + return err + }) +} + +func fmtCopy(dst *os.File, src *os.File) (int64, error) { + buf := make([]byte, 32*1024) + var written int64 + for { + n, err := src.Read(buf) + if n > 0 { + wn, err := dst.Write(buf[:n]) + if err != nil { + return written, err + } + written += int64(wn) + } + if err != nil { + if err == io.EOF { + return written, nil + } + return written, err + } + } +} + +func copyEmbeddedToTarget(targetDir string) error { + // Ensure target directory exists + if err := os.MkdirAll(targetDir, 0755); err != nil { + return fmt.Errorf("Failed to create target directory: %w", err) + } + + // Walk through all files in embed.FS + err := fs.WalkDir(embeddedFiles, "workspace", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + // Skip directories + if d.IsDir() { + return nil + } + + // Read embedded file + data, err := embeddedFiles.ReadFile(path) + if err != nil { + return fmt.Errorf("Failed to read embedded file %s: %w", path, err) + } + + newPath, err := filepath.Rel("workspace", path) + if err != nil { + return fmt.Errorf("Failed to get relative path for %s: %v\n", path, err) + } + + // Build target file path + targetPath := filepath.Join(targetDir, newPath) + + // Ensure target file's directory exists + if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { + return fmt.Errorf("Failed to create directory %s: %w", filepath.Dir(targetPath), err) + } + + // Write file + if err := os.WriteFile(targetPath, data, 0644); err != nil { + return fmt.Errorf("Failed to write file %s: %w", targetPath, err) + } + + return nil + }) + + return err +} + +func createWorkspaceTemplates(workspace string) { + err := copyEmbeddedToTarget(workspace) + if err != nil { + fmt.Printf("Error copying workspace templates: %v\n", err) + } +} diff --git a/cmd/picoclaw/root.go b/cmd/picoclaw/root.go new file mode 100644 index 000000000..57120bcb4 --- /dev/null +++ b/cmd/picoclaw/root.go @@ -0,0 +1,59 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT + +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var showVersion bool + +var rootCmd = &cobra.Command{ + Use: "picoclaw", + Short: "Personal AI Assistant", + Long: `PicoClaw - Ultra-lightweight personal AI agent. +A simple and powerful AI assistant that runs on your local machine.`, + Run: func(cmd *cobra.Command, args []string) { + if showVersion { + printVersion() + return + } + printHelp() + os.Exit(1) + }, +} + +func printHelp() { + fmt.Printf("%s picoclaw - Personal AI Assistant v%s\n\n", logo, version) + fmt.Println("Usage: picoclaw ") + 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 init() { + rootCmd.Flags().BoolVarP(&showVersion, "version", "v", false, "Show version information") + + // Add subcommands + rootCmd.AddCommand(onboardCmd) + rootCmd.AddCommand(agentCmd) + rootCmd.AddCommand(gatewayCmd) + rootCmd.AddCommand(statusCmd) + rootCmd.AddCommand(migrateCmd) + rootCmd.AddCommand(authCmd) + rootCmd.AddCommand(cronCmd) + rootCmd.AddCommand(skillsCmd) + rootCmd.AddCommand(versionCmd) +} diff --git a/cmd/picoclaw/skills.go b/cmd/picoclaw/skills.go new file mode 100644 index 000000000..a26dfd622 --- /dev/null +++ b/cmd/picoclaw/skills.go @@ -0,0 +1,67 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT + +package main + +import ( + "fmt" + "path/filepath" + + "github.com/sipeed/picoclaw/cmd/picoclaw/skillspkg" + "github.com/spf13/cobra" +) + +var skillsCmd = &cobra.Command{ + Use: "skills", + Short: "Manage skills", + Long: `Manage skills installation and listing.`, + Run: func(cmd *cobra.Command, args []string) { + skillsHelp() + }, +} + +func init() { + // PreRun to load config and create installer/loader for subcommands + skillsCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { + cfg, err := loadConfig() + if err != nil { + fmt.Printf("Error loading config: %v\n", err) + return + } + workspace := cfg.WorkspacePath() + + // čŽ·å–å…Øå±€é…ē½®ē›®å½•å’Œå†…ē½® skills 目录 + globalDir := filepath.Dir(getConfigPath()) + globalSkillsDir := filepath.Join(globalDir, "skills") + builtinSkillsDir := filepath.Join(globalDir, "picoclaw", "skills") + + skillspkg.SetWorkspace(workspace) + skillspkg.SetGlobalDirs(globalSkillsDir, builtinSkillsDir) + } + + skillsCmd.AddCommand(skillspkg.ListCmd) + skillsCmd.AddCommand(skillspkg.InstallCmd) + skillsCmd.AddCommand(skillspkg.RemoveCmd) + skillsCmd.AddCommand(skillspkg.InstallBuiltinCmd) + skillsCmd.AddCommand(skillspkg.ListBuiltinCmd) + skillsCmd.AddCommand(skillspkg.SearchCmd) + skillsCmd.AddCommand(skillspkg.ShowCmd) +} + +func skillsHelp() { + fmt.Println("\nSkills commands:") + fmt.Println(" list List installed skills") + fmt.Println(" install 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 Remove installed skill") + fmt.Println(" search Search available skills") + fmt.Println(" show 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") +} diff --git a/cmd/picoclaw/skillspkg/install.go b/cmd/picoclaw/skillspkg/install.go new file mode 100644 index 000000000..16b14b244 --- /dev/null +++ b/cmd/picoclaw/skillspkg/install.go @@ -0,0 +1,39 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT + +package skillspkg + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/spf13/cobra" +) + +var InstallCmd = &cobra.Command{ + Use: "install ", + Short: "Install skill from GitHub", + Long: `Install a skill from a GitHub repository.`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + installImpl(args[0]) + }, +} + +func installImpl(repo string) { + fmt.Printf("Installing skill from %s...\n", repo) + + installer := getInstaller() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := installer.InstallFromGitHub(ctx, repo); err != nil { + fmt.Printf("āœ— Failed to install skill: %v\n", err) + os.Exit(1) + } + + fmt.Printf("āœ“ Skill '%s' installed successfully!\n", filepath.Base(repo)) +} diff --git a/cmd/picoclaw/skillspkg/install_builtin.go b/cmd/picoclaw/skillspkg/install_builtin.go new file mode 100644 index 000000000..0850aad50 --- /dev/null +++ b/cmd/picoclaw/skillspkg/install_builtin.go @@ -0,0 +1,113 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT + +package skillspkg + +import ( + "fmt" + "io" + "os" + "path/filepath" + + "github.com/spf13/cobra" +) + +var InstallBuiltinCmd = &cobra.Command{ + Use: "install-builtin", + Short: "Install all builtin skills to workspace", + Long: `Copy all builtin skills from the global skills directory to the workspace.`, + Run: func(cmd *cobra.Command, args []string) { + installBuiltinImpl() + }, +} + +func installBuiltinImpl() { + builtinSkillsDir := getBuiltinSkillsDir() + workspaceSkillsDir := filepath.Join(getWorkspace(), "skills") + + fmt.Printf("Copying builtin skills to workspace...\n") + + skillsToInstall := []string{ + "weather", + "news", + "stock", + "calculator", + } + + for _, skillName := range skillsToInstall { + builtinPath := filepath.Join(builtinSkillsDir, skillName) + workspacePath := filepath.Join(workspaceSkillsDir, skillName) + + if _, err := os.Stat(builtinPath); err != nil { + fmt.Printf("⊘ Builtin skill '%s' not found: %v\n", skillName, err) + continue + } + + if err := os.MkdirAll(workspacePath, 0755); err != nil { + fmt.Printf("āœ— Failed to create directory for %s: %v\n", skillName, err) + continue + } + + if err := copyDirectory(builtinPath, workspacePath); err != nil { + fmt.Printf("āœ— Failed to copy %s: %v\n", skillName, err) + } + } + + fmt.Println("\nāœ“ All builtin skills installed!") + fmt.Println("Now you can use them in your workspace.") +} + +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 = fmtCopy(dstFile, srcFile) + return err + }) +} + +func fmtCopy(dst *os.File, src *os.File) (int64, error) { + buf := make([]byte, 32*1024) + var written int64 + for { + n, err := src.Read(buf) + if n > 0 { + wn, err := dst.Write(buf[:n]) + if err != nil { + return written, err + } + written += int64(wn) + } + if err != nil { + if err == io.EOF { + return written, nil + } + return written, err + } + } +} diff --git a/cmd/picoclaw/skillspkg/list.go b/cmd/picoclaw/skillspkg/list.go new file mode 100644 index 000000000..63ee8cdbc --- /dev/null +++ b/cmd/picoclaw/skillspkg/list.go @@ -0,0 +1,38 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT + +package skillspkg + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var ListCmd = &cobra.Command{ + Use: "list", + Short: "List installed skills", + Long: `Display all installed skills with their descriptions.`, + Run: func(cmd *cobra.Command, args []string) { + listImpl() + }, +} + +func listImpl() { + loader := getLoader() + allSkills := loader.ListSkills() + + if len(allSkills) == 0 { + fmt.Println("No skills installed.") + return + } + + fmt.Println("\nInstalled Skills:") + fmt.Println("------------------") + for _, skill := range allSkills { + fmt.Printf(" āœ“ %s (%s)\n", skill.Name, skill.Source) + if skill.Description != "" { + fmt.Printf(" %s\n", skill.Description) + } + } +} diff --git a/cmd/picoclaw/skillspkg/list_builtin.go b/cmd/picoclaw/skillspkg/list_builtin.go new file mode 100644 index 000000000..d17d951e9 --- /dev/null +++ b/cmd/picoclaw/skillspkg/list_builtin.go @@ -0,0 +1,69 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT + +package skillspkg + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" +) + +var ListBuiltinCmd = &cobra.Command{ + Use: "list-builtin", + Short: "List available builtin skills", + Long: `Display all builtin skills that can be installed to the workspace.`, + Run: func(cmd *cobra.Command, args []string) { + listBuiltinImpl() + }, +} + +func listBuiltinImpl() { + builtinSkillsDir := getBuiltinSkillsDir() + + fmt.Println("\nAvailable Builtin Skills:") + fmt.Println("-----------------------") + + entries, err := os.ReadDir(builtinSkillsDir) + if err != nil { + fmt.Printf("Error reading builtin skills: %v\n", err) + return + } + + if len(entries) == 0 { + fmt.Println("No builtin skills available.") + return + } + + for _, entry := range entries { + if entry.IsDir() { + skillName := entry.Name() + skillFile := filepath.Join(builtinSkillsDir, skillName, "SKILL.md") + + description := "No description" + if _, err := os.Stat(skillFile); err == nil { + data, err := os.ReadFile(skillFile) + if err == nil { + content := string(data) + if idx := strings.Index(content, "\n"); idx > 0 { + firstLine := content[:idx] + if strings.Contains(firstLine, "description:") { + descLine := strings.Index(content[idx:], "\n") + if descLine > 0 { + description = strings.TrimSpace(content[idx+descLine : idx+descLine]) + } + } + } + } + } + status := "āœ“" + fmt.Printf(" %s %s\n", status, entry.Name()) + if description != "" { + fmt.Printf(" %s\n", description) + } + } + } +} diff --git a/cmd/picoclaw/skillspkg/remove.go b/cmd/picoclaw/skillspkg/remove.go new file mode 100644 index 000000000..7ae6b92f6 --- /dev/null +++ b/cmd/picoclaw/skillspkg/remove.go @@ -0,0 +1,33 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT + +package skillspkg + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var RemoveCmd = &cobra.Command{ + Use: "remove ", + Short: "Remove installed skill", + Long: `Remove an installed skill by name.`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + removeImpl(args[0]) + }, +} + +func removeImpl(skillName string) { + fmt.Printf("Removing skill '%s'...\n", skillName) + + installer := getInstaller() + if err := installer.Uninstall(skillName); err != nil { + fmt.Printf("āœ— Failed to remove skill: %v\n", err) + os.Exit(1) + } + + fmt.Printf("āœ“ Skill '%s' removed successfully!\n", skillName) +} diff --git a/cmd/picoclaw/skillspkg/search.go b/cmd/picoclaw/skillspkg/search.go new file mode 100644 index 000000000..7eeb2d1b7 --- /dev/null +++ b/cmd/picoclaw/skillspkg/search.go @@ -0,0 +1,55 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT + +package skillspkg + +import ( + "context" + "fmt" + "time" + + "github.com/spf13/cobra" +) + +var SearchCmd = &cobra.Command{ + Use: "search", + Short: "Search available skills", + Long: `Search and list all available skills from the skills repository.`, + Run: func(cmd *cobra.Command, args []string) { + searchImpl() + }, +} + +func searchImpl() { + fmt.Println("Searching for available skills...") + + installer := getInstaller() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + availableSkills, err := installer.ListAvailableSkills(ctx) + if err != nil { + fmt.Printf("āœ— Failed to fetch skills list: %v\n", err) + return + } + + if len(availableSkills) == 0 { + fmt.Println("No skills available.") + return + } + + fmt.Printf("\nAvailable Skills (%d):\n", len(availableSkills)) + fmt.Println("--------------------") + for _, skill := range availableSkills { + fmt.Printf(" šŸ“¦ %s\n", skill.Name) + fmt.Printf(" %s\n", skill.Description) + fmt.Printf(" Repo: %s\n", skill.Repository) + if skill.Author != "" { + fmt.Printf(" Author: %s\n", skill.Author) + } + if len(skill.Tags) > 0 { + fmt.Printf(" Tags: %v\n", skill.Tags) + } + fmt.Println() + } +} diff --git a/cmd/picoclaw/skillspkg/show.go b/cmd/picoclaw/skillspkg/show.go new file mode 100644 index 000000000..5f75e3acc --- /dev/null +++ b/cmd/picoclaw/skillspkg/show.go @@ -0,0 +1,33 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT + +package skillspkg + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var ShowCmd = &cobra.Command{ + Use: "show ", + Short: "Show skill details", + Long: `Display the full content and details of an installed skill.`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + showImpl(args[0]) + }, +} + +func showImpl(skillName string) { + loader := getLoader() + content, ok := loader.LoadSkill(skillName) + if !ok { + fmt.Printf("āœ— Skill '%s' not found\n", skillName) + return + } + + fmt.Printf("\nšŸ“¦ Skill: %s\n", skillName) + fmt.Println("----------------------") + fmt.Println(content) +} diff --git a/cmd/picoclaw/skillspkg/skills.go b/cmd/picoclaw/skillspkg/skills.go new file mode 100644 index 000000000..e3ef08459 --- /dev/null +++ b/cmd/picoclaw/skillspkg/skills.go @@ -0,0 +1,41 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT + +package skillspkg + +import ( + "path/filepath" + + "github.com/sipeed/picoclaw/pkg/skills" +) + +var ( + workspace string + globalSkillsDir string + builtinSkillsDir string +) + +func SetWorkspace(ws string) { + workspace = ws +} + +func SetGlobalDirs(global, builtin string) { + globalSkillsDir = global + builtinSkillsDir = builtin +} + +func getInstaller() *skills.SkillInstaller { + return skills.NewSkillInstaller(workspace) +} + +func getLoader() *skills.SkillsLoader { + return skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir) +} + +func getWorkspace() string { + return workspace +} + +func getBuiltinSkillsDir() string { + return filepath.Join(workspace, "../picoclaw/skills") +} diff --git a/cmd/picoclaw/status.go b/cmd/picoclaw/status.go new file mode 100644 index 000000000..7cdbfae71 --- /dev/null +++ b/cmd/picoclaw/status.go @@ -0,0 +1,96 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT + +package main + +import ( + "fmt" + "os" + + "github.com/sipeed/picoclaw/pkg/auth" + "github.com/spf13/cobra" +) + +var statusCmd = &cobra.Command{ + Use: "status", + Short: "Show picoclaw status", + Long: "Display current picoclaw configuration and status information.", + Run: func(cmd *cobra.Command, args []string) { + statusCmdImpl() + }, +} + +func statusCmdImpl() { + cfg, err := loadConfig() + if err != nil { + fmt.Printf("Error loading config: %v\n", err) + return + } + + configPath := getConfigPath() + + fmt.Printf("%s picoclaw Status\n", logo) + fmt.Printf("Version: %s\n", formatVersion()) + build, _ := formatBuildInfo() + if build != "" { + fmt.Printf("Build: %s\n", build) + } + fmt.Println() + + if _, err := os.Stat(configPath); err == nil { + fmt.Println("Config:", configPath, "āœ“") + } else { + fmt.Println("Config:", configPath, "āœ—") + } + + workspace := cfg.WorkspacePath() + if _, err := os.Stat(workspace); err == nil { + fmt.Println("Workspace:", workspace, "āœ“") + } else { + fmt.Println("Workspace:", workspace, "āœ—") + } + + if _, err := os.Stat(configPath); err == nil { + fmt.Printf("Model: %s\n", cfg.Agents.Defaults.Model) + + hasOpenRouter := cfg.Providers.OpenRouter.APIKey != "" + hasAnthropic := cfg.Providers.Anthropic.APIKey != "" + hasOpenAI := cfg.Providers.OpenAI.APIKey != "" + hasGemini := cfg.Providers.Gemini.APIKey != "" + hasZhipu := cfg.Providers.Zhipu.APIKey != "" + hasGroq := cfg.Providers.Groq.APIKey != "" + hasVLLM := cfg.Providers.VLLM.APIBase != "" + + status := func(enabled bool) string { + if enabled { + return "āœ“" + } + return "not set" + } + fmt.Println("OpenRouter API:", status(hasOpenRouter)) + fmt.Println("Anthropic API:", status(hasAnthropic)) + fmt.Println("OpenAI API:", status(hasOpenAI)) + fmt.Println("Gemini API:", status(hasGemini)) + fmt.Println("Zhipu API:", status(hasZhipu)) + fmt.Println("Groq API:", status(hasGroq)) + if hasVLLM { + fmt.Printf("vLLM/Local: āœ“ %s\n", cfg.Providers.VLLM.APIBase) + } else { + fmt.Println("vLLM/Local: not set") + } + + store, _ := auth.LoadStore() + if store != nil && len(store.Credentials) > 0 { + fmt.Println("\nOAuth/Token Auth:") + for provider, cred := range store.Credentials { + authStatus := "authenticated" + if cred.IsExpired() { + authStatus = "expired" + } else if cred.NeedsRefresh() { + authStatus = "needs refresh" + } + fmt.Printf(" %s (%s): %s\n", provider, cred.AuthMethod, authStatus) + } + } + } +} diff --git a/cmd/picoclaw/utils.go b/cmd/picoclaw/utils.go new file mode 100644 index 000000000..9fe224e0d --- /dev/null +++ b/cmd/picoclaw/utils.go @@ -0,0 +1,59 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT + +package main + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + + "github.com/sipeed/picoclaw/pkg/config" +) + +var ( + version = "dev" + gitCommit string + buildTime string + goVersion string + logo = "šŸ¦ž" +) + +// 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 +} + +// getConfigPath returns the path to the config file +func getConfigPath() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".picoclaw", "config.json") +} + +// loadConfig loads the configuration from the default path +func loadConfig() (*config.Config, error) { + return config.LoadConfig(getConfigPath()) +} + +// exitOnError prints an error message and exits with status 1 +func exitOnError(format string, args ...interface{}) { + fmt.Printf(format+"\n", args...) + os.Exit(1) +} diff --git a/cmd/picoclaw/version.go b/cmd/picoclaw/version.go new file mode 100644 index 000000000..989f40e39 --- /dev/null +++ b/cmd/picoclaw/version.go @@ -0,0 +1,30 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT + +package main + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Show version information", + Long: "Display PicoClaw version and build information.", + Run: func(cmd *cobra.Command, args []string) { + printVersion() + }, +} + +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) + } +} diff --git a/go.mod b/go.mod index 1f88639c8..17ac5f133 100644 --- a/go.mod +++ b/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.9 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 0e95bf5cd..4a92d79ce 100644 --- a/go.sum +++ b/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,13 @@ 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 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/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 +159,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=