From 446b4eca23e0c82e5106852e533829d82e088e12 Mon Sep 17 00:00:00 2001 From: Kai Xia Date: Fri, 20 Feb 2026 17:15:22 +1100 Subject: [PATCH] refactor: parsing cli arguments use cobra Signed-off-by: Kai Xia --- cmd/picoclaw/cmd_agent.go | 51 ++--- cmd/picoclaw/cmd_auth.go | 446 +++++++++++++++++------------------- cmd/picoclaw/cmd_cron.go | 266 ++++++++++----------- cmd/picoclaw/cmd_gateway.go | 60 +++-- cmd/picoclaw/cmd_migrate.go | 88 +++---- cmd/picoclaw/cmd_onboard.go | 21 +- cmd/picoclaw/cmd_skills.go | 259 +++++++++++++++------ cmd/picoclaw/cmd_status.go | 28 ++- cmd/picoclaw/main.go | 131 +++-------- go.mod | 3 + go.sum | 9 + 11 files changed, 697 insertions(+), 665 deletions(-) diff --git a/cmd/picoclaw/cmd_agent.go b/cmd/picoclaw/cmd_agent.go index cee9f68ec..1ab3bd72a 100644 --- a/cmd/picoclaw/cmd_agent.go +++ b/cmd/picoclaw/cmd_agent.go @@ -17,35 +17,31 @@ import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" + "github.com/spf13/cobra" ) -func agentCmd() { - message := "" - sessionKey := "cli:default" - modelOverride := "" +func newAgentCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "agent", + Short: "Interact with the agent directly", + RunE: runAgent, + } + cmd.Flags().BoolP("debug", "d", false, "Enable debug mode") + cmd.Flags().StringP("message", "m", "", "Send a single message") + cmd.Flags().StringP("session", "s", "cli:default", "Session key") + cmd.Flags().String("model", "", "Override model") + return cmd +} - args := os.Args[2:] - for i := 0; i < len(args); i++ { - switch args[i] { - case "--debug", "-d": - logger.SetLevel(logger.DEBUG) - fmt.Println("šŸ” Debug mode enabled") - case "-m", "--message": - if i+1 < len(args) { - message = args[i+1] - i++ - } - case "-s", "--session": - if i+1 < len(args) { - sessionKey = args[i+1] - i++ - } - case "--model", "-model": - if i+1 < len(args) { - modelOverride = args[i+1] - i++ - } - } +func runAgent(cmd *cobra.Command, args []string) error { + debug, _ := cmd.Flags().GetBool("debug") + message, _ := cmd.Flags().GetString("message") + sessionKey, _ := cmd.Flags().GetString("session") + modelOverride, _ := cmd.Flags().GetString("model") + + if debug { + logger.SetLevel(logger.DEBUG) + fmt.Println("\U0001f50d Debug mode enabled") } cfg, err := loadConfig() @@ -63,7 +59,6 @@ func agentCmd() { fmt.Printf("Error creating provider: %v\n", err) os.Exit(1) } - // Use the resolved model ID from provider creation if modelID != "" { cfg.Agents.Defaults.Model = modelID } @@ -71,7 +66,6 @@ func agentCmd() { 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{}{ @@ -92,6 +86,7 @@ func agentCmd() { fmt.Printf("%s Interactive mode (Ctrl+C to exit)\n\n", logo) interactiveMode(agentLoop, sessionKey) } + return nil } func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) { diff --git a/cmd/picoclaw/cmd_auth.go b/cmd/picoclaw/cmd_auth.go index 5bed7f116..112410066 100644 --- a/cmd/picoclaw/cmd_auth.go +++ b/cmd/picoclaw/cmd_auth.go @@ -15,74 +15,66 @@ import ( "github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/providers" + "github.com/spf13/cobra" ) const supportedProvidersMsg = "Supported providers: openai, anthropic, google-antigravity" -func authCmd() { - if len(os.Args) < 3 { - authHelp() - return +func newAuthCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "auth", + Short: "Manage authentication (login, logout, status)", } + cmd.AddCommand( + newAuthLoginCmd(), + newAuthLogoutCmd(), + newAuthStatusCmd(), + newAuthModelsCmd(), + ) + return cmd +} - switch os.Args[2] { - case "login": - authLoginCmd() - case "logout": - authLogoutCmd() - case "status": - authStatusCmd() - case "models": - authModelsCmd() - default: - fmt.Printf("Unknown auth command: %s\n", os.Args[2]) - authHelp() +func newAuthLoginCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "login", + Short: "Login via OAuth or paste token", + RunE: runAuthLogin, + } + cmd.Flags().StringP("provider", "p", "", "Provider (openai, anthropic, google-antigravity)") + cmd.MarkFlagRequired("provider") + cmd.Flags().Bool("device-code", false, "Use device code flow (for headless environments)") + return cmd +} + +func newAuthLogoutCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "logout", + Short: "Remove stored credentials", + RunE: runAuthLogout, + } + cmd.Flags().StringP("provider", "p", "", "Provider to logout from (omit for all)") + return cmd +} + +func newAuthStatusCmd() *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Show current auth status", + RunE: runAuthStatus, } } -func authHelp() { - fmt.Println("\nAuth commands:") - fmt.Println(" login Login via OAuth or paste token") - fmt.Println(" logout Remove stored credentials") - fmt.Println(" status Show current auth status") - fmt.Println(" models List available Antigravity models") - fmt.Println() - fmt.Println("Login options:") - fmt.Println(" --provider Provider to login with (openai, anthropic, google-antigravity)") - fmt.Println(" --device-code Use device code flow (for headless environments)") - fmt.Println() - fmt.Println("Examples:") - fmt.Println(" picoclaw auth login --provider openai") - fmt.Println(" picoclaw auth login --provider openai --device-code") - fmt.Println(" picoclaw auth login --provider anthropic") - fmt.Println(" picoclaw auth login --provider google-antigravity") - fmt.Println(" picoclaw auth models") - fmt.Println(" picoclaw auth logout --provider openai") - fmt.Println(" picoclaw auth status") +func newAuthModelsCmd() *cobra.Command { + return &cobra.Command{ + Use: "models", + Short: "List available Antigravity models", + RunE: runAuthModels, + } } -func authLoginCmd() { - provider := "" - useDeviceCode := false - - args := os.Args[3:] - for i := 0; i < len(args); i++ { - switch args[i] { - case "--provider", "-p": - if i+1 < len(args) { - provider = args[i+1] - i++ - } - case "--device-code": - useDeviceCode = true - } - } - - if provider == "" { - fmt.Println("Error: --provider is required") - fmt.Println(supportedProvidersMsg) - return - } +func runAuthLogin(cmd *cobra.Command, args []string) error { + provider, _ := cmd.Flags().GetString("provider") + useDeviceCode, _ := cmd.Flags().GetBool("device-code") switch provider { case "openai": @@ -95,6 +87,162 @@ func authLoginCmd() { fmt.Printf("Unsupported provider: %s\n", provider) fmt.Println(supportedProvidersMsg) } + return nil +} + +func runAuthLogout(cmd *cobra.Command, args []string) error { + provider, _ := cmd.Flags().GetString("provider") + + 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 { + for i := range appCfg.ModelList { + switch provider { + case "openai": + if isOpenAIModel(appCfg.ModelList[i].Model) { + appCfg.ModelList[i].AuthMethod = "" + } + case "anthropic": + if isAnthropicModel(appCfg.ModelList[i].Model) { + appCfg.ModelList[i].AuthMethod = "" + } + case "google-antigravity", "antigravity": + if isAntigravityModel(appCfg.ModelList[i].Model) { + appCfg.ModelList[i].AuthMethod = "" + } + } + } + switch provider { + case "openai": + appCfg.Providers.OpenAI.AuthMethod = "" + case "anthropic": + appCfg.Providers.Anthropic.AuthMethod = "" + case "google-antigravity", "antigravity": + appCfg.Providers.Antigravity.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 { + for i := range appCfg.ModelList { + appCfg.ModelList[i].AuthMethod = "" + } + appCfg.Providers.OpenAI.AuthMethod = "" + appCfg.Providers.Anthropic.AuthMethod = "" + appCfg.Providers.Antigravity.AuthMethod = "" + config.SaveConfig(getConfigPath(), appCfg) + } + + fmt.Println("Logged out from all providers") + } + return nil +} + +func runAuthStatus(cmd *cobra.Command, args []string) error { + store, err := auth.LoadStore() + if err != nil { + fmt.Printf("Error loading auth store: %v\n", err) + return nil + } + + if len(store.Credentials) == 0 { + fmt.Println("No authenticated providers.") + fmt.Println("Run: picoclaw auth login --provider ") + return nil + } + + 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.Email != "" { + fmt.Printf(" Email: %s\n", cred.Email) + } + if cred.ProjectID != "" { + fmt.Printf(" Project: %s\n", cred.ProjectID) + } + if !cred.ExpiresAt.IsZero() { + fmt.Printf(" Expires: %s\n", cred.ExpiresAt.Format("2006-01-02 15:04")) + } + } + return nil +} + +func runAuthModels(cmd *cobra.Command, args []string) error { + cred, err := auth.GetCredential("google-antigravity") + if err != nil || cred == nil { + fmt.Println("Not logged in to Google Antigravity.") + fmt.Println("Run: picoclaw auth login --provider google-antigravity") + return nil + } + + if cred.NeedsRefresh() && cred.RefreshToken != "" { + oauthCfg := auth.GoogleAntigravityOAuthConfig() + refreshed, refreshErr := auth.RefreshAccessToken(cred, oauthCfg) + if refreshErr == nil { + cred = refreshed + _ = auth.SetCredential("google-antigravity", cred) + } + } + + projectID := cred.ProjectID + if projectID == "" { + fmt.Println("No project ID stored. Try logging in again.") + return nil + } + + fmt.Printf("Fetching models for project: %s\n\n", projectID) + + models, err := providers.FetchAntigravityModels(cred.AccessToken, projectID) + if err != nil { + fmt.Printf("Error fetching models: %v\n", err) + return nil + } + + if len(models) == 0 { + fmt.Println("No models available.") + return nil + } + + fmt.Println("Available Antigravity Models:") + fmt.Println("-----------------------------") + for _, m := range models { + status := "\u2713" + if m.IsExhausted { + status = "\u2717 (quota exhausted)" + } + name := m.ID + if m.DisplayName != "" { + name = fmt.Sprintf("%s (%s)", m.ID, m.DisplayName) + } + fmt.Printf(" %s %s\n", status, name) + } + return nil } func authLoginOpenAI(useDeviceCode bool) { @@ -121,10 +269,8 @@ func authLoginOpenAI(useDeviceCode bool) { appCfg, err := loadConfig() if err == nil { - // Update Providers (legacy format) appCfg.Providers.OpenAI.AuthMethod = "oauth" - // Update or add openai in ModelList foundOpenAI := false for i := range appCfg.ModelList { if isOpenAIModel(appCfg.ModelList[i].Model) { @@ -134,7 +280,6 @@ func authLoginOpenAI(useDeviceCode bool) { } } - // If no openai in ModelList, add it if !foundOpenAI { appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{ ModelName: "gpt-5.2", @@ -143,7 +288,6 @@ func authLoginOpenAI(useDeviceCode bool) { }) } - // Update default model to use OpenAI appCfg.Agents.Defaults.Model = "gpt-5.2" if err := config.SaveConfig(getConfigPath(), appCfg); err != nil { @@ -169,7 +313,6 @@ func authLoginGoogleAntigravity() { cred.Provider = "google-antigravity" - // Fetch user email from Google userinfo email, err := fetchGoogleUserEmail(cred.AccessToken) if err != nil { fmt.Printf("Warning: could not fetch email: %v\n", err) @@ -178,7 +321,6 @@ func authLoginGoogleAntigravity() { fmt.Printf("Email: %s\n", email) } - // Fetch Cloud Code Assist project ID projectID, err := providers.FetchAntigravityProjectID(cred.AccessToken) if err != nil { fmt.Printf("Warning: could not fetch project ID: %v\n", err) @@ -195,10 +337,8 @@ func authLoginGoogleAntigravity() { appCfg, err := loadConfig() if err == nil { - // Update Providers (legacy format, for backward compatibility) appCfg.Providers.Antigravity.AuthMethod = "oauth" - // Update or add antigravity in ModelList foundAntigravity := false for i := range appCfg.ModelList { if isAntigravityModel(appCfg.ModelList[i].Model) { @@ -208,7 +348,6 @@ func authLoginGoogleAntigravity() { } } - // If no antigravity in ModelList, add it if !foundAntigravity { appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{ ModelName: "gemini-flash", @@ -217,7 +356,6 @@ func authLoginGoogleAntigravity() { }) } - // Update default model appCfg.Agents.Defaults.Model = "gemini-flash" if err := config.SaveConfig(getConfigPath(), appCfg); err != nil { @@ -225,7 +363,7 @@ func authLoginGoogleAntigravity() { } } - fmt.Println("\nāœ“ Google Antigravity login successful!") + fmt.Println("\n\u2713 Google Antigravity login successful!") fmt.Println("Default model set to: gemini-flash") fmt.Println("Try it: picoclaw agent -m \"Hello world\"") } @@ -275,7 +413,6 @@ func authLoginPasteToken(provider string) { switch provider { case "anthropic": appCfg.Providers.Anthropic.AuthMethod = "token" - // Update ModelList found := false for i := range appCfg.ModelList { if isAnthropicModel(appCfg.ModelList[i].Model) { @@ -291,11 +428,9 @@ func authLoginPasteToken(provider string) { AuthMethod: "token", }) } - // Update default model appCfg.Agents.Defaults.Model = "claude-sonnet-4.6" case "openai": appCfg.Providers.OpenAI.AuthMethod = "token" - // Update ModelList found := false for i := range appCfg.ModelList { if isOpenAIModel(appCfg.ModelList[i].Model) { @@ -311,7 +446,6 @@ func authLoginPasteToken(provider string) { AuthMethod: "token", }) } - // Update default model appCfg.Agents.Defaults.Model = "gpt-5.2" } if err := config.SaveConfig(getConfigPath(), appCfg); err != nil { @@ -323,174 +457,6 @@ func authLoginPasteToken(provider string) { fmt.Printf("Default model set to: %s\n", appCfg.Agents.Defaults.Model) } -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 { - // Clear AuthMethod in ModelList - for i := range appCfg.ModelList { - switch provider { - case "openai": - if isOpenAIModel(appCfg.ModelList[i].Model) { - appCfg.ModelList[i].AuthMethod = "" - } - case "anthropic": - if isAnthropicModel(appCfg.ModelList[i].Model) { - appCfg.ModelList[i].AuthMethod = "" - } - case "google-antigravity", "antigravity": - if isAntigravityModel(appCfg.ModelList[i].Model) { - appCfg.ModelList[i].AuthMethod = "" - } - } - } - // Clear AuthMethod in Providers (legacy) - switch provider { - case "openai": - appCfg.Providers.OpenAI.AuthMethod = "" - case "anthropic": - appCfg.Providers.Anthropic.AuthMethod = "" - case "google-antigravity", "antigravity": - appCfg.Providers.Antigravity.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 { - // Clear all AuthMethods in ModelList - for i := range appCfg.ModelList { - appCfg.ModelList[i].AuthMethod = "" - } - // Clear all AuthMethods in Providers (legacy) - appCfg.Providers.OpenAI.AuthMethod = "" - appCfg.Providers.Anthropic.AuthMethod = "" - appCfg.Providers.Antigravity.AuthMethod = "" - config.SaveConfig(getConfigPath(), appCfg) - } - - fmt.Println("Logged out from all providers") - } -} - -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.Email != "" { - fmt.Printf(" Email: %s\n", cred.Email) - } - if cred.ProjectID != "" { - fmt.Printf(" Project: %s\n", cred.ProjectID) - } - if !cred.ExpiresAt.IsZero() { - fmt.Printf(" Expires: %s\n", cred.ExpiresAt.Format("2006-01-02 15:04")) - } - } -} - -func authModelsCmd() { - cred, err := auth.GetCredential("google-antigravity") - if err != nil || cred == nil { - fmt.Println("Not logged in to Google Antigravity.") - fmt.Println("Run: picoclaw auth login --provider google-antigravity") - return - } - - // Refresh token if needed - if cred.NeedsRefresh() && cred.RefreshToken != "" { - oauthCfg := auth.GoogleAntigravityOAuthConfig() - refreshed, refreshErr := auth.RefreshAccessToken(cred, oauthCfg) - if refreshErr == nil { - cred = refreshed - _ = auth.SetCredential("google-antigravity", cred) - } - } - - projectID := cred.ProjectID - if projectID == "" { - fmt.Println("No project ID stored. Try logging in again.") - return - } - - fmt.Printf("Fetching models for project: %s\n\n", projectID) - - models, err := providers.FetchAntigravityModels(cred.AccessToken, projectID) - if err != nil { - fmt.Printf("Error fetching models: %v\n", err) - return - } - - if len(models) == 0 { - fmt.Println("No models available.") - return - } - - fmt.Println("Available Antigravity Models:") - fmt.Println("-----------------------------") - for _, m := range models { - status := "āœ“" - if m.IsExhausted { - status = "āœ— (quota exhausted)" - } - name := m.ID - if m.DisplayName != "" { - name = fmt.Sprintf("%s (%s)", m.ID, m.DisplayName) - } - fmt.Printf(" %s %s\n", status, name) - } -} - // isAntigravityModel checks if a model string belongs to antigravity provider func isAntigravityModel(model string) bool { return model == "antigravity" || diff --git a/cmd/picoclaw/cmd_cron.go b/cmd/picoclaw/cmd_cron.go index 8c42bde06..42525eb0d 100644 --- a/cmd/picoclaw/cmd_cron.go +++ b/cmd/picoclaw/cmd_cron.go @@ -5,76 +5,102 @@ package main import ( "fmt" - "os" "path/filepath" "time" "github.com/sipeed/picoclaw/pkg/cron" + "github.com/spf13/cobra" ) -func cronCmd() { - if len(os.Args) < 3 { - cronHelp() - return +func newCronCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "cron", + Short: "Manage scheduled tasks", } + cmd.AddCommand( + newCronListCmd(), + newCronAddCmd(), + newCronRemoveCmd(), + newCronEnableCmd(), + newCronDisableCmd(), + ) + return cmd +} - subcommand := os.Args[2] +func newCronListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List all scheduled jobs", + RunE: runCronList, + } +} - // Load config to get workspace path +func newCronAddCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "add", + Short: "Add a new scheduled job", + RunE: runCronAdd, + } + cmd.Flags().StringP("name", "n", "", "Job name") + cmd.MarkFlagRequired("name") + cmd.Flags().StringP("message", "m", "", "Message for agent") + cmd.MarkFlagRequired("message") + cmd.Flags().Int64P("every", "e", 0, "Run every N seconds") + cmd.Flags().StringP("cron", "c", "", "Cron expression (e.g. '0 9 * * *')") + cmd.Flags().BoolP("deliver", "d", false, "Deliver response to channel") + cmd.Flags().String("to", "", "Recipient for delivery") + cmd.Flags().String("channel", "", "Channel for delivery") + return cmd +} + +func newCronRemoveCmd() *cobra.Command { + return &cobra.Command{ + Use: "remove ", + Short: "Remove a job by ID", + Args: cobra.ExactArgs(1), + RunE: runCronRemove, + } +} + +func newCronEnableCmd() *cobra.Command { + return &cobra.Command{ + Use: "enable ", + Short: "Enable a job", + Args: cobra.ExactArgs(1), + RunE: runCronEnable, + } +} + +func newCronDisableCmd() *cobra.Command { + return &cobra.Command{ + Use: "disable ", + Short: "Disable a job", + Args: cobra.ExactArgs(1), + RunE: runCronDisable, + } +} + +func getCronStorePath() (string, error) { 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() + return "", fmt.Errorf("Error loading config: %w", err) } + return filepath.Join(cfg.WorkspacePath(), "cron", "jobs.json"), nil } -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 runCronList(cmd *cobra.Command, args []string) error { + storePath, err := getCronStorePath() + if err != nil { + fmt.Println(err) + return nil + } -func cronListCmd(storePath string) { cs := cron.NewCronService(storePath, nil) - jobs := cs.ListJobs(true) // Show all jobs, including disabled + jobs := cs.ListJobs(true) if len(jobs) == 0 { fmt.Println("No scheduled jobs.") - return + return nil } fmt.Println("\nScheduled Jobs:") @@ -105,75 +131,32 @@ func cronListCmd(storePath string) { fmt.Printf(" Status: %s\n", status) fmt.Printf(" Next run: %s\n", nextRun) } + return nil } -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++ - } - } +func runCronAdd(cmd *cobra.Command, args []string) error { + storePath, err := getCronStorePath() + if err != nil { + fmt.Println(err) + return nil } - if name == "" { - fmt.Println("Error: --name is required") - return - } + name, _ := cmd.Flags().GetString("name") + message, _ := cmd.Flags().GetString("message") + everySec, _ := cmd.Flags().GetInt64("every") + cronExpr, _ := cmd.Flags().GetString("cron") + deliver, _ := cmd.Flags().GetBool("deliver") + to, _ := cmd.Flags().GetString("to") + channel, _ := cmd.Flags().GetString("channel") - if message == "" { - fmt.Println("Error: --message is required") - return - } - - if everySec == nil && cronExpr == "" { + if everySec == 0 && cronExpr == "" { fmt.Println("Error: Either --every or --cron must be specified") - return + return nil } var schedule cron.CronSchedule - if everySec != nil { - everyMS := *everySec * 1000 + if everySec != 0 { + everyMS := everySec * 1000 schedule = cron.CronSchedule{ Kind: "every", EveryMS: &everyMS, @@ -189,39 +172,62 @@ func cronAddCmd(storePath string) { job, err := cs.AddJob(name, schedule, message, deliver, channel, to) if err != nil { fmt.Printf("Error adding job: %v\n", err) - return + return nil } - fmt.Printf("āœ“ Added job '%s' (%s)\n", job.Name, job.ID) + fmt.Printf("\u2713 Added job '%s' (%s)\n", job.Name, job.ID) + return nil } -func cronRemoveCmd(storePath, jobID string) { +func runCronRemove(cmd *cobra.Command, args []string) error { + storePath, err := getCronStorePath() + if err != nil { + fmt.Println(err) + return nil + } + + jobID := args[0] cs := cron.NewCronService(storePath, nil) if cs.RemoveJob(jobID) { - fmt.Printf("āœ“ Removed job %s\n", jobID) + fmt.Printf("\u2713 Removed job %s\n", jobID) } else { - fmt.Printf("āœ— Job %s not found\n", jobID) + fmt.Printf("\u2717 Job %s not found\n", jobID) } + return nil } -func cronEnableCmd(storePath string, disable bool) { - if len(os.Args) < 4 { - fmt.Println("Usage: picoclaw cron enable/disable ") - return +func runCronEnable(cmd *cobra.Command, args []string) error { + storePath, err := getCronStorePath() + if err != nil { + fmt.Println(err) + return nil } - jobID := os.Args[3] + jobID := args[0] cs := cron.NewCronService(storePath, nil) - enabled := !disable - - job := cs.EnableJob(jobID, enabled) + job := cs.EnableJob(jobID, true) if job != nil { - status := "enabled" - if disable { - status = "disabled" - } - fmt.Printf("āœ“ Job '%s' %s\n", job.Name, status) + fmt.Printf("\u2713 Job '%s' enabled\n", job.Name) } else { - fmt.Printf("āœ— Job %s not found\n", jobID) + fmt.Printf("\u2717 Job %s not found\n", jobID) } + return nil +} + +func runCronDisable(cmd *cobra.Command, args []string) error { + storePath, err := getCronStorePath() + if err != nil { + fmt.Println(err) + return nil + } + + jobID := args[0] + cs := cron.NewCronService(storePath, nil) + job := cs.EnableJob(jobID, false) + if job != nil { + fmt.Printf("\u2713 Job '%s' disabled\n", job.Name) + } else { + fmt.Printf("\u2717 Job %s not found\n", jobID) + } + return nil } diff --git a/cmd/picoclaw/cmd_gateway.go b/cmd/picoclaw/cmd_gateway.go index 1f1bf5491..83c4c52c1 100644 --- a/cmd/picoclaw/cmd_gateway.go +++ b/cmd/picoclaw/cmd_gateway.go @@ -25,17 +25,24 @@ import ( "github.com/sipeed/picoclaw/pkg/state" "github.com/sipeed/picoclaw/pkg/tools" "github.com/sipeed/picoclaw/pkg/voice" + "github.com/spf13/cobra" ) -func gatewayCmd() { - // Check for --debug flag - args := os.Args[2:] - for _, arg := range args { - if arg == "--debug" || arg == "-d" { - logger.SetLevel(logger.DEBUG) - fmt.Println("šŸ” Debug mode enabled") - break - } +func newGatewayCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "gateway", + Short: "Start picoclaw gateway", + RunE: runGateway, + } + cmd.Flags().BoolP("debug", "d", false, "Enable debug mode") + return cmd +} + +func runGateway(cmd *cobra.Command, args []string) error { + debug, _ := cmd.Flags().GetBool("debug") + if debug { + logger.SetLevel(logger.DEBUG) + fmt.Println("\U0001f50d Debug mode enabled") } cfg, err := loadConfig() @@ -49,7 +56,6 @@ func gatewayCmd() { fmt.Printf("Error creating provider: %v\n", err) os.Exit(1) } - // Use the resolved model ID from provider creation if modelID != "" { cfg.Agents.Defaults.Model = modelID } @@ -57,17 +63,15 @@ func gatewayCmd() { msgBus := bus.NewMessageBus() agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) - // Print agent startup info - fmt.Println("\nšŸ“¦ Agent Status:") + fmt.Println("\n\U0001f4e6 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", + fmt.Printf(" \u2022 Tools: %d loaded\n", toolsInfo["count"]) + fmt.Printf(" \u2022 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"], @@ -75,7 +79,6 @@ func gatewayCmd() { "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) @@ -86,11 +89,9 @@ func gatewayCmd() { ) 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)) @@ -98,8 +99,6 @@ func gatewayCmd() { 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) }) @@ -109,7 +108,6 @@ func gatewayCmd() { os.Exit(1) } - // Inject channel manager into agent loop for command handling agentLoop.SetChannelManager(channelManager) var transcriber *voice.GroqTranscriber @@ -141,12 +139,12 @@ func gatewayCmd() { enabledChannels := channelManager.GetEnabledChannels() if len(enabledChannels) > 0 { - fmt.Printf("āœ“ Channels enabled: %s\n", enabledChannels) + fmt.Printf("\u2713 Channels enabled: %s\n", enabledChannels) } else { - fmt.Println("⚠ Warning: No channels enabled") + fmt.Println("\u26a0 Warning: No channels enabled") } - fmt.Printf("āœ“ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port) + fmt.Printf("\u2713 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()) @@ -155,12 +153,12 @@ func gatewayCmd() { if err := cronService.Start(); err != nil { fmt.Printf("Error starting cron service: %v\n", err) } - fmt.Println("āœ“ Cron service started") + fmt.Println("\u2713 Cron service started") if err := heartbeatService.Start(); err != nil { fmt.Printf("Error starting heartbeat service: %v\n", err) } - fmt.Println("āœ“ Heartbeat service started") + fmt.Println("\u2713 Heartbeat service started") stateManager := state.NewManager(cfg.WorkspacePath()) deviceService := devices.NewService(devices.Config{ @@ -171,7 +169,7 @@ func gatewayCmd() { 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") + fmt.Println("\u2713 Device event service started") } if err := channelManager.StartAll(ctx); err != nil { @@ -184,7 +182,7 @@ func gatewayCmd() { 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) + fmt.Printf("\u2713 Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port) go agentLoop.Run(ctx) @@ -200,20 +198,18 @@ func gatewayCmd() { cronService.Stop() agentLoop.Stop() channelManager.StopAll(ctx) - fmt.Println("āœ“ Gateway stopped") + fmt.Println("\u2713 Gateway stopped") + return nil } func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration, cfg *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, cfg) agentLoop.RegisterTool(cronTool) - // Set the onJob handler cronService.SetOnJob(func(job *cron.CronJob) (string, error) { result := cronTool.ExecuteJob(context.Background(), job) return result, nil diff --git a/cmd/picoclaw/cmd_migrate.go b/cmd/picoclaw/cmd_migrate.go index 86d4903ef..b672460c0 100644 --- a/cmd/picoclaw/cmd_migrate.go +++ b/cmd/picoclaw/cmd_migrate.go @@ -8,45 +8,41 @@ import ( "os" "github.com/sipeed/picoclaw/pkg/migrate" + "github.com/spf13/cobra" ) -func migrateCmd() { - if len(os.Args) > 2 && (os.Args[2] == "--help" || os.Args[2] == "-h") { - migrateHelp() - return - } +func newMigrateCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "migrate", + Short: "Migrate from OpenClaw to PicoClaw", + Long: `Migrate from OpenClaw to PicoClaw +Examples: + picoclaw migrate + picoclaw migrate --dry-run + picoclaw migrate --refresh + picoclaw migrate --force`, + RunE: runMigrate, + } + cmd.Flags().Bool("dry-run", false, "Show what would be migrated without making changes") + cmd.Flags().Bool("config-only", false, "Only migrate config, skip workspace files") + cmd.Flags().Bool("workspace-only", false, "Only migrate workspace files, skip config") + cmd.Flags().Bool("force", false, "Skip confirmation prompts") + cmd.Flags().Bool("refresh", false, "Re-sync workspace files from OpenClaw (repeatable)") + cmd.Flags().String("openclaw-home", "", "Override OpenClaw home directory (default: ~/.openclaw)") + cmd.Flags().String("picoclaw-home", "", "Override PicoClaw home directory (default: ~/.picoclaw)") + return cmd +} + +func runMigrate(cmd *cobra.Command, args []string) error { 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) - } - } + opts.DryRun, _ = cmd.Flags().GetBool("dry-run") + opts.ConfigOnly, _ = cmd.Flags().GetBool("config-only") + opts.WorkspaceOnly, _ = cmd.Flags().GetBool("workspace-only") + opts.Force, _ = cmd.Flags().GetBool("force") + opts.Refresh, _ = cmd.Flags().GetBool("refresh") + opts.OpenClawHome, _ = cmd.Flags().GetString("openclaw-home") + opts.PicoClawHome, _ = cmd.Flags().GetString("picoclaw-home") result, err := migrate.Run(opts) if err != nil { @@ -57,25 +53,5 @@ func migrateCmd() { 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") + return nil } diff --git a/cmd/picoclaw/cmd_onboard.go b/cmd/picoclaw/cmd_onboard.go index 6e61e3267..d8029dbf2 100644 --- a/cmd/picoclaw/cmd_onboard.go +++ b/cmd/picoclaw/cmd_onboard.go @@ -11,13 +11,22 @@ import ( "path/filepath" "github.com/sipeed/picoclaw/pkg/config" + "github.com/spf13/cobra" ) //go:generate cp -r ../../workspace . //go:embed workspace var embeddedFiles embed.FS -func onboard() { +func newOnboardCmd() *cobra.Command { + return &cobra.Command{ + Use: "onboard", + Short: "Initialize picoclaw configuration and workspace", + RunE: runOnboard, + } +} + +func runOnboard(cmd *cobra.Command, args []string) error { configPath := getConfigPath() if _, err := os.Stat(configPath); err == nil { @@ -27,7 +36,7 @@ func onboard() { fmt.Scanln(&response) if response != "y" { fmt.Println("Aborted.") - return + return nil } } @@ -51,26 +60,23 @@ func onboard() { fmt.Println(" See README.md for 17+ supported providers.") fmt.Println("") fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"") + return nil } 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) @@ -81,15 +87,12 @@ func copyEmbeddedToTarget(targetDir string) error { 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) } diff --git a/cmd/picoclaw/cmd_skills.go b/cmd/picoclaw/cmd_skills.go index 32b7c62b8..2266b9d65 100644 --- a/cmd/picoclaw/cmd_skills.go +++ b/cmd/picoclaw/cmd_skills.go @@ -14,33 +14,129 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/skills" "github.com/sipeed/picoclaw/pkg/utils" + "github.com/spf13/cobra" ) -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") - fmt.Println(" picoclaw skills install --registry clawhub github") +type skillsContext struct { + installer *skills.SkillInstaller + loader *skills.SkillsLoader + workspace string + cfg *config.Config } -func skillsListCmd(loader *skills.SkillsLoader) { - allSkills := loader.ListSkills() +func loadSkillsContext() (*skillsContext, error) { + cfg, err := loadConfig() + if err != nil { + return nil, fmt.Errorf("Error loading config: %w", err) + } + + workspace := cfg.WorkspacePath() + installer := skills.NewSkillInstaller(workspace) + globalDir := filepath.Dir(getConfigPath()) + globalSkillsDir := filepath.Join(globalDir, "skills") + builtinSkillsDir := filepath.Join(globalDir, "picoclaw", "skills") + loader := skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir) + + return &skillsContext{ + installer: installer, + loader: loader, + workspace: workspace, + cfg: cfg, + }, nil +} + +func newSkillsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "skills", + Short: "Manage skills (install, list, remove)", + } + cmd.AddCommand( + newSkillsListCmd(), + newSkillsInstallCmd(), + newSkillsRemoveCmd(), + newSkillsInstallBuiltinCmd(), + newSkillsListBuiltinCmd(), + newSkillsSearchCmd(), + newSkillsShowCmd(), + ) + return cmd +} + +func newSkillsListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List installed skills", + RunE: runSkillsList, + } +} + +func newSkillsInstallCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "install ", + Short: "Install skill from GitHub", + Args: cobra.MinimumNArgs(1), + RunE: runSkillsInstall, + } + // Add --registry flag support + cmd.Flags().String("registry", "", "Install from registry (e.g., clawhub)") + return cmd +} + +func newSkillsRemoveCmd() *cobra.Command { + return &cobra.Command{ + Use: "remove ", + Aliases: []string{"uninstall"}, + Short: "Remove installed skill", + Args: cobra.ExactArgs(1), + RunE: runSkillsRemove, + } +} + +func newSkillsInstallBuiltinCmd() *cobra.Command { + return &cobra.Command{ + Use: "install-builtin", + Short: "Install all builtin skills to workspace", + RunE: runSkillsInstallBuiltin, + } +} + +func newSkillsListBuiltinCmd() *cobra.Command { + return &cobra.Command{ + Use: "list-builtin", + Short: "List available builtin skills", + RunE: runSkillsListBuiltin, + } +} + +func newSkillsSearchCmd() *cobra.Command { + return &cobra.Command{ + Use: "search", + Short: "Search available skills", + RunE: runSkillsSearch, + } +} + +func newSkillsShowCmd() *cobra.Command { + return &cobra.Command{ + Use: "show ", + Short: "Show skill details", + Args: cobra.ExactArgs(1), + RunE: runSkillsShow, + } +} + +func runSkillsList(cmd *cobra.Command, args []string) error { + sc, err := loadSkillsContext() + if err != nil { + fmt.Println(err) + return nil + } + + allSkills := sc.loader.ListSkills() if len(allSkills) == 0 { fmt.Println("No skills installed.") - return + return nil } fmt.Println("\nInstalled Skills:") @@ -51,54 +147,55 @@ func skillsListCmd(loader *skills.SkillsLoader) { fmt.Printf(" %s\n", skill.Description) } } + return nil } -func skillsInstallCmd(installer *skills.SkillInstaller, cfg *config.Config) { - if len(os.Args) < 4 { - fmt.Println("Usage: picoclaw skills install ") - fmt.Println(" picoclaw skills install --registry ") - return +func runSkillsInstall(cmd *cobra.Command, args []string) error { + sc, err := loadSkillsContext() + if err != nil { + fmt.Println(err) + return nil } - // Check for --registry flag. - if os.Args[3] == "--registry" { - if len(os.Args) < 6 { + // Check for --registry flag + registry, _ := cmd.Flags().GetString("registry") + if registry != "" { + if len(args) < 2 { fmt.Println("Usage: picoclaw skills install --registry ") fmt.Println("Example: picoclaw skills install --registry clawhub github") - return + return nil } - registryName := os.Args[4] - slug := os.Args[5] - skillsInstallFromRegistry(cfg, registryName, slug) - return + slug := args[1] + return skillsInstallFromRegistry(sc.cfg, registry, slug) } - // Default: install from GitHub (backward compatible). - repo := os.Args[3] + // Default: install from GitHub + repo := args[0] fmt.Printf("Installing skill from %s...\n", repo) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - if err := installer.InstallFromGitHub(ctx, repo); err != nil { - fmt.Printf("\u2717 Failed to install skill: %v\n", err) + if err := sc.installer.InstallFromGitHub(ctx, repo); err != nil { + fmt.Printf("āœ— Failed to install skill: %v\n", err) os.Exit(1) } - fmt.Printf("\u2713 Skill '%s' installed successfully!\n", filepath.Base(repo)) + fmt.Printf("āœ“ Skill '%s' installed successfully!\n", filepath.Base(repo)) + return nil } // skillsInstallFromRegistry installs a skill from a named registry (e.g. clawhub). -func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) { +func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) error { err := utils.ValidateSkillIdentifier(registryName) if err != nil { - fmt.Printf("\u2717 Invalid registry name: %v\n", err) + fmt.Printf("āœ— Invalid registry name: %v\n", err) os.Exit(1) } err = utils.ValidateSkillIdentifier(slug) if err != nil { - fmt.Printf("\u2717 Invalid slug: %v\n", err) + fmt.Printf("āœ— Invalid slug: %v\n", err) os.Exit(1) } @@ -111,7 +208,7 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) { registry := registryMgr.GetRegistry(registryName) if registry == nil { - fmt.Printf("\u2717 Registry '%s' not found or not enabled. Check your config.json.\n", registryName) + fmt.Printf("āœ— Registry '%s' not found or not enabled. Check your config.json.\n", registryName) os.Exit(1) } @@ -119,7 +216,7 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) { targetDir := filepath.Join(workspace, "skills", slug) if _, err := os.Stat(targetDir); err == nil { - fmt.Printf("\u2717 Skill '%s' already installed at %s\n", slug, targetDir) + fmt.Printf("āœ— Skill '%s' already installed at %s\n", slug, targetDir) os.Exit(1) } @@ -127,7 +224,7 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) { defer cancel() if err := os.MkdirAll(filepath.Join(workspace, "skills"), 0755); err != nil { - fmt.Printf("\u2717 Failed to create skills directory: %v\n", err) + fmt.Printf("āœ— Failed to create skills directory: %v\n", err) os.Exit(1) } @@ -135,45 +232,60 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) { if err != nil { rmErr := os.RemoveAll(targetDir) if rmErr != nil { - fmt.Printf("\u2717 Failed to remove partial install: %v\n", rmErr) + fmt.Printf("āœ— Failed to remove partial install: %v\n", rmErr) } - fmt.Printf("\u2717 Failed to install skill: %v\n", err) + fmt.Printf("āœ— Failed to install skill: %v\n", err) os.Exit(1) } if result.IsMalwareBlocked { rmErr := os.RemoveAll(targetDir) if rmErr != nil { - fmt.Printf("\u2717 Failed to remove partial install: %v\n", rmErr) + fmt.Printf("āœ— Failed to remove partial install: %v\n", rmErr) } - fmt.Printf("\u2717 Skill '%s' is flagged as malicious and cannot be installed.\n", slug) + fmt.Printf("āœ— Skill '%s' is flagged as malicious and cannot be installed.\n", slug) os.Exit(1) } if result.IsSuspicious { - fmt.Printf("\u26a0\ufe0f Warning: skill '%s' is flagged as suspicious.\n", slug) + fmt.Printf("āš ļø Warning: skill '%s' is flagged as suspicious.\n", slug) } - fmt.Printf("\u2713 Skill '%s' v%s installed successfully!\n", slug, result.Version) + fmt.Printf("āœ“ Skill '%s' v%s installed successfully!\n", slug, result.Version) if result.Summary != "" { fmt.Printf(" %s\n", result.Summary) } + return nil } -func skillsRemoveCmd(installer *skills.SkillInstaller, skillName string) { +func runSkillsRemove(cmd *cobra.Command, args []string) error { + sc, err := loadSkillsContext() + if err != nil { + fmt.Println(err) + return nil + } + + skillName := args[0] fmt.Printf("Removing skill '%s'...\n", skillName) - if err := installer.Uninstall(skillName); err != nil { + if err := sc.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) + return nil } -func skillsInstallBuiltinCmd(workspace string) { +func runSkillsInstallBuiltin(cmd *cobra.Command, args []string) error { + sc, err := loadSkillsContext() + if err != nil { + fmt.Println(err) + return nil + } + builtinSkillsDir := "./picoclaw/skills" - workspaceSkillsDir := filepath.Join(workspace, "skills") + workspaceSkillsDir := filepath.Join(sc.workspace, "skills") fmt.Printf("Copying builtin skills to workspace...\n") @@ -205,13 +317,14 @@ func skillsInstallBuiltinCmd(workspace string) { fmt.Println("\nāœ“ All builtin skills installed!") fmt.Println("Now you can use them in your workspace.") + return nil } -func skillsListBuiltinCmd() { +func runSkillsListBuiltin(cmd *cobra.Command, args []string) error { cfg, err := loadConfig() if err != nil { fmt.Printf("Error loading config: %v\n", err) - return + return nil } builtinSkillsDir := filepath.Join(filepath.Dir(cfg.WorkspacePath()), "picoclaw", "skills") @@ -221,12 +334,12 @@ func skillsListBuiltinCmd() { entries, err := os.ReadDir(builtinSkillsDir) if err != nil { fmt.Printf("Error reading builtin skills: %v\n", err) - return + return nil } if len(entries) == 0 { fmt.Println("No builtin skills available.") - return + return nil } for _, entry := range entries { @@ -257,23 +370,30 @@ func skillsListBuiltinCmd() { } } } + return nil } -func skillsSearchCmd(installer *skills.SkillInstaller) { +func runSkillsSearch(cmd *cobra.Command, args []string) error { + sc, err := loadSkillsContext() + if err != nil { + fmt.Println(err) + return nil + } + fmt.Println("Searching for available skills...") ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - availableSkills, err := installer.ListAvailableSkills(ctx) + availableSkills, err := sc.installer.ListAvailableSkills(ctx) if err != nil { fmt.Printf("āœ— Failed to fetch skills list: %v\n", err) - return + return nil } if len(availableSkills) == 0 { fmt.Println("No skills available.") - return + return nil } fmt.Printf("\nAvailable Skills (%d):\n", len(availableSkills)) @@ -290,16 +410,25 @@ func skillsSearchCmd(installer *skills.SkillInstaller) { } fmt.Println() } + return nil } -func skillsShowCmd(loader *skills.SkillsLoader, skillName string) { - content, ok := loader.LoadSkill(skillName) +func runSkillsShow(cmd *cobra.Command, args []string) error { + sc, err := loadSkillsContext() + if err != nil { + fmt.Println(err) + return nil + } + + skillName := args[0] + content, ok := sc.loader.LoadSkill(skillName) if !ok { fmt.Printf("āœ— Skill '%s' not found\n", skillName) - return + return nil } fmt.Printf("\nšŸ“¦ Skill: %s\n", skillName) fmt.Println("----------------------") fmt.Println(content) + return nil } diff --git a/cmd/picoclaw/cmd_status.go b/cmd/picoclaw/cmd_status.go index 07296784e..0f5b79d24 100644 --- a/cmd/picoclaw/cmd_status.go +++ b/cmd/picoclaw/cmd_status.go @@ -8,13 +8,22 @@ import ( "os" "github.com/sipeed/picoclaw/pkg/auth" + "github.com/spf13/cobra" ) -func statusCmd() { +func newStatusCmd() *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Show picoclaw status", + RunE: runStatus, + } +} + +func runStatus(cmd *cobra.Command, args []string) error { cfg, err := loadConfig() if err != nil { fmt.Printf("Error loading config: %v\n", err) - return + return nil } configPath := getConfigPath() @@ -28,16 +37,16 @@ func statusCmd() { fmt.Println() if _, err := os.Stat(configPath); err == nil { - fmt.Println("Config:", configPath, "āœ“") + fmt.Println("Config:", configPath, "\u2713") } else { - fmt.Println("Config:", configPath, "āœ—") + fmt.Println("Config:", configPath, "\u2717") } workspace := cfg.WorkspacePath() if _, err := os.Stat(workspace); err == nil { - fmt.Println("Workspace:", workspace, "āœ“") + fmt.Println("Workspace:", workspace, "\u2713") } else { - fmt.Println("Workspace:", workspace, "āœ—") + fmt.Println("Workspace:", workspace, "\u2717") } if _, err := os.Stat(configPath); err == nil { @@ -59,7 +68,7 @@ func statusCmd() { status := func(enabled bool) string { if enabled { - return "āœ“" + return "\u2713" } return "not set" } @@ -75,12 +84,12 @@ func statusCmd() { fmt.Println("VolcEngine API:", status(hasVolcEngine)) fmt.Println("Nvidia API:", status(hasNvidia)) if hasVLLM { - fmt.Printf("vLLM/Local: āœ“ %s\n", cfg.Providers.VLLM.APIBase) + fmt.Printf("vLLM/Local: \u2713 %s\n", cfg.Providers.VLLM.APIBase) } else { fmt.Println("vLLM/Local: not set") } if hasOllama { - fmt.Printf("Ollama: āœ“ %s\n", cfg.Providers.Ollama.APIBase) + fmt.Printf("Ollama: \u2713 %s\n", cfg.Providers.Ollama.APIBase) } else { fmt.Println("Ollama: not set") } @@ -99,4 +108,5 @@ func statusCmd() { } } } + return nil } diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 1e4b393f8..c914ba88c 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -14,14 +14,14 @@ import ( "runtime" "github.com/sipeed/picoclaw/pkg/config" - "github.com/sipeed/picoclaw/pkg/skills" + "github.com/spf13/cobra" ) var ( - version = "dev" - gitCommit string - buildTime string - goVersion string + version = "dev" + gitCommit string + buildTime string + goVersion string ) const logo = "šŸ¦ž" @@ -92,101 +92,40 @@ func copyDirectory(src, dst string) error { }) } -func main() { - if len(os.Args) < 2 { - printHelp() - os.Exit(1) - } +var rootCmd = &cobra.Command{ + Use: "picoclaw", + Short: fmt.Sprintf("%s picoclaw - Personal AI Assistant", logo), +} - 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, cfg) - 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 newVersionCmd() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Show version information", + Run: func(cmd *cobra.Command, args []string) { + printVersion() + }, } } -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.AddCommand( + newOnboardCmd(), + newAgentCmd(), + newGatewayCmd(), + newStatusCmd(), + newMigrateCmd(), + newAuthCmd(), + newCronCmd(), + newSkillsCmd(), + newVersionCmd(), + ) + rootCmd.Version = formatVersion() +} + +func main() { + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } } func getConfigPath() string { 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=