Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 158 additions & 6 deletions frontend/cli/cmd/config.go
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 <key>",
Short: "Describe a configuration value",
Long: `The "describe" command allows you to describe a configuration value`,
Use: "explain <key>",
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]
Expand Down
72 changes: 69 additions & 3 deletions frontend/cli/cmd/config_get.go
Original file line number Diff line number Diff line change
@@ -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 <key>",
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
}
}

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