diff --git a/README.md b/README.md index 03a1ede..d9aecca 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,6 @@ After installing Chat With Code, you're just a few steps away from a conversatio cwc login \ --api-key=$API_KEY \ --endpoint "https://your-endpoint.openai.azure.com/" \ - --api-version "2023-12-01-preview" \ --deployment-model "gpt-4-turbo" ``` @@ -142,7 +141,30 @@ PROMPT="please write me a conventional commit for these changes" git diff HEAD | cwc $PROMPT | git commit -e --file - ``` -## Template Features +## Configuration + +Managing your configuration is simple with the `cwc config` command. This command allows you to view and set configuration options for cwc. +To view the current configuration, use: + +```sh +cwc config get +``` + +To set a configuration option, use: + +```sh +cwc config set key1=value1 key2=value2 ... +``` + +For example, to disable the gitignore feature and the git directory exclusion, use: + +```sh +cwc config set useGitignore=false excludeGitDir=false +``` + +To reset the configuration to default values use `cwc login` to re-authenticate. + +## Templates ### Overview diff --git a/cmd/config.go b/cmd/config.go new file mode 100644 index 0000000..8668ec4 --- /dev/null +++ b/cmd/config.go @@ -0,0 +1,232 @@ +package cmd + +import ( + stdErrors "errors" + "fmt" + "strconv" + "strings" + + "github.com/intility/cwc/pkg/config" + "github.com/intility/cwc/pkg/errors" + "github.com/intility/cwc/pkg/ui" + "github.com/spf13/cobra" +) + +func createConfigCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: "Get or set config variables", + Long: `Get or set config variables`, + RunE: func(cmd *cobra.Command, args []string) error { + err := cmd.Usage() + if err != nil { + return fmt.Errorf("failed to print usage: %w", err) + } + + return nil + }, + } + + cmd.AddCommand(createGetConfigCommand()) + cmd.AddCommand(createSetConfigCommand()) + + return cmd +} + +func createGetConfigCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "get", + Short: "Print current config", + Long: "Print current config", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + printConfig(cfg) + + return nil + }, + } + + return cmd +} + +func createSetConfigCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "set", + Short: "Set config variables", + Long: "Set config variables", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + // if no args are given, print the help and exit + if len(args) == 0 { + err = cmd.Help() + if err != nil { + return fmt.Errorf("failed to print help: %w", err) + } + + return nil + } + + err = processKeyValuePairs(cfg, args) + + if err != nil { + var suppressedError errors.SuppressedError + if ok := stdErrors.As(err, &suppressedError); ok { + cmd.SilenceUsage = true + cmd.SilenceErrors = true + } + + return err + } + + return nil + }, + } + + return cmd +} + +func processKeyValuePairs(cfg *config.Config, kvPairs []string) error { + // iterate over each argument and process them as key=value pairs + argKvSubstrCount := 2 + for _, arg := range kvPairs { + kvPair := strings.SplitN(arg, "=", argKvSubstrCount) + if len(kvPair) != argKvSubstrCount { + return errors.ArgParseError{Message: fmt.Sprintf("invalid argument format: %s, expected key=value", arg)} + } + + key := kvPair[0] + value := kvPair[1] + + err := setConfigValue(cfg, key, value) + if err != nil { + return fmt.Errorf("failed to set config value: %w", err) + } + } + + err := config.SaveConfig(cfg) + if err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + printConfig(cfg) + + return nil +} + +func setConfigValue(cfg *config.Config, key, value string) error { + switch key { + case "endpoint": + cfg.Endpoint = value + case "deploymentName": + cfg.ModelDeployment = value + case "apiKey": + cfg.SetAPIKey(value) + case "useGitignore": + b, err := strconv.ParseBool(value) + if err != nil { + return errors.ArgParseError{Message: "invalid boolean value for useGitignore: " + value} + } + + cfg.UseGitignore = b + case "excludeGitDir": + b, err := strconv.ParseBool(value) + if err != nil { + return errors.ArgParseError{Message: "invalid boolean value for excludeGitDir: " + value} + } + + cfg.ExcludeGitDir = b + default: + ui.PrintMessage(fmt.Sprintf("Unknown config key: %s\n", key), ui.MessageTypeError) + + validKeys := []string{ + "endpoint", + "deploymentName", + "apiKey", + "useGitignore", + "excludeGitDir", + } + + ui.PrintMessage("Valid keys are: "+strings.Join(validKeys, ", "), ui.MessageTypeInfo) + + return errors.SuppressedError{} + } + + return nil +} + +func printConfig(cfg *config.Config) { + table := [][]string{ + {"Name", "Value"}, + {"endpoint", cfg.Endpoint}, + {"deploymentName", cfg.ModelDeployment}, + {"apiKey", cfg.APIKey()}, + {"SEP", ""}, + {"useGitignore", fmt.Sprintf("%t", cfg.UseGitignore)}, + {"excludeGitDir", fmt.Sprintf("%t", cfg.ExcludeGitDir)}, + } + + printTable(table) +} + +func printTable(table [][]string) { + columnLengths := calculateColumnLengths(table) + + var lineLength int + + additionalChars := 3 // +3 for 3 additional characters before and after each field: "| %s " + for _, c := range columnLengths { + lineLength += c + additionalChars // +3 for 3 additional characters before and after each field: "| %s " + } + + lineLength++ // +1 for the last "|" in the line + singleLineLength := lineLength - len("++") // -2 because of "+" as first and last character + + for lineIndex, line := range table { + if lineIndex == 0 { // table header + // lineLength-2 because of "+" as first and last charactr + ui.PrintMessage(fmt.Sprintf("+%s+\n", strings.Repeat("-", singleLineLength)), ui.MessageTypeInfo) + } + + lineLoop: + for rowIndex, val := range line { + if val == "SEP" { + // lineLength-2 because of "+" as first and last character + ui.PrintMessage(fmt.Sprintf("+%s+\n", strings.Repeat("-", singleLineLength)), ui.MessageTypeInfo) + break lineLoop + } + + ui.PrintMessage(fmt.Sprintf("| %-*s ", columnLengths[rowIndex], val), ui.MessageTypeInfo) + if rowIndex == len(line)-1 { + ui.PrintMessage("|\n", ui.MessageTypeInfo) + } + } + + if lineIndex == 0 || lineIndex == len(table)-1 { // table header or last line + // lineLength-2 because of "+" as first and last character + ui.PrintMessage(fmt.Sprintf("+%s+\n", strings.Repeat("-", singleLineLength)), ui.MessageTypeInfo) + } + } +} + +func calculateColumnLengths(table [][]string) []int { + columnLengths := make([]int, len(table[0])) + + for _, line := range table { + for i, val := range line { + if len(val) > columnLengths[i] { + columnLengths[i] = len(val) + } + } + } + + return columnLengths +} diff --git a/cmd/cwc.go b/cmd/cwc.go index 1c91725..4100975 100644 --- a/cmd/cwc.go +++ b/cmd/cwc.go @@ -55,13 +55,11 @@ Using a specific template: func CreateRootCommand() *cobra.Command { var ( - includeFlag string - excludeFlag string - pathsFlag []string - excludeFromGitignoreFlag bool - excludeGitDirFlag bool - templateFlag string - templateVariablesFlag map[string]string + includeFlag string + excludeFlag string + pathsFlag []string + templateFlag string + templateVariablesFlag map[string]string ) loginCmd := createLoginCmd() @@ -78,13 +76,11 @@ func CreateRootCommand() *cobra.Command { } chatOpts := &chatOptions{ - includeFlag: includeFlag, - excludeFlag: excludeFlag, - pathsFlag: pathsFlag, - excludeFromGitignoreFlag: excludeFromGitignoreFlag, - excludeGitDirFlag: excludeGitDirFlag, - templateFlag: templateFlag, - templateVariablesFlag: templateVariablesFlag, + includeFlag: includeFlag, + excludeFlag: excludeFlag, + pathsFlag: pathsFlag, + templateFlag: templateFlag, + templateVariablesFlag: templateVariablesFlag, } var prompt string @@ -97,39 +93,33 @@ func CreateRootCommand() *cobra.Command { } initFlags(cmd, &flags{ - includeFlag: &includeFlag, - excludeFlag: &excludeFlag, - pathsFlag: &pathsFlag, - excludeFromGitignoreFlag: &excludeFromGitignoreFlag, - excludeGitDirFlag: &excludeGitDirFlag, - templateFlag: &templateFlag, - templateVariablesFlag: &templateVariablesFlag, + includeFlag: &includeFlag, + excludeFlag: &excludeFlag, + pathsFlag: &pathsFlag, + templateFlag: &templateFlag, + templateVariablesFlag: &templateVariablesFlag, }) cmd.AddCommand(loginCmd) cmd.AddCommand(logoutCmd) cmd.AddCommand(createTemplatesCmd()) + cmd.AddCommand(createConfigCommand()) return cmd } type flags struct { - includeFlag *string - excludeFlag *string - pathsFlag *[]string - excludeFromGitignoreFlag *bool - excludeGitDirFlag *bool - templateFlag *string - templateVariablesFlag *map[string]string + includeFlag *string + excludeFlag *string + pathsFlag *[]string + templateFlag *string + templateVariablesFlag *map[string]string } func initFlags(cmd *cobra.Command, flags *flags) { cmd.Flags().StringVarP(flags.includeFlag, "include", "i", ".*", "a regular expression to match files to include") cmd.Flags().StringVarP(flags.excludeFlag, "exclude", "x", "", "a regular expression to match files to exclude") cmd.Flags().StringSliceVarP(flags.pathsFlag, "paths", "p", []string{"."}, "a list of paths to search for files") - cmd.Flags().BoolVarP(flags.excludeFromGitignoreFlag, - "exclude-from-gitignore", "e", true, "exclude files from .gitignore") - cmd.Flags().BoolVarP(flags.excludeGitDirFlag, "exclude-git-dir", "g", true, "exclude the .git directory") cmd.Flags().StringVarP(flags.templateFlag, "template", "t", "default", "the name of the template to use") cmd.Flags().StringToStringVarP(flags.templateVariablesFlag, "template-variables", "v", nil, "variables to use in the template") @@ -142,10 +132,6 @@ func initFlags(cmd *cobra.Command, flags *flags) { cmd.Flag("paths"). Usage = "Specify a list of paths to search for files. For example, " + "to search in the 'cmd' and 'pkg' directories, use --paths cmd,pkg" - cmd.Flag("exclude-from-gitignore"). - Usage = "Exclude files from .gitignore. If set to false, files mentioned in .gitignore will not be excluded" - cmd.Flag("exclude-git-dir"). - Usage = "Exclude the .git directory. If set to false, the .git directory will not be excluded" cmd.Flag("template"). Usage = "Specify the name of the template to use. For example, " + "to use a template named 'tech_writer', use --template tech_writer" @@ -217,7 +203,13 @@ func getPromptFromUserOrTemplate(templateName string) string { template, err := getTemplate(templateName) if err != nil { - ui.PrintMessage(err.Error()+"\n", ui.MessageTypeWarning) + var notFoundErr errors.TemplateNotFoundError + ok := stdErrors.As(err, ¬FoundErr) + + if !ok || (len(templateName) != 0 && templateName != "default") { + ui.PrintMessage(err.Error()+"\n", ui.MessageTypeWarning) + } + ui.PrintMessage("👤: ", ui.MessageTypeInfo) return ui.ReadUserInput() @@ -323,6 +315,10 @@ func getTemplate(templateName string) (*templates.Template, error) { tmpl, err := mergedLocator.GetTemplate(templateName) if err != nil { + if errors.IsTemplateNotFoundError(err) { + return nil, errors.TemplateNotFoundError{TemplateName: templateName} + } + return nil, fmt.Errorf("error getting template: %w", err) } @@ -337,8 +333,7 @@ func createSystemMessage(ctx string, templateName string, templateVariables map[ } // if no template found, create a basic template as fallback - var templateNotFoundError errors.TemplateNotFoundError - if err != nil && stdErrors.As(err, &templateNotFoundError) { + if errors.IsTemplateNotFoundError(err) { return createBuiltinSystemMessageFromContext(ctx), nil } @@ -397,17 +392,16 @@ func nonInteractive(args []string, templateName string, templateVars map[string] template, err := getTemplate(templateName) if err != nil { - // if no template found, create a basic template as fallback var templateNotFoundError errors.TemplateNotFoundError if stdErrors.As(err, &templateNotFoundError) { if len(args) == 0 { return &errors.NoPromptProvidedError{Message: "no prompt provided"} } } + } else { + prompt = template.DefaultPrompt } - prompt = template.DefaultPrompt - // args takes precedence over template.DefaultPrompt if len(args) > 0 { prompt = args[0] @@ -457,21 +451,17 @@ func createBuiltinSystemMessageFromContext(context string) string { } type chatOptions struct { - includeFlag string - excludeFlag string - pathsFlag []string - excludeFromGitignoreFlag bool - excludeGitDirFlag bool - templateFlag string - templateVariablesFlag map[string]string + includeFlag string + excludeFlag string + pathsFlag []string + templateFlag string + templateVariablesFlag map[string]string } func gatherContext(opts *chatOptions) ([]filetree.File, *filetree.FileNode, error) { includeFlag := opts.includeFlag excludeFlag := opts.excludeFlag pathsFlag := opts.pathsFlag - excludeFromGitignoreFlag := opts.excludeFromGitignoreFlag - excludeGitDirFlag := opts.excludeGitDirFlag var excludeMatchers []pathmatcher.PathMatcher @@ -485,31 +475,15 @@ func gatherContext(opts *chatOptions) ([]filetree.File, *filetree.FileNode, erro excludeMatchers = append(excludeMatchers, excludeMatcher) } - if excludeFromGitignoreFlag { - gitignoreMatcher, err := pathmatcher.NewGitignorePathMatcher() - if err != nil { - if errors.IsGitNotInstalledError(err) { - ui.PrintMessage("warning: git not found in PATH, skipping .gitignore\n", ui.MessageTypeWarning) - } else { - return nil, nil, fmt.Errorf("error creating gitignore matcher: %w", err) - } - } - - excludeMatchers = append(excludeMatchers, gitignoreMatcher) + excludeMatchersFromConfig, err := excludeMatchersFromConfig() + if err != nil { + return nil, nil, err } - if excludeGitDirFlag { - gitDirMatcher, err := pathmatcher.NewRegexPathMatcher(`^\.git(/|\\)`) - if err != nil { - return nil, nil, fmt.Errorf("error creating git directory matcher: %w", err) - } - - excludeMatchers = append(excludeMatchers, gitDirMatcher) - } + excludeMatchers = append(excludeMatchers, excludeMatchersFromConfig...) excludeMatcher := pathmatcher.NewCompoundPathMatcher(excludeMatchers...) - // includeMatcher includeMatcher, err := pathmatcher.NewRegexPathMatcher(includeFlag) if err != nil { return nil, nil, fmt.Errorf("error creating include matcher: %w", err) @@ -526,3 +500,36 @@ func gatherContext(opts *chatOptions) ([]filetree.File, *filetree.FileNode, erro return files, rootNode, nil } + +func excludeMatchersFromConfig() ([]pathmatcher.PathMatcher, error) { + var excludeMatchers []pathmatcher.PathMatcher + + cfg, err := config.LoadConfig() + if err != nil { + return excludeMatchers, fmt.Errorf("error loading config: %w", err) + } + + if cfg.UseGitignore { + gitignoreMatcher, err := pathmatcher.NewGitignorePathMatcher() + if err != nil { + if errors.IsGitNotInstalledError(err) { + ui.PrintMessage("warning: git not found in PATH, skipping .gitignore\n", ui.MessageTypeWarning) + } else { + return nil, fmt.Errorf("error creating gitignore matcher: %w", err) + } + } + + excludeMatchers = append(excludeMatchers, gitignoreMatcher) + } + + if cfg.ExcludeGitDir { + gitDirMatcher, err := pathmatcher.NewRegexPathMatcher(`^\.git(/|\\)`) + if err != nil { + return nil, fmt.Errorf("error creating git directory matcher: %w", err) + } + + excludeMatchers = append(excludeMatchers, gitDirMatcher) + } + + return excludeMatchers, nil +} diff --git a/cmd/login.go b/cmd/login.go index c29039b..864c51c 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -12,7 +12,6 @@ import ( var ( apiKeyFlag string //nolint:gochecknoglobals endpointFlag string //nolint:gochecknoglobals - apiVersionFlag string //nolint:gochecknoglobals modelDeploymentFlag string //nolint:gochecknoglobals ) @@ -35,17 +34,12 @@ func createLoginCmd() *cobra.Command { endpointFlag = config.SanitizeInput(ui.ReadUserInput()) } - if apiVersionFlag == "" { - ui.PrintMessage("Enter the Azure OpenAI API Version: ", ui.MessageTypeInfo) - apiVersionFlag = config.SanitizeInput(ui.ReadUserInput()) - } - if modelDeploymentFlag == "" { ui.PrintMessage("Enter the Azure OpenAI Model Deployment: ", ui.MessageTypeInfo) modelDeploymentFlag = config.SanitizeInput(ui.ReadUserInput()) } - cfg := config.NewConfig(endpointFlag, apiVersionFlag, modelDeploymentFlag) + cfg := config.NewConfig(endpointFlag, modelDeploymentFlag) cfg.SetAPIKey(apiKeyFlag) err := config.SaveConfig(cfg) @@ -69,7 +63,6 @@ func createLoginCmd() *cobra.Command { cmd.Flags().StringVarP(&apiKeyFlag, "api-key", "k", "", "Azure OpenAI API Key") cmd.Flags().StringVarP(&endpointFlag, "endpoint", "e", "", "Azure OpenAI API Endpoint") - cmd.Flags().StringVarP(&apiVersionFlag, "api-version", "v", "", "Azure OpenAI API Version") cmd.Flags().StringVarP(&modelDeploymentFlag, "model-deployment", "m", "", "Azure OpenAI Model Deployment") return cmd diff --git a/main.go b/main.go index 40380bf..8364a92 100644 --- a/main.go +++ b/main.go @@ -1,10 +1,12 @@ package main import ( + stdErrors "errors" "fmt" "os" "github.com/intility/cwc/cmd" + "github.com/intility/cwc/pkg/errors" "github.com/intility/cwc/pkg/ui" ) @@ -15,7 +17,12 @@ func main() { err := command.Execute() if err != nil { - ui.PrintMessage(fmt.Sprintf("Error: %s\n", err), ui.MessageTypeError) + // if error is of type suppressedError, do not print error message + var suppressedError errors.SuppressedError + if ok := stdErrors.As(err, &suppressedError); !ok { + ui.PrintMessage(fmt.Sprintf("Error: %s\n", err), ui.MessageTypeError) + } + os.Exit(1) } } diff --git a/pkg/config/config.go b/pkg/config/config.go index aaf3a19..6e5cbd0 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1,7 +1,6 @@ package config import ( - "encoding/json" "fmt" "os" "path/filepath" @@ -9,11 +8,13 @@ import ( "github.com/intility/cwc/pkg/errors" "github.com/sashabaranov/go-openai" + "gopkg.in/yaml.v3" ) const ( - configFileName = "cwc.json" // The name of the config file we want to save + configFileName = "cwc.yaml" // The name of the config file we want to save configFilePermissions = 0o600 // The permissions we want to set on the config file + apiVersion = "2024-02-01" ) func NewFromConfigFile() (openai.ClientConfig, error) { @@ -29,7 +30,7 @@ func NewFromConfigFile() (openai.ClientConfig, error) { } config := openai.DefaultAzureConfig(cfg.APIKey(), cfg.Endpoint) - config.APIVersion = cfg.APIVersion + config.APIVersion = apiVersion config.AzureModelMapperFunc = func(model string) string { return cfg.ModelDeployment } @@ -43,19 +44,21 @@ func SanitizeInput(input string) string { } type Config struct { - Endpoint string `json:"endpoint"` - APIVersion string `json:"apiVersion"` - ModelDeployment string `json:"modelDeployment"` + Endpoint string `yaml:"endpoint"` + ModelDeployment string `yaml:"modelDeployment"` + ExcludeGitDir bool `yaml:"excludeGitDir"` + UseGitignore bool `yaml:"useGitignore"` // Keep APIKey unexported to avoid accidental exposure apiKey string } // NewConfig creates a new Config object. -func NewConfig(endpoint, apiVersion, modelDeployment string) *Config { +func NewConfig(endpoint, modelDeployment string) *Config { return &Config{ Endpoint: endpoint, - APIVersion: apiVersion, ModelDeployment: modelDeployment, + ExcludeGitDir: true, + UseGitignore: true, apiKey: "", } } @@ -82,10 +85,6 @@ func ValidateConfig(cfg *Config) error { validationErrors = append(validationErrors, "endpoint must be provided and not be empty") } - if cfg.APIVersion == "" { - validationErrors = append(validationErrors, "apiVersion must be provided and not be empty") - } - if cfg.ModelDeployment == "" { validationErrors = append(validationErrors, "modelDeployment must be provided and not be empty") } @@ -112,7 +111,7 @@ func SaveConfig(config *Config) error { configFilePath := filepath.Join(configDir, configFileName) - data, err := json.Marshal(config) + data, err := yaml.Marshal(config) if err != nil { return fmt.Errorf("error marshalling config data: %w", err) } @@ -151,7 +150,7 @@ func LoadConfig() (*Config, error) { } var cfg Config - err = json.Unmarshal(data, &cfg) + err = yaml.Unmarshal(data, &cfg) if err != nil { return nil, errors.ConfigValidationError{Errors: []string{ diff --git a/pkg/errors/errors.go b/pkg/errors/errors.go index 22328c6..d9c20b1 100644 --- a/pkg/errors/errors.go +++ b/pkg/errors/errors.go @@ -91,3 +91,22 @@ type TemplateNotFoundError struct { func (e TemplateNotFoundError) Error() string { return "template not found: " + e.TemplateName } + +func IsTemplateNotFoundError(err error) bool { + var templateNotFoundError TemplateNotFoundError + return errors.As(err, &templateNotFoundError) +} + +type SuppressedError struct{} + +func (e SuppressedError) Error() string { + return "error suppressed" +} + +type ArgParseError struct { + Message string +} + +func (e ArgParseError) Error() string { + return e.Message +}