diff --git a/frontend/cli/cmd/config.go b/frontend/cli/cmd/config.go index 75a5e07..e604e1f 100644 --- a/frontend/cli/cmd/config.go +++ b/frontend/cli/cmd/config.go @@ -1,14 +1,15 @@ package cmd import ( + "fmt" + "strconv" + "strings" + + "github.com/sahilm/fuzzy" "github.com/spf13/cobra" + "gopkg.in/yaml.v3" ) -var configOptions = []string{ - "task.default-agent", - "agent.default-model", -} - func NewConfigCmd() *cobra.Command { cmd := &cobra.Command{ Use: "config", @@ -19,8 +20,159 @@ func NewConfigCmd() *cobra.Command { cmd.AddCommand(NewConfigSetCmd()) cmd.AddCommand(NewConfigGetCmd()) cmd.AddCommand(NewConfigUnsetCmd()) - cmd.AddCommand(NewConfigDescribeCmd()) + cmd.AddCommand(NewConfigExplainCmd()) cmd.AddCommand(NewConfigListCmd()) return cmd } + +func getSupportedConfigKeys() []string { + return []string{ + // Command + "cmd", + "cmd.new", + "cmd.new.agent", + + "cmd.ask", + "cmd.ask.agent", + "cmd.ask.max-turns", + + "cmd.resume", + "cmd.resume.recent_task_limit", + + // Logging + "log", + "log.level", + "log.file", + "log.format", + + // Misc + "editor", + "output", + "output.format", + "output.no-headers", + "output.wide", + } +} + +func isSupportedKey(key string) error { + if !supportedKeys(key) { + suggestions := getSuggestions(key) + if len(suggestions) > 0 { + return fmt.Errorf("unsupported configuration key: '%s'\n\nDid you mean one of these?\n%s", key, formatSuggestions(suggestions)) + } + return fmt.Errorf("unsupported configuration key: '%s'", key) + } + + return nil +} + +func getSuggestions(input string) []string { + supportedKeys := getSupportedConfigKeys() + + matches := fuzzy.Find(input, supportedKeys) + + var suggestions []string + for i, match := range matches { + if i >= 3 { + break + } + suggestions = append(suggestions, match.Str) + } + + return suggestions +} + +func formatSuggestions(suggestions []string) string { + var formatted []string + for _, suggestion := range suggestions { + formatted = append(formatted, fmt.Sprintf(" - %s", suggestion)) + } + return fmt.Sprintln(strings.Join(formatted, "\n")) +} + +func supportedKeys(key string) bool { + supportedKeys := getSupportedConfigKeys() + + for _, supportedKey := range supportedKeys { + if supportedKey == key { + return true + } + } + + return false +} + +func isSectionKey(key string) bool { + supportedKeys := getSupportedConfigKeys() + + for _, supportedKey := range supportedKeys { + if strings.HasPrefix(supportedKey, key+".") { + return true + } + } + + return false +} + +func getKeysUnderSection(section string) []string { + supportedKeys := getSupportedConfigKeys() + var childKeys []string + + prefix := section + "." + for _, key := range supportedKeys { + if strings.HasPrefix(key, prefix) { + remainder := strings.TrimPrefix(key, prefix) + if !strings.Contains(remainder, ".") { + childKeys = append(childKeys, key) + } + } + } + + return childKeys +} + +func formatAvailableKeys(keys []string) string { + var formatted []string + for _, key := range keys { + parts := strings.Split(key, ".") + leafKey := parts[len(parts)-1] + formatted = append(formatted, fmt.Sprintf(" - %s", leafKey)) + } + return strings.Join(formatted, "\n") +} + +func parseValue(value string) (any, error) { + if boolVal, err := strconv.ParseBool(value); err == nil { + return boolVal, nil + } + + if intVal, err := strconv.ParseInt(value, 10, 64); err == nil { + return intVal, nil + } + + if floatVal, err := strconv.ParseFloat(value, 64); err == nil { + return floatVal, nil + } + + return nil, fmt.Errorf("value must be a boolean, integer or float") +} + +func MarshalYAMLWithSpacing(v interface{}) ([]byte, error) { + data, err := yaml.Marshal(v) + if err != nil { + return nil, err + } + + lines := strings.Split(string(data), "\n") + var result []string + + for i, line := range lines { + if i > 0 && len(line) > 0 && !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") { + result = append(result, "") + } + result = append(result, line) + } + + return []byte(strings.Join(result, "\n")), nil +} diff --git a/frontend/cli/cmd/config_describe.go b/frontend/cli/cmd/config_explain.go similarity index 85% rename from frontend/cli/cmd/config_describe.go rename to frontend/cli/cmd/config_explain.go index 2d8c8c2..ece7da5 100644 --- a/frontend/cli/cmd/config_describe.go +++ b/frontend/cli/cmd/config_explain.go @@ -15,17 +15,17 @@ type configDescription struct { var configDescriptions = map[string]configDescription{ "defaults.agent": { - Type: "String (Agent Name or ID)", Description: "Specifies the default agent to use when running `construct new` without the\n --agent flag. This allows you to set a preferred agent for new conversations.", - Example: "construct config set defaults.agent \"my-favorite-coder\"", + Type: "String (Agent Name or ID)", + Example: "construct config set defaults.agent \"my-favorite-agent\"", }, } -func NewConfigDescribeCmd() *cobra.Command { +func NewConfigExplainCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "describe ", - Short: "Describe a configuration value", - Long: `The "describe" command allows you to describe a configuration value`, + Use: "explain ", + Short: "Explain a configuration value", + Long: `The "explain" command allows you to explain a configuration value`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { key := args[0] diff --git a/frontend/cli/cmd/config_get.go b/frontend/cli/cmd/config_get.go index 083b180..e3514da 100644 --- a/frontend/cli/cmd/config_get.go +++ b/frontend/cli/cmd/config_get.go @@ -1,23 +1,89 @@ package cmd -import "github.com/spf13/cobra" +import ( + "fmt" + "path/filepath" + + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) func NewConfigGetCmd() *cobra.Command { cmd := &cobra.Command{ Use: "get ", Short: "Get a configuration value", Long: `The "get" command allows you to get a configuration value`, + Args: cobra.ExactArgs(1), ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if len(args) == 0 { - return configOptions, cobra.ShellCompDirectiveNoFileComp + return getSupportedConfigKeys(), cobra.ShellCompDirectiveNoFileComp } return []string{}, cobra.ShellCompDirectiveDefault }, RunE: func(cmd *cobra.Command, args []string) error { + key := args[0] + userInfo := getUserInfo(cmd.Context()) + + err := isSupportedKey(key) + if err != nil { + return err + } + + constructDir, err := userInfo.ConstructDir() + if err != nil { + return fmt.Errorf("failed to get construct directory: %w", err) + } + + settingsFile := filepath.Join(constructDir, "config.yaml") + fs := getFileSystem(cmd.Context()) + + exists, err := fs.Exists(settingsFile) + if err != nil { + return fmt.Errorf("failed to check config file: %w", err) + } + + if !exists { + return nil + } + + content, err := fs.ReadFile(settingsFile) + if err != nil { + return fmt.Errorf("failed to read config file: %w", err) + } + + var settings map[string]any + if err := yaml.Unmarshal(content, &settings); err != nil { + return fmt.Errorf("failed to parse config file: %w", err) + } + + value, found := getNestedValue(settings, key) + if !found { + return nil + } + + if isLeafValue(value) { + fmt.Println(value) + } else { + renderConfigValue(value, key) + } + return nil }, } return cmd -} \ No newline at end of file +} + +func renderConfigValue(value any, prefix string) { + if m, ok := value.(map[string]any); ok { + for k, v := range m { + fullKey := prefix + "." + k + if isLeafValue(v) { + fmt.Printf("%s: %v\n", fullKey, v) + } else { + renderConfigValue(v, fullKey) + } + } + } +} diff --git a/frontend/cli/cmd/config_set.go b/frontend/cli/cmd/config_set.go index eb07317..5581dd7 100644 --- a/frontend/cli/cmd/config_set.go +++ b/frontend/cli/cmd/config_set.go @@ -1,23 +1,120 @@ package cmd -import "github.com/spf13/cobra" +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) func NewConfigSetCmd() *cobra.Command { cmd := &cobra.Command{ Use: "set ", Short: "Set a configuration value", Long: `The "config set" command allows you to set a configuration value`, + Args: cobra.ExactArgs(2), ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if len(args) == 0 { - return configOptions, cobra.ShellCompDirectiveNoFileComp + return getSupportedConfigKeys(), cobra.ShellCompDirectiveNoFileComp } return []string{}, cobra.ShellCompDirectiveDefault }, RunE: func(cmd *cobra.Command, args []string) error { + key := args[0] + value := args[1] + userInfo := getUserInfo(cmd.Context()) + + err := isSupportedKey(key) + if err != nil { + return err + } + + parsedValue, err := parseValue(value) + if err != nil { + return fmt.Errorf("invalid value: %w", err) + } + + constructDir, err := userInfo.ConstructDir() + if err != nil { + return fmt.Errorf("failed to get construct directory: %w", err) + } + + configFile := filepath.Join(constructDir, "config.yaml") + fs := getFileSystem(cmd.Context()) + + var config map[string]any + + exists, err := fs.Exists(configFile) + if err != nil { + return fmt.Errorf("failed to check config file: %w", err) + } + + if exists { + content, err := fs.ReadFile(configFile) + if err != nil { + return fmt.Errorf("failed to read config file: %w", err) + } + + if err := yaml.Unmarshal(content, &config); err != nil { + return fmt.Errorf("failed to parse config file: %w", err) + } + } else { + config = make(map[string]any) + } + + if isSectionKey(key) { + availableKeys := getKeysUnderSection(key) + if len(availableKeys) > 0 { + return fmt.Errorf("'%s' is a configuration section, not a single value.\nYou can only set a specific key within a section.\n\nAvailable keys under '%s' are:\n%s\n\nExample: construct config set %s %s", + key, key, formatAvailableKeys(availableKeys), availableKeys[0], "value") + } + } + + err = setNestedValue(config, key, parsedValue) + if err != nil { + return err + } + + output, err := MarshalYAMLWithSpacing(config) + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + if err := fs.WriteFile(configFile, output, 0644); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + return nil }, } return cmd } + +func setNestedValue(data map[string]any, key string, value any) error { + keys := strings.Split(key, ".") + current := data + + for i := 0; i < len(keys)-1; i++ { + k := keys[i] + if existing, exists := current[k]; exists { + if nested, ok := existing.(map[string]any); ok { + current = nested + } else { + return fmt.Errorf("key '%s' already exists as a non-object value", strings.Join(keys[:i+1], ".")) + } + } else { + newMap := make(map[string]any) + current[k] = newMap + current = newMap + } + } + + finalKey := keys[len(keys)-1] + current[finalKey] = value + + return nil +} diff --git a/frontend/cli/cmd/config_unset.go b/frontend/cli/cmd/config_unset.go index 602bcc7..216a430 100644 --- a/frontend/cli/cmd/config_unset.go +++ b/frontend/cli/cmd/config_unset.go @@ -1,23 +1,187 @@ package cmd -import "github.com/spf13/cobra" +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +type ConfigUnsetOptions struct { + Force bool +} func NewConfigUnsetCmd() *cobra.Command { + options := ConfigUnsetOptions{} + cmd := &cobra.Command{ Use: "unset ", Short: "Unset a configuration value", Long: `The "unset" command allows you to unset a configuration value`, + Args: cobra.ExactArgs(1), ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if len(args) == 0 { - return configOptions, cobra.ShellCompDirectiveNoFileComp + return getSupportedConfigKeys(), cobra.ShellCompDirectiveNoFileComp } return []string{}, cobra.ShellCompDirectiveDefault }, RunE: func(cmd *cobra.Command, args []string) error { + key := args[0] + userInfo := getUserInfo(cmd.Context()) + + err := isSupportedKey(key) + if err != nil { + return err + } + + constructDir, err := userInfo.ConstructDir() + if err != nil { + return fmt.Errorf("failed to get construct directory: %w", err) + } + + configFile := filepath.Join(constructDir, "config.yaml") + fs := getFileSystem(cmd.Context()) + + exists, err := fs.Exists(configFile) + if err != nil { + return fmt.Errorf("failed to check config file: %w", err) + } + + if !exists { + return nil + } + + content, err := fs.ReadFile(configFile) + if err != nil { + return fmt.Errorf("failed to read config file: %w", err) + } + + var config map[string]any + if err := yaml.Unmarshal(content, &config); err != nil { + return fmt.Errorf("failed to parse config file: %w", err) + } + + value, found := getNestedValue(config, key) + if !found { + return nil + } + + if !options.Force && !isLeafValue(value) { + availableKeys := getKeysUnderSection(key) + if len(availableKeys) > 0 { + cmd.Printf("You are about to remove the entire '%s' section and all its children:\n%s\n\n", key, formatAvailableKeys(availableKeys)) + if !confirm(cmd.InOrStdin(), cmd.OutOrStdout(), "Are you sure?") { + return nil + } + } + } + + err = unsetNestedValue(config, key) + if err != nil { + return err + } + + content, err = MarshalYAMLWithSpacing(config) + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + if err := fs.WriteFile(configFile, content, 0644); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + return nil }, } + cmd.Flags().BoolVarP(&options.Force, "force", "f", false, "Force the removal of the configuration value") + return cmd } + +func unsetNestedValue(data map[string]any, key string) error { + keys := strings.Split(key, ".") + current := data + + for i := 0; i < len(keys)-1; i++ { + k := keys[i] + if existing, exists := current[k]; exists { + if nested, ok := existing.(map[string]any); ok { + current = nested + } else { + return nil + } + } else { + return nil + } + } + + finalKey := keys[len(keys)-1] + delete(current, finalKey) + + cleanupEmptyMaps(data, keys[:len(keys)-1]) + return nil +} + +func cleanupEmptyMaps(data map[string]any, keyPath []string) { + if len(keyPath) == 0 { + return + } + + current := data + for i := 0; i < len(keyPath)-1; i++ { + if nested, ok := current[keyPath[i]].(map[string]any); ok { + current = nested + } else { + return + } + } + + targetKey := keyPath[len(keyPath)-1] + if targetMap, ok := current[targetKey].(map[string]any); ok && len(targetMap) == 0 { + delete(current, targetKey) + cleanupEmptyMaps(data, keyPath[:len(keyPath)-1]) + } +} + +func getNestedValue(data map[string]any, key string) (any, bool) { + keys := strings.Split(key, ".") + current := data + + for i, k := range keys { + if value, exists := current[k]; exists { + if i == len(keys)-1 { + return value, true + } + + if nested, ok := value.(map[string]any); ok { + current = nested + } else { + return nil, false + } + } else { + return nil, false + } + } + + return nil, false +} + +func isLeafValue(value any) bool { + switch value.(type) { + case map[string]any: + return false + case []any: + if arr, ok := value.([]any); ok && len(arr) > 0 { + if _, isMap := arr[0].(map[string]any); isMap { + return false + } + } + return true + default: + return true + } +}