// PicoClaw - Ultra-lightweight personal AI agent // Inspired by and based on nanobot: https://github.com/HKUDS/nanobot // License: MIT // // Copyright (c) 2026 PicoClaw contributors package main import ( "bufio" "context" "fmt" "io" "io/fs" "os" "os/signal" "path/filepath" "runtime" "strings" "time" "embed" "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/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: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 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 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) *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 } 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) }