refactor: parsing cli arguments use cobra

Signed-off-by: Kai Xia <kaix+github@fastmail.com>
This commit is contained in:
Kai Xia 2026-02-20 17:15:22 +11:00
parent 1ef33c90ed
commit 446b4eca23
11 changed files with 697 additions and 665 deletions

View file

@ -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":
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("🔍 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++
}
}
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) {

View file

@ -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 <name> Provider to login with (openai, anthropic, google-antigravity)")
fmt.Println(" --device-code Use device code flow (for headless environments)")
fmt.Println()
fmt.Println("Examples:")
fmt.Println(" picoclaw auth login --provider openai")
fmt.Println(" picoclaw auth login --provider openai --device-code")
fmt.Println(" picoclaw auth login --provider anthropic")
fmt.Println(" picoclaw auth login --provider google-antigravity")
fmt.Println(" picoclaw auth models")
fmt.Println(" picoclaw auth logout --provider openai")
fmt.Println(" picoclaw auth status")
}
func authLoginCmd() {
provider := ""
useDeviceCode := false
args := os.Args[3:]
for i := 0; i < len(args); i++ {
switch args[i] {
case "--provider", "-p":
if i+1 < len(args) {
provider = args[i+1]
i++
}
case "--device-code":
useDeviceCode = true
func newAuthModelsCmd() *cobra.Command {
return &cobra.Command{
Use: "models",
Short: "List available Antigravity models",
RunE: runAuthModels,
}
}
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 <name>")
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 <name>")
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" ||

View file

@ -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 <job_id>",
Short: "Remove a job by ID",
Args: cobra.ExactArgs(1),
RunE: runCronRemove,
}
}
func newCronEnableCmd() *cobra.Command {
return &cobra.Command{
Use: "enable <job_id>",
Short: "Enable a job",
Args: cobra.ExactArgs(1),
RunE: runCronEnable,
}
}
func newCronDisableCmd() *cobra.Command {
return &cobra.Command{
Use: "disable <job_id>",
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
return "", fmt.Errorf("Error loading config: %w", err)
}
return filepath.Join(cfg.WorkspacePath(), "cron", "jobs.json"), nil
}
cronStorePath := filepath.Join(cfg.WorkspacePath(), "cron", "jobs.json")
switch subcommand {
case "list":
cronListCmd(cronStorePath)
case "add":
cronAddCmd(cronStorePath)
case "remove":
if len(os.Args) < 4 {
fmt.Println("Usage: picoclaw cron remove <job_id>")
return
}
cronRemoveCmd(cronStorePath, os.Args[3])
case "enable":
cronEnableCmd(cronStorePath, false)
case "disable":
cronEnableCmd(cronStorePath, true)
default:
fmt.Printf("Unknown cron command: %s\n", subcommand)
cronHelp()
}
func runCronList(cmd *cobra.Command, args []string) error {
storePath, err := getCronStorePath()
if err != nil {
fmt.Println(err)
return 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 <id> Remove a job by ID")
fmt.Println(" enable <id> Enable a job")
fmt.Println(" disable <id> Disable a job")
fmt.Println()
fmt.Println("Add options:")
fmt.Println(" -n, --name Job name")
fmt.Println(" -m, --message Message for agent")
fmt.Println(" -e, --every Run every N seconds")
fmt.Println(" -c, --cron Cron expression (e.g. '0 9 * * *')")
fmt.Println(" -d, --deliver Deliver response to channel")
fmt.Println(" --to Recipient for delivery")
fmt.Println(" --channel Channel for delivery")
}
func cronListCmd(storePath string) {
cs := cron.NewCronService(storePath, nil)
jobs := cs.ListJobs(true) // Show all jobs, including disabled
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 <job_id>")
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
}

View file

@ -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

View file

@ -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
}

View file

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

View file

@ -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 <repo> Install skill from GitHub")
fmt.Println(" install-builtin Install all builtin skills to workspace")
fmt.Println(" list-builtin List available builtin skills")
fmt.Println(" remove <name> Remove installed skill")
fmt.Println(" search Search available skills")
fmt.Println(" show <name> Show skill details")
fmt.Println()
fmt.Println("Examples:")
fmt.Println(" picoclaw skills list")
fmt.Println(" picoclaw skills install sipeed/picoclaw-skills/weather")
fmt.Println(" picoclaw skills install-builtin")
fmt.Println(" picoclaw skills list-builtin")
fmt.Println(" picoclaw skills remove weather")
fmt.Println(" picoclaw skills install --registry clawhub github")
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 <repo>",
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 <name>",
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 <name>",
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 <github-repo>")
fmt.Println(" picoclaw skills install --registry <name> <slug>")
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 <name> <slug>")
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 runSkillsInstallBuiltin(cmd *cobra.Command, args []string) error {
sc, err := loadSkillsContext()
if err != nil {
fmt.Println(err)
return nil
}
func skillsInstallBuiltinCmd(workspace string) {
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 runSkillsSearch(cmd *cobra.Command, args []string) error {
sc, err := loadSkillsContext()
if err != nil {
fmt.Println(err)
return nil
}
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)
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
}

View file

@ -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
}

View file

@ -14,7 +14,7 @@ import (
"runtime"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/skills"
"github.com/spf13/cobra"
)
var (
@ -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 <skill-name>")
return
}
skillsRemoveCmd(installer, os.Args[3])
case "install-builtin":
skillsInstallBuiltinCmd(workspace)
case "list-builtin":
skillsListBuiltinCmd()
case "search":
skillsSearchCmd(installer)
case "show":
if len(os.Args) < 4 {
fmt.Println("Usage: picoclaw skills show <skill-name>")
return
}
skillsShowCmd(skillsLoader, os.Args[3])
default:
fmt.Printf("Unknown skills command: %s\n", subcommand)
skillsHelp()
}
case "version", "--version", "-v":
func newVersionCmd() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Show version information",
Run: func(cmd *cobra.Command, args []string) {
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 <command>")
fmt.Println()
fmt.Println("Commands:")
fmt.Println(" onboard Initialize picoclaw configuration and workspace")
fmt.Println(" agent Interact with the agent directly")
fmt.Println(" auth Manage authentication (login, logout, status)")
fmt.Println(" gateway Start picoclaw gateway")
fmt.Println(" status Show picoclaw status")
fmt.Println(" cron Manage scheduled tasks")
fmt.Println(" migrate Migrate from OpenClaw to PicoClaw")
fmt.Println(" skills Manage skills (install, list, remove)")
fmt.Println(" version Show version information")
func 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 {

3
go.mod
View file

@ -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
)

9
go.sum
View file

@ -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=