Add config command with model list and prompt subcommands

Made-with: Cursor
This commit is contained in:
YS Liu 2026-02-26 14:44:43 +08:00
parent 094d65916d
commit 0e8cae377c
10 changed files with 736 additions and 0 deletions

View file

@ -0,0 +1,38 @@
package configcmd
import (
"github.com/spf13/cobra"
)
func NewConfigCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "config",
Short: "Manage configuration (model_list)",
RunE: func(cmd *cobra.Command, _ []string) error {
return cmd.Help()
},
}
cmd.AddCommand(newModelListCommand())
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
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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 <model_name>",
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
}

View file

@ -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 <model_name> <key> <value>",
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
}
}

View file

@ -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 <model_name>",
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
}

View file

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

View file

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