From e754ab711bdbbda7146e58030cf357129cf437e5 Mon Sep 17 00:00:00 2001 From: Emil Kjelsrud Date: Sat, 16 Mar 2024 20:35:30 +0100 Subject: [PATCH 1/2] feat(templates): add support for custom templates in CWC Introduce a flexible template system in Chat With Code (CWC) that enables custom system messages, prompts, and variables for an improved coding conversation experience. This feature includes a schema for template definitions in YAML files, mechanisms to discover and apply templates at both local repository and global user configuration levels, and the implementation of command-line flags `-t` and `-v` to specify templates and variables respectively. Additionally, include README documentation and a cwc command for listing templates. --- .cwc/templates.yaml | 37 ++++ README.md | 102 ++++++++++- cmd/cwc.go | 206 ++++++++++++++++++++--- cmd/templates.go | 136 +++++++++++++++ pkg/config/config.go | 4 + pkg/errors/errors.go | 8 + pkg/templates/mergedTemplateLocator.go | 65 +++++++ pkg/templates/templates.go | 37 ++++ pkg/templates/yamlFileTemplateLocator.go | 65 +++++++ 9 files changed, 631 insertions(+), 29 deletions(-) create mode 100644 .cwc/templates.yaml create mode 100644 cmd/templates.go create mode 100644 pkg/templates/mergedTemplateLocator.go create mode 100644 pkg/templates/templates.go create mode 100644 pkg/templates/yamlFileTemplateLocator.go diff --git a/.cwc/templates.yaml b/.cwc/templates.yaml new file mode 100644 index 0000000..4d229c0 --- /dev/null +++ b/.cwc/templates.yaml @@ -0,0 +1,37 @@ +templates: + - name: default + description: The default template to use if not otherwise specified. + systemMessage: | + You are {{ .Variables.personality }}. + Using the following context you will try to help the user as best as you can. + + Context: + {{ .Context }} + + Please keep in mind your personality when responding to the user. + variables: + - name: personality + description: The personality of the assistant. e.g. "a helpful assistant" + defaultValue: "a helpful assistant" + + - name: cc + description: A template for conventional commits. + defaultPrompt: "Given these changes please help me author a conventional commit message." + systemMessage: | + You are an expert coder and technical writer. + Using the following diff you will be able to create a conventional commit message. + + Diff: + ```diff + {{ .Context }} + ``` + + Instructions: + + * Unless otherwise specified, please respond with only the commit message. + * Do not guess any type of issues references or otherwise that are not present in the diff. + * Keep the line length to 50 in the title and 72 in the body. + * Do not format the output with ``` blocks or other markdown features, + only return the message title and body in plain text. + + My job depends on your ability to follow these instructions, you can do this! \ No newline at end of file diff --git a/README.md b/README.md index 4256031..9187c14 100644 --- a/README.md +++ b/README.md @@ -108,14 +108,112 @@ PROMPT="please write me a conventional commit for these changes" git diff HEAD | cwc $PROMPT | git commit -e --file - ``` +## Template Features + +### Overview + +Chat With Code (CWC) introduces the flexibility of custom templates to enhance the conversational coding experience. +Templates are pre-defined system messages and prompts that tailor interactions with your codebase. +A template envelops default prompts, system messages and variables, allowing for a personalized and context-aware dialogue. + +### Template Schema + +Each template follows a specific YAML schema defined in `templates.yaml`. +Here's an outline of the schema for a CWC template: + +```yaml +templates: + - name: template_name + description: A brief description of the template's purpose + defaultPrompt: An optional default prompt to use if none is provided + systemMessage: | + The system message that details the instructions and context for the chat session. + This message supports placeholders for {{ .Context }} which is the gathered file context, + as well as custom variables `{{ .Variables.variableName }}` fed into the session with cli args. + variables: + - name: variableName + description: Description of the variable + defaultValue: Default value for the variable +``` + +### Placement + +Templates may be placed within the repository or under the user's configuration directory, adhering to the XDG Base Directory Specification: + +1. **In the Repository Directory**: To include the templates specifically for a repository, place a `templates.yaml` inside the `.cwc` directory at the root of your repository: + + ``` + . + ├── .cwc + │ └── templates.yaml + ... + ``` + +2. **In the User XDG CWC Config Directory**: For global user templates, place the `templates.yaml` within the XDG configuration directory for CWC, which is typically `~/.config/cwc/` on Unix-like systems: + + ``` + $XDG_CONFIG_HOME/cwc/templates.yaml + ``` + + If `$XDG_CONFIG_HOME` is not set, it defaults to `~/.config`. + +### Example Usage + +You can specify a template using the `-t` flag and pass variables with the `-v` flag in the terminal. These flags allow you to customize the chat session based on the selected template and provided variables. + +#### Selecting a Template + +To begin a chat session using a specific template, use the `-t` flag followed by the template name: + +```sh +cwc -t my_template +``` + +This command will start a conversation with the system message and default prompt defined in the template named `my_template`. + +#### Passing Variables to a Template + +You can pass variables to a template using the `-v` flag followed by a key-value pair: + +```sh +cwc -t my_template -v personality="a helpful assistant",name="Juno" +``` + +Here, the `my_template` template is used. The `personality` variable is set to "a helpful coding assistant", and +the `name` variable is set to "Juno". These variables will be fed into the template's system message where placeholders are specified. + +The template supporting these variables might look like this: + +```yaml +name: my_template +description: A custom template with modifiable personality and name +systemMessage: | + You are {{ .Variables.personality }} named {{ .Variables.name }}. + Using the following context you will be able to help the user. + + Context: + {{ .Context }} + + Please keep in mind your personality when responding to the user. + If the user asks for your name, you should respond with {{ .Variables.name }}. +variables: + - name: personality + description: The personality of the assistant. e.g. "a helpful assistant" + defaultValue: a helpful assistant + - name: name + description: The name of the assistant. e.g. "Juno" + defaultValue: Juno +``` + +> Notice that the `personality` and `name` variables have default values, which will be used if no value is provided in the `-v` flag. + ## Roadmap -> Note: these items may or may not be implemented in the future. +These items may or may not be implemented in the future. - [ ] tests - [ ] support both azure and openai credentials - [ ] customizable tools -- [ ] system message / prompt templates with `-t` flag ## Contributing diff --git a/cmd/cwc.go b/cmd/cwc.go index 9fc881a..595e26c 100644 --- a/cmd/cwc.go +++ b/cmd/cwc.go @@ -1,16 +1,20 @@ package cmd import ( + stdErrors "errors" "fmt" "io" "os" + "path/filepath" "strings" + tt "text/template" "github.com/intility/cwc/pkg/chat" "github.com/intility/cwc/pkg/config" "github.com/intility/cwc/pkg/errors" "github.com/intility/cwc/pkg/filetree" "github.com/intility/cwc/pkg/pathmatcher" + "github.com/intility/cwc/pkg/templates" "github.com/intility/cwc/pkg/ui" "github.com/sashabaranov/go-openai" "github.com/spf13/cobra" @@ -29,6 +33,7 @@ Features at a glance: - Option to specify directories for inclusion scope - Interactive file selection and confirmation - Reading from standard input for a non-interactive session +- Use of templates for system messages and default prompts The command can also receive context from standard input, useful for piping the output from another command as input. @@ -41,7 +46,11 @@ Including 'main.go' files from a specific path: > cwc --include='main.go' --paths='./cmd' Using the output of another command: -> git diff | cwc "Short commit message for these changes"` +> git diff | cwc "Short commit message for these changes" + +Using a specific template: +> cwc --template=tech_writer --template-variables rizz=max +` ) func CreateRootCommand() *cobra.Command { @@ -51,6 +60,8 @@ func CreateRootCommand() *cobra.Command { pathsFlag []string excludeFromGitignoreFlag bool excludeGitDirFlag bool + templateFlag string + templateVariablesFlag map[string]string ) loginCmd := createLoginCmd() @@ -63,15 +74,17 @@ func CreateRootCommand() *cobra.Command { Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if isPiped(os.Stdin) { - return nonInteractive(args) + return nonInteractive(args, templateFlag, templateVariablesFlag) } - gatherOpts := &chatOptions{ + chatOpts := &chatOptions{ includeFlag: includeFlag, excludeFlag: excludeFlag, pathsFlag: pathsFlag, excludeFromGitignoreFlag: excludeFromGitignoreFlag, excludeGitDirFlag: excludeGitDirFlag, + templateFlag: templateFlag, + templateVariablesFlag: templateVariablesFlag, } var prompt string @@ -79,7 +92,7 @@ func CreateRootCommand() *cobra.Command { prompt = args[0] } - return interactiveChat(prompt, gatherOpts) + return interactiveChat(prompt, chatOpts) }, } @@ -89,10 +102,13 @@ func CreateRootCommand() *cobra.Command { pathsFlag: &pathsFlag, excludeFromGitignoreFlag: &excludeFromGitignoreFlag, excludeGitDirFlag: &excludeGitDirFlag, + templateFlag: &templateFlag, + templateVariablesFlag: &templateVariablesFlag, }) cmd.AddCommand(loginCmd) cmd.AddCommand(logoutCmd) + cmd.AddCommand(createTemplatesCmd()) return cmd } @@ -103,6 +119,8 @@ type flags struct { pathsFlag *[]string excludeFromGitignoreFlag *bool excludeGitDirFlag *bool + templateFlag *string + templateVariablesFlag *map[string]string } func initFlags(cmd *cobra.Command, flags *flags) { @@ -112,6 +130,9 @@ func initFlags(cmd *cobra.Command, flags *flags) { 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") cmd.Flag("include"). Usage = "Specify a regex pattern to include files. " + @@ -125,6 +146,11 @@ func initFlags(cmd *cobra.Command, flags *flags) { 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" + cmd.Flag("template-variables"). + Usage = "Specify variables to use in the template. For example, to use the variable 'name' " + + "with the value 'John', use --template-variables name=John" } func isPiped(file *os.File) bool { @@ -136,7 +162,7 @@ func isPiped(file *os.File) bool { return (fileInfo.Mode() & os.ModeCharDevice) == 0 } -func interactiveChat(prompt string, gatherOpts *chatOptions) error { +func interactiveChat(prompt string, chatOpts *chatOptions) error { // Load configuration cfg, err := config.NewFromConfigFile() if err != nil { @@ -146,7 +172,7 @@ func interactiveChat(prompt string, gatherOpts *chatOptions) error { client := openai.NewClientWithConfig(cfg) // Gather context from files - files, fileTree, err := gatherAndPrintContext(gatherOpts) + files, fileTree, err := gatherAndPrintContext(chatOpts) if err != nil { return err } else if len(files) == 0 { // No files found, terminating or confirming to proceed @@ -155,13 +181,17 @@ func interactiveChat(prompt string, gatherOpts *chatOptions) error { } } - systemMessage := createSystemMessage(fileTree, files) + contextStr := createContext(fileTree, files) + + systemMessage, err := createSystemMessage(contextStr, chatOpts.templateFlag, chatOpts.templateVariablesFlag) + if err != nil { + return fmt.Errorf("error creating system message: %w", err) + } ui.PrintMessage("Type '/exit' to end the chat.\n", ui.MessageTypeNotice) if prompt == "" { - ui.PrintMessage("👤: ", ui.MessageTypeInfo) - prompt = ui.ReadUserInput() + prompt = getPromptFromUserOrTemplate(chatOpts.templateFlag) } else { ui.PrintMessage(fmt.Sprintf("👤: %s\n", prompt), ui.MessageTypeInfo) } @@ -170,6 +200,40 @@ func interactiveChat(prompt string, gatherOpts *chatOptions) error { return nil } + handleChat(client, systemMessage, prompt) + + return nil +} + +func getPromptFromUserOrTemplate(templateName string) string { + // get default prompt from template + var prompt string + + if templateName == "" { + ui.PrintMessage("👤: ", ui.MessageTypeInfo) + return ui.ReadUserInput() + } + + template, err := getTemplate(templateName) + if err != nil { + ui.PrintMessage(err.Error()+"\n", ui.MessageTypeWarning) + ui.PrintMessage("👤: ", ui.MessageTypeInfo) + + return ui.ReadUserInput() + } + + if template.DefaultPrompt == "" { + ui.PrintMessage("👤: ", ui.MessageTypeInfo) + prompt = ui.ReadUserInput() + } else { + prompt = template.DefaultPrompt + ui.PrintMessage(fmt.Sprintf("👤: %s\n", prompt), ui.MessageTypeInfo) + } + + return prompt +} + +func handleChat(client *openai.Client, systemMessage string, prompt string) { chatInstance := chat.NewChat(client, systemMessage, printMessageChunk) conversation := chatInstance.BeginConversation(prompt) @@ -185,13 +249,24 @@ func interactiveChat(prompt string, gatherOpts *chatOptions) error { conversation.Reply(userMessage) } +} - return nil +func createContext(fileTree string, files []filetree.File) string { + contextStr := "File tree:\n\n" + contextStr += "```\n" + fileTree + "```\n\n" + contextStr += "File contents:\n\n" + + for _, file := range files { + // find extension by splitting on ".". if no extension, use + contextStr += fmt.Sprintf("./%s\n```%s\n%s\n```\n\n", file.Path, file.Type, file.Data) + } + + return contextStr } // gatherAndPrintContext gathers file context based on provided options and prints it out. -func gatherAndPrintContext(gatherOpts *chatOptions) ([]filetree.File, string, error) { - files, rootNode, err := gatherContext(gatherOpts) +func gatherAndPrintContext(chatOptions *chatOptions) ([]filetree.File, string, error) { + files, rootNode, err := gatherContext(chatOptions) if err != nil { return nil, "", err } @@ -230,17 +305,73 @@ func printLargeFileWarning(file filetree.File) { } } -func createSystemMessage(fileTree string, files []filetree.File) string { - contextStr := "File tree:\n\n" - contextStr += "```\n" + fileTree + "```\n\n" - contextStr += "File contents:\n\n" +func getTemplate(templateName string) (*templates.Template, error) { + if templateName == "" { + templateName = "default" + } - for _, file := range files { - // find extension by splitting on ".". if no extension, use - contextStr += fmt.Sprintf("./%s\n```%s\n%s\n```\n\n", file.Path, file.Type, file.Data) + var locators []templates.TemplateLocator + + configDir, err := config.GetConfigDir() + if err == nil { + locators = append(locators, templates.NewYamlFileTemplateLocator(filepath.Join(configDir, "templates.yaml"))) } - return createSystemMessageFromContext(contextStr) + locators = append(locators, templates.NewYamlFileTemplateLocator(filepath.Join(".cwc", "templates.yaml"))) + mergedLocator := templates.NewMergedTemplateLocator(locators...) + + tmpl, err := mergedLocator.GetTemplate(templateName) + if err != nil { + return nil, fmt.Errorf("error getting template: %w", err) + } + + return tmpl, nil +} + +func createSystemMessage(ctx string, templateName string, templateVariables map[string]string) (string, error) { + template, err := getTemplate(templateName) + + if templateVariables == nil { + templateVariables = make(map[string]string) + } + + // if no template found, create a basic template as fallback + var templateNotFoundError errors.TemplateNotFoundError + if err != nil && stdErrors.As(err, &templateNotFoundError) { + return createBuiltinSystemMessageFromContext(ctx), nil + } + + // compile the template.SystemMessage as a go template + tmpl, err := tt.New("systemMessage").Parse(template.SystemMessage) + if err != nil { + return "", fmt.Errorf("error parsing template: %w", err) + } + + type valueBag struct { + Context string + Variables map[string]string + } + + // populate the variables map with default values if not provided + for _, v := range template.Variables { + if _, ok := templateVariables[v.Name]; !ok { + templateVariables[v.Name] = v.DefaultValue + } + } + + values := valueBag{ + Context: ctx, + Variables: templateVariables, + } + + writer := &strings.Builder{} + err = tmpl.Execute(writer, values) + + if err != nil { + return "", fmt.Errorf("error executing template: %w", err) + } + + return writer.String(), nil } func printMessageChunk(chunk *chat.ConversationChunk) { @@ -260,10 +391,25 @@ func printMessageChunk(chunk *chat.ConversationChunk) { ui.PrintMessage(chunk.Content, ui.MessageTypeInfo) } -func nonInteractive(args []string) error { - // stdin is not a terminal, typically piped from another command - if len(args) == 0 { - return &errors.NoPromptProvidedError{Message: "no prompt provided"} +func nonInteractive(args []string, templateName string, templateVars map[string]string) error { + var prompt 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"} + } + } + } + + prompt = template.DefaultPrompt + + // args takes precedence over template.DefaultPrompt + if len(args) > 0 { + prompt = args[0] } inputBytes, err := io.ReadAll(os.Stdin) @@ -272,7 +418,11 @@ func nonInteractive(args []string) error { } systemContext := string(inputBytes) - systemMessage := createSystemMessageFromContext(systemContext) + + systemMessage, err := createSystemMessage(systemContext, templateName, templateVars) + if err != nil { + return fmt.Errorf("error creating system message: %w", err) + } cfg, err := config.NewFromConfigFile() if err != nil { @@ -285,14 +435,14 @@ func nonInteractive(args []string) error { ui.PrintMessage(chunk.Content, ui.MessageTypeInfo) } chatInstance := chat.NewChat(client, systemMessage, onChunk) - conversation := chatInstance.BeginConversation(args[0]) + conversation := chatInstance.BeginConversation(prompt) conversation.WaitMyTurn() return nil } -func createSystemMessageFromContext(context string) string { +func createBuiltinSystemMessageFromContext(context string) string { var systemMessage strings.Builder systemMessage.WriteString("You are a helpful coding assistant. ") @@ -311,6 +461,8 @@ type chatOptions struct { pathsFlag []string excludeFromGitignoreFlag bool excludeGitDirFlag bool + templateFlag string + templateVariablesFlag map[string]string } func gatherContext(opts *chatOptions) ([]filetree.File, *filetree.FileNode, error) { diff --git a/cmd/templates.go b/cmd/templates.go new file mode 100644 index 0000000..120cbf5 --- /dev/null +++ b/cmd/templates.go @@ -0,0 +1,136 @@ +package cmd + +import ( + "path/filepath" + "strconv" + + "github.com/intility/cwc/pkg/config" + "github.com/intility/cwc/pkg/templates" + "github.com/intility/cwc/pkg/ui" + "github.com/spf13/cobra" +) + +type Template struct { + template templates.Template + placement string + isOverridingGlobal bool +} + +func createTemplatesCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "templates", + Short: "Lists available templates", + RunE: func(cmd *cobra.Command, args []string) error { + tmpls := locateTemplates() + + if len(tmpls) == 0 { + ui.PrintMessage("No templates found", ui.MessageTypeInfo) + return nil + } + + if cfgDir, err := config.GetConfigDir(); err == nil { + ui.PrintMessage("global", ui.MessageTypeWarning) + ui.PrintMessage(": the template is defined in "+ + filepath.Join(cfgDir, "templates.yaml")+"\n", ui.MessageTypeInfo) + } + + ui.PrintMessage("local", ui.MessageTypeSuccess) + ui.PrintMessage(": the template is defined in ./cwc/templates.yaml\n", ui.MessageTypeInfo) + + ui.PrintMessage("overridden", ui.MessageTypeError) + ui.PrintMessage(": the local template is overriding a global template with the same name\n\n", ui.MessageTypeInfo) + + ui.PrintMessage("Available templates:\n", ui.MessageTypeInfo) + + for _, template := range tmpls { + if template.isOverridingGlobal { + template.placement = "overridden" + } + + placementMessageType := ui.MessageTypeSuccess + if template.placement == "global" { + placementMessageType = ui.MessageTypeWarning + } + + if template.isOverridingGlobal { + placementMessageType = ui.MessageTypeError + } + + ui.PrintMessage("- name: ", ui.MessageTypeInfo) + ui.PrintMessage(template.template.Name, ui.MessageTypeInfo) + ui.PrintMessage(" ("+template.placement+")\n", placementMessageType) + printTemplateInfo(template.template) + } + + return nil + }, + } + + return cmd +} + +func locateTemplates() map[string]Template { + var localTemplates, globalTemplates []templates.Template + + cfgDir, err := config.GetConfigDir() + if err == nil { + globalTemplatesLocator := templates.NewYamlFileTemplateLocator(filepath.Join(cfgDir, "templates.yaml")) + locatedTemplates, err := globalTemplatesLocator.ListTemplates() + + if err == nil { + globalTemplates = locatedTemplates + } + } + + localTemplatesLocator := templates.NewYamlFileTemplateLocator(filepath.Join(".cwc", "templates.yaml")) + locatedTemplates, err := localTemplatesLocator.ListTemplates() + + if err == nil { + localTemplates = locatedTemplates + } + + tmpls := make(map[string]Template) + + // populate the list of templates, marking the local ones as overriding the global ones if they have the same name + for _, t := range globalTemplates { + tmpls[t.Name] = Template{template: t, placement: "global", isOverridingGlobal: false} + } + + for _, t := range localTemplates { + _, exists := tmpls[t.Name] + tmpls[t.Name] = Template{template: t, placement: "local", isOverridingGlobal: exists} + } + + return tmpls +} + +func printTemplateInfo(template templates.Template) { + ui.PrintMessage(" description: "+template.Description+"\n", ui.MessageTypeInfo) + + dfp := "no" + if template.DefaultPrompt != "" { + dfp = "yes" + } + + ui.PrintMessage(" has_default_prompt: "+dfp+"\n", ui.MessageTypeInfo) + + variablesCount := len(template.Variables) + + ui.PrintMessage(" variables: "+strconv.Itoa(variablesCount)+"\n", ui.MessageTypeInfo) + + for _, variable := range template.Variables { + ui.PrintMessage(" - name: ", ui.MessageTypeInfo) + ui.PrintMessage(variable.Name, ui.MessageTypeInfo) + ui.PrintMessage("\n", ui.MessageTypeInfo) + ui.PrintMessage(" description: "+variable.Description+"\n", ui.MessageTypeInfo) + + dv := "no" + if variable.DefaultValue != "" { + dv = "yes" + } + + ui.PrintMessage(" has_default_value: "+dv+"\n", ui.MessageTypeInfo) + } + + ui.PrintMessage("\n", ui.MessageTypeInfo) +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 9fa60a4..aaf3a19 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -193,3 +193,7 @@ func ClearConfig() error { return nil } + +func GetConfigDir() (string, error) { + return xdgConfigPath() +} diff --git a/pkg/errors/errors.go b/pkg/errors/errors.go index 78d8689..22328c6 100644 --- a/pkg/errors/errors.go +++ b/pkg/errors/errors.go @@ -83,3 +83,11 @@ type NoPromptProvidedError struct { func (e NoPromptProvidedError) Error() string { return e.Message } + +type TemplateNotFoundError struct { + TemplateName string +} + +func (e TemplateNotFoundError) Error() string { + return "template not found: " + e.TemplateName +} diff --git a/pkg/templates/mergedTemplateLocator.go b/pkg/templates/mergedTemplateLocator.go new file mode 100644 index 0000000..abb159e --- /dev/null +++ b/pkg/templates/mergedTemplateLocator.go @@ -0,0 +1,65 @@ +package templates + +import ( + stdErrors "errors" + "fmt" + + "github.com/intility/cwc/pkg/errors" +) + +// MergedTemplateLocator is a TemplateLocator that merges templates from multiple locators +// making the last applied locator the one that takes precedence in case of name conflicts. +type MergedTemplateLocator struct { + locators []TemplateLocator +} + +// NewMergedTemplateLocator creates a new MergedTemplateLocator. +func NewMergedTemplateLocator(locators ...TemplateLocator) *MergedTemplateLocator { + return &MergedTemplateLocator{ + locators: locators, + } +} + +// ListTemplates returns a list of available templates. +func (c *MergedTemplateLocator) ListTemplates() ([]Template, error) { + // Merge templates from all locators + templates := make(map[string]Template) + + for _, l := range c.locators { + t, err := l.ListTemplates() + if err != nil { + return nil, fmt.Errorf("error listing templates: %w", err) + } + + for _, template := range t { + templates[template.Name] = template + } + } + + mergedTemplates := make([]Template, 0, len(templates)) + for _, t := range templates { + mergedTemplates = append(mergedTemplates, t) + } + + return mergedTemplates, nil +} + +// GetTemplate returns a template by name. +func (c *MergedTemplateLocator) GetTemplate(name string) (*Template, error) { + // Get template from the last locator that has it + for i := len(c.locators) - 1; i >= 0; i-- { + tmpl, err := c.locators[i].GetTemplate(name) + + // if template not found, continue to the next locator + var templateNotFoundError errors.TemplateNotFoundError + if stdErrors.As(err, &templateNotFoundError) { + continue + } else if err != nil { + return nil, fmt.Errorf("error getting template: %w", err) + } + + return tmpl, nil + } + + return nil, errors.TemplateNotFoundError{TemplateName: name} +} diff --git a/pkg/templates/templates.go b/pkg/templates/templates.go new file mode 100644 index 0000000..c421cf2 --- /dev/null +++ b/pkg/templates/templates.go @@ -0,0 +1,37 @@ +package templates + +type Template struct { + // Name is the name of the template + Name string `yaml:"name"` + + // Description is a short description of the template + Description string `yaml:"description"` + + // DefaultPrompt is the prompt that is used if no prompt is provided + DefaultPrompt string `yaml:"defaultPrompt,omitempty"` + + // SystemMessage is the message that primes the conversation + SystemMessage string `yaml:"systemMessage"` + + // Variables is a list of input variables for the template + Variables []TemplateVariable `yaml:"variables"` +} + +type TemplateVariable struct { + // Name is the name of the input variable + Name string `yaml:"name"` + + // Description is a short description of the input variable + Description string `yaml:"description"` + + // DefaultValue is the value used if no override is provided + DefaultValue string `yaml:"defaultValue,omitempty"` +} + +type TemplateLocator interface { + // ListTemplates returns a list of available templates + ListTemplates() ([]Template, error) + + // GetTemplate returns a template by name + GetTemplate(name string) (*Template, error) +} diff --git a/pkg/templates/yamlFileTemplateLocator.go b/pkg/templates/yamlFileTemplateLocator.go new file mode 100644 index 0000000..ba51115 --- /dev/null +++ b/pkg/templates/yamlFileTemplateLocator.go @@ -0,0 +1,65 @@ +package templates + +import ( + "fmt" + "os" + + "github.com/intility/cwc/pkg/errors" + "gopkg.in/yaml.v3" +) + +type YamlFileTemplateLocator struct { + // Path is the path to the directory containing the templates + Path string +} + +// configFile is a struct that represents the yaml file containing the templates. +type configFile struct { + Templates []Template `yaml:"templates"` +} + +func NewYamlFileTemplateLocator(path string) *YamlFileTemplateLocator { + return &YamlFileTemplateLocator{ + Path: path, + } +} + +func (y *YamlFileTemplateLocator) ListTemplates() ([]Template, error) { + // no configured templates file is a valid state + // and should not return an error + _, err := os.Stat(y.Path) + if os.IsNotExist(err) { + return []Template{}, nil + } + + file, err := os.Open(y.Path) + if err != nil { + return nil, fmt.Errorf("error opening file: %w", err) + } + + decoder := yaml.NewDecoder(file) + + var cfg configFile + err = decoder.Decode(&cfg) + + if err != nil { + return nil, fmt.Errorf("error decoding file: %w", err) + } + + return cfg.Templates, nil +} + +func (y *YamlFileTemplateLocator) GetTemplate(name string) (*Template, error) { + templates, err := y.ListTemplates() + if err != nil { + return nil, fmt.Errorf("error getting template: %w", err) + } + + for _, tmpl := range templates { + if tmpl.Name == name { + return &tmpl, nil + } + } + + return nil, errors.TemplateNotFoundError{TemplateName: name} +} From 00457f7f67968863e077a18402b7b9b30b497bfd Mon Sep 17 00:00:00 2001 From: Emil Kjelsrud Date: Sat, 16 Mar 2024 20:45:26 +0100 Subject: [PATCH 2/2] fix(cwc): fix lint error (long lines) --- cmd/cwc.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd/cwc.go b/cmd/cwc.go index 595e26c..32346c3 100644 --- a/cmd/cwc.go +++ b/cmd/cwc.go @@ -147,7 +147,8 @@ func initFlags(cmd *cobra.Command, flags *flags) { 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" + Usage = "Specify the name of the template to use. For example, " + + "to use a template named 'tech_writer', use --template tech_writer" cmd.Flag("template-variables"). Usage = "Specify variables to use in the template. For example, to use the variable 'name' " + "with the value 'John', use --template-variables name=John"