diff --git a/cmd/picoclaw/internal/configcmd/agent_add.go b/cmd/picoclaw/internal/configcmd/agent_add.go new file mode 100644 index 000000000..57fd1810a --- /dev/null +++ b/cmd/picoclaw/internal/configcmd/agent_add.go @@ -0,0 +1,83 @@ +package configcmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/pkg/config" +) + +func newAgentAddCommand() *cobra.Command { + var ( + name string + model string + workspace string + defaultAgent bool + ) + + cmd := &cobra.Command{ + Use: "add ", + Short: "Add an agent to agents.list", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + return runAgentAdd(args[0], name, model, workspace, defaultAgent) + }, + } + + cmd.Flags().StringVar(&name, "name", "", "Display name") + cmd.Flags().StringVar(&model, "model", "", "Model name (from model_list)") + cmd.Flags().StringVar(&workspace, "workspace", "", "Workspace path") + cmd.Flags().BoolVar(&defaultAgent, "default", false, "Set as default agent") + + return cmd +} + +func runAgentAdd(id, name, model, workspace string, defaultAgent bool) error { + cfg, err := internal.LoadConfig() + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + configPath := internal.GetConfigPath() + if _, err := os.Stat(configPath); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("config not found; run: picoclaw onboard") + } + return err + } + + // Interactive prompt when TTY and missing fields + if IsTTY() { + if name == "" { + name, _ = Prompt("Name (display): ") + } + if model == "" { + model, _ = Prompt("Model (model_name from model_list): ") + } + if workspace == "" { + workspace, _ = Prompt("Workspace: ") + } + } + + entry := config.AgentConfig{ + ID: id, + Default: defaultAgent, + Name: name, + Workspace: workspace, + } + if model != "" { + entry.Model = &config.AgentModelConfig{Primary: model} + } + + cfg.Agents.List = append(cfg.Agents.List, entry) + + if err := config.SaveConfig(configPath, cfg); err != nil { + return fmt.Errorf("saving config: %w", err) + } + + fmt.Printf("Added agent %q to agents.list.\n", id) + return nil +} diff --git a/cmd/picoclaw/internal/configcmd/agent_defaults.go b/cmd/picoclaw/internal/configcmd/agent_defaults.go new file mode 100644 index 000000000..12ed1c76a --- /dev/null +++ b/cmd/picoclaw/internal/configcmd/agent_defaults.go @@ -0,0 +1,18 @@ +package configcmd + +import ( + "github.com/spf13/cobra" +) + +func newAgentDefaultsCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "defaults", + Short: "Get or set agents.defaults", + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Help() + }, + } + cmd.AddCommand(newAgentDefaultsGetCommand()) + cmd.AddCommand(newAgentDefaultsSetCommand()) + return cmd +} diff --git a/cmd/picoclaw/internal/configcmd/agent_defaults_get.go b/cmd/picoclaw/internal/configcmd/agent_defaults_get.go new file mode 100644 index 000000000..0f04f6b90 --- /dev/null +++ b/cmd/picoclaw/internal/configcmd/agent_defaults_get.go @@ -0,0 +1,104 @@ +package configcmd + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/pkg/config" +) + +// agentDefaultsKeys in display order for get (all). +var agentDefaultsKeys = []string{ + "workspace", "restrict_to_workspace", "provider", "model_name", "model", + "model_fallbacks", "image_model", "image_model_fallbacks", + "max_tokens", "temperature", "max_tool_iterations", +} + +var agentDefaultsKeySet map[string]bool + +func init() { + agentDefaultsKeySet = make(map[string]bool, len(agentDefaultsKeys)) + for _, k := range agentDefaultsKeys { + agentDefaultsKeySet[k] = true + } +} + +func newAgentDefaultsGetCommand() *cobra.Command { + return &cobra.Command{ + Use: "get [key]", + Short: "Get agents.defaults (all fields or one key)", + Args: cobra.MaximumNArgs(1), + RunE: runAgentDefaultsGet, + } +} + +func runAgentDefaultsGet(_ *cobra.Command, args []string) error { + cfg, err := internal.LoadConfig() + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + configPath := internal.GetConfigPath() + if _, err := os.Stat(configPath); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("config not found; run: picoclaw onboard") + } + return err + } + + d := &cfg.Agents.Defaults + + if len(args) == 0 { + for _, key := range agentDefaultsKeys { + value := agentDefaultsGetValue(d, key) + fmt.Printf("%s: %s\n", key, value) + } + return nil + } + + key := args[0] + if !agentDefaultsKeySet[key] { + return fmt.Errorf("invalid key %q; allowed: %s", key, strings.Join(agentDefaultsKeys, ", ")) + } + fmt.Println(agentDefaultsGetValue(d, key)) + return nil +} + +func agentDefaultsGetValue(d *config.AgentDefaults, key string) string { + switch key { + case "workspace": + return d.Workspace + case "restrict_to_workspace": + if d.RestrictToWorkspace { + return "true" + } + return "false" + case "provider": + return d.Provider + case "model_name": + return d.ModelName + case "model": + return d.Model + case "model_fallbacks": + return strings.Join(d.ModelFallbacks, ",") + case "image_model": + return d.ImageModel + case "image_model_fallbacks": + return strings.Join(d.ImageModelFallbacks, ",") + case "max_tokens": + return fmt.Sprintf("%d", d.MaxTokens) + case "temperature": + if d.Temperature == nil { + return "" + } + return fmt.Sprintf("%g", *d.Temperature) + case "max_tool_iterations": + return fmt.Sprintf("%d", d.MaxToolIterations) + default: + return "" + } +} diff --git a/cmd/picoclaw/internal/configcmd/agent_defaults_set.go b/cmd/picoclaw/internal/configcmd/agent_defaults_set.go new file mode 100644 index 000000000..3b787bfb6 --- /dev/null +++ b/cmd/picoclaw/internal/configcmd/agent_defaults_set.go @@ -0,0 +1,120 @@ +package configcmd + +import ( + "fmt" + "os" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/pkg/config" +) + +func newAgentDefaultsSetCommand() *cobra.Command { + return &cobra.Command{ + Use: "set ", + Short: "Set a single field in agents.defaults", + Args: cobra.ExactArgs(2), + RunE: runAgentDefaultsSet, + } +} + +func runAgentDefaultsSet(_ *cobra.Command, args []string) error { + cfg, err := internal.LoadConfig() + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + configPath := internal.GetConfigPath() + if _, err := os.Stat(configPath); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("config not found; run: picoclaw onboard") + } + return err + } + + key, value := args[0], args[1] + if !agentDefaultsKeySet[key] { + return fmt.Errorf("invalid key %q; allowed: %s", key, strings.Join(agentDefaultsKeys, ", ")) + } + + d := &cfg.Agents.Defaults + + switch key { + case "workspace": + d.Workspace = value + case "restrict_to_workspace": + b, err := parseBool(value) + if err != nil { + return fmt.Errorf("restrict_to_workspace: %w", err) + } + d.RestrictToWorkspace = b + case "provider": + d.Provider = value + case "model_name": + d.ModelName = value + case "model": + d.Model = value + case "model_fallbacks": + if value == "" { + d.ModelFallbacks = nil + } else { + d.ModelFallbacks = strings.Split(value, ",") + for i := range d.ModelFallbacks { + d.ModelFallbacks[i] = strings.TrimSpace(d.ModelFallbacks[i]) + } + } + case "image_model": + d.ImageModel = value + case "image_model_fallbacks": + if value == "" { + d.ImageModelFallbacks = nil + } else { + d.ImageModelFallbacks = strings.Split(value, ",") + for i := range d.ImageModelFallbacks { + d.ImageModelFallbacks[i] = strings.TrimSpace(d.ImageModelFallbacks[i]) + } + } + case "max_tokens": + n, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("max_tokens: %w", err) + } + d.MaxTokens = n + case "temperature": + if value == "" { + d.Temperature = nil + } else { + f, err := strconv.ParseFloat(value, 64) + if err != nil { + return fmt.Errorf("temperature: %w", err) + } + d.Temperature = &f + } + case "max_tool_iterations": + n, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("max_tool_iterations: %w", err) + } + d.MaxToolIterations = n + } + + if err := config.SaveConfig(configPath, cfg); err != nil { + return fmt.Errorf("saving config: %w", err) + } + fmt.Printf("Set agents.defaults %s.\n", key) + return nil +} + +func parseBool(s string) (bool, error) { + switch strings.ToLower(s) { + case "true", "1", "yes": + return true, nil + case "false", "0", "no": + return false, nil + default: + return false, fmt.Errorf("expected true/false, got %q", s) + } +} diff --git a/cmd/picoclaw/internal/configcmd/agent_list.go b/cmd/picoclaw/internal/configcmd/agent_list.go new file mode 100644 index 000000000..0ad817ef8 --- /dev/null +++ b/cmd/picoclaw/internal/configcmd/agent_list.go @@ -0,0 +1,69 @@ +package configcmd + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal" +) + +func newAgentListCommand() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List all agents in agents.list", + Args: cobra.NoArgs, + RunE: runAgentList, + } +} + +func runAgentList(_ *cobra.Command, _ []string) error { + cfg, err := internal.LoadConfig() + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + configPath := internal.GetConfigPath() + if _, err := os.Stat(configPath); err != nil { + if os.IsNotExist(err) { + fmt.Println("No config file found. Run: picoclaw onboard") + return nil + } + return err + } + + if len(cfg.Agents.List) == 0 { + fmt.Println("agents.list is empty.") + return nil + } + + fmt.Printf("%-20s %-25s %-20s %s\n", "ID", "NAME", "MODEL", "WORKSPACE") + fmt.Println(strings.Repeat("-", 85)) + + for _, a := range cfg.Agents.List { + model := "" + if a.Model != nil && a.Model.Primary != "" { + model = a.Model.Primary + } + if len(model) > 18 { + model = model[:15] + "..." + } + name := a.Name + if len(name) > 23 { + name = name[:20] + "..." + } + id := a.ID + if len(id) > 18 { + id = id[:15] + "..." + } + ws := a.Workspace + if len(ws) > 35 { + ws = ws[:32] + "..." + } + fmt.Printf("%-20s %-25s %-20s %s\n", id, name, model, ws) + } + + return nil +} diff --git a/cmd/picoclaw/internal/configcmd/agent_remove.go b/cmd/picoclaw/internal/configcmd/agent_remove.go new file mode 100644 index 000000000..30ddce4d6 --- /dev/null +++ b/cmd/picoclaw/internal/configcmd/agent_remove.go @@ -0,0 +1,56 @@ +package configcmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/pkg/config" +) + +func newAgentRemoveCommand() *cobra.Command { + return &cobra.Command{ + Use: "remove ", + Short: "Remove an agent from agents.list by id", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + return runAgentRemove(args[0]) + }, + } +} + +func runAgentRemove(id string) error { + cfg, err := internal.LoadConfig() + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + configPath := internal.GetConfigPath() + if _, err := os.Stat(configPath); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("config not found; run: picoclaw onboard") + } + return err + } + + var kept []config.AgentConfig + for _, a := range cfg.Agents.List { + if a.ID != id { + kept = append(kept, a) + } + } + + if len(kept) == len(cfg.Agents.List) { + return fmt.Errorf("no agent with id %q", id) + } + + cfg.Agents.List = kept + if err := config.SaveConfig(configPath, cfg); err != nil { + return fmt.Errorf("saving config: %w", err) + } + + fmt.Printf("Removed agent %q from agents.list.\n", id) + return nil +} diff --git a/cmd/picoclaw/internal/configcmd/agent_update.go b/cmd/picoclaw/internal/configcmd/agent_update.go new file mode 100644 index 000000000..d85056e35 --- /dev/null +++ b/cmd/picoclaw/internal/configcmd/agent_update.go @@ -0,0 +1,87 @@ +package configcmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/pkg/config" +) + +func newAgentUpdateCommand() *cobra.Command { + var ( + name string + model string + workspace string + defaultAgent bool + ) + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update the first matching agent in agents.list", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + return runAgentUpdate(args[0], name, model, workspace, defaultAgent) + }, + } + + cmd.Flags().StringVar(&name, "name", "", "Display name") + cmd.Flags().StringVar(&model, "model", "", "Model name (from model_list)") + cmd.Flags().StringVar(&workspace, "workspace", "", "Workspace path") + cmd.Flags().BoolVar(&defaultAgent, "default", false, "Set as default agent") + + return cmd +} + +func runAgentUpdate(id, name, model, workspace string, defaultAgent bool) error { + cfg, err := internal.LoadConfig() + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + configPath := internal.GetConfigPath() + if _, err := os.Stat(configPath); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("config not found; run: picoclaw onboard") + } + return err + } + + idx := -1 + for i := range cfg.Agents.List { + if cfg.Agents.List[i].ID == id { + idx = i + break + } + } + if idx < 0 { + return fmt.Errorf("no agent with id %q", id) + } + + entry := &cfg.Agents.List[idx] + + if name != "" { + entry.Name = name + } + if model != "" { + if entry.Model == nil { + entry.Model = &config.AgentModelConfig{} + } + entry.Model.Primary = model + } + if workspace != "" { + entry.Workspace = workspace + } + if defaultAgent { + entry.Default = true + } + + if err := config.SaveConfig(configPath, cfg); err != nil { + return fmt.Errorf("saving config: %w", err) + } + + fmt.Printf("Updated agent %q in agents.list.\n", id) + return nil +} diff --git a/cmd/picoclaw/internal/configcmd/command.go b/cmd/picoclaw/internal/configcmd/command.go new file mode 100644 index 000000000..1d8ab9e93 --- /dev/null +++ b/cmd/picoclaw/internal/configcmd/command.go @@ -0,0 +1,56 @@ +package configcmd + +import ( + "github.com/spf13/cobra" +) + +func NewConfigCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: "Manage configuration (model_list, agents)", + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Help() + }, + } + + cmd.AddCommand(newModelListCommand()) + cmd.AddCommand(newAgentCommand()) + return cmd +} + +func newModelListCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "model_list", + Short: "Manage model_list (list, get, set, add, remove, update)", + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Help() + }, + } + + cmd.AddCommand( + newModelListListCommand(), + newModelListGetCommand(), + newModelListSetCommand(), + newModelListAddCommand(), + newModelListRemoveCommand(), + newModelListUpdateCommand(), + ) + return cmd +} + +func newAgentCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "agent", + Short: "Manage agents (defaults, list, add, remove, update)", + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Help() + }, + } + + cmd.AddCommand(newAgentDefaultsCommand()) + cmd.AddCommand(newAgentListCommand()) + cmd.AddCommand(newAgentAddCommand()) + cmd.AddCommand(newAgentRemoveCommand()) + cmd.AddCommand(newAgentUpdateCommand()) + return cmd +} diff --git a/cmd/picoclaw/internal/configcmd/model_list_add.go b/cmd/picoclaw/internal/configcmd/model_list_add.go new file mode 100644 index 000000000..33627eca2 --- /dev/null +++ b/cmd/picoclaw/internal/configcmd/model_list_add.go @@ -0,0 +1,129 @@ +package configcmd + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/pkg/config" +) + +func newModelListAddCommand() *cobra.Command { + var ( + modelName string + model string + apiBase string + apiKey string + proxy string + authMethod string + maxTokensFld string + tokenURL string + clientID string + clientSecret string + ) + + cmd := &cobra.Command{ + Use: "add [model_name]", + Short: "Add a model to model_list", + Args: cobra.MaximumNArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + if len(args) > 0 && modelName == "" { + modelName = args[0] + } + return runModelListAdd(modelName, model, apiBase, apiKey, proxy, authMethod, maxTokensFld, tokenURL, clientID, clientSecret) + }, + } + + cmd.Flags().StringVar(&modelName, "model-name", "", "User-facing model name (e.g. qwen-turbo)") + cmd.Flags().StringVar(&model, "model", "", "Protocol/model (e.g. litellm/qwen-turbo, openai/gpt-4o)") + cmd.Flags().StringVar(&apiBase, "api-base", "", "API base URL") + cmd.Flags().StringVar(&apiKey, "api-key", "", "API key") + cmd.Flags().StringVar(&proxy, "proxy", "", "HTTP proxy URL") + cmd.Flags().StringVar(&authMethod, "auth-method", "", "Auth method: oauth, token") + cmd.Flags().StringVar(&maxTokensFld, "max-tokens-field", "", "Field name for max tokens") + cmd.Flags().StringVar(&tokenURL, "token-url", "", "Keycloak token URL (for litellm)") + cmd.Flags().StringVar(&clientID, "client-id", "", "Client ID (for litellm)") + cmd.Flags().StringVar(&clientSecret, "client-secret", "", "Client secret (for litellm)") + + return cmd +} + +func runModelListAdd(modelName, model, apiBase, apiKey, proxy, authMethod, maxTokensFld, tokenURL, clientID, clientSecret string) error { + cfg, err := internal.LoadConfig() + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + configPath := internal.GetConfigPath() + if _, err := os.Stat(configPath); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("config not found; run: picoclaw onboard") + } + return err + } + + isLiteLLM := strings.HasPrefix(strings.ToLower(model), "litellm/") + + // Interactive prompt for missing required fields when TTY + if IsTTY() { + if modelName == "" { + modelName, _ = Prompt("Model name (e.g. qwen-turbo): ") + } + if model == "" { + model, _ = Prompt("Model (e.g. litellm/qwen-turbo or openai/gpt-4o): ") + model = strings.TrimSpace(model) + isLiteLLM = strings.HasPrefix(strings.ToLower(model), "litellm/") + } + if apiBase == "" { + apiBase, _ = Prompt("API base URL: ") + } + if isLiteLLM { + if tokenURL == "" { + tokenURL, _ = Prompt("Token URL (Keycloak): ") + } + if clientID == "" { + clientID, _ = Prompt("Client ID: ") + } + if clientSecret == "" { + clientSecret, _ = Prompt("Client secret: ") + } + } + } + + // Validate required + if modelName == "" { + return fmt.Errorf("model_name is required") + } + if model == "" { + return fmt.Errorf("model is required (e.g. litellm/qwen-turbo)") + } + if isLiteLLM { + if apiBase == "" || tokenURL == "" || clientID == "" || clientSecret == "" { + return fmt.Errorf("litellm requires api_base, token_url, client_id, client_secret") + } + } + + entry := config.ModelConfig{ + ModelName: modelName, + Model: model, + APIBase: apiBase, + APIKey: apiKey, + Proxy: proxy, + AuthMethod: authMethod, + MaxTokensField: maxTokensFld, + TokenURL: tokenURL, + ClientID: clientID, + ClientSecret: clientSecret, + } + + cfg.ModelList = append(cfg.ModelList, entry) + if err := config.SaveConfig(configPath, cfg); err != nil { + return fmt.Errorf("saving config: %w", err) + } + + fmt.Printf("Added model %q to model_list.\n", modelName) + return nil +} diff --git a/cmd/picoclaw/internal/configcmd/model_list_get.go b/cmd/picoclaw/internal/configcmd/model_list_get.go new file mode 100644 index 000000000..d1dd4d65e --- /dev/null +++ b/cmd/picoclaw/internal/configcmd/model_list_get.go @@ -0,0 +1,99 @@ +package configcmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/pkg/config" +) + +func newModelListGetCommand() *cobra.Command { + return &cobra.Command{ + Use: "get [key]", + Short: "Get one model's config or a single field", + Args: cobra.MatchAll(cobra.MinimumNArgs(1), cobra.MaximumNArgs(2)), + RunE: runModelListGet, + } +} + +func runModelListGet(_ *cobra.Command, args []string) error { + cfg, err := internal.LoadConfig() + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + configPath := internal.GetConfigPath() + if _, err := os.Stat(configPath); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("config not found; run: picoclaw onboard") + } + return err + } + + modelName := args[0] + idx, err := findModelIndex(cfg, modelName) + if err != nil { + return err + } + entry := &cfg.ModelList[idx] + + if len(args) == 1 { + // No key: print all common fields (mask secrets) + for _, key := range modelConfigKeys { + value, mask := modelConfigGetValue(entry, key) + if mask && value != "" { + value = "***" + } + fmt.Printf("%s: %s\n", key, value) + } + return nil + } + + key := args[1] + if !isModelConfigKey(key) { + return fmt.Errorf("invalid key %q; allowed: %s", key, allowedModelConfigKeysString()) + } + value, _ := modelConfigGetValue(entry, key) + fmt.Println(value) + return nil +} + +// modelConfigGetValue returns the string value for key and whether it should be masked in "get all" output. +func modelConfigGetValue(m *config.ModelConfig, key string) (string, bool) { + switch key { + case "model_name": + return m.ModelName, false + case "model": + return m.Model, false + case "api_base": + return m.APIBase, false + case "api_key": + return m.APIKey, true + case "proxy": + return m.Proxy, false + case "auth_method": + return m.AuthMethod, false + case "connect_mode": + return m.ConnectMode, false + case "workspace": + return m.Workspace, false + case "token_url": + return m.TokenURL, false + case "client_id": + return m.ClientID, false + case "client_secret": + return m.ClientSecret, true + case "max_tokens_field": + return m.MaxTokensField, false + case "rpm": + if m.RPM == 0 { + return "0", false + } + return fmt.Sprintf("%d", m.RPM), false + default: + return "", false + } +} diff --git a/cmd/picoclaw/internal/configcmd/model_list_keys.go b/cmd/picoclaw/internal/configcmd/model_list_keys.go new file mode 100644 index 000000000..712891804 --- /dev/null +++ b/cmd/picoclaw/internal/configcmd/model_list_keys.go @@ -0,0 +1,50 @@ +package configcmd + +import ( + "fmt" + "strings" + + "github.com/sipeed/picoclaw/pkg/config" +) + +// modelConfigKeys defines allowed keys for get/set, in display order. +var modelConfigKeys = []string{ + "model_name", "model", "api_base", "api_key", "proxy", + "auth_method", "connect_mode", "workspace", + "token_url", "client_id", "client_secret", + "max_tokens_field", "rpm", +} + +// modelConfigKeySet is the set of allowed keys. +var modelConfigKeySet map[string]bool + +func init() { + modelConfigKeySet = make(map[string]bool, len(modelConfigKeys)) + for _, k := range modelConfigKeys { + modelConfigKeySet[k] = true + } +} + +func isModelConfigKey(key string) bool { + return modelConfigKeySet[key] +} + +// isIntModelConfigKey returns true for keys that must be set as int (e.g. rpm). +func isIntModelConfigKey(key string) bool { + return key == "rpm" +} + +// findModelIndex returns the index of the first ModelConfig with ModelName == name. +// It returns -1 and an error if not found. +func findModelIndex(cfg *config.Config, name string) (int, error) { + for i := range cfg.ModelList { + if cfg.ModelList[i].ModelName == name { + return i, nil + } + } + return -1, fmt.Errorf("no model with model_name %q", name) +} + +func allowedModelConfigKeysString() string { + return strings.Join(modelConfigKeys, ", ") +} diff --git a/cmd/picoclaw/internal/configcmd/model_list_list.go b/cmd/picoclaw/internal/configcmd/model_list_list.go new file mode 100644 index 000000000..faf384b55 --- /dev/null +++ b/cmd/picoclaw/internal/configcmd/model_list_list.go @@ -0,0 +1,78 @@ +package configcmd + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/pkg/config" +) + +func newModelListListCommand() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List all models in model_list", + Args: cobra.NoArgs, + RunE: runModelListList, + } +} + +func runModelListList(_ *cobra.Command, _ []string) error { + cfg, err := internal.LoadConfig() + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + configPath := internal.GetConfigPath() + if _, err := os.Stat(configPath); err != nil { + if os.IsNotExist(err) { + fmt.Println("No config file found. Run: picoclaw onboard") + return nil + } + return err + } + + if len(cfg.ModelList) == 0 { + fmt.Println("model_list is empty.") + return nil + } + + // Table header + fmt.Printf("%-20s %-35s %-40s %s\n", "MODEL_NAME", "MODEL", "API_BASE", "AUTH") + fmt.Println(strings.Repeat("-", 100)) + + for _, m := range cfg.ModelList { + auth := authSummary(m) + apiBase := m.APIBase + if len(apiBase) > 38 { + apiBase = apiBase[:35] + "..." + } + model := m.Model + if len(model) > 33 { + model = model[:30] + "..." + } + modelName := m.ModelName + if len(modelName) > 18 { + modelName = modelName[:15] + "..." + } + fmt.Printf("%-20s %-35s %-40s %s\n", modelName, model, apiBase, auth) + } + + return nil +} + +func authSummary(m config.ModelConfig) string { + if m.AuthMethod != "" { + return m.AuthMethod + } + if m.TokenURL != "" { + return "litellm (keycloak)" + } + if m.APIKey != "" { + return "api_key" + } + return "-" +} diff --git a/cmd/picoclaw/internal/configcmd/model_list_remove.go b/cmd/picoclaw/internal/configcmd/model_list_remove.go new file mode 100644 index 000000000..b2ba397f6 --- /dev/null +++ b/cmd/picoclaw/internal/configcmd/model_list_remove.go @@ -0,0 +1,72 @@ +package configcmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/pkg/config" +) + +func newModelListRemoveCommand() *cobra.Command { + var first bool + + cmd := &cobra.Command{ + Use: "remove ", + Short: "Remove model(s) from model_list by model_name", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + return runModelListRemove(args[0], first) + }, + } + + cmd.Flags().BoolVar(&first, "first", false, "Remove only the first matching entry (default: remove all)") + + return cmd +} + +func runModelListRemove(modelName string, firstOnly bool) error { + cfg, err := internal.LoadConfig() + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + configPath := internal.GetConfigPath() + if _, err := os.Stat(configPath); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("config not found; run: picoclaw onboard") + } + return err + } + + var kept []config.ModelConfig + removedFirst := false + for _, m := range cfg.ModelList { + if m.ModelName == modelName { + if firstOnly { + if !removedFirst { + removedFirst = true + continue + } + } else { + continue + } + } + kept = append(kept, m) + } + + removed := len(cfg.ModelList) - len(kept) + if removed == 0 { + return fmt.Errorf("no model with model_name %q", modelName) + } + + cfg.ModelList = kept + if err := config.SaveConfig(configPath, cfg); err != nil { + return fmt.Errorf("saving config: %w", err) + } + + fmt.Printf("Removed %d model(s) %q from model_list.\n", removed, modelName) + return nil +} diff --git a/cmd/picoclaw/internal/configcmd/model_list_set.go b/cmd/picoclaw/internal/configcmd/model_list_set.go new file mode 100644 index 000000000..c59527f6f --- /dev/null +++ b/cmd/picoclaw/internal/configcmd/model_list_set.go @@ -0,0 +1,100 @@ +package configcmd + +import ( + "fmt" + "os" + "strconv" + + "github.com/spf13/cobra" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/pkg/config" +) + +func newModelListSetCommand() *cobra.Command { + return &cobra.Command{ + Use: "set ", + Short: "Set a single field for a model in model_list", + Args: cobra.ExactArgs(3), + RunE: runModelListSet, + } +} + +func runModelListSet(_ *cobra.Command, args []string) error { + cfg, err := internal.LoadConfig() + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + configPath := internal.GetConfigPath() + if _, err := os.Stat(configPath); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("config not found; run: picoclaw onboard") + } + return err + } + + modelName := args[0] + key := args[1] + value := args[2] + + if !isModelConfigKey(key) { + return fmt.Errorf("invalid key %q; allowed: %s", key, allowedModelConfigKeysString()) + } + + idx, err := findModelIndex(cfg, modelName) + if err != nil { + return err + } + entry := &cfg.ModelList[idx] + + if isIntModelConfigKey(key) { + n, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("key %q requires an integer: %w", key, err) + } + entry.RPM = n + } else { + modelConfigSetString(entry, key, value) + } + + if err := entry.Validate(); err != nil { + return err + } + + if err := config.SaveConfig(configPath, cfg); err != nil { + return fmt.Errorf("saving config: %w", err) + } + + fmt.Printf("Set %s for model %q.\n", key, entry.ModelName) + return nil +} + +func modelConfigSetString(m *config.ModelConfig, key, value string) { + switch key { + case "model_name": + m.ModelName = value + case "model": + m.Model = value + case "api_base": + m.APIBase = value + case "api_key": + m.APIKey = value + case "proxy": + m.Proxy = value + case "auth_method": + m.AuthMethod = value + case "connect_mode": + m.ConnectMode = value + case "workspace": + m.Workspace = value + case "token_url": + m.TokenURL = value + case "client_id": + m.ClientID = value + case "client_secret": + m.ClientSecret = value + case "max_tokens_field": + m.MaxTokensField = value + } +} diff --git a/cmd/picoclaw/internal/configcmd/model_list_update.go b/cmd/picoclaw/internal/configcmd/model_list_update.go new file mode 100644 index 000000000..333c0d9b8 --- /dev/null +++ b/cmd/picoclaw/internal/configcmd/model_list_update.go @@ -0,0 +1,138 @@ +package configcmd + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/pkg/config" +) + +func newModelListUpdateCommand() *cobra.Command { + var ( + model string + apiBase string + apiKey string + proxy string + authMethod string + maxTokensFld string + tokenURL string + clientID string + clientSecret string + ) + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update the first matching model in model_list", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + return runModelListUpdate(args[0], model, apiBase, apiKey, proxy, authMethod, maxTokensFld, tokenURL, clientID, clientSecret) + }, + } + + cmd.Flags().StringVar(&model, "model", "", "Protocol/model (e.g. litellm/qwen-turbo)") + cmd.Flags().StringVar(&apiBase, "api-base", "", "API base URL") + cmd.Flags().StringVar(&apiKey, "api-key", "", "API key") + cmd.Flags().StringVar(&proxy, "proxy", "", "HTTP proxy URL") + cmd.Flags().StringVar(&authMethod, "auth-method", "", "Auth method: oauth, token") + cmd.Flags().StringVar(&maxTokensFld, "max-tokens-field", "", "Field name for max tokens") + cmd.Flags().StringVar(&tokenURL, "token-url", "", "Keycloak token URL (for litellm)") + cmd.Flags().StringVar(&clientID, "client-id", "", "Client ID (for litellm)") + cmd.Flags().StringVar(&clientSecret, "client-secret", "", "Client secret (for litellm)") + + return cmd +} + +func runModelListUpdate(modelName, model, apiBase, apiKey, proxy, authMethod, maxTokensFld, tokenURL, clientID, clientSecret string) error { + cfg, err := internal.LoadConfig() + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + configPath := internal.GetConfigPath() + if _, err := os.Stat(configPath); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("config not found; run: picoclaw onboard") + } + return err + } + + var idx int = -1 + for i := range cfg.ModelList { + if cfg.ModelList[i].ModelName == modelName { + idx = i + break + } + } + if idx < 0 { + return fmt.Errorf("no model with model_name %q", modelName) + } + + entry := &cfg.ModelList[idx] + + if model != "" { + entry.Model = model + } + if apiBase != "" { + entry.APIBase = apiBase + } + if apiKey != "" { + entry.APIKey = apiKey + } + if proxy != "" { + entry.Proxy = proxy + } + if authMethod != "" { + entry.AuthMethod = authMethod + } + if maxTokensFld != "" { + entry.MaxTokensField = maxTokensFld + } + if tokenURL != "" { + entry.TokenURL = tokenURL + } + if clientID != "" { + entry.ClientID = clientID + } + if clientSecret != "" { + entry.ClientSecret = clientSecret + } + + isLiteLLM := strings.HasPrefix(strings.ToLower(entry.Model), "litellm/") + if IsTTY() && isLiteLLM { + if entry.APIBase == "" { + v, _ := Prompt("API base URL: ") + entry.APIBase = v + } + if entry.TokenURL == "" { + v, _ := Prompt("Token URL (Keycloak): ") + entry.TokenURL = v + } + if entry.ClientID == "" { + v, _ := Prompt("Client ID: ") + entry.ClientID = v + } + if entry.ClientSecret == "" { + v, _ := Prompt("Client secret: ") + entry.ClientSecret = v + } + } + + if isLiteLLM && (entry.APIBase == "" || entry.TokenURL == "" || entry.ClientID == "" || entry.ClientSecret == "") { + return fmt.Errorf("litellm requires api_base, token_url, client_id, client_secret") + } + + if err := entry.Validate(); err != nil { + return err + } + + if err := config.SaveConfig(configPath, cfg); err != nil { + return fmt.Errorf("saving config: %w", err) + } + + fmt.Printf("Updated model %q in model_list.\n", modelName) + return nil +} diff --git a/cmd/picoclaw/internal/configcmd/prompt.go b/cmd/picoclaw/internal/configcmd/prompt.go new file mode 100644 index 000000000..7855eb158 --- /dev/null +++ b/cmd/picoclaw/internal/configcmd/prompt.go @@ -0,0 +1,30 @@ +package configcmd + +import ( + "bufio" + "fmt" + "os" + "strings" +) + +// IsTTY returns true if stdin is a terminal (interactive). +func IsTTY() bool { + fi, err := os.Stdin.Stat() + if err != nil { + return false + } + return (fi.Mode() & os.ModeCharDevice) != 0 +} + +// Prompt reads a line from stdin after printing the prompt label. The returned string is trimmed. +func Prompt(label string) (string, error) { + fmt.Print(label) + scanner := bufio.NewScanner(os.Stdin) + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + return "", err + } + return "", nil + } + return strings.TrimSpace(scanner.Text()), nil +} diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 6db69c990..d75017cde 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -15,6 +15,7 @@ import ( "github.com/sipeed/picoclaw/cmd/picoclaw/internal" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/agent" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/auth" + "github.com/sipeed/picoclaw/cmd/picoclaw/internal/configcmd" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/migrate" @@ -37,6 +38,7 @@ func NewPicoclawCommand() *cobra.Command { onboard.NewOnboardCommand(), agent.NewAgentCommand(), auth.NewAuthCommand(), + configcmd.NewConfigCommand(), gateway.NewGatewayCommand(), status.NewStatusCommand(), cron.NewCronCommand(), diff --git a/docs/config-cli.md b/docs/config-cli.md new file mode 100644 index 000000000..3b2900ec0 --- /dev/null +++ b/docs/config-cli.md @@ -0,0 +1,173 @@ +# Config CLI Usage + +Use `picoclaw config` to manage `model_list` and `agents` without editing `config.json` directly. + +Default config path: `~/.picoclaw/config.json`. If the file does not exist, some commands will prompt you to run `picoclaw onboard` first. + +--- + +## 1. model_list + +### list — List all models + +```bash +picoclaw config model_list list +``` + +Prints the current `model_list` in a table (MODEL_NAME, MODEL, API_BASE, AUTH, etc.). Shows a message when the list is empty or the config file is missing. + +### get — Inspect a single model’s config + +```bash +# Show all fields for that model (sensitive fields are masked) +picoclaw config model_list get + +# Show only one key’s value (for scripting; not masked) +picoclaw config model_list get +``` + +Supported keys (snake_case, matching JSON): +`model_name`, `model`, `api_base`, `api_key`, `proxy`, `auth_method`, `connect_mode`, `workspace`, `token_url`, `client_id`, `client_secret`, `max_tokens_field`, `rpm`. + +### set — Set a single field + +```bash +picoclaw config model_list set +``` + +Sets the given key for the **first** entry whose `model_name` matches, then writes the config. +`rpm` is an integer; all other keys are strings. The key must be one of the supported keys above. + +Examples: + +```bash +picoclaw config model_list set gpt4 api_base https://api.openai.com/v1 +picoclaw config model_list set gpt4 rpm 60 +``` + +### add — Add a model + +```bash +# Specify everything with flags +picoclaw config model_list add --model-name qwen-turbo --model litellm/qwen-turbo \ + --api-base https://litellm.example.com \ + --token-url https://keycloak.example.com/realms/xxx/protocol/openid-connect/token \ + --client-id ai-bot --client-secret xxx + +# Interactive: pass model_name only or omit; in a TTY you’ll be prompted for the rest +picoclaw config model_list add qwen-turbo +picoclaw config model_list add +``` + +Common flags: +`--model-name`, `--model`, `--api-base`, `--api-key`, `--proxy`, `--auth-method`, `--max-tokens-field`, `--token-url`, `--client-id`, `--client-secret`. +For `litellm/...` protocol you must provide `api_base`, `token_url`, `client_id`, and `client_secret`; in a TTY, missing values are prompted interactively. + +### remove — Remove model(s) + +```bash +picoclaw config model_list remove +``` + +Removes entries by `model_name`. If there are multiple entries with the same name (e.g. round-robin), all are removed by default. Use `--first` to remove only the first match: + +```bash +picoclaw config model_list remove loadbalanced-gpt4 --first +``` + +### update — Update a model + +```bash +picoclaw config model_list update [flags] +``` + +Updates the **first** entry matching `model_name`; only the fields corresponding to the given flags are changed. +Flags are the same as for add: `--model`, `--api-base`, `--api-key`, `--token-url`, `--client-id`, `--client-secret`, `--proxy`, `--auth-method`, `--max-tokens-field`, etc. + +Example: + +```bash +picoclaw config model_list update qwen-turbo --api-base https://new.example.com +``` + +--- + +## 2. agent + +### defaults — Global defaults (agents.defaults) + +**get** + +```bash +# Print all agents.defaults fields +picoclaw config agent defaults get + +# Print a single key +picoclaw config agent defaults get model_name +``` + +Supported keys: +`workspace`, `restrict_to_workspace`, `provider`, `model_name`, `model`, `model_fallbacks`, `image_model`, `image_model_fallbacks`, `max_tokens`, `temperature`, `max_tool_iterations`. + +**set** + +```bash +picoclaw config agent defaults set +``` + +Examples: + +```bash +picoclaw config agent defaults set model_name gpt4 +picoclaw config agent defaults set max_tokens 8192 +picoclaw config agent defaults set restrict_to_workspace true +picoclaw config agent defaults set model_fallbacks "gpt4,claude" +``` + +`restrict_to_workspace` is true/false; `max_tokens` and `max_tool_iterations` are integers; `temperature` is a float; `model_fallbacks` and `image_model_fallbacks` are comma-separated strings. + +### list — List all agents + +```bash +picoclaw config agent list +``` + +Prints `agents.list` in a table (ID, NAME, MODEL, WORKSPACE). Shows a message when the list is empty. + +### add — Add an agent + +```bash +picoclaw config agent add [--name "Display name"] [--model gpt4] [--workspace ~/ws] [--default] +``` + +`id` is required; `--name`, `--model`, and `--workspace` are optional. In a TTY, missing values are prompted. `--default` sets this agent as the default. + +### remove — Remove an agent + +```bash +picoclaw config agent remove +``` + +Removes the agent with the given id from `agents.list` and saves the config. + +### update — Update an agent + +```bash +picoclaw config agent update [--name "New name"] [--model gpt4] [--workspace ~/ws] [--default] +``` + +Only the provided fields are updated; others are left unchanged. + +--- + +## 3. Quick reference + +| Purpose | Command | +|----------------------|--------| +| List models | `picoclaw config model_list list` | +| Get/set one model | `picoclaw config model_list get/set [key] [value]` | +| Add/remove/update models | `picoclaw config model_list add/remove/update ...` | +| Agent defaults | `picoclaw config agent defaults get [key]` / `set ` | +| List/add/remove/update agents | `picoclaw config agent list/add/remove/update ...` | + +For more options and descriptions, run `picoclaw config --help`, `picoclaw config model_list --help`, and `picoclaw config agent --help`. diff --git a/pkg/config/config.go b/pkg/config/config.go index 6f76614cf..856cf6592 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -401,7 +401,12 @@ type ModelConfig struct { // Special providers (CLI-based, OAuth, etc.) AuthMethod string `json:"auth_method,omitempty"` // Authentication method: oauth, token ConnectMode string `json:"connect_mode,omitempty"` // Connection mode: stdio, grpc - Workspace string `json:"workspace,omitempty"` // Workspace path for CLI-based providers + Workspace string `json:"workspace,omitempty"` // Workspace path for CLI-based providers + + // OAuth / client credentials (e.g. LiteLLM + Keycloak) + TokenURL string `json:"token_url,omitempty"` + ClientID string `json:"client_id,omitempty"` + ClientSecret string `json:"client_secret,omitempty"` // Optional optimizations RPM int `json:"rpm,omitempty"` // Requests per minute limit