├── .cwc └── templates.yaml ├── .github ├── RELEASE-TEMPLATE.md └── workflows │ ├── pull-request.yaml │ └── release.yaml ├── .gitignore ├── .golangci.yaml ├── .mockery.yaml ├── LICENSE ├── Makefile ├── README.md ├── cmd ├── config.go ├── cwc.go ├── login.go ├── logout.go └── templates.go ├── docs └── assets │ ├── screenshot.png │ └── yelling_at_code.webp ├── generator ├── go.mod ├── go.sum └── lang-gen.go ├── go.mod ├── go.sum ├── internal ├── interactive.go └── noninteractive.go ├── main.go ├── mocks ├── APIKeyStore.go ├── ClientProvider.go ├── ContextRetriever.go ├── FileManager.go ├── FileReader.go ├── Marshaller.go ├── Parser.go ├── Provider.go ├── SystemMessageGenerator.go ├── TemplateLocator.go ├── UsernameRetriever.go ├── Validator.go └── optFunc.go ├── pkg ├── chat │ └── chat.go ├── config │ ├── apiKeyFileStore.go │ ├── apiKeyKeyringStore.go │ ├── apikeyStore.go │ ├── client.go │ ├── client_test.go │ ├── config.go │ ├── fs.go │ ├── provider.go │ ├── validator.go │ ├── xdg.go │ └── yaml.go ├── errors │ └── errors.go ├── filetree │ ├── filetree.go │ └── languages.go ├── pathmatcher │ ├── compound.go │ ├── gitignore.go │ ├── pathmatcher.go │ └── regex.go ├── prompting │ ├── prompt.go │ └── prompt_test.go ├── systemcontext │ ├── file.go │ ├── ioreader.go │ ├── retriever.go │ ├── systemmessage.go │ └── systemmessage_test.go ├── templates │ ├── mergedTemplateLocator.go │ ├── templates.go │ └── yamlFileTemplateLocator.go └── ui │ ├── ui.go │ └── ui_test.go └── scripts └── install.sh /.cwc/templates.yaml: -------------------------------------------------------------------------------- 1 | templates: 2 | - name: default 3 | description: The default template to use if not otherwise specified. 4 | systemMessage: | 5 | You are {{ .Variables.personality }}. 6 | Using the following context you will try to help the user as best as you can. 7 | 8 | Context: 9 | {{ .Context }} 10 | 11 | Please keep in mind your personality when responding to the user. 12 | variables: 13 | - name: personality 14 | description: The personality of the assistant. e.g. "a helpful assistant" 15 | defaultValue: "a helpful assistant" 16 | 17 | - name: cc 18 | description: A template for conventional commits. 19 | defaultPrompt: "Given these changes please help me author a conventional commit message." 20 | systemMessage: | 21 | You are an expert coder and technical writer. 22 | Using the following diff you will be able to create a conventional commit message. 23 | 24 | Diff: 25 | ```diff 26 | {{ .Context }} 27 | ``` 28 | 29 | Instructions: 30 | 31 | * Unless otherwise specified, please respond with only the commit message. 32 | * Do not guess any type of issues references or otherwise that are not present in the diff. 33 | * Keep the line length to 50 in the title and 72 in the body. 34 | * Do not format the output with ``` blocks or other markdown features, 35 | only return the message title and body in plain text. 36 | 37 | My job depends on your ability to follow these instructions, you can do this! 38 | - name: refactor 39 | description: A template for refactoring code. 40 | systemMessage: | 41 | You are an expert programmer specializing in refactoring code. 42 | Using the following context you will be able to refactor the code as the user requests. 43 | 44 | Context: 45 | {{ .Context }} 46 | 47 | Procedure: 48 | 49 | 1. Identify the problematic code. 50 | 2. Think about refactoring patterns and best practices put forward by Martin Fowler and others. 51 | 3. Reason about how to apply those patterns to the code. 52 | 4. Refactor the code. 53 | 54 | Assume that the user is a senior developer and can understand and discuss the refactoring decisions. 55 | Don't make assumptions about the code that are not present in the context. 56 | Please defer to the user for any additional information needed to effectively refactor the code. -------------------------------------------------------------------------------- /.github/RELEASE-TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | -------------------------------------------------------------------------------- /.github/workflows/pull-request.yaml: -------------------------------------------------------------------------------- 1 | # Triggers the workflow on pull request events 2 | name: PR Checks 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | defaults: 9 | run: 10 | shell: bash 11 | 12 | env: 13 | GO111MODULE: 'on' 14 | GOVERSION: '1.22' 15 | 16 | jobs: 17 | lint: 18 | name: Lint files 19 | runs-on: 'ubuntu-latest' 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: actions/setup-go@v4 23 | with: 24 | go-version: ${{ env.GOVERSION }} 25 | - name: golangci-lint 26 | uses: golangci/golangci-lint-action@v4 27 | with: 28 | version: v1.54 29 | test: 30 | name: Run tests 31 | runs-on: 'ubuntu-latest' 32 | steps: 33 | - uses: actions/checkout@v4 34 | - uses: actions/setup-go@v4 35 | with: 36 | go-version: ${{ env.GOVERSION }} 37 | - name: Run Tests 38 | run: go test -v -cover 39 | security: 40 | name: Security Scan 41 | runs-on: 'ubuntu-latest' 42 | steps: 43 | - name: Checkout Source 44 | uses: actions/checkout@v4 45 | - name: Setup Go 46 | uses: actions/setup-go@v4 47 | with: 48 | go-version: ${{ env.GOVERSION }} 49 | - name: Run Gosec Security Scanner 50 | uses: securego/gosec@master 51 | with: 52 | args: '-no-fail -fmt sarif -out results.sarif ./...' 53 | - name: Upload SARIF file 54 | uses: github/codeql-action/upload-sarif@v2 55 | with: 56 | # Path to SARIF file relative to the root of the repository 57 | sarif_file: results.sarif -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | # Sequence of patterns matched against refs/tags 4 | tags: 5 | - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 6 | 7 | name: Latest Release 8 | 9 | defaults: 10 | run: 11 | shell: bash 12 | 13 | jobs: 14 | lint: 15 | name: Lint files 16 | runs-on: 'ubuntu-latest' 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: actions/setup-go@v4 20 | with: 21 | go-version: '1.22' 22 | - name: golangci-lint 23 | uses: golangci/golangci-lint-action@v4 24 | with: 25 | version: v1.54 26 | test: 27 | name: Run tests 28 | runs-on: 'ubuntu-latest' 29 | needs: lint 30 | steps: 31 | - uses: actions/checkout@v4 32 | - uses: actions/setup-go@v4 33 | with: 34 | go-version: '1.22' 35 | - run: go test -v -cover 36 | 37 | build: 38 | name: Build binaries 39 | runs-on: 'ubuntu-latest' 40 | needs: test 41 | strategy: 42 | matrix: 43 | os: [ 'darwin', 'linux', 'windows' ] 44 | arch: [ 'amd64', 'arm64' ] 45 | steps: 46 | - name: Checkout code 47 | uses: actions/checkout@v4 48 | - uses: actions/setup-go@v4 49 | with: 50 | go-version: '1.22' 51 | - name: Build binary 52 | run: | 53 | GOOS=${{ matrix.os }} 54 | GOARCH=${{ matrix.arch }} 55 | BINARY_NAME=cwc-$GOOS-$GOARCH 56 | if [ "$GOOS" = "windows" ]; then 57 | BINARY_NAME="$BINARY_NAME".exe 58 | fi 59 | GOOS=$GOOS GOARCH=$GOARCH go build -o "$BINARY_NAME" -v 60 | echo "BINARY_NAME=$BINARY_NAME" >> $GITHUB_ENV 61 | echo "GOOS=$GOOS" >> $GITHUB_ENV 62 | echo "GOARCH=$GOARCH" >> $GITHUB_ENV 63 | - name: Upload binary 64 | uses: actions/upload-artifact@v4 65 | with: 66 | name: ${{env.BINARY_NAME}} 67 | path: ${{env.BINARY_NAME}} 68 | 69 | release: 70 | name: Create Release 71 | runs-on: 'ubuntu-latest' 72 | needs: [ build ] 73 | steps: 74 | - name: Checkout code 75 | uses: actions/checkout@v4 76 | - name: Download all binaries 77 | uses: actions/download-artifact@v4 78 | - name: Release Notes 79 | run: | 80 | git log $(git describe HEAD~ --tags --abbrev=0)..HEAD --pretty='format:* %h %s%n * %an <%ae>' --no-merges >> ".github/RELEASE-TEMPLATE.md" 81 | - name: Create Release with Notes 82 | uses: softprops/action-gh-release@v1 83 | with: 84 | body_path: ".github/RELEASE-TEMPLATE.md" 85 | draft: true 86 | files: "**/cwc-*" 87 | token: ${{secrets.GITHUB_TOKEN}} 88 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | bin/ 3 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | linters: 2 | disable-all: true 3 | enable: 4 | - depguard 5 | - revive 6 | - stylecheck 7 | - gosec 8 | - goconst 9 | - gocyclo 10 | - gci 11 | - unparam 12 | - govet 13 | presets: 14 | - bugs 15 | - comment 16 | - complexity 17 | - error 18 | - format 19 | - import 20 | # - metalinter #slow 21 | - module 22 | - performance 23 | # - sql 24 | - style 25 | - test 26 | - unused 27 | linters-settings: 28 | revive: 29 | ignore-generated-header: true 30 | enable-all-rules: false 31 | # Specifying any rule explicitly will disable the default-enabled rules. 32 | # Manually specify the defaults along with `context-as-argument`. 33 | rules: 34 | # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md 35 | - name: argument-limit 36 | arguments: [4] 37 | - name: bare-return 38 | disabled: false 39 | - name: blank-imports 40 | disabled: false 41 | - name: error-naming 42 | disabled: false 43 | - name: error-return 44 | disabled: false 45 | - name: error-strings 46 | disabled: false 47 | - name: exported 48 | disabled: false 49 | - name: increment-decrement 50 | disabled: false 51 | - name: indent-error-flow 52 | disabled: false 53 | - name: receiver-naming 54 | disabled: false 55 | - name: range 56 | disabled: false 57 | - name: var-naming 58 | disabled: false 59 | stylecheck: 60 | checks: ["all"] 61 | gci: 62 | custom-order: true 63 | sections: 64 | - standard 65 | - default 66 | - prefix(github.com/intility/cwc) 67 | depguard: 68 | rules: 69 | prevent_unmaintained_packages: 70 | list-mode: lax 71 | deny: 72 | - pkg: io/ioutil 73 | desc: "replaced by io and os packages since Go 1.16" 74 | nlreturn: 75 | block-size: 2 76 | forbidigo: 77 | forbid: 78 | - p: ^fmt\.Print.*$ 79 | msg: "use ui.PrintMessage" 80 | 81 | varnamelen: 82 | ignore-decls: 83 | - w http.ResponseWriter 84 | - r *http.Request 85 | - r chi.Router 86 | exhaustruct: 87 | # List of regular expressions to exclude struct packages and their names from checks. 88 | # Regular expressions must match complete canonical struct package/name/structname. 89 | # These packages typically contains no suitable constructors, but needs to be used with zero values. 90 | # Default: [] 91 | exclude: 92 | # std libs 93 | - "^net/http.Client$" 94 | - "^net/http.Cookie$" 95 | - "^net/http.Request$" 96 | - "^net/http.Response$" 97 | - "^net/http.Server$" 98 | - "^net/http.Transport$" 99 | # 3rd party 100 | - ".+/cobra.Command$" 101 | - ".+openai.ChatCompletionRequest" 102 | - ".+openai.ChatCompletionMessage" 103 | run: 104 | tests: false # skip test files 105 | skip-dirs: 106 | - bin 107 | - vendor 108 | - domain/house/mocks 109 | - .codecov.yml 110 | - .coveralls.yml 111 | - .git 112 | - .gitattributes 113 | - .github 114 | - .gitignore 115 | - .gitlab 116 | - .gitmodules 117 | - .golangci 118 | - .golangci.yaml 119 | - .goreleaser 120 | - .goreleaser.d 121 | - .goreleaser.yml 122 | - .idea 123 | - .mockery.yaml 124 | - .travis.yml 125 | - .vscode -------------------------------------------------------------------------------- /.mockery.yaml: -------------------------------------------------------------------------------- 1 | quiet: false 2 | disable-version-string: true 3 | with-expecter: true 4 | all: true 5 | mockname: "{{.InterfaceName}}" 6 | filename: "{{.MockName}}.go" 7 | dir: "mocks" 8 | outpkg: "mocks" 9 | packages: 10 | github.com/intility/cwc/internal: 11 | github.com/intility/cwc/pkg/templates: 12 | github.com/intility/cwc/pkg/config: 13 | github.com/intility/cwc/pkg/systemcontext: -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Emil Kjelsrud 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set) 2 | ifeq (,$(shell go env GOBIN)) 3 | GOBIN=$(shell go env GOPATH)/bin 4 | else 5 | GOBIN=$(shell go env GOBIN) 6 | endif 7 | 8 | # Setting SHELL to bash allows bash commands to be executed by recipes. 9 | # Options are set to exit when a recipe line exits non-zero or a piped command fails. 10 | SHELL = /usr/bin/env bash -o pipefail 11 | .SHELLFLAGS = -ec 12 | 13 | .PHONY: all 14 | all: build 15 | 16 | ##@ General 17 | 18 | # The help target prints out all targets with their descriptions organized 19 | # beneath their categories. The categories are represented by '##@' and the 20 | # target descriptions by '##'. The awk command is responsible for reading the 21 | # entire set of makefiles included in this invocation, looking for lines of the 22 | # file as xyz: ## something, and then pretty-format the target and help. Then, 23 | # if there's a line with ##@ something, that gets pretty-printed as a category. 24 | # More info on the usage of ANSI control characters for terminal formatting: 25 | # https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters 26 | # More info on the awk command: 27 | # http://linuxcommand.org/lc3_adv_awk.php 28 | 29 | .PHONY: help 30 | help: ## Display this help. 31 | @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) 32 | 33 | ##@ Development 34 | 35 | .PHONY: fmt 36 | fmt: ## Run go fmt against code. 37 | go fmt ./... 38 | 39 | .PHONY: vet 40 | vet: ## Run go vet against code. 41 | go vet ./... 42 | 43 | .PHONY: test 44 | test: fmt vet ## Run tests. 45 | go test ./... 46 | 47 | .PHONY: lint 48 | lint: golangci-lint ## Run golangci-lint linter & yamllint 49 | $(GOLANGCI_LINT) run ./... 50 | 51 | .PHONY: lint-fix 52 | lint-fix: golangci-lint ## Run golangci-lint linter and perform fixes 53 | $(GOLANGCI_LINT) run --fix ./... 54 | 55 | .PHONY: mocks 56 | mocks: mockery ## Generate mocks 57 | $(MOCKERY) 58 | 59 | ##@ Build 60 | 61 | .PHONY: build 62 | build: fmt vet ## Build the code generator. 63 | go build -o $(LOCALBIN)/cwc ./main.go 64 | 65 | .PHONY: run 66 | run: fmt vet check-env-vars ## Run the example app. 67 | go run main.go 68 | 69 | .PHONY: generate 70 | generate: lang-gen ## Generate code. 71 | go generate ./... 72 | 73 | .PHONY: cross-compile 74 | cross-compile: ## Cross compile the code. 75 | GOOS=linux GOARCH=arm64 go build -o $(LOCALBIN)/cwc-linux-arm64 ./main.go 76 | GOOS=linux GOARCH=amd64 go build -o $(LOCALBIN)/cwc-linux-amd64 ./main.go 77 | GOOS=darwin GOARCH=arm64 go build -o $(LOCALBIN)/cwc-darwin-arm64 ./main.go 78 | GOOS=darwin GOARCH=amd64 go build -o $(LOCALBIN)/cwc-darwin-amd64 ./main.go 79 | GOOS=windows GOARCH=amd64 go build -o $(LOCALBIN)/cwc-windows-amd64.exe ./main.go 80 | 81 | ##@ Pre Deployment 82 | 83 | .PHONY: security-scan 84 | security-scan: gosec ## Run security scan on the codebase. 85 | $(GOSEC) ./... 86 | 87 | .PHONY: dependency-scan 88 | dependency-scan: govulncheck ## Run dependency scan on the codebase. 89 | $(GOVULNCHECK) ./... 90 | 91 | ##@ Deployment 92 | 93 | 94 | ##@ Dependencies 95 | 96 | .PHONY: check-env-vars 97 | check-env-vars: 98 | @missing_vars=""; \ 99 | for var in AOAI_API_KEY AOAI_ENDPOINT AOAI_API_VERSION AOAI_MODEL_DEPLOYMENT; do \ 100 | if [ -z "$${!var}" ]; then \ 101 | missing_vars="$$missing_vars $$var"; \ 102 | fi \ 103 | done; \ 104 | if [ -n "$$missing_vars" ]; then \ 105 | echo "Error: the following env vars are not set:$$missing_vars"; \ 106 | exit 1; \ 107 | fi 108 | 109 | .PHONY: lang-gen 110 | lang-gen: 111 | cd generator && go build -o $(LOCALBIN)/lang-gen lang-gen.go 112 | 113 | ## Location to install dependencies to 114 | LOCALBIN ?= $(shell pwd)/bin 115 | $(LOCALBIN): 116 | mkdir -p $(LOCALBIN) 117 | 118 | ## Tool Binaries 119 | TOOLKIT_TOOLS_GEN = $(LOCALBIN)/toolkit-tools-gen-$(TOOLKIT_TOOLS_GEN_VERSION) 120 | GOLANGCI_LINT = $(LOCALBIN)/golangci-lint-$(GOLANGCI_LINT_VERSION) 121 | GOSEC = $(LOCALBIN)/gosec-$(GOSEC_VERSION) 122 | GOVULNCHECK = $(LOCALBIN)/govulncheck-$(GOVULNCHECK_VERSION) 123 | MOCKERY = $(LOCALBIN)/mockery-$(MOCKERY_VERSION) 124 | 125 | ## Tool Versions 126 | TOOLKIT_TOOLS_GEN_VERSION ?= latest 127 | GOLANGCI_LINT_VERSION ?= v1.54 128 | GOSEC_VERSION ?= latest 129 | GOVULNCHECK_VERSION ?= latest 130 | MOCKERY_VERSION ?= v2.42.1 131 | 132 | 133 | .PHONY: golangci-lint 134 | golangci-lint: $(GOLANGCI_LINT) ## Download golangci-lint locally if necessary. 135 | $(GOLANGCI_LINT): $(LOCALBIN) 136 | $(call go-install-tool,$(GOLANGCI_LINT),github.com/golangci/golangci-lint/cmd/golangci-lint,${GOLANGCI_LINT_VERSION}) 137 | 138 | .PHONY: gosec 139 | gosec: $(GOSEC) ## Download golangci-lint locally if necessary. 140 | $(GOSEC): $(LOCALBIN) 141 | $(call go-install-tool,$(GOSEC),github.com/securego/gosec/v2/cmd/gosec,$(GOSEC_VERSION)) 142 | 143 | .PHONY: govulncheck 144 | govulncheck: $(GOVULNCHECK) ## Download golangci-lint locally if necessary. 145 | $(GOVULNCHECK): $(LOCALBIN) 146 | $(call go-install-tool,$(GOVULNCHECK),golang.org/x/vuln/cmd/govulncheck,$(GOVULNCHECK_VERSION)) 147 | 148 | .PHONY: toolkit-tools-gen 149 | toolkit-tools-gen: $(TOOLKIT_TOOLS_GEN) ## Download toolkit-tools-gen locally if necessary. 150 | $(TOOLKIT_TOOLS_GEN): $(LOCALBIN) 151 | $(call go-install-tool,$(TOOLKIT_TOOLS_GEN),github.com/intility/go-openai-toolkit/cmd/toolkit-tools-gen,$(TOOLKIT_TOOLS_GEN_VERSION)) 152 | 153 | .PHONY: mockery 154 | mockery: $(MOCKERY) ## Download toolkit-tools-gen locally if necessary. 155 | $(MOCKERY): $(LOCALBIN) 156 | $(call go-install-tool,$(MOCKERY),github.com/vektra/mockery/v2,$(MOCKERY_VERSION)) 157 | 158 | 159 | # go-install-tool will 'go install' any package with custom target and name of binary, if it doesn't exist 160 | # $1 - target path with name of binary (ideally with version) 161 | # $2 - package url which can be installed 162 | # $3 - specific version of package 163 | define go-install-tool 164 | @[ -f $(1) ] || { \ 165 | set -e; \ 166 | package=$(2)@$(3) ;\ 167 | echo "Downloading $${package}" ;\ 168 | GOBIN=$(LOCALBIN) go install $${package} ;\ 169 | mv "$$(echo "$(1)" | sed "s/-$(3)$$//")" $(1) ;\ 170 | } 171 | endef 172 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # **C**hat **W**ith **C**ode 2 | 3 |
4 | 5 | Logo 6 | 7 |
8 | 9 | ## Overview 10 | 11 | Chat With Code is yet another command-line application that bridges the gap between coding and conversation. This tool lets you engage with your codebases using natural language prompts, offering a fresh approach to code exploration and problem-solving. 12 | 13 | **Why does this tool exist?** 14 | 15 | I was frequently disappointed with Github Copilot's chat context discovery, as it often missed relevant files for accurate answers. 16 | CWC improves this by allowing you to specify include and exclude patterns across your codebase, giving you complete control over 17 | the context window during chats. Additionally, its terminal-based operation makes it independent of IDEs like VSCode, 18 | enhancing flexibility with other text editors not natively supporting Github Copilot. 19 | 20 | ## Features 21 | 22 | - **Interactive Chat Sessions**: Start a dialogue with your codebase to learn about its structure, get summaries of different parts, or even debug issues. 23 | - **Intelligent Context-Aware Responses**: Powered by OpenAI, Chat With Code understands the context of your project, providing meaningful insights and relevant code snippets. 24 | - **Customizable File Inclusion**: Filter the files you want the tool to consider using regular expressions, ensuring focused and relevant chat interactions. 25 | - **Gitignore Awareness**: Exclude files listed in `.gitignore` from the chat context to maintain confidentiality and relevance. 26 | - **Simplicity**: A simple and intuitive interface that requires minimal setup to get started. 27 | 28 | ## Installation 29 | 30 | ### Using homebrew 31 | 32 | Intility provides a shared Homebrew tap with all our formulae. Install Chat With Code using: 33 | 34 | ```sh 35 | brew tap intility/tap 36 | brew install cwc 37 | ``` 38 | 39 | ### Using Go 40 | 41 | If you have Go installed (version 1.22 or higher), you can install Chat With Code using the following command: 42 | 43 | ```sh 44 | go install github.com/intility/cwc@latest 45 | ``` 46 | 47 | ### Pre-built Binaries 48 | 49 | We also provide pre-built binaries for Windows, macOS, and Linux. You can download them from the [releases page](https://github.com/intility/cwc/releases) on GitHub. 50 | You can install the latest release with the following command using a bash shell (git bash or WSL on Windows): 51 | 52 | ```sh 53 | bash <(curl -sSL https://raw.githubusercontent.com/intility/cwc/main/scripts/install.sh) 54 | 55 | # move the binary to a directory in your PATH 56 | mv cwc /usr/local/bin 57 | ``` 58 | 59 | ## Getting Started 60 | 61 | After installing Chat With Code, you're just a few steps away from a conversational coding experience. Get everything up and running with these instructions: 62 | 63 | 1. **Launch Chat With Code**: Open your terminal and enter `cwc` to start an interactive session with your codebase. 64 | If you are not already signed you will be prompted to configure your credentials. 65 | 66 | 2. **Authenticate**: To enable communication with your code, authenticate using your Azure credentials by executing: 67 | 68 | ```sh 69 | cwc login 70 | ``` 71 | 72 | *For a seamless login experience, follow the non-interactive authentication method below:* 73 | 74 | 1. Safeguard your API Key by storing it in a variable (avoid direct command-line input to protect the key from your history logs): 75 | 76 | ```sh 77 | read -s API_KEY 78 | ``` 79 | 80 | 2. Authenticate securely using the following command: 81 | 82 | ```sh 83 | cwc login \ 84 | --api-key=$API_KEY \ 85 | --endpoint "https://your-endpoint.openai.azure.com/" \ 86 | --deployment-name "gpt-4-turbo" 87 | ``` 88 | 89 | > **Security Notice**: Never input your API key directly into the command-line arguments to prevent potential exposure in shell history and process listings. The API key is securely stored in your personal keyring. 90 | 91 | After completing these steps, you will have established a secure session, ready to explore and interact with your codebase in the most natural way. 92 | 93 | ![screenshot][screenshot-url] 94 | 95 | Need a more tailored experience? Try customizing your session. Use the `--include`, `--exclude` flag to filter for specific file patterns or `--paths` to set directories included in the session. Discover all the available options with: 96 | 97 | ```sh 98 | cwc --help 99 | ``` 100 | 101 | ## Example usage 102 | 103 | The simplest example would be to chat with a single file or output from a command. This use-case is easy using a pipe: 104 | 105 | ```sh 106 | cat README.md | cwc "help me rewrite the getting started section" 107 | ``` 108 | 109 | If you have multiple files you want to include in the context you can provide a regular expression matching your criteria for inclusion using the `-i` flag: 110 | 111 | ```sh 112 | # chat across all .go files 113 | cwc -i ".*.go" 114 | 115 | # chat with README and test files 116 | cwc -i "README.md|.*_test.go" 117 | ``` 118 | 119 | The include flag can also be combined with exclusion expressions, these work exactly the same as the inclusion patterns, but takes priority: 120 | 121 | ```sh 122 | # chat with all .ts files, excluding a large .ts file 123 | cwc -i ".*.ts$" -x "large_file.ts" 124 | ``` 125 | 126 | In addition to include and exclude expressions you can also scope the search space to a particular directory. Multiple paths can be provided by a comma separated list or by providing multiple instances of the `-p` flag. 127 | 128 | ```sh 129 | # chat with everything inside src/ except .tsx files 130 | cwc -x ".*.tsx" -p src 131 | 132 | # chat with all yaml files in prod and lab 133 | cwc -i ".*.ya?ml" -p prod,lab 134 | ``` 135 | 136 | The result output from cwc can also be piped to other commands as well. This example automates the creation of a conventional commit based on the current git diff. 137 | 138 | ```sh 139 | # generate a commit message for current changes 140 | CWC_PROMPT="please write me a conventional commit for these changes" 141 | git diff HEAD | cwc $CWC_PROMPT | git commit -e --file - 142 | ``` 143 | 144 | ## Configuration 145 | 146 | Managing your configuration is simple with the `cwc config` command. This command allows you to view and set configuration options for cwc. 147 | To view the current configuration, use: 148 | 149 | ```sh 150 | cwc config get 151 | ``` 152 | 153 | To set a configuration option, use: 154 | 155 | ```sh 156 | cwc config set key1=value1 key2=value2 ... 157 | ``` 158 | 159 | For example, to disable the gitignore feature and the git directory exclusion, use: 160 | 161 | ```sh 162 | cwc config set useGitignore=false excludeGitDir=false 163 | ``` 164 | 165 | To reset the configuration to default values use `cwc login` to re-authenticate. 166 | 167 | ## Templates 168 | 169 | ### Overview 170 | 171 | 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 easier access to common tasks. 172 | 173 | ### Template Schema 174 | 175 | Each template follows a specific YAML schema defined in `templates.yaml`. 176 | Here's an outline of the schema for a CWC template: 177 | 178 | ```yaml 179 | templates: 180 | - name: template_name 181 | description: A brief description of the template's purpose 182 | defaultPrompt: An optional default prompt to use if none is provided 183 | systemMessage: | 184 | The system message that details the instructions and context for the chat session. 185 | This message supports placeholders for {{ .Context }} which is the gathered file context, 186 | as well as custom variables `{{ .Variables.variableName }}` fed into the session with cli args. 187 | variables: 188 | - name: variableName 189 | description: Description of the variable 190 | defaultValue: Default value for the variable 191 | ``` 192 | 193 | ### Placement 194 | 195 | Templates may be placed within the repository or under the user's configuration directory, adhering to the XDG Base Directory Specification: 196 | 197 | 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: 198 | 199 | ``` 200 | . 201 | ├── .cwc 202 | │ └── templates.yaml 203 | ... 204 | 205 | 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: 206 | 207 | ``` 208 | $XDG_CONFIG_HOME/cwc/templates.yaml 209 | ``` 210 | 211 | If `$XDG_CONFIG_HOME` is not set, it defaults to `~/.config`. 212 | 213 | ### Example Usage 214 | 215 | 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. 216 | 217 | #### Selecting a Template 218 | 219 | To begin a chat session using a specific template, use the `-t` flag followed by the template name: 220 | 221 | ```sh 222 | cwc -t my_template 223 | ``` 224 | 225 | This command will start a conversation with the system message and default prompt defined in the template named `my_template`. 226 | 227 | #### Passing Variables to a Template 228 | 229 | You can pass variables to a template using the `-v` flag followed by a key-value pair: 230 | 231 | ```sh 232 | cwc -t my_template -v personality="a helpful assistant",name="Juno" 233 | ``` 234 | 235 | Here, the `my_template` template is used. The `personality` variable is set to "a helpful coding assistant", and 236 | the `name` variable is set to "Juno". These variables will be fed into the template's system message where placeholders are specified. 237 | 238 | The template supporting these variables might look like this: 239 | 240 | ```yaml 241 | name: my_template 242 | description: A custom template with modifiable personality and name 243 | systemMessage: | 244 | You are {{ .Variables.personality }} named {{ .Variables.name }}. 245 | Using the following context you will be able to help the user. 246 | 247 | Context: 248 | {{ .Context }} 249 | 250 | Please keep in mind your personality when responding to the user. 251 | If the user asks for your name, you should respond with {{ .Variables.name }}. 252 | variables: 253 | - name: personality 254 | description: The personality of the assistant. e.g. "a helpful assistant" 255 | defaultValue: a helpful assistant 256 | - name: name 257 | description: The name of the assistant. e.g. "Juno" 258 | defaultValue: Juno 259 | ``` 260 | 261 | > Notice that the `personality` and `name` variables have default values, which will be used if no value is provided in the `-v` flag. 262 | 263 | ## Roadmap 264 | 265 | These items may or may not be implemented in the future. 266 | 267 | - [ ] tests 268 | - [ ] support both azure and openai credentials 269 | - [ ] customizable tools 270 | 271 | ## Contributing 272 | 273 | Please file an issue if you encounter any problems or have suggestions for improvement. We welcome contributions in the form of pull requests, bug reports, and feature requests. 274 | 275 | ## License 276 | 277 | Chat With Code is provided under the MIT License. For more details, see the [LICENSE](LICENSE) file. 278 | 279 | If you encounter any issues or have suggestions for improvement, please open an issue in the project's [issue tracker](https://github.com/intility/chat-with-code/issues). 280 | 281 | [banner-photo-url]: ./docs/assets/yelling_at_code.webp 282 | [screenshot-url]: ./docs/assets/screenshot.png 283 | -------------------------------------------------------------------------------- /cmd/config.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | stdErrors "errors" 5 | "fmt" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/spf13/cobra" 10 | 11 | "github.com/intility/cwc/pkg/config" 12 | "github.com/intility/cwc/pkg/errors" 13 | cwcui "github.com/intility/cwc/pkg/ui" 14 | ) 15 | 16 | func createConfigCommand() *cobra.Command { 17 | cmd := &cobra.Command{ 18 | Use: "config", 19 | Short: "Get or set config variables", 20 | Long: `Get or set config variables`, 21 | RunE: func(cmd *cobra.Command, args []string) error { 22 | err := cmd.Usage() 23 | if err != nil { 24 | return fmt.Errorf("failed to print usage: %w", err) 25 | } 26 | 27 | return nil 28 | }, 29 | } 30 | 31 | cmd.AddCommand(createGetConfigCommand()) 32 | cmd.AddCommand(createSetConfigCommand()) 33 | 34 | return cmd 35 | } 36 | 37 | func createGetConfigCommand() *cobra.Command { 38 | cmd := &cobra.Command{ 39 | Use: "get", 40 | Short: "Print current config", 41 | Long: "Print current config", 42 | Args: cobra.NoArgs, 43 | RunE: func(cmd *cobra.Command, args []string) error { 44 | provider := config.NewDefaultProvider() 45 | cfg, err := provider.GetConfig() 46 | if err != nil { 47 | return fmt.Errorf("failed to load config: %w", err) 48 | } 49 | 50 | printConfig(cfg) 51 | 52 | return nil 53 | }, 54 | } 55 | 56 | return cmd 57 | } 58 | 59 | func createSetConfigCommand() *cobra.Command { 60 | cmd := &cobra.Command{ 61 | Use: "set", 62 | Short: "Set config variables", 63 | Long: "Set config variables", 64 | RunE: func(cmd *cobra.Command, args []string) error { 65 | cfgProvider := config.NewDefaultProvider() 66 | cfg, err := cfgProvider.GetConfig() 67 | if err != nil { 68 | return fmt.Errorf("failed to load config: %w", err) 69 | } 70 | 71 | // if no args are given, print the help and exit 72 | if len(args) == 0 { 73 | err = cmd.Help() 74 | if err != nil { 75 | return fmt.Errorf("failed to print help: %w", err) 76 | } 77 | 78 | return nil 79 | } 80 | 81 | err = processKeyValuePairs(cfg, args) 82 | 83 | if err != nil { 84 | var suppressedError errors.SuppressedError 85 | if ok := stdErrors.As(err, &suppressedError); ok { 86 | cmd.SilenceUsage = true 87 | cmd.SilenceErrors = true 88 | } 89 | 90 | return err 91 | } 92 | 93 | return nil 94 | }, 95 | } 96 | 97 | return cmd 98 | } 99 | 100 | func processKeyValuePairs(cfg *config.Config, kvPairs []string) error { 101 | // iterate over each argument and process them as key=value pairs 102 | argKvSubstrCount := 2 103 | for _, arg := range kvPairs { 104 | kvPair := strings.SplitN(arg, "=", argKvSubstrCount) 105 | if len(kvPair) != argKvSubstrCount { 106 | return errors.ArgParseError{Message: fmt.Sprintf("invalid argument format: %s, expected key=value", arg)} 107 | } 108 | 109 | key := kvPair[0] 110 | value := kvPair[1] 111 | 112 | err := setConfigValue(cfg, key, value) 113 | if err != nil { 114 | return fmt.Errorf("failed to set config value: %w", err) 115 | } 116 | } 117 | 118 | provider := config.NewDefaultProvider() 119 | 120 | err := provider.SaveConfig(cfg) 121 | if err != nil { 122 | return fmt.Errorf("failed to save config: %w", err) 123 | } 124 | 125 | printConfig(cfg) 126 | 127 | return nil 128 | } 129 | 130 | func setConfigValue(cfg *config.Config, key, value string) error { 131 | ui := cwcui.NewUI() //nolint:varnamelen 132 | 133 | switch key { 134 | case "endpoint": 135 | cfg.Endpoint = value 136 | case "deploymentName": 137 | cfg.ModelDeployment = value 138 | case "apiKey": 139 | cfg.SetAPIKey(value) 140 | case "useGitignore": 141 | b, err := strconv.ParseBool(value) 142 | if err != nil { 143 | return errors.ArgParseError{Message: "invalid boolean value for useGitignore: " + value} 144 | } 145 | 146 | cfg.UseGitignore = b 147 | case "excludeGitDir": 148 | b, err := strconv.ParseBool(value) 149 | if err != nil { 150 | return errors.ArgParseError{Message: "invalid boolean value for excludeGitDir: " + value} 151 | } 152 | 153 | cfg.ExcludeGitDir = b 154 | default: 155 | ui.PrintMessage(fmt.Sprintf("Unknown config key: %s\n", key), cwcui.MessageTypeError) 156 | 157 | validKeys := []string{ 158 | "endpoint", 159 | "deploymentName", 160 | "apiKey", 161 | "useGitignore", 162 | "excludeGitDir", 163 | } 164 | 165 | ui.PrintMessage("Valid keys are: "+strings.Join(validKeys, ", "), cwcui.MessageTypeInfo) 166 | 167 | return errors.SuppressedError{} 168 | } 169 | 170 | return nil 171 | } 172 | 173 | func printConfig(cfg *config.Config) { 174 | table := [][]string{ 175 | {"Name", "Value"}, 176 | {"endpoint", cfg.Endpoint}, 177 | {"deploymentName", cfg.ModelDeployment}, 178 | {"apiKey", cfg.APIKey()}, 179 | {"SEP", ""}, 180 | {"useGitignore", fmt.Sprintf("%t", cfg.UseGitignore)}, 181 | {"excludeGitDir", fmt.Sprintf("%t", cfg.ExcludeGitDir)}, 182 | } 183 | 184 | printTable(table) 185 | } 186 | 187 | func printTable(table [][]string) { 188 | ui := cwcui.NewUI() //nolint:varnamelen 189 | columnLengths := calculateColumnLengths(table) 190 | 191 | var lineLength int 192 | 193 | additionalChars := 3 // +3 for 3 additional characters before and after each field: "| %s " 194 | for _, c := range columnLengths { 195 | lineLength += c + additionalChars // +3 for 3 additional characters before and after each field: "| %s " 196 | } 197 | 198 | lineLength++ // +1 for the last "|" in the line 199 | singleLineLength := lineLength - len("++") // -2 because of "+" as first and last character 200 | 201 | for lineIndex, line := range table { 202 | if lineIndex == 0 { // table header 203 | // lineLength-2 because of "+" as first and last charactr 204 | ui.PrintMessage(fmt.Sprintf("+%s+\n", strings.Repeat("-", singleLineLength)), cwcui.MessageTypeInfo) 205 | } 206 | 207 | lineLoop: 208 | for rowIndex, val := range line { 209 | if val == "SEP" { 210 | // lineLength-2 because of "+" as first and last character 211 | ui.PrintMessage(fmt.Sprintf("+%s+\n", strings.Repeat("-", singleLineLength)), cwcui.MessageTypeInfo) 212 | break lineLoop 213 | } 214 | 215 | ui.PrintMessage(fmt.Sprintf("| %-*s ", columnLengths[rowIndex], val), cwcui.MessageTypeInfo) 216 | if rowIndex == len(line)-1 { 217 | ui.PrintMessage("|\n", cwcui.MessageTypeInfo) 218 | } 219 | } 220 | 221 | if lineIndex == 0 || lineIndex == len(table)-1 { // table header or last line 222 | // lineLength-2 because of "+" as first and last character 223 | ui.PrintMessage(fmt.Sprintf("+%s+\n", strings.Repeat("-", singleLineLength)), cwcui.MessageTypeInfo) 224 | } 225 | } 226 | } 227 | 228 | func calculateColumnLengths(table [][]string) []int { 229 | columnLengths := make([]int, len(table[0])) 230 | 231 | for _, line := range table { 232 | for i, val := range line { 233 | if len(val) > columnLengths[i] { 234 | columnLengths[i] = len(val) 235 | } 236 | } 237 | } 238 | 239 | return columnLengths 240 | } 241 | -------------------------------------------------------------------------------- /cmd/cwc.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/spf13/cobra" 9 | 10 | "github.com/intility/cwc/internal" 11 | "github.com/intility/cwc/pkg/config" 12 | "github.com/intility/cwc/pkg/filetree" 13 | "github.com/intility/cwc/pkg/prompting" 14 | "github.com/intility/cwc/pkg/systemcontext" 15 | "github.com/intility/cwc/pkg/templates" 16 | cwcui "github.com/intility/cwc/pkg/ui" 17 | ) 18 | 19 | const ( 20 | warnFileSizeThreshold = 100000 21 | longDescription = `The 'cwc' command initiates a new chat session, 22 | providing granular control over the inclusion and exclusion of files via regular expression patterns. 23 | It allows for specification of paths to include or exclude files from the chat context. 24 | 25 | Features at a glance: 26 | 27 | - Regex-based file inclusion and exclusion patterns 28 | - .gitignore integration for ignoring files 29 | - Option to specify directories for inclusion scope 30 | - Interactive file selection and confirmation 31 | - Reading from standard input for a non-interactive session 32 | - Use of templates for system messages and default prompts 33 | 34 | The command can also receive context from standard input, useful for piping the output from another command as input. 35 | 36 | Examples: 37 | 38 | Including all '.go' files while excluding the 'vendor/' directory: 39 | > cwc --include='.*.go$' --exclude='vendor/' 40 | 41 | Including 'main.go' files from a specific path: 42 | > cwc --include='main.go' --paths='./cmd' 43 | 44 | Using the output of another command: 45 | > git diff | cwc "Short commit message for these changes" 46 | 47 | Using a specific template: 48 | > cwc --template=tech_writer --template-variables rizz=max 49 | ` 50 | ) 51 | 52 | func CreateRootCommand() *cobra.Command { 53 | chatOpts := internal.InteractiveChatOptions{ 54 | IncludePattern: "", 55 | ExcludePattern: "", 56 | Paths: []string{}, 57 | TemplateName: "", 58 | TemplateVariables: nil, 59 | } 60 | 61 | loginCmd := createLoginCmd() 62 | logoutCmd := createLogoutCmd() 63 | 64 | rootCmd := &cobra.Command{ 65 | Use: "cwc [prompt]", 66 | Short: "starts a new chat session", 67 | Long: longDescription, 68 | Args: cobra.MaximumNArgs(1), 69 | RunE: func(cobraCmd *cobra.Command, args []string) error { 70 | cfgProvider, err := getPlatformSpecificConfigProvider() 71 | if err != nil { 72 | return fmt.Errorf("error getting config provider: %w", err) 73 | } 74 | 75 | if isPiped(os.Stdin) { 76 | nic := createNonInteractiveCommand(cfgProvider, args, chatOpts.TemplateName, chatOpts.TemplateVariables) 77 | 78 | err = nic.Run() 79 | if err != nil { 80 | return fmt.Errorf("error running non-interactive command: %w", err) 81 | } 82 | 83 | return nil 84 | } 85 | 86 | interactiveCmd := createInteractiveCommand(args, chatOpts, cfgProvider) 87 | 88 | err = interactiveCmd.Run() 89 | if err != nil { 90 | return fmt.Errorf("error running interactive command: %w", err) 91 | } 92 | 93 | return nil 94 | }, 95 | } 96 | 97 | initFlags(rootCmd, &chatOpts) 98 | 99 | rootCmd.AddCommand(loginCmd) 100 | rootCmd.AddCommand(logoutCmd) 101 | rootCmd.AddCommand(createTemplatesCmd()) 102 | rootCmd.AddCommand(createConfigCommand()) 103 | 104 | return rootCmd 105 | } 106 | 107 | func createNonInteractiveCommand( 108 | cfgProvider config.Provider, 109 | args []string, 110 | templateName string, 111 | templateVars map[string]string, 112 | ) *internal.NonInteractiveCmd { 113 | clientProvider := config.NewOpenAIClientProvider(cfgProvider) 114 | templateLocator := getTemplateLocator(cfgProvider) 115 | promptResolver := prompting.NewArgsOrTemplatePromptResolver(templateLocator, args, templateName) 116 | 117 | contextRetriever := systemcontext.NewIOReaderContextRetriever(os.Stdin) 118 | smGenerator := systemcontext.NewTemplatedSystemMessageGenerator( 119 | templateLocator, 120 | templateName, 121 | templateVars, 122 | contextRetriever, 123 | ) 124 | 125 | return internal.NewNonInteractiveCmd( 126 | clientProvider, 127 | promptResolver, 128 | smGenerator, 129 | ) 130 | } 131 | 132 | func createInteractiveCommand( 133 | args []string, 134 | opts internal.InteractiveChatOptions, 135 | cfgProvider config.Provider, 136 | ) *internal.InteractiveCmd { 137 | clientProvider := config.NewOpenAIClientProvider(cfgProvider) 138 | templateLocator := getTemplateLocator(cfgProvider) 139 | promptResolver := prompting.NewArgsOrTemplatePromptResolver(templateLocator, args, opts.TemplateName) 140 | 141 | retrieverConfig := systemcontext.FileContextRetrieverOptions{ 142 | CfgProvider: cfgProvider, 143 | IncludePattern: opts.IncludePattern, 144 | ExcludePattern: opts.ExcludePattern, 145 | SearchScopes: opts.Paths, 146 | ContextPrinter: printContext, 147 | } 148 | 149 | contextRetriever := systemcontext.NewFileContextRetriever(retrieverConfig) 150 | 151 | smGenerator := systemcontext.NewTemplatedSystemMessageGenerator( 152 | templateLocator, 153 | opts.TemplateName, 154 | opts.TemplateVariables, 155 | contextRetriever, 156 | ) 157 | 158 | return internal.NewInteractiveCmd( 159 | promptResolver, 160 | clientProvider, 161 | smGenerator, 162 | opts, 163 | ) 164 | } 165 | 166 | func getPlatformSpecificConfigProvider() (config.Provider, error) { //nolint: ireturn 167 | var cfgProvider config.Provider 168 | 169 | if config.IsWSL() { 170 | configDir, err := config.GetConfigDir() 171 | if err != nil { 172 | return nil, fmt.Errorf("error getting config directory: %w", err) 173 | } 174 | 175 | keyStore := config.NewAPIKeyFileStore(filepath.Join(configDir, "api.key")) 176 | cfgProvider = config.NewDefaultProvider(config.WithKeyStore(keyStore)) 177 | } else { 178 | cfgProvider = config.NewDefaultProvider() 179 | } 180 | 181 | return cfgProvider, nil 182 | } 183 | 184 | func printContext(fileTree string, files []filetree.File) { 185 | ui := cwcui.NewUI() 186 | ui.PrintMessage(fileTree, cwcui.MessageTypeInfo) 187 | 188 | for _, file := range files { 189 | printLargeFileWarning(file) 190 | } 191 | } 192 | 193 | func printLargeFileWarning(file filetree.File) { 194 | if len(file.Data) > warnFileSizeThreshold { 195 | largeFileMsg := fmt.Sprintf( 196 | "warning: %s is very large (%d bytes) and will degrade performance.\n", 197 | file.Path, len(file.Data)) 198 | 199 | ui := cwcui.NewUI() 200 | ui.PrintMessage(largeFileMsg, cwcui.MessageTypeWarning) 201 | } 202 | } 203 | 204 | func getTemplateLocator(cfgProvider config.Provider) *templates.MergedTemplateLocator { 205 | var locators []templates.TemplateLocator 206 | 207 | configDir, err := cfgProvider.GetConfigDir() 208 | if err == nil { 209 | locators = append(locators, templates.NewYamlFileTemplateLocator(filepath.Join(configDir, "templates.yaml"))) 210 | } 211 | 212 | locators = append(locators, templates.NewYamlFileTemplateLocator(filepath.Join(".cwc", "templates.yaml"))) 213 | mergedLocator := templates.NewMergedTemplateLocator(locators...) 214 | 215 | return mergedLocator 216 | } 217 | 218 | func initFlags(cmd *cobra.Command, opts *internal.InteractiveChatOptions) { 219 | cmd.Flags().StringVarP(&opts.IncludePattern, "include", "i", ".*", "a regular expression to match files to include") 220 | cmd.Flags().StringVarP(&opts.ExcludePattern, "exclude", "x", "", "a regular expression to match files to exclude") 221 | cmd.Flags().StringSliceVarP(&opts.Paths, "paths", "p", []string{"."}, "a list of paths to search for files") 222 | cmd.Flags().StringVarP(&opts.TemplateName, "template", "t", "default", "the name of the template to use") 223 | cmd.Flags().StringToStringVarP(&opts.TemplateVariables, 224 | "template-variables", "v", nil, "variables to use in the template") 225 | 226 | cmd.Flag("include"). 227 | Usage = "Specify a regex pattern to include files. " + 228 | "For example, to include only Markdown files, use --include '\\.md$'" 229 | cmd.Flag("exclude"). 230 | Usage = "Specify a regex pattern to exclude files. For example, to exclude test files, use --exclude '_test\\\\.go$'" 231 | cmd.Flag("paths"). 232 | Usage = "Specify a list of paths to search for files. For example, " + 233 | "to search in the 'cmd' and 'pkg' directories, use --paths cmd,pkg" 234 | cmd.Flag("template"). 235 | Usage = "Specify the name of the template to use. For example, " + 236 | "to use a template named 'tech_writer', use --template tech_writer" 237 | cmd.Flag("template-variables"). 238 | Usage = "Specify variables to use in the template. For example, to use the variable 'name' " + 239 | "with the value 'John', use --template-variables name=John" 240 | } 241 | 242 | func isPiped(file *os.File) bool { 243 | fileInfo, err := file.Stat() 244 | if err != nil { 245 | return false 246 | } 247 | 248 | return (fileInfo.Mode() & os.ModeCharDevice) == 0 249 | } 250 | -------------------------------------------------------------------------------- /cmd/login.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/intility/cwc/pkg/config" 9 | "github.com/intility/cwc/pkg/errors" 10 | cwcui "github.com/intility/cwc/pkg/ui" 11 | ) 12 | 13 | var ( 14 | apiKeyFlag string //nolint:gochecknoglobals 15 | endpointFlag string //nolint:gochecknoglobals 16 | modelDeploymentFlag string //nolint:gochecknoglobals 17 | ) 18 | 19 | func createLoginCmd() *cobra.Command { 20 | ui := cwcui.NewUI() //nolint:varnamelen 21 | cmd := &cobra.Command{ 22 | Use: "login", 23 | Short: "Authenticate with Azure OpenAI", 24 | Long: "Login will prompt you to enter your Azure OpenAI API key " + 25 | "and other relevant information required for authentication.\n" + 26 | "Your credentials will be stored securely in your keyring and will never be exposed on the file system directly.", 27 | RunE: func(cmd *cobra.Command, args []string) error { 28 | // Prompt for other required authentication details (apiKey, endpoint, version, and deployment) 29 | if apiKeyFlag == "" { 30 | ui.PrintMessage("Enter the Azure OpenAI API Key: ", cwcui.MessageTypeInfo) 31 | apiKeyFlag = config.SanitizeInput(ui.ReadUserInput()) 32 | } 33 | 34 | if endpointFlag == "" { 35 | ui.PrintMessage("Enter the Azure OpenAI API Endpoint: ", cwcui.MessageTypeInfo) 36 | endpointFlag = config.SanitizeInput(ui.ReadUserInput()) 37 | } 38 | 39 | if modelDeploymentFlag == "" { 40 | ui.PrintMessage("Enter the Azure OpenAI Model Deployment: ", cwcui.MessageTypeInfo) 41 | modelDeploymentFlag = config.SanitizeInput(ui.ReadUserInput()) 42 | } 43 | 44 | cfg := config.NewConfig(endpointFlag, modelDeploymentFlag) 45 | cfg.SetAPIKey(apiKeyFlag) 46 | 47 | provider := config.NewDefaultProvider() 48 | err := provider.SaveConfig(cfg) 49 | if err != nil { 50 | if validationErr, ok := errors.AsConfigValidationError(err); ok { 51 | for _, e := range validationErr.Errors { 52 | ui.PrintMessage(e+"\n", cwcui.MessageTypeError) 53 | } 54 | 55 | return nil // suppress the error 56 | } 57 | 58 | return fmt.Errorf("error saving configuration: %w", err) 59 | } 60 | 61 | ui.PrintMessage("config saved successfully\n", cwcui.MessageTypeSuccess) 62 | 63 | return nil 64 | }, 65 | } 66 | 67 | cmd.Flags().StringVarP(&apiKeyFlag, "api-key", "k", "", "Azure OpenAI API Key") 68 | cmd.Flags().StringVarP(&endpointFlag, "endpoint", "e", "", "Azure OpenAI API Endpoint") 69 | cmd.Flags().StringVarP(&modelDeploymentFlag, "deployment-name", "d", "", "Azure OpenAI Deployment Name") 70 | 71 | return cmd 72 | } 73 | -------------------------------------------------------------------------------- /cmd/logout.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/intility/cwc/pkg/config" 9 | ) 10 | 11 | func createLogoutCmd() *cobra.Command { 12 | cmd := &cobra.Command{ 13 | Use: "logout", 14 | Short: "Clear the configuration and remove the stored API key", 15 | Long: `Logout will clear the configuration and remove the stored API key. 16 | This will require you to login again to use the chat with context tool.`, 17 | RunE: func(cmd *cobra.Command, args []string) error { 18 | provider := config.NewDefaultProvider() 19 | err := provider.ClearConfig() 20 | if err != nil { 21 | return fmt.Errorf("error clearing configuration: %w", err) 22 | } 23 | 24 | return nil 25 | }, 26 | } 27 | 28 | return cmd 29 | } 30 | -------------------------------------------------------------------------------- /cmd/templates.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "path/filepath" 5 | "strconv" 6 | 7 | "github.com/spf13/cobra" 8 | 9 | "github.com/intility/cwc/pkg/config" 10 | "github.com/intility/cwc/pkg/templates" 11 | cwcui "github.com/intility/cwc/pkg/ui" 12 | ) 13 | 14 | type Template struct { 15 | template templates.Template 16 | placement string 17 | isOverridingGlobal bool 18 | } 19 | 20 | func createTemplatesCmd() *cobra.Command { 21 | ui := cwcui.NewUI() //nolint:varnamelen 22 | cmd := &cobra.Command{ 23 | Use: "templates", 24 | Short: "Lists available templates", 25 | RunE: func(cmd *cobra.Command, args []string) error { 26 | tmpls := locateTemplates() 27 | 28 | if len(tmpls) == 0 { 29 | ui.PrintMessage("No templates found", cwcui.MessageTypeInfo) 30 | return nil 31 | } 32 | 33 | if cfgDir, err := config.GetConfigDir(); err == nil { 34 | ui.PrintMessage("global", cwcui.MessageTypeWarning) 35 | ui.PrintMessage(": the template is defined in "+ 36 | filepath.Join(cfgDir, "templates.yaml")+"\n", cwcui.MessageTypeInfo) 37 | } 38 | 39 | ui.PrintMessage("local", cwcui.MessageTypeSuccess) 40 | ui.PrintMessage(": the template is defined in ./cwc/templates.yaml\n", cwcui.MessageTypeInfo) 41 | 42 | ui.PrintMessage("overridden", cwcui.MessageTypeError) 43 | ui.PrintMessage(": the local template is overriding a global template with the same name\n\n", cwcui.MessageTypeInfo) 44 | 45 | ui.PrintMessage("Available templates:\n", cwcui.MessageTypeInfo) 46 | 47 | for _, template := range tmpls { 48 | if template.isOverridingGlobal { 49 | template.placement = "overridden" 50 | } 51 | 52 | placementMessageType := cwcui.MessageTypeSuccess 53 | if template.placement == "global" { 54 | placementMessageType = cwcui.MessageTypeWarning 55 | } 56 | 57 | if template.isOverridingGlobal { 58 | placementMessageType = cwcui.MessageTypeError 59 | } 60 | 61 | ui.PrintMessage("- name: ", cwcui.MessageTypeInfo) 62 | ui.PrintMessage(template.template.Name, cwcui.MessageTypeInfo) 63 | ui.PrintMessage(" ("+template.placement+")\n", placementMessageType) 64 | printTemplateInfo(template.template) 65 | } 66 | 67 | return nil 68 | }, 69 | } 70 | 71 | return cmd 72 | } 73 | 74 | func locateTemplates() map[string]Template { 75 | var localTemplates, globalTemplates []templates.Template 76 | 77 | cfgDir, err := config.GetConfigDir() 78 | if err == nil { 79 | globalTemplatesLocator := templates.NewYamlFileTemplateLocator(filepath.Join(cfgDir, "templates.yaml")) 80 | locatedTemplates, err := globalTemplatesLocator.ListTemplates() 81 | 82 | if err == nil { 83 | globalTemplates = locatedTemplates 84 | } 85 | } 86 | 87 | localTemplatesLocator := templates.NewYamlFileTemplateLocator(filepath.Join(".cwc", "templates.yaml")) 88 | locatedTemplates, err := localTemplatesLocator.ListTemplates() 89 | 90 | if err == nil { 91 | localTemplates = locatedTemplates 92 | } 93 | 94 | tmpls := make(map[string]Template) 95 | 96 | // populate the list of templates, marking the local ones as overriding the global ones if they have the same name 97 | for _, t := range globalTemplates { 98 | tmpls[t.Name] = Template{template: t, placement: "global", isOverridingGlobal: false} 99 | } 100 | 101 | for _, t := range localTemplates { 102 | _, exists := tmpls[t.Name] 103 | tmpls[t.Name] = Template{template: t, placement: "local", isOverridingGlobal: exists} 104 | } 105 | 106 | return tmpls 107 | } 108 | 109 | func printTemplateInfo(template templates.Template) { 110 | ui := cwcui.NewUI() //nolint:varnamelen 111 | ui.PrintMessage(" description: "+template.Description+"\n", cwcui.MessageTypeInfo) 112 | 113 | dfp := "no" 114 | if template.DefaultPrompt != "" { 115 | dfp = "yes" 116 | } 117 | 118 | ui.PrintMessage(" has_default_prompt: "+dfp+"\n", cwcui.MessageTypeInfo) 119 | 120 | variablesCount := len(template.Variables) 121 | 122 | ui.PrintMessage(" variables: "+strconv.Itoa(variablesCount)+"\n", cwcui.MessageTypeInfo) 123 | 124 | for _, variable := range template.Variables { 125 | ui.PrintMessage(" - name: ", cwcui.MessageTypeInfo) 126 | ui.PrintMessage(variable.Name, cwcui.MessageTypeInfo) 127 | ui.PrintMessage("\n", cwcui.MessageTypeInfo) 128 | ui.PrintMessage(" description: "+variable.Description+"\n", cwcui.MessageTypeInfo) 129 | 130 | dv := "no" 131 | if variable.DefaultValue != "" { 132 | dv = "yes" 133 | } 134 | 135 | ui.PrintMessage(" has_default_value: "+dv+"\n", cwcui.MessageTypeInfo) 136 | } 137 | 138 | ui.PrintMessage("\n", cwcui.MessageTypeInfo) 139 | } 140 | -------------------------------------------------------------------------------- /docs/assets/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/cwc/16d81dc409b1ebf8728c532e3086bd6f457501de/docs/assets/screenshot.png -------------------------------------------------------------------------------- /docs/assets/yelling_at_code.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intility/cwc/16d81dc409b1ebf8728c532e3086bd6f457501de/docs/assets/yelling_at_code.webp -------------------------------------------------------------------------------- /generator/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/emilkje/cwc/generator 2 | 3 | go 1.22.0 4 | 5 | require gopkg.in/yaml.v3 v3.0.1 6 | -------------------------------------------------------------------------------- /generator/go.sum: -------------------------------------------------------------------------------- 1 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 2 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 3 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 4 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 5 | -------------------------------------------------------------------------------- /generator/lang-gen.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "gopkg.in/yaml.v3" 5 | "io" 6 | "log" 7 | "net/http" 8 | "os" 9 | "strings" 10 | "text/template" 11 | ) 12 | 13 | type Language struct { 14 | Type string `yaml:"type"` 15 | TmScope string `yaml:"tm_scope,omitempty"` 16 | AceMode string `yaml:"ace_mode"` 17 | CodemirrorMode string `yaml:"codemirror_mode,omitempty"` 18 | CodemirrorMimeType string `yaml:"codemirror_mime_type,omitempty"` 19 | Color string `yaml:"color,omitempty"` 20 | Aliases []string `yaml:"aliases,omitempty"` 21 | Extensions []string `yaml:"extensions"` 22 | Filenames []string `yaml:"filenames,omitempty"` 23 | Interpreters []string `yaml:"interpreters,omitempty"` 24 | Group string `yaml:"group,omitempty"` 25 | LanguageID int64 `yaml:"language_id"` 26 | } 27 | 28 | type Languages map[string]Language 29 | 30 | var languageTemplate = `// Code generated with lang-gen. DO NOT EDIT. 31 | 32 | package filetree 33 | 34 | type Language struct { 35 | Type string ` + "`yaml:\"type\"`" + ` 36 | TmScope string ` + "`yaml:\"tm_scope,omitempty\"`" + ` 37 | AceMode string ` + "`yaml:\"ace_mode\"`" + ` 38 | CodemirrorMode string ` + "`yaml:\"codemirror_mode,omitempty\"`" + ` 39 | CodemirrorMimeType string ` + "`yaml:\"codemirror_mime_type,omitempty\"`" + ` 40 | Color string ` + "`yaml:\"color,omitempty\"`" + ` 41 | Aliases []string ` + "`yaml:\"aliases,omitempty\"`" + ` 42 | Extensions []string ` + "`yaml:\"extensions\"`" + ` 43 | Filenames []string ` + "`yaml:\"filenames,omitempty\"`" + ` 44 | Interpreters []string ` + "`yaml:\"interpreters,omitempty\"`" + ` 45 | Group string ` + "`yaml:\"group,omitempty\"`" + ` 46 | LanguageID int64 ` + "`yaml:\"language_id\"`" + ` 47 | } 48 | 49 | var languages = map[string]Language{ 50 | {{- range $key, $value := .}} 51 | "{{ $key }}": { 52 | Type: "{{ $value.Type }}", 53 | TmScope: "{{ $value.TmScope }}", 54 | AceMode: "{{ $value.AceMode }}", 55 | CodemirrorMode: "{{ $value.CodemirrorMode }}", 56 | CodemirrorMimeType: "{{ $value.CodemirrorMimeType }}", 57 | Color: "{{ $value.Color }}", 58 | Aliases: []string{ {{ join "\", \"" $value.Aliases "\"" }} }, 59 | Extensions: []string{ {{ join "\", \"" $value.Extensions "\"" }} }, 60 | Filenames: []string{ {{ join "\", \"" $value.Filenames "\"" }} }, 61 | Interpreters: []string{ {{ join "\", \"" $value.Interpreters "\"" }} }, 62 | Group: "{{ $value.Group }}", 63 | LanguageID: {{ $value.LanguageID }}, 64 | }, 65 | {{- end}} 66 | } 67 | ` 68 | 69 | // join function will join the string slice with a comma 70 | func join(sep string, s []string, surroundingStr string) string { 71 | if len(s) == 0 { 72 | return "" 73 | } 74 | if surroundingStr == "" { 75 | return strings.Join(s, sep) 76 | } 77 | return surroundingStr + strings.Join(s, sep) + surroundingStr 78 | } 79 | 80 | func main() { 81 | 82 | var languages Languages 83 | 84 | // read the file from web 85 | client := &http.Client{} 86 | resp, err := client.Get("https://raw.githubusercontent.com/github/linguist/master/lib/linguist/languages.yml") 87 | 88 | if err != nil { 89 | log.Fatalf("error: %v", err) 90 | } 91 | 92 | defer resp.Body.Close() 93 | 94 | data, err := io.ReadAll(resp.Body) 95 | 96 | if err != nil { 97 | log.Fatalf("error: %v", err) 98 | } 99 | 100 | err = yaml.Unmarshal(data, &languages) 101 | if err != nil { 102 | log.Fatalf("error: %v", err) 103 | } 104 | 105 | // write the data to a go file using the template 106 | // so that we can embed the data in the binary 107 | 108 | // create a file 109 | file, err := os.Create("pkg/filetree/languages.go") 110 | if err != nil { 111 | log.Fatalf("error: %v", err) 112 | } 113 | defer file.Close() 114 | 115 | // use the data to render the template to disk 116 | tmpl, err := template.New("languages").Funcs(template.FuncMap{"join": join}).Parse(languageTemplate) 117 | if err != nil { 118 | log.Fatalf("error: %v", err) 119 | } 120 | 121 | err = tmpl.Execute(file, languages) 122 | if err != nil { 123 | log.Fatalf("error: %v", err) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/intility/cwc 2 | 3 | go 1.22 4 | 5 | require ( 6 | github.com/google/go-cmp v0.6.0 7 | github.com/sashabaranov/go-openai v1.20.1 8 | github.com/spf13/cobra v1.8.0 9 | github.com/stretchr/testify v1.9.0 10 | github.com/zalando/go-keyring v0.2.3 11 | gopkg.in/yaml.v3 v3.0.1 12 | ) 13 | 14 | require ( 15 | github.com/alessio/shellescape v1.4.1 // indirect 16 | github.com/danieljoos/wincred v1.2.0 // indirect 17 | github.com/davecgh/go-spew v1.1.1 // indirect 18 | github.com/godbus/dbus/v5 v5.1.0 // indirect 19 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 20 | github.com/pmezard/go-difflib v1.0.0 // indirect 21 | github.com/spf13/pflag v1.0.5 // indirect 22 | github.com/stretchr/objx v0.5.2 // indirect 23 | golang.org/x/sys v0.17.0 // indirect 24 | ) 25 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0= 2 | github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= 3 | github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 4 | github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE= 5 | github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec= 6 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= 9 | github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 10 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 11 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 12 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 13 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 14 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 15 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 16 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 17 | github.com/sashabaranov/go-openai v1.20.1 h1:cFnTixAtc0I0cCBFr8gkvEbGCm6Rjf2JyoVWCjXwy9g= 18 | github.com/sashabaranov/go-openai v1.20.1/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= 19 | github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= 20 | github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= 21 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 22 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 23 | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 24 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 25 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 26 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 27 | github.com/zalando/go-keyring v0.2.3 h1:v9CUu9phlABObO4LPWycf+zwMG7nlbb3t/B5wa97yms= 28 | github.com/zalando/go-keyring v0.2.3/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk= 29 | golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= 30 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 31 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 32 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 33 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 34 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 35 | -------------------------------------------------------------------------------- /internal/interactive.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/sashabaranov/go-openai" 7 | 8 | "github.com/intility/cwc/pkg/chat" 9 | "github.com/intility/cwc/pkg/config" 10 | "github.com/intility/cwc/pkg/prompting" 11 | "github.com/intility/cwc/pkg/systemcontext" 12 | "github.com/intility/cwc/pkg/ui" 13 | ) 14 | 15 | type InteractiveChatOptions struct { 16 | IncludePattern string 17 | ExcludePattern string 18 | Paths []string 19 | TemplateName string 20 | TemplateVariables map[string]string 21 | } 22 | 23 | type InteractiveCmd struct { 24 | ui ui.UI 25 | clientProvider config.ClientProvider 26 | promptResolver prompting.PromptResolver 27 | smGenerator systemcontext.SystemMessageGenerator 28 | chatOptions InteractiveChatOptions 29 | } 30 | 31 | func NewInteractiveCmd( 32 | promptResolver prompting.PromptResolver, 33 | clientProvider config.ClientProvider, 34 | smGenerator systemcontext.SystemMessageGenerator, 35 | chatOptions InteractiveChatOptions, 36 | ) *InteractiveCmd { 37 | return &InteractiveCmd{ 38 | ui: ui.NewUI(), 39 | promptResolver: promptResolver, 40 | clientProvider: clientProvider, 41 | chatOptions: chatOptions, 42 | smGenerator: smGenerator, 43 | } 44 | } 45 | 46 | func (c *InteractiveCmd) Run() error { 47 | openaiClient, err := c.clientProvider.NewClientFromConfig() 48 | if err != nil { 49 | return fmt.Errorf("error creating openaiClient: %w", err) 50 | } 51 | 52 | generatedSystemMessage, err := c.smGenerator.GenerateSystemMessage() 53 | if err != nil { 54 | return fmt.Errorf("error creating system message: %w", err) 55 | } 56 | 57 | c.ui.PrintMessage("Type '/exit' to end the chat.\n", ui.MessageTypeNotice) 58 | 59 | userPrompt := c.promptResolver.ResolvePrompt() 60 | 61 | if userPrompt == "" { 62 | c.ui.PrintMessage("👤: ", ui.MessageTypeInfo) 63 | userPrompt = c.ui.ReadUserInput() 64 | } else { 65 | c.ui.PrintMessage(fmt.Sprintf("👤: %s\n", userPrompt), ui.MessageTypeInfo) 66 | } 67 | 68 | if userPrompt == "/exit" { 69 | return nil 70 | } 71 | 72 | c.handleChat(openaiClient, generatedSystemMessage, userPrompt) 73 | 74 | return nil 75 | } 76 | 77 | func (c *InteractiveCmd) handleChat(client *openai.Client, systemMessage string, prompt string) { 78 | chatInstance := chat.NewChat(client, systemMessage, c.printMessageChunk) 79 | conversation := chatInstance.BeginConversation(prompt) 80 | 81 | for { 82 | conversation.WaitMyTurn() 83 | c.ui.PrintMessage("👤: ", ui.MessageTypeInfo) 84 | 85 | userMessage := c.ui.ReadUserInput() 86 | 87 | if userMessage == "/exit" { 88 | break 89 | } 90 | 91 | conversation.Reply(userMessage) 92 | } 93 | } 94 | 95 | func (c *InteractiveCmd) printMessageChunk(chunk *chat.ConversationChunk) { 96 | if chunk.IsInitialChunk { 97 | c.ui.PrintMessage("🤖: ", ui.MessageTypeInfo) 98 | return 99 | } 100 | 101 | if chunk.IsErrorChunk { 102 | c.ui.PrintMessage(chunk.Content, ui.MessageTypeError) 103 | } 104 | 105 | if chunk.IsFinalChunk { 106 | c.ui.PrintMessage("\n", ui.MessageTypeInfo) 107 | } 108 | 109 | c.ui.PrintMessage(chunk.Content, ui.MessageTypeInfo) 110 | } 111 | -------------------------------------------------------------------------------- /internal/noninteractive.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/intility/cwc/pkg/chat" 7 | "github.com/intility/cwc/pkg/config" 8 | "github.com/intility/cwc/pkg/errors" 9 | "github.com/intility/cwc/pkg/prompting" 10 | "github.com/intility/cwc/pkg/systemcontext" 11 | "github.com/intility/cwc/pkg/ui" 12 | ) 13 | 14 | type NonInteractiveCmd struct { 15 | ui ui.UI 16 | clientProvider config.ClientProvider 17 | promptResolver prompting.PromptResolver 18 | smGenerator systemcontext.SystemMessageGenerator 19 | } 20 | 21 | func NewNonInteractiveCmd( 22 | clientProvider config.ClientProvider, 23 | promptResolver prompting.PromptResolver, 24 | smGenerator systemcontext.SystemMessageGenerator, 25 | ) *NonInteractiveCmd { 26 | return &NonInteractiveCmd{ 27 | ui: ui.NewUI(), 28 | clientProvider: clientProvider, 29 | promptResolver: promptResolver, 30 | smGenerator: smGenerator, 31 | } 32 | } 33 | 34 | func (c *NonInteractiveCmd) Run() error { 35 | openaiClient, err := c.clientProvider.NewClientFromConfig() 36 | if err != nil { 37 | return fmt.Errorf("error creating openaiClient: %w", err) 38 | } 39 | 40 | generateSystemMessage, err := c.smGenerator.GenerateSystemMessage() 41 | if err != nil { 42 | return fmt.Errorf("error creating system message: %w", err) 43 | } 44 | 45 | userPrompt := c.promptResolver.ResolvePrompt() 46 | 47 | if userPrompt == "" { 48 | return errors.NoPromptProvidedError{Message: "non-interactive mode requires a prompt"} 49 | } 50 | 51 | chatInstance := chat.NewChat(openaiClient, generateSystemMessage, c.printChunk) 52 | conversation := chatInstance.BeginConversation(userPrompt) 53 | 54 | conversation.WaitMyTurn() 55 | 56 | return nil 57 | } 58 | 59 | func (c *NonInteractiveCmd) printChunk(chunk *chat.ConversationChunk) { 60 | c.ui.PrintMessage(chunk.Content, ui.MessageTypeInfo) 61 | } 62 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | stdErrors "errors" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/intility/cwc/cmd" 9 | "github.com/intility/cwc/pkg/errors" 10 | cwcui "github.com/intility/cwc/pkg/ui" 11 | ) 12 | 13 | //go:generate ./bin/lang-gen 14 | 15 | func main() { 16 | command := cmd.CreateRootCommand() 17 | ui := cwcui.NewUI() //nolint:varnamelen 18 | 19 | err := command.Execute() 20 | if err != nil { 21 | // if error is of type suppressedError, do not print error message 22 | var suppressedError errors.SuppressedError 23 | if ok := stdErrors.As(err, &suppressedError); !ok { 24 | ui.PrintMessage(fmt.Sprintf("Error: %s\n", err), cwcui.MessageTypeError) 25 | } 26 | 27 | os.Exit(1) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /mocks/APIKeyStore.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery. DO NOT EDIT. 2 | 3 | package mocks 4 | 5 | import mock "github.com/stretchr/testify/mock" 6 | 7 | // APIKeyStore is an autogenerated mock type for the APIKeyStore type 8 | type APIKeyStore struct { 9 | mock.Mock 10 | } 11 | 12 | type APIKeyStore_Expecter struct { 13 | mock *mock.Mock 14 | } 15 | 16 | func (_m *APIKeyStore) EXPECT() *APIKeyStore_Expecter { 17 | return &APIKeyStore_Expecter{mock: &_m.Mock} 18 | } 19 | 20 | // ClearAPIKey provides a mock function with given fields: 21 | func (_m *APIKeyStore) ClearAPIKey() error { 22 | ret := _m.Called() 23 | 24 | if len(ret) == 0 { 25 | panic("no return value specified for ClearAPIKey") 26 | } 27 | 28 | var r0 error 29 | if rf, ok := ret.Get(0).(func() error); ok { 30 | r0 = rf() 31 | } else { 32 | r0 = ret.Error(0) 33 | } 34 | 35 | return r0 36 | } 37 | 38 | // APIKeyStore_ClearAPIKey_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClearAPIKey' 39 | type APIKeyStore_ClearAPIKey_Call struct { 40 | *mock.Call 41 | } 42 | 43 | // ClearAPIKey is a helper method to define mock.On call 44 | func (_e *APIKeyStore_Expecter) ClearAPIKey() *APIKeyStore_ClearAPIKey_Call { 45 | return &APIKeyStore_ClearAPIKey_Call{Call: _e.mock.On("ClearAPIKey")} 46 | } 47 | 48 | func (_c *APIKeyStore_ClearAPIKey_Call) Run(run func()) *APIKeyStore_ClearAPIKey_Call { 49 | _c.Call.Run(func(args mock.Arguments) { 50 | run() 51 | }) 52 | return _c 53 | } 54 | 55 | func (_c *APIKeyStore_ClearAPIKey_Call) Return(_a0 error) *APIKeyStore_ClearAPIKey_Call { 56 | _c.Call.Return(_a0) 57 | return _c 58 | } 59 | 60 | func (_c *APIKeyStore_ClearAPIKey_Call) RunAndReturn(run func() error) *APIKeyStore_ClearAPIKey_Call { 61 | _c.Call.Return(run) 62 | return _c 63 | } 64 | 65 | // GetAPIKey provides a mock function with given fields: 66 | func (_m *APIKeyStore) GetAPIKey() (string, error) { 67 | ret := _m.Called() 68 | 69 | if len(ret) == 0 { 70 | panic("no return value specified for GetAPIKey") 71 | } 72 | 73 | var r0 string 74 | var r1 error 75 | if rf, ok := ret.Get(0).(func() (string, error)); ok { 76 | return rf() 77 | } 78 | if rf, ok := ret.Get(0).(func() string); ok { 79 | r0 = rf() 80 | } else { 81 | r0 = ret.Get(0).(string) 82 | } 83 | 84 | if rf, ok := ret.Get(1).(func() error); ok { 85 | r1 = rf() 86 | } else { 87 | r1 = ret.Error(1) 88 | } 89 | 90 | return r0, r1 91 | } 92 | 93 | // APIKeyStore_GetAPIKey_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAPIKey' 94 | type APIKeyStore_GetAPIKey_Call struct { 95 | *mock.Call 96 | } 97 | 98 | // GetAPIKey is a helper method to define mock.On call 99 | func (_e *APIKeyStore_Expecter) GetAPIKey() *APIKeyStore_GetAPIKey_Call { 100 | return &APIKeyStore_GetAPIKey_Call{Call: _e.mock.On("GetAPIKey")} 101 | } 102 | 103 | func (_c *APIKeyStore_GetAPIKey_Call) Run(run func()) *APIKeyStore_GetAPIKey_Call { 104 | _c.Call.Run(func(args mock.Arguments) { 105 | run() 106 | }) 107 | return _c 108 | } 109 | 110 | func (_c *APIKeyStore_GetAPIKey_Call) Return(_a0 string, _a1 error) *APIKeyStore_GetAPIKey_Call { 111 | _c.Call.Return(_a0, _a1) 112 | return _c 113 | } 114 | 115 | func (_c *APIKeyStore_GetAPIKey_Call) RunAndReturn(run func() (string, error)) *APIKeyStore_GetAPIKey_Call { 116 | _c.Call.Return(run) 117 | return _c 118 | } 119 | 120 | // SetAPIKey provides a mock function with given fields: apiKey 121 | func (_m *APIKeyStore) SetAPIKey(apiKey string) error { 122 | ret := _m.Called(apiKey) 123 | 124 | if len(ret) == 0 { 125 | panic("no return value specified for SetAPIKey") 126 | } 127 | 128 | var r0 error 129 | if rf, ok := ret.Get(0).(func(string) error); ok { 130 | r0 = rf(apiKey) 131 | } else { 132 | r0 = ret.Error(0) 133 | } 134 | 135 | return r0 136 | } 137 | 138 | // APIKeyStore_SetAPIKey_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetAPIKey' 139 | type APIKeyStore_SetAPIKey_Call struct { 140 | *mock.Call 141 | } 142 | 143 | // SetAPIKey is a helper method to define mock.On call 144 | // - apiKey string 145 | func (_e *APIKeyStore_Expecter) SetAPIKey(apiKey interface{}) *APIKeyStore_SetAPIKey_Call { 146 | return &APIKeyStore_SetAPIKey_Call{Call: _e.mock.On("SetAPIKey", apiKey)} 147 | } 148 | 149 | func (_c *APIKeyStore_SetAPIKey_Call) Run(run func(apiKey string)) *APIKeyStore_SetAPIKey_Call { 150 | _c.Call.Run(func(args mock.Arguments) { 151 | run(args[0].(string)) 152 | }) 153 | return _c 154 | } 155 | 156 | func (_c *APIKeyStore_SetAPIKey_Call) Return(_a0 error) *APIKeyStore_SetAPIKey_Call { 157 | _c.Call.Return(_a0) 158 | return _c 159 | } 160 | 161 | func (_c *APIKeyStore_SetAPIKey_Call) RunAndReturn(run func(string) error) *APIKeyStore_SetAPIKey_Call { 162 | _c.Call.Return(run) 163 | return _c 164 | } 165 | 166 | // NewAPIKeyStore creates a new instance of APIKeyStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 167 | // The first argument is typically a *testing.T value. 168 | func NewAPIKeyStore(t interface { 169 | mock.TestingT 170 | Cleanup(func()) 171 | }) *APIKeyStore { 172 | mock := &APIKeyStore{} 173 | mock.Mock.Test(t) 174 | 175 | t.Cleanup(func() { mock.AssertExpectations(t) }) 176 | 177 | return mock 178 | } 179 | -------------------------------------------------------------------------------- /mocks/ClientProvider.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery. DO NOT EDIT. 2 | 3 | package mocks 4 | 5 | import ( 6 | openai "github.com/sashabaranov/go-openai" 7 | mock "github.com/stretchr/testify/mock" 8 | ) 9 | 10 | // ClientProvider is an autogenerated mock type for the ClientProvider type 11 | type ClientProvider struct { 12 | mock.Mock 13 | } 14 | 15 | type ClientProvider_Expecter struct { 16 | mock *mock.Mock 17 | } 18 | 19 | func (_m *ClientProvider) EXPECT() *ClientProvider_Expecter { 20 | return &ClientProvider_Expecter{mock: &_m.Mock} 21 | } 22 | 23 | // NewClientFromConfig provides a mock function with given fields: 24 | func (_m *ClientProvider) NewClientFromConfig() (*openai.Client, error) { 25 | ret := _m.Called() 26 | 27 | if len(ret) == 0 { 28 | panic("no return value specified for NewClientFromConfig") 29 | } 30 | 31 | var r0 *openai.Client 32 | var r1 error 33 | if rf, ok := ret.Get(0).(func() (*openai.Client, error)); ok { 34 | return rf() 35 | } 36 | if rf, ok := ret.Get(0).(func() *openai.Client); ok { 37 | r0 = rf() 38 | } else { 39 | if ret.Get(0) != nil { 40 | r0 = ret.Get(0).(*openai.Client) 41 | } 42 | } 43 | 44 | if rf, ok := ret.Get(1).(func() error); ok { 45 | r1 = rf() 46 | } else { 47 | r1 = ret.Error(1) 48 | } 49 | 50 | return r0, r1 51 | } 52 | 53 | // ClientProvider_NewClientFromConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'NewClientFromConfig' 54 | type ClientProvider_NewClientFromConfig_Call struct { 55 | *mock.Call 56 | } 57 | 58 | // NewClientFromConfig is a helper method to define mock.On call 59 | func (_e *ClientProvider_Expecter) NewClientFromConfig() *ClientProvider_NewClientFromConfig_Call { 60 | return &ClientProvider_NewClientFromConfig_Call{Call: _e.mock.On("NewClientFromConfig")} 61 | } 62 | 63 | func (_c *ClientProvider_NewClientFromConfig_Call) Run(run func()) *ClientProvider_NewClientFromConfig_Call { 64 | _c.Call.Run(func(args mock.Arguments) { 65 | run() 66 | }) 67 | return _c 68 | } 69 | 70 | func (_c *ClientProvider_NewClientFromConfig_Call) Return(_a0 *openai.Client, _a1 error) *ClientProvider_NewClientFromConfig_Call { 71 | _c.Call.Return(_a0, _a1) 72 | return _c 73 | } 74 | 75 | func (_c *ClientProvider_NewClientFromConfig_Call) RunAndReturn(run func() (*openai.Client, error)) *ClientProvider_NewClientFromConfig_Call { 76 | _c.Call.Return(run) 77 | return _c 78 | } 79 | 80 | // NewClientProvider creates a new instance of ClientProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 81 | // The first argument is typically a *testing.T value. 82 | func NewClientProvider(t interface { 83 | mock.TestingT 84 | Cleanup(func()) 85 | }) *ClientProvider { 86 | mock := &ClientProvider{} 87 | mock.Mock.Test(t) 88 | 89 | t.Cleanup(func() { mock.AssertExpectations(t) }) 90 | 91 | return mock 92 | } 93 | -------------------------------------------------------------------------------- /mocks/ContextRetriever.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery. DO NOT EDIT. 2 | 3 | package mocks 4 | 5 | import mock "github.com/stretchr/testify/mock" 6 | 7 | // ContextRetriever is an autogenerated mock type for the ContextRetriever type 8 | type ContextRetriever struct { 9 | mock.Mock 10 | } 11 | 12 | type ContextRetriever_Expecter struct { 13 | mock *mock.Mock 14 | } 15 | 16 | func (_m *ContextRetriever) EXPECT() *ContextRetriever_Expecter { 17 | return &ContextRetriever_Expecter{mock: &_m.Mock} 18 | } 19 | 20 | // RetrieveContext provides a mock function with given fields: 21 | func (_m *ContextRetriever) RetrieveContext() (string, error) { 22 | ret := _m.Called() 23 | 24 | if len(ret) == 0 { 25 | panic("no return value specified for RetrieveContext") 26 | } 27 | 28 | var r0 string 29 | var r1 error 30 | if rf, ok := ret.Get(0).(func() (string, error)); ok { 31 | return rf() 32 | } 33 | if rf, ok := ret.Get(0).(func() string); ok { 34 | r0 = rf() 35 | } else { 36 | r0 = ret.Get(0).(string) 37 | } 38 | 39 | if rf, ok := ret.Get(1).(func() error); ok { 40 | r1 = rf() 41 | } else { 42 | r1 = ret.Error(1) 43 | } 44 | 45 | return r0, r1 46 | } 47 | 48 | // ContextRetriever_RetrieveContext_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RetrieveContext' 49 | type ContextRetriever_RetrieveContext_Call struct { 50 | *mock.Call 51 | } 52 | 53 | // RetrieveContext is a helper method to define mock.On call 54 | func (_e *ContextRetriever_Expecter) RetrieveContext() *ContextRetriever_RetrieveContext_Call { 55 | return &ContextRetriever_RetrieveContext_Call{Call: _e.mock.On("RetrieveContext")} 56 | } 57 | 58 | func (_c *ContextRetriever_RetrieveContext_Call) Run(run func()) *ContextRetriever_RetrieveContext_Call { 59 | _c.Call.Run(func(args mock.Arguments) { 60 | run() 61 | }) 62 | return _c 63 | } 64 | 65 | func (_c *ContextRetriever_RetrieveContext_Call) Return(_a0 string, _a1 error) *ContextRetriever_RetrieveContext_Call { 66 | _c.Call.Return(_a0, _a1) 67 | return _c 68 | } 69 | 70 | func (_c *ContextRetriever_RetrieveContext_Call) RunAndReturn(run func() (string, error)) *ContextRetriever_RetrieveContext_Call { 71 | _c.Call.Return(run) 72 | return _c 73 | } 74 | 75 | // NewContextRetriever creates a new instance of ContextRetriever. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 76 | // The first argument is typically a *testing.T value. 77 | func NewContextRetriever(t interface { 78 | mock.TestingT 79 | Cleanup(func()) 80 | }) *ContextRetriever { 81 | mock := &ContextRetriever{} 82 | mock.Mock.Test(t) 83 | 84 | t.Cleanup(func() { mock.AssertExpectations(t) }) 85 | 86 | return mock 87 | } 88 | -------------------------------------------------------------------------------- /mocks/FileManager.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery. DO NOT EDIT. 2 | 3 | package mocks 4 | 5 | import ( 6 | fs "io/fs" 7 | 8 | mock "github.com/stretchr/testify/mock" 9 | ) 10 | 11 | // FileManager is an autogenerated mock type for the FileManager type 12 | type FileManager struct { 13 | mock.Mock 14 | } 15 | 16 | type FileManager_Expecter struct { 17 | mock *mock.Mock 18 | } 19 | 20 | func (_m *FileManager) EXPECT() *FileManager_Expecter { 21 | return &FileManager_Expecter{mock: &_m.Mock} 22 | } 23 | 24 | // Read provides a mock function with given fields: path 25 | func (_m *FileManager) Read(path string) ([]byte, error) { 26 | ret := _m.Called(path) 27 | 28 | if len(ret) == 0 { 29 | panic("no return value specified for Read") 30 | } 31 | 32 | var r0 []byte 33 | var r1 error 34 | if rf, ok := ret.Get(0).(func(string) ([]byte, error)); ok { 35 | return rf(path) 36 | } 37 | if rf, ok := ret.Get(0).(func(string) []byte); ok { 38 | r0 = rf(path) 39 | } else { 40 | if ret.Get(0) != nil { 41 | r0 = ret.Get(0).([]byte) 42 | } 43 | } 44 | 45 | if rf, ok := ret.Get(1).(func(string) error); ok { 46 | r1 = rf(path) 47 | } else { 48 | r1 = ret.Error(1) 49 | } 50 | 51 | return r0, r1 52 | } 53 | 54 | // FileManager_Read_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Read' 55 | type FileManager_Read_Call struct { 56 | *mock.Call 57 | } 58 | 59 | // Read is a helper method to define mock.On call 60 | // - path string 61 | func (_e *FileManager_Expecter) Read(path interface{}) *FileManager_Read_Call { 62 | return &FileManager_Read_Call{Call: _e.mock.On("Read", path)} 63 | } 64 | 65 | func (_c *FileManager_Read_Call) Run(run func(path string)) *FileManager_Read_Call { 66 | _c.Call.Run(func(args mock.Arguments) { 67 | run(args[0].(string)) 68 | }) 69 | return _c 70 | } 71 | 72 | func (_c *FileManager_Read_Call) Return(_a0 []byte, _a1 error) *FileManager_Read_Call { 73 | _c.Call.Return(_a0, _a1) 74 | return _c 75 | } 76 | 77 | func (_c *FileManager_Read_Call) RunAndReturn(run func(string) ([]byte, error)) *FileManager_Read_Call { 78 | _c.Call.Return(run) 79 | return _c 80 | } 81 | 82 | // Write provides a mock function with given fields: path, content, perm 83 | func (_m *FileManager) Write(path string, content []byte, perm fs.FileMode) error { 84 | ret := _m.Called(path, content, perm) 85 | 86 | if len(ret) == 0 { 87 | panic("no return value specified for Write") 88 | } 89 | 90 | var r0 error 91 | if rf, ok := ret.Get(0).(func(string, []byte, fs.FileMode) error); ok { 92 | r0 = rf(path, content, perm) 93 | } else { 94 | r0 = ret.Error(0) 95 | } 96 | 97 | return r0 98 | } 99 | 100 | // FileManager_Write_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Write' 101 | type FileManager_Write_Call struct { 102 | *mock.Call 103 | } 104 | 105 | // Write is a helper method to define mock.On call 106 | // - path string 107 | // - content []byte 108 | // - perm fs.FileMode 109 | func (_e *FileManager_Expecter) Write(path interface{}, content interface{}, perm interface{}) *FileManager_Write_Call { 110 | return &FileManager_Write_Call{Call: _e.mock.On("Write", path, content, perm)} 111 | } 112 | 113 | func (_c *FileManager_Write_Call) Run(run func(path string, content []byte, perm fs.FileMode)) *FileManager_Write_Call { 114 | _c.Call.Run(func(args mock.Arguments) { 115 | run(args[0].(string), args[1].([]byte), args[2].(fs.FileMode)) 116 | }) 117 | return _c 118 | } 119 | 120 | func (_c *FileManager_Write_Call) Return(_a0 error) *FileManager_Write_Call { 121 | _c.Call.Return(_a0) 122 | return _c 123 | } 124 | 125 | func (_c *FileManager_Write_Call) RunAndReturn(run func(string, []byte, fs.FileMode) error) *FileManager_Write_Call { 126 | _c.Call.Return(run) 127 | return _c 128 | } 129 | 130 | // NewFileManager creates a new instance of FileManager. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 131 | // The first argument is typically a *testing.T value. 132 | func NewFileManager(t interface { 133 | mock.TestingT 134 | Cleanup(func()) 135 | }) *FileManager { 136 | mock := &FileManager{} 137 | mock.Mock.Test(t) 138 | 139 | t.Cleanup(func() { mock.AssertExpectations(t) }) 140 | 141 | return mock 142 | } 143 | -------------------------------------------------------------------------------- /mocks/FileReader.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery. DO NOT EDIT. 2 | 3 | package mocks 4 | 5 | import mock "github.com/stretchr/testify/mock" 6 | 7 | // FileReader is an autogenerated mock type for the FileReader type 8 | type FileReader struct { 9 | mock.Mock 10 | } 11 | 12 | type FileReader_Expecter struct { 13 | mock *mock.Mock 14 | } 15 | 16 | func (_m *FileReader) EXPECT() *FileReader_Expecter { 17 | return &FileReader_Expecter{mock: &_m.Mock} 18 | } 19 | 20 | // Execute provides a mock function with given fields: path 21 | func (_m *FileReader) Execute(path string) ([]byte, error) { 22 | ret := _m.Called(path) 23 | 24 | if len(ret) == 0 { 25 | panic("no return value specified for Execute") 26 | } 27 | 28 | var r0 []byte 29 | var r1 error 30 | if rf, ok := ret.Get(0).(func(string) ([]byte, error)); ok { 31 | return rf(path) 32 | } 33 | if rf, ok := ret.Get(0).(func(string) []byte); ok { 34 | r0 = rf(path) 35 | } else { 36 | if ret.Get(0) != nil { 37 | r0 = ret.Get(0).([]byte) 38 | } 39 | } 40 | 41 | if rf, ok := ret.Get(1).(func(string) error); ok { 42 | r1 = rf(path) 43 | } else { 44 | r1 = ret.Error(1) 45 | } 46 | 47 | return r0, r1 48 | } 49 | 50 | // FileReader_Execute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Execute' 51 | type FileReader_Execute_Call struct { 52 | *mock.Call 53 | } 54 | 55 | // Execute is a helper method to define mock.On call 56 | // - path string 57 | func (_e *FileReader_Expecter) Execute(path interface{}) *FileReader_Execute_Call { 58 | return &FileReader_Execute_Call{Call: _e.mock.On("Execute", path)} 59 | } 60 | 61 | func (_c *FileReader_Execute_Call) Run(run func(path string)) *FileReader_Execute_Call { 62 | _c.Call.Run(func(args mock.Arguments) { 63 | run(args[0].(string)) 64 | }) 65 | return _c 66 | } 67 | 68 | func (_c *FileReader_Execute_Call) Return(_a0 []byte, _a1 error) *FileReader_Execute_Call { 69 | _c.Call.Return(_a0, _a1) 70 | return _c 71 | } 72 | 73 | func (_c *FileReader_Execute_Call) RunAndReturn(run func(string) ([]byte, error)) *FileReader_Execute_Call { 74 | _c.Call.Return(run) 75 | return _c 76 | } 77 | 78 | // NewFileReader creates a new instance of FileReader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 79 | // The first argument is typically a *testing.T value. 80 | func NewFileReader(t interface { 81 | mock.TestingT 82 | Cleanup(func()) 83 | }) *FileReader { 84 | mock := &FileReader{} 85 | mock.Mock.Test(t) 86 | 87 | t.Cleanup(func() { mock.AssertExpectations(t) }) 88 | 89 | return mock 90 | } 91 | -------------------------------------------------------------------------------- /mocks/Marshaller.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery. DO NOT EDIT. 2 | 3 | package mocks 4 | 5 | import mock "github.com/stretchr/testify/mock" 6 | 7 | // Marshaller is an autogenerated mock type for the Marshaller type 8 | type Marshaller struct { 9 | mock.Mock 10 | } 11 | 12 | type Marshaller_Expecter struct { 13 | mock *mock.Mock 14 | } 15 | 16 | func (_m *Marshaller) EXPECT() *Marshaller_Expecter { 17 | return &Marshaller_Expecter{mock: &_m.Mock} 18 | } 19 | 20 | // Marshal provides a mock function with given fields: in 21 | func (_m *Marshaller) Marshal(in interface{}) ([]byte, error) { 22 | ret := _m.Called(in) 23 | 24 | if len(ret) == 0 { 25 | panic("no return value specified for Marshal") 26 | } 27 | 28 | var r0 []byte 29 | var r1 error 30 | if rf, ok := ret.Get(0).(func(interface{}) ([]byte, error)); ok { 31 | return rf(in) 32 | } 33 | if rf, ok := ret.Get(0).(func(interface{}) []byte); ok { 34 | r0 = rf(in) 35 | } else { 36 | if ret.Get(0) != nil { 37 | r0 = ret.Get(0).([]byte) 38 | } 39 | } 40 | 41 | if rf, ok := ret.Get(1).(func(interface{}) error); ok { 42 | r1 = rf(in) 43 | } else { 44 | r1 = ret.Error(1) 45 | } 46 | 47 | return r0, r1 48 | } 49 | 50 | // Marshaller_Marshal_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Marshal' 51 | type Marshaller_Marshal_Call struct { 52 | *mock.Call 53 | } 54 | 55 | // Marshal is a helper method to define mock.On call 56 | // - in interface{} 57 | func (_e *Marshaller_Expecter) Marshal(in interface{}) *Marshaller_Marshal_Call { 58 | return &Marshaller_Marshal_Call{Call: _e.mock.On("Marshal", in)} 59 | } 60 | 61 | func (_c *Marshaller_Marshal_Call) Run(run func(in interface{})) *Marshaller_Marshal_Call { 62 | _c.Call.Run(func(args mock.Arguments) { 63 | run(args[0].(interface{})) 64 | }) 65 | return _c 66 | } 67 | 68 | func (_c *Marshaller_Marshal_Call) Return(_a0 []byte, _a1 error) *Marshaller_Marshal_Call { 69 | _c.Call.Return(_a0, _a1) 70 | return _c 71 | } 72 | 73 | func (_c *Marshaller_Marshal_Call) RunAndReturn(run func(interface{}) ([]byte, error)) *Marshaller_Marshal_Call { 74 | _c.Call.Return(run) 75 | return _c 76 | } 77 | 78 | // Unmarshal provides a mock function with given fields: in, out 79 | func (_m *Marshaller) Unmarshal(in []byte, out interface{}) error { 80 | ret := _m.Called(in, out) 81 | 82 | if len(ret) == 0 { 83 | panic("no return value specified for Unmarshal") 84 | } 85 | 86 | var r0 error 87 | if rf, ok := ret.Get(0).(func([]byte, interface{}) error); ok { 88 | r0 = rf(in, out) 89 | } else { 90 | r0 = ret.Error(0) 91 | } 92 | 93 | return r0 94 | } 95 | 96 | // Marshaller_Unmarshal_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Unmarshal' 97 | type Marshaller_Unmarshal_Call struct { 98 | *mock.Call 99 | } 100 | 101 | // Unmarshal is a helper method to define mock.On call 102 | // - in []byte 103 | // - out interface{} 104 | func (_e *Marshaller_Expecter) Unmarshal(in interface{}, out interface{}) *Marshaller_Unmarshal_Call { 105 | return &Marshaller_Unmarshal_Call{Call: _e.mock.On("Unmarshal", in, out)} 106 | } 107 | 108 | func (_c *Marshaller_Unmarshal_Call) Run(run func(in []byte, out interface{})) *Marshaller_Unmarshal_Call { 109 | _c.Call.Run(func(args mock.Arguments) { 110 | run(args[0].([]byte), args[1].(interface{})) 111 | }) 112 | return _c 113 | } 114 | 115 | func (_c *Marshaller_Unmarshal_Call) Return(_a0 error) *Marshaller_Unmarshal_Call { 116 | _c.Call.Return(_a0) 117 | return _c 118 | } 119 | 120 | func (_c *Marshaller_Unmarshal_Call) RunAndReturn(run func([]byte, interface{}) error) *Marshaller_Unmarshal_Call { 121 | _c.Call.Return(run) 122 | return _c 123 | } 124 | 125 | // NewMarshaller creates a new instance of Marshaller. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 126 | // The first argument is typically a *testing.T value. 127 | func NewMarshaller(t interface { 128 | mock.TestingT 129 | Cleanup(func()) 130 | }) *Marshaller { 131 | mock := &Marshaller{} 132 | mock.Mock.Test(t) 133 | 134 | t.Cleanup(func() { mock.AssertExpectations(t) }) 135 | 136 | return mock 137 | } 138 | -------------------------------------------------------------------------------- /mocks/Parser.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery. DO NOT EDIT. 2 | 3 | package mocks 4 | 5 | import mock "github.com/stretchr/testify/mock" 6 | 7 | // Parser is an autogenerated mock type for the Parser type 8 | type Parser struct { 9 | mock.Mock 10 | } 11 | 12 | type Parser_Expecter struct { 13 | mock *mock.Mock 14 | } 15 | 16 | func (_m *Parser) EXPECT() *Parser_Expecter { 17 | return &Parser_Expecter{mock: &_m.Mock} 18 | } 19 | 20 | // Execute provides a mock function with given fields: in, out 21 | func (_m *Parser) Execute(in []byte, out interface{}) error { 22 | ret := _m.Called(in, out) 23 | 24 | if len(ret) == 0 { 25 | panic("no return value specified for Execute") 26 | } 27 | 28 | var r0 error 29 | if rf, ok := ret.Get(0).(func([]byte, interface{}) error); ok { 30 | r0 = rf(in, out) 31 | } else { 32 | r0 = ret.Error(0) 33 | } 34 | 35 | return r0 36 | } 37 | 38 | // Parser_Execute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Execute' 39 | type Parser_Execute_Call struct { 40 | *mock.Call 41 | } 42 | 43 | // Execute is a helper method to define mock.On call 44 | // - in []byte 45 | // - out interface{} 46 | func (_e *Parser_Expecter) Execute(in interface{}, out interface{}) *Parser_Execute_Call { 47 | return &Parser_Execute_Call{Call: _e.mock.On("Execute", in, out)} 48 | } 49 | 50 | func (_c *Parser_Execute_Call) Run(run func(in []byte, out interface{})) *Parser_Execute_Call { 51 | _c.Call.Run(func(args mock.Arguments) { 52 | run(args[0].([]byte), args[1].(interface{})) 53 | }) 54 | return _c 55 | } 56 | 57 | func (_c *Parser_Execute_Call) Return(_a0 error) *Parser_Execute_Call { 58 | _c.Call.Return(_a0) 59 | return _c 60 | } 61 | 62 | func (_c *Parser_Execute_Call) RunAndReturn(run func([]byte, interface{}) error) *Parser_Execute_Call { 63 | _c.Call.Return(run) 64 | return _c 65 | } 66 | 67 | // NewParser creates a new instance of Parser. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 68 | // The first argument is typically a *testing.T value. 69 | func NewParser(t interface { 70 | mock.TestingT 71 | Cleanup(func()) 72 | }) *Parser { 73 | mock := &Parser{} 74 | mock.Mock.Test(t) 75 | 76 | t.Cleanup(func() { mock.AssertExpectations(t) }) 77 | 78 | return mock 79 | } 80 | -------------------------------------------------------------------------------- /mocks/Provider.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery. DO NOT EDIT. 2 | 3 | package mocks 4 | 5 | import ( 6 | config "github.com/intility/cwc/pkg/config" 7 | mock "github.com/stretchr/testify/mock" 8 | 9 | openai "github.com/sashabaranov/go-openai" 10 | ) 11 | 12 | // Provider is an autogenerated mock type for the Provider type 13 | type Provider struct { 14 | mock.Mock 15 | } 16 | 17 | type Provider_Expecter struct { 18 | mock *mock.Mock 19 | } 20 | 21 | func (_m *Provider) EXPECT() *Provider_Expecter { 22 | return &Provider_Expecter{mock: &_m.Mock} 23 | } 24 | 25 | // ClearConfig provides a mock function with given fields: 26 | func (_m *Provider) ClearConfig() error { 27 | ret := _m.Called() 28 | 29 | if len(ret) == 0 { 30 | panic("no return value specified for ClearConfig") 31 | } 32 | 33 | var r0 error 34 | if rf, ok := ret.Get(0).(func() error); ok { 35 | r0 = rf() 36 | } else { 37 | r0 = ret.Error(0) 38 | } 39 | 40 | return r0 41 | } 42 | 43 | // Provider_ClearConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClearConfig' 44 | type Provider_ClearConfig_Call struct { 45 | *mock.Call 46 | } 47 | 48 | // ClearConfig is a helper method to define mock.On call 49 | func (_e *Provider_Expecter) ClearConfig() *Provider_ClearConfig_Call { 50 | return &Provider_ClearConfig_Call{Call: _e.mock.On("ClearConfig")} 51 | } 52 | 53 | func (_c *Provider_ClearConfig_Call) Run(run func()) *Provider_ClearConfig_Call { 54 | _c.Call.Run(func(args mock.Arguments) { 55 | run() 56 | }) 57 | return _c 58 | } 59 | 60 | func (_c *Provider_ClearConfig_Call) Return(_a0 error) *Provider_ClearConfig_Call { 61 | _c.Call.Return(_a0) 62 | return _c 63 | } 64 | 65 | func (_c *Provider_ClearConfig_Call) RunAndReturn(run func() error) *Provider_ClearConfig_Call { 66 | _c.Call.Return(run) 67 | return _c 68 | } 69 | 70 | // GetConfig provides a mock function with given fields: 71 | func (_m *Provider) GetConfig() (*config.Config, error) { 72 | ret := _m.Called() 73 | 74 | if len(ret) == 0 { 75 | panic("no return value specified for GetConfig") 76 | } 77 | 78 | var r0 *config.Config 79 | var r1 error 80 | if rf, ok := ret.Get(0).(func() (*config.Config, error)); ok { 81 | return rf() 82 | } 83 | if rf, ok := ret.Get(0).(func() *config.Config); ok { 84 | r0 = rf() 85 | } else { 86 | if ret.Get(0) != nil { 87 | r0 = ret.Get(0).(*config.Config) 88 | } 89 | } 90 | 91 | if rf, ok := ret.Get(1).(func() error); ok { 92 | r1 = rf() 93 | } else { 94 | r1 = ret.Error(1) 95 | } 96 | 97 | return r0, r1 98 | } 99 | 100 | // Provider_GetConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetConfig' 101 | type Provider_GetConfig_Call struct { 102 | *mock.Call 103 | } 104 | 105 | // GetConfig is a helper method to define mock.On call 106 | func (_e *Provider_Expecter) GetConfig() *Provider_GetConfig_Call { 107 | return &Provider_GetConfig_Call{Call: _e.mock.On("GetConfig")} 108 | } 109 | 110 | func (_c *Provider_GetConfig_Call) Run(run func()) *Provider_GetConfig_Call { 111 | _c.Call.Run(func(args mock.Arguments) { 112 | run() 113 | }) 114 | return _c 115 | } 116 | 117 | func (_c *Provider_GetConfig_Call) Return(_a0 *config.Config, _a1 error) *Provider_GetConfig_Call { 118 | _c.Call.Return(_a0, _a1) 119 | return _c 120 | } 121 | 122 | func (_c *Provider_GetConfig_Call) RunAndReturn(run func() (*config.Config, error)) *Provider_GetConfig_Call { 123 | _c.Call.Return(run) 124 | return _c 125 | } 126 | 127 | // GetConfigDir provides a mock function with given fields: 128 | func (_m *Provider) GetConfigDir() (string, error) { 129 | ret := _m.Called() 130 | 131 | if len(ret) == 0 { 132 | panic("no return value specified for GetConfigDir") 133 | } 134 | 135 | var r0 string 136 | var r1 error 137 | if rf, ok := ret.Get(0).(func() (string, error)); ok { 138 | return rf() 139 | } 140 | if rf, ok := ret.Get(0).(func() string); ok { 141 | r0 = rf() 142 | } else { 143 | r0 = ret.Get(0).(string) 144 | } 145 | 146 | if rf, ok := ret.Get(1).(func() error); ok { 147 | r1 = rf() 148 | } else { 149 | r1 = ret.Error(1) 150 | } 151 | 152 | return r0, r1 153 | } 154 | 155 | // Provider_GetConfigDir_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetConfigDir' 156 | type Provider_GetConfigDir_Call struct { 157 | *mock.Call 158 | } 159 | 160 | // GetConfigDir is a helper method to define mock.On call 161 | func (_e *Provider_Expecter) GetConfigDir() *Provider_GetConfigDir_Call { 162 | return &Provider_GetConfigDir_Call{Call: _e.mock.On("GetConfigDir")} 163 | } 164 | 165 | func (_c *Provider_GetConfigDir_Call) Run(run func()) *Provider_GetConfigDir_Call { 166 | _c.Call.Run(func(args mock.Arguments) { 167 | run() 168 | }) 169 | return _c 170 | } 171 | 172 | func (_c *Provider_GetConfigDir_Call) Return(_a0 string, _a1 error) *Provider_GetConfigDir_Call { 173 | _c.Call.Return(_a0, _a1) 174 | return _c 175 | } 176 | 177 | func (_c *Provider_GetConfigDir_Call) RunAndReturn(run func() (string, error)) *Provider_GetConfigDir_Call { 178 | _c.Call.Return(run) 179 | return _c 180 | } 181 | 182 | // NewFromConfigFile provides a mock function with given fields: 183 | func (_m *Provider) NewFromConfigFile() (openai.ClientConfig, error) { 184 | ret := _m.Called() 185 | 186 | if len(ret) == 0 { 187 | panic("no return value specified for NewFromConfigFile") 188 | } 189 | 190 | var r0 openai.ClientConfig 191 | var r1 error 192 | if rf, ok := ret.Get(0).(func() (openai.ClientConfig, error)); ok { 193 | return rf() 194 | } 195 | if rf, ok := ret.Get(0).(func() openai.ClientConfig); ok { 196 | r0 = rf() 197 | } else { 198 | r0 = ret.Get(0).(openai.ClientConfig) 199 | } 200 | 201 | if rf, ok := ret.Get(1).(func() error); ok { 202 | r1 = rf() 203 | } else { 204 | r1 = ret.Error(1) 205 | } 206 | 207 | return r0, r1 208 | } 209 | 210 | // Provider_NewFromConfigFile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'NewFromConfigFile' 211 | type Provider_NewFromConfigFile_Call struct { 212 | *mock.Call 213 | } 214 | 215 | // NewFromConfigFile is a helper method to define mock.On call 216 | func (_e *Provider_Expecter) NewFromConfigFile() *Provider_NewFromConfigFile_Call { 217 | return &Provider_NewFromConfigFile_Call{Call: _e.mock.On("NewFromConfigFile")} 218 | } 219 | 220 | func (_c *Provider_NewFromConfigFile_Call) Run(run func()) *Provider_NewFromConfigFile_Call { 221 | _c.Call.Run(func(args mock.Arguments) { 222 | run() 223 | }) 224 | return _c 225 | } 226 | 227 | func (_c *Provider_NewFromConfigFile_Call) Return(_a0 openai.ClientConfig, _a1 error) *Provider_NewFromConfigFile_Call { 228 | _c.Call.Return(_a0, _a1) 229 | return _c 230 | } 231 | 232 | func (_c *Provider_NewFromConfigFile_Call) RunAndReturn(run func() (openai.ClientConfig, error)) *Provider_NewFromConfigFile_Call { 233 | _c.Call.Return(run) 234 | return _c 235 | } 236 | 237 | // SaveConfig provides a mock function with given fields: _a0 238 | func (_m *Provider) SaveConfig(_a0 *config.Config) error { 239 | ret := _m.Called(_a0) 240 | 241 | if len(ret) == 0 { 242 | panic("no return value specified for SaveConfig") 243 | } 244 | 245 | var r0 error 246 | if rf, ok := ret.Get(0).(func(*config.Config) error); ok { 247 | r0 = rf(_a0) 248 | } else { 249 | r0 = ret.Error(0) 250 | } 251 | 252 | return r0 253 | } 254 | 255 | // Provider_SaveConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveConfig' 256 | type Provider_SaveConfig_Call struct { 257 | *mock.Call 258 | } 259 | 260 | // SaveConfig is a helper method to define mock.On call 261 | // - _a0 *config.Config 262 | func (_e *Provider_Expecter) SaveConfig(_a0 interface{}) *Provider_SaveConfig_Call { 263 | return &Provider_SaveConfig_Call{Call: _e.mock.On("SaveConfig", _a0)} 264 | } 265 | 266 | func (_c *Provider_SaveConfig_Call) Run(run func(_a0 *config.Config)) *Provider_SaveConfig_Call { 267 | _c.Call.Run(func(args mock.Arguments) { 268 | run(args[0].(*config.Config)) 269 | }) 270 | return _c 271 | } 272 | 273 | func (_c *Provider_SaveConfig_Call) Return(_a0 error) *Provider_SaveConfig_Call { 274 | _c.Call.Return(_a0) 275 | return _c 276 | } 277 | 278 | func (_c *Provider_SaveConfig_Call) RunAndReturn(run func(*config.Config) error) *Provider_SaveConfig_Call { 279 | _c.Call.Return(run) 280 | return _c 281 | } 282 | 283 | // NewProvider creates a new instance of Provider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 284 | // The first argument is typically a *testing.T value. 285 | func NewProvider(t interface { 286 | mock.TestingT 287 | Cleanup(func()) 288 | }) *Provider { 289 | mock := &Provider{} 290 | mock.Mock.Test(t) 291 | 292 | t.Cleanup(func() { mock.AssertExpectations(t) }) 293 | 294 | return mock 295 | } 296 | -------------------------------------------------------------------------------- /mocks/SystemMessageGenerator.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery. DO NOT EDIT. 2 | 3 | package mocks 4 | 5 | import mock "github.com/stretchr/testify/mock" 6 | 7 | // SystemMessageGenerator is an autogenerated mock type for the SystemMessageGenerator type 8 | type SystemMessageGenerator struct { 9 | mock.Mock 10 | } 11 | 12 | type SystemMessageGenerator_Expecter struct { 13 | mock *mock.Mock 14 | } 15 | 16 | func (_m *SystemMessageGenerator) EXPECT() *SystemMessageGenerator_Expecter { 17 | return &SystemMessageGenerator_Expecter{mock: &_m.Mock} 18 | } 19 | 20 | // GenerateSystemMessage provides a mock function with given fields: 21 | func (_m *SystemMessageGenerator) GenerateSystemMessage() (string, error) { 22 | ret := _m.Called() 23 | 24 | if len(ret) == 0 { 25 | panic("no return value specified for GenerateSystemMessage") 26 | } 27 | 28 | var r0 string 29 | var r1 error 30 | if rf, ok := ret.Get(0).(func() (string, error)); ok { 31 | return rf() 32 | } 33 | if rf, ok := ret.Get(0).(func() string); ok { 34 | r0 = rf() 35 | } else { 36 | r0 = ret.Get(0).(string) 37 | } 38 | 39 | if rf, ok := ret.Get(1).(func() error); ok { 40 | r1 = rf() 41 | } else { 42 | r1 = ret.Error(1) 43 | } 44 | 45 | return r0, r1 46 | } 47 | 48 | // SystemMessageGenerator_GenerateSystemMessage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GenerateSystemMessage' 49 | type SystemMessageGenerator_GenerateSystemMessage_Call struct { 50 | *mock.Call 51 | } 52 | 53 | // GenerateSystemMessage is a helper method to define mock.On call 54 | func (_e *SystemMessageGenerator_Expecter) GenerateSystemMessage() *SystemMessageGenerator_GenerateSystemMessage_Call { 55 | return &SystemMessageGenerator_GenerateSystemMessage_Call{Call: _e.mock.On("GenerateSystemMessage")} 56 | } 57 | 58 | func (_c *SystemMessageGenerator_GenerateSystemMessage_Call) Run(run func()) *SystemMessageGenerator_GenerateSystemMessage_Call { 59 | _c.Call.Run(func(args mock.Arguments) { 60 | run() 61 | }) 62 | return _c 63 | } 64 | 65 | func (_c *SystemMessageGenerator_GenerateSystemMessage_Call) Return(_a0 string, _a1 error) *SystemMessageGenerator_GenerateSystemMessage_Call { 66 | _c.Call.Return(_a0, _a1) 67 | return _c 68 | } 69 | 70 | func (_c *SystemMessageGenerator_GenerateSystemMessage_Call) RunAndReturn(run func() (string, error)) *SystemMessageGenerator_GenerateSystemMessage_Call { 71 | _c.Call.Return(run) 72 | return _c 73 | } 74 | 75 | // NewSystemMessageGenerator creates a new instance of SystemMessageGenerator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 76 | // The first argument is typically a *testing.T value. 77 | func NewSystemMessageGenerator(t interface { 78 | mock.TestingT 79 | Cleanup(func()) 80 | }) *SystemMessageGenerator { 81 | mock := &SystemMessageGenerator{} 82 | mock.Mock.Test(t) 83 | 84 | t.Cleanup(func() { mock.AssertExpectations(t) }) 85 | 86 | return mock 87 | } 88 | -------------------------------------------------------------------------------- /mocks/TemplateLocator.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery. DO NOT EDIT. 2 | 3 | package mocks 4 | 5 | import ( 6 | templates "github.com/intility/cwc/pkg/templates" 7 | mock "github.com/stretchr/testify/mock" 8 | ) 9 | 10 | // TemplateLocator is an autogenerated mock type for the TemplateLocator type 11 | type TemplateLocator struct { 12 | mock.Mock 13 | } 14 | 15 | type TemplateLocator_Expecter struct { 16 | mock *mock.Mock 17 | } 18 | 19 | func (_m *TemplateLocator) EXPECT() *TemplateLocator_Expecter { 20 | return &TemplateLocator_Expecter{mock: &_m.Mock} 21 | } 22 | 23 | // GetTemplate provides a mock function with given fields: name 24 | func (_m *TemplateLocator) GetTemplate(name string) (*templates.Template, error) { 25 | ret := _m.Called(name) 26 | 27 | if len(ret) == 0 { 28 | panic("no return value specified for GetTemplate") 29 | } 30 | 31 | var r0 *templates.Template 32 | var r1 error 33 | if rf, ok := ret.Get(0).(func(string) (*templates.Template, error)); ok { 34 | return rf(name) 35 | } 36 | if rf, ok := ret.Get(0).(func(string) *templates.Template); ok { 37 | r0 = rf(name) 38 | } else { 39 | if ret.Get(0) != nil { 40 | r0 = ret.Get(0).(*templates.Template) 41 | } 42 | } 43 | 44 | if rf, ok := ret.Get(1).(func(string) error); ok { 45 | r1 = rf(name) 46 | } else { 47 | r1 = ret.Error(1) 48 | } 49 | 50 | return r0, r1 51 | } 52 | 53 | // TemplateLocator_GetTemplate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetTemplate' 54 | type TemplateLocator_GetTemplate_Call struct { 55 | *mock.Call 56 | } 57 | 58 | // GetTemplate is a helper method to define mock.On call 59 | // - name string 60 | func (_e *TemplateLocator_Expecter) GetTemplate(name interface{}) *TemplateLocator_GetTemplate_Call { 61 | return &TemplateLocator_GetTemplate_Call{Call: _e.mock.On("GetTemplate", name)} 62 | } 63 | 64 | func (_c *TemplateLocator_GetTemplate_Call) Run(run func(name string)) *TemplateLocator_GetTemplate_Call { 65 | _c.Call.Run(func(args mock.Arguments) { 66 | run(args[0].(string)) 67 | }) 68 | return _c 69 | } 70 | 71 | func (_c *TemplateLocator_GetTemplate_Call) Return(_a0 *templates.Template, _a1 error) *TemplateLocator_GetTemplate_Call { 72 | _c.Call.Return(_a0, _a1) 73 | return _c 74 | } 75 | 76 | func (_c *TemplateLocator_GetTemplate_Call) RunAndReturn(run func(string) (*templates.Template, error)) *TemplateLocator_GetTemplate_Call { 77 | _c.Call.Return(run) 78 | return _c 79 | } 80 | 81 | // ListTemplates provides a mock function with given fields: 82 | func (_m *TemplateLocator) ListTemplates() ([]templates.Template, error) { 83 | ret := _m.Called() 84 | 85 | if len(ret) == 0 { 86 | panic("no return value specified for ListTemplates") 87 | } 88 | 89 | var r0 []templates.Template 90 | var r1 error 91 | if rf, ok := ret.Get(0).(func() ([]templates.Template, error)); ok { 92 | return rf() 93 | } 94 | if rf, ok := ret.Get(0).(func() []templates.Template); ok { 95 | r0 = rf() 96 | } else { 97 | if ret.Get(0) != nil { 98 | r0 = ret.Get(0).([]templates.Template) 99 | } 100 | } 101 | 102 | if rf, ok := ret.Get(1).(func() error); ok { 103 | r1 = rf() 104 | } else { 105 | r1 = ret.Error(1) 106 | } 107 | 108 | return r0, r1 109 | } 110 | 111 | // TemplateLocator_ListTemplates_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListTemplates' 112 | type TemplateLocator_ListTemplates_Call struct { 113 | *mock.Call 114 | } 115 | 116 | // ListTemplates is a helper method to define mock.On call 117 | func (_e *TemplateLocator_Expecter) ListTemplates() *TemplateLocator_ListTemplates_Call { 118 | return &TemplateLocator_ListTemplates_Call{Call: _e.mock.On("ListTemplates")} 119 | } 120 | 121 | func (_c *TemplateLocator_ListTemplates_Call) Run(run func()) *TemplateLocator_ListTemplates_Call { 122 | _c.Call.Run(func(args mock.Arguments) { 123 | run() 124 | }) 125 | return _c 126 | } 127 | 128 | func (_c *TemplateLocator_ListTemplates_Call) Return(_a0 []templates.Template, _a1 error) *TemplateLocator_ListTemplates_Call { 129 | _c.Call.Return(_a0, _a1) 130 | return _c 131 | } 132 | 133 | func (_c *TemplateLocator_ListTemplates_Call) RunAndReturn(run func() ([]templates.Template, error)) *TemplateLocator_ListTemplates_Call { 134 | _c.Call.Return(run) 135 | return _c 136 | } 137 | 138 | // NewTemplateLocator creates a new instance of TemplateLocator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 139 | // The first argument is typically a *testing.T value. 140 | func NewTemplateLocator(t interface { 141 | mock.TestingT 142 | Cleanup(func()) 143 | }) *TemplateLocator { 144 | mock := &TemplateLocator{} 145 | mock.Mock.Test(t) 146 | 147 | t.Cleanup(func() { mock.AssertExpectations(t) }) 148 | 149 | return mock 150 | } 151 | -------------------------------------------------------------------------------- /mocks/UsernameRetriever.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery. DO NOT EDIT. 2 | 3 | package mocks 4 | 5 | import ( 6 | user "os/user" 7 | 8 | mock "github.com/stretchr/testify/mock" 9 | ) 10 | 11 | // UsernameRetriever is an autogenerated mock type for the UsernameRetriever type 12 | type UsernameRetriever struct { 13 | mock.Mock 14 | } 15 | 16 | type UsernameRetriever_Expecter struct { 17 | mock *mock.Mock 18 | } 19 | 20 | func (_m *UsernameRetriever) EXPECT() *UsernameRetriever_Expecter { 21 | return &UsernameRetriever_Expecter{mock: &_m.Mock} 22 | } 23 | 24 | // Execute provides a mock function with given fields: 25 | func (_m *UsernameRetriever) Execute() (*user.User, error) { 26 | ret := _m.Called() 27 | 28 | if len(ret) == 0 { 29 | panic("no return value specified for Execute") 30 | } 31 | 32 | var r0 *user.User 33 | var r1 error 34 | if rf, ok := ret.Get(0).(func() (*user.User, error)); ok { 35 | return rf() 36 | } 37 | if rf, ok := ret.Get(0).(func() *user.User); ok { 38 | r0 = rf() 39 | } else { 40 | if ret.Get(0) != nil { 41 | r0 = ret.Get(0).(*user.User) 42 | } 43 | } 44 | 45 | if rf, ok := ret.Get(1).(func() error); ok { 46 | r1 = rf() 47 | } else { 48 | r1 = ret.Error(1) 49 | } 50 | 51 | return r0, r1 52 | } 53 | 54 | // UsernameRetriever_Execute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Execute' 55 | type UsernameRetriever_Execute_Call struct { 56 | *mock.Call 57 | } 58 | 59 | // Execute is a helper method to define mock.On call 60 | func (_e *UsernameRetriever_Expecter) Execute() *UsernameRetriever_Execute_Call { 61 | return &UsernameRetriever_Execute_Call{Call: _e.mock.On("Execute")} 62 | } 63 | 64 | func (_c *UsernameRetriever_Execute_Call) Run(run func()) *UsernameRetriever_Execute_Call { 65 | _c.Call.Run(func(args mock.Arguments) { 66 | run() 67 | }) 68 | return _c 69 | } 70 | 71 | func (_c *UsernameRetriever_Execute_Call) Return(_a0 *user.User, _a1 error) *UsernameRetriever_Execute_Call { 72 | _c.Call.Return(_a0, _a1) 73 | return _c 74 | } 75 | 76 | func (_c *UsernameRetriever_Execute_Call) RunAndReturn(run func() (*user.User, error)) *UsernameRetriever_Execute_Call { 77 | _c.Call.Return(run) 78 | return _c 79 | } 80 | 81 | // NewUsernameRetriever creates a new instance of UsernameRetriever. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 82 | // The first argument is typically a *testing.T value. 83 | func NewUsernameRetriever(t interface { 84 | mock.TestingT 85 | Cleanup(func()) 86 | }) *UsernameRetriever { 87 | mock := &UsernameRetriever{} 88 | mock.Mock.Test(t) 89 | 90 | t.Cleanup(func() { mock.AssertExpectations(t) }) 91 | 92 | return mock 93 | } 94 | -------------------------------------------------------------------------------- /mocks/Validator.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery. DO NOT EDIT. 2 | 3 | package mocks 4 | 5 | import ( 6 | config "github.com/intility/cwc/pkg/config" 7 | mock "github.com/stretchr/testify/mock" 8 | ) 9 | 10 | // Validator is an autogenerated mock type for the Validator type 11 | type Validator struct { 12 | mock.Mock 13 | } 14 | 15 | type Validator_Expecter struct { 16 | mock *mock.Mock 17 | } 18 | 19 | func (_m *Validator) EXPECT() *Validator_Expecter { 20 | return &Validator_Expecter{mock: &_m.Mock} 21 | } 22 | 23 | // Execute provides a mock function with given fields: cfg 24 | func (_m *Validator) Execute(cfg *config.Config) error { 25 | ret := _m.Called(cfg) 26 | 27 | if len(ret) == 0 { 28 | panic("no return value specified for Execute") 29 | } 30 | 31 | var r0 error 32 | if rf, ok := ret.Get(0).(func(*config.Config) error); ok { 33 | r0 = rf(cfg) 34 | } else { 35 | r0 = ret.Error(0) 36 | } 37 | 38 | return r0 39 | } 40 | 41 | // Validator_Execute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Execute' 42 | type Validator_Execute_Call struct { 43 | *mock.Call 44 | } 45 | 46 | // Execute is a helper method to define mock.On call 47 | // - cfg *config.Config 48 | func (_e *Validator_Expecter) Execute(cfg interface{}) *Validator_Execute_Call { 49 | return &Validator_Execute_Call{Call: _e.mock.On("Execute", cfg)} 50 | } 51 | 52 | func (_c *Validator_Execute_Call) Run(run func(cfg *config.Config)) *Validator_Execute_Call { 53 | _c.Call.Run(func(args mock.Arguments) { 54 | run(args[0].(*config.Config)) 55 | }) 56 | return _c 57 | } 58 | 59 | func (_c *Validator_Execute_Call) Return(_a0 error) *Validator_Execute_Call { 60 | _c.Call.Return(_a0) 61 | return _c 62 | } 63 | 64 | func (_c *Validator_Execute_Call) RunAndReturn(run func(*config.Config) error) *Validator_Execute_Call { 65 | _c.Call.Return(run) 66 | return _c 67 | } 68 | 69 | // NewValidator creates a new instance of Validator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 70 | // The first argument is typically a *testing.T value. 71 | func NewValidator(t interface { 72 | mock.TestingT 73 | Cleanup(func()) 74 | }) *Validator { 75 | mock := &Validator{} 76 | mock.Mock.Test(t) 77 | 78 | t.Cleanup(func() { mock.AssertExpectations(t) }) 79 | 80 | return mock 81 | } 82 | -------------------------------------------------------------------------------- /mocks/optFunc.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery. DO NOT EDIT. 2 | 3 | package mocks 4 | 5 | import ( 6 | config "github.com/intility/cwc/pkg/config" 7 | mock "github.com/stretchr/testify/mock" 8 | ) 9 | 10 | // optFunc is an autogenerated mock type for the optFunc type 11 | type optFunc struct { 12 | mock.Mock 13 | } 14 | 15 | type optFunc_Expecter struct { 16 | mock *mock.Mock 17 | } 18 | 19 | func (_m *optFunc) EXPECT() *optFunc_Expecter { 20 | return &optFunc_Expecter{mock: &_m.Mock} 21 | } 22 | 23 | // Execute provides a mock function with given fields: _a0 24 | func (_m *optFunc) Execute(_a0 *config.DefaultProviderOptions) { 25 | _m.Called(_a0) 26 | } 27 | 28 | // optFunc_Execute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Execute' 29 | type optFunc_Execute_Call struct { 30 | *mock.Call 31 | } 32 | 33 | // Execute is a helper method to define mock.On call 34 | // - _a0 *config.DefaultProviderOptions 35 | func (_e *optFunc_Expecter) Execute(_a0 interface{}) *optFunc_Execute_Call { 36 | return &optFunc_Execute_Call{Call: _e.mock.On("Execute", _a0)} 37 | } 38 | 39 | func (_c *optFunc_Execute_Call) Run(run func(_a0 *config.DefaultProviderOptions)) *optFunc_Execute_Call { 40 | _c.Call.Run(func(args mock.Arguments) { 41 | run(args[0].(*config.DefaultProviderOptions)) 42 | }) 43 | return _c 44 | } 45 | 46 | func (_c *optFunc_Execute_Call) Return() *optFunc_Execute_Call { 47 | _c.Call.Return() 48 | return _c 49 | } 50 | 51 | func (_c *optFunc_Execute_Call) RunAndReturn(run func(*config.DefaultProviderOptions)) *optFunc_Execute_Call { 52 | _c.Call.Return(run) 53 | return _c 54 | } 55 | 56 | // newOptFunc creates a new instance of optFunc. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 57 | // The first argument is typically a *testing.T value. 58 | func newOptFunc(t interface { 59 | mock.TestingT 60 | Cleanup(func()) 61 | }) *optFunc { 62 | mock := &optFunc{} 63 | mock.Mock.Test(t) 64 | 65 | t.Cleanup(func() { mock.AssertExpectations(t) }) 66 | 67 | return mock 68 | } 69 | -------------------------------------------------------------------------------- /pkg/chat/chat.go: -------------------------------------------------------------------------------- 1 | package chat 2 | 3 | import ( 4 | "context" 5 | stderrors "errors" 6 | "fmt" 7 | "io" 8 | "strings" 9 | "sync" 10 | 11 | "github.com/sashabaranov/go-openai" 12 | ) 13 | 14 | type Chat struct { 15 | client *openai.Client 16 | systemMessage string 17 | chunkHandler MessageChunkHandler 18 | } 19 | 20 | type MessageChunkHandler func(chunk *ConversationChunk) 21 | 22 | func NewChat(client *openai.Client, systemMessage string, onChunk MessageChunkHandler) *Chat { 23 | return &Chat{ 24 | client: client, 25 | systemMessage: systemMessage, 26 | chunkHandler: onChunk, 27 | } 28 | } 29 | 30 | func (c *Chat) BeginConversation(initialMessage string) *Conversation { 31 | conversation := &Conversation{ 32 | client: c.client, 33 | wg: sync.WaitGroup{}, 34 | onChunk: c.chunkHandler, 35 | messages: []openai.ChatCompletionMessage{ 36 | { 37 | Role: openai.ChatMessageRoleSystem, 38 | Content: c.systemMessage, 39 | }, 40 | }, 41 | } 42 | 43 | conversation.Reply(initialMessage) 44 | 45 | return conversation 46 | } 47 | 48 | type Conversation struct { 49 | client *openai.Client 50 | messages []openai.ChatCompletionMessage 51 | wg sync.WaitGroup 52 | onChunk func(chunk *ConversationChunk) 53 | } 54 | 55 | func (c *Conversation) addMessage(role string, message string) { 56 | c.messages = append(c.messages, openai.ChatCompletionMessage{ 57 | Role: role, 58 | Content: message, 59 | }) 60 | } 61 | 62 | type ConversationChunk struct { 63 | Role string 64 | Content string 65 | IsInitialChunk bool 66 | IsFinalChunk bool 67 | IsErrorChunk bool 68 | } 69 | 70 | func (c *Conversation) OnMessageChunk(onChunk func(chunk *ConversationChunk)) { 71 | c.onChunk = onChunk 72 | } 73 | 74 | func (c *Conversation) WaitMyTurn() { 75 | c.wg.Wait() 76 | } 77 | 78 | func (c *Conversation) Reply(message string) { 79 | c.wg.Add(1) 80 | 81 | c.addMessage(openai.ChatMessageRoleUser, message) 82 | 83 | ctx := context.Background() 84 | 85 | go func() { 86 | err := c.processMessages(ctx) 87 | if err != nil { 88 | c.onChunk(&ConversationChunk{ 89 | Role: openai.ChatMessageRoleAssistant, 90 | Content: "Sorry, I'm having trouble processing your request: " + err.Error(), 91 | IsInitialChunk: false, 92 | IsFinalChunk: true, 93 | IsErrorChunk: true, 94 | }) 95 | } 96 | 97 | c.wg.Done() 98 | }() 99 | } 100 | 101 | func (c *Conversation) processMessages(ctx context.Context) error { 102 | req := openai.ChatCompletionRequest{ 103 | Model: openai.GPT4TurboPreview, 104 | Messages: c.messages, 105 | Stream: true, 106 | } 107 | 108 | stream, err := c.client.CreateChatCompletionStream(ctx, req) 109 | if err != nil { 110 | return fmt.Errorf("error creating chat completion stream: %w", err) 111 | } 112 | 113 | defer stream.Close() 114 | 115 | return c.handleStream(stream) 116 | } 117 | 118 | func (c *Conversation) handleStream(stream *openai.ChatCompletionStream) error { 119 | var reply strings.Builder 120 | 121 | c.onChunk(&ConversationChunk{ 122 | Role: openai.ChatMessageRoleAssistant, 123 | Content: "", 124 | IsInitialChunk: true, 125 | IsFinalChunk: false, 126 | IsErrorChunk: false, 127 | }) 128 | 129 | answer: 130 | for { 131 | response, err := stream.Recv() 132 | if stderrors.Is(err, io.EOF) { 133 | c.onChunk(&ConversationChunk{ 134 | Role: openai.ChatMessageRoleAssistant, 135 | Content: "", 136 | IsInitialChunk: false, 137 | IsFinalChunk: true, 138 | IsErrorChunk: false, 139 | }) 140 | 141 | break answer 142 | } 143 | 144 | if err != nil { 145 | return fmt.Errorf("error receiving chat completion response: %w", err) 146 | } 147 | 148 | if len(response.Choices) == 0 { 149 | continue answer 150 | } 151 | 152 | reply.WriteString(response.Choices[0].Delta.Content) 153 | 154 | c.onChunk(&ConversationChunk{ 155 | Role: response.Choices[0].Delta.Role, 156 | Content: response.Choices[0].Delta.Content, 157 | IsInitialChunk: false, 158 | IsFinalChunk: false, 159 | IsErrorChunk: false, 160 | }) 161 | } 162 | 163 | c.messages = append(c.messages, openai.ChatCompletionMessage{ 164 | Role: openai.ChatMessageRoleAssistant, 165 | Content: reply.String(), 166 | }) 167 | 168 | return nil 169 | } 170 | -------------------------------------------------------------------------------- /pkg/config/apiKeyFileStore.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | ) 8 | 9 | type APIKeyFileStore struct { 10 | Filepath string 11 | } 12 | 13 | func NewAPIKeyFileStore(filepath string) *APIKeyFileStore { 14 | return &APIKeyFileStore{Filepath: filepath} 15 | } 16 | 17 | func (fs *APIKeyFileStore) SetAPIKey(key string) error { 18 | var ( 19 | dirFileMode os.FileMode = 0o700 20 | keyFileMode os.FileMode = 0o600 21 | ) 22 | 23 | if err := os.MkdirAll(filepath.Dir(fs.Filepath), dirFileMode); err != nil { 24 | return fmt.Errorf("error creating directories for file storage: %w", err) 25 | } 26 | 27 | err := os.WriteFile(fs.Filepath, []byte(key), keyFileMode) 28 | if err != nil { 29 | return fmt.Errorf("error storing API key in file: %w", err) 30 | } 31 | 32 | return nil 33 | } 34 | 35 | func (fs *APIKeyFileStore) GetAPIKey() (string, error) { 36 | data, err := os.ReadFile(fs.Filepath) 37 | if os.IsNotExist(err) { 38 | return "", nil // Return an empty string if the file does not exist 39 | } else if err != nil { 40 | return "", fmt.Errorf("error reading API key from file: %w", err) 41 | } 42 | 43 | return string(data), nil 44 | } 45 | 46 | func (fs *APIKeyFileStore) ClearAPIKey() error { 47 | err := os.Remove(fs.Filepath) 48 | if err != nil && !os.IsNotExist(err) { 49 | return fmt.Errorf("error removing API key from file: %w", err) 50 | } 51 | 52 | return nil 53 | } 54 | -------------------------------------------------------------------------------- /pkg/config/apiKeyKeyringStore.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os/user" 6 | 7 | "github.com/zalando/go-keyring" 8 | ) 9 | 10 | type UsernameRetriever func() (*user.User, error) 11 | 12 | type APIKeyKeyringStore struct { 13 | serviceName string 14 | usernameRetriever UsernameRetriever 15 | } 16 | 17 | func NewAPIKeyKeyringStore(serviceName string, usernameRetriever UsernameRetriever) *APIKeyKeyringStore { 18 | return &APIKeyKeyringStore{ 19 | serviceName: serviceName, 20 | usernameRetriever: usernameRetriever, 21 | } 22 | } 23 | 24 | func (k *APIKeyKeyringStore) GetAPIKey() (string, error) { 25 | usr, err := k.usernameRetriever() 26 | if err != nil { 27 | return "", fmt.Errorf("error getting current user: %w", err) 28 | } 29 | 30 | apiKey, err := keyring.Get(k.serviceName, usr.Username) 31 | if err != nil { 32 | return "", fmt.Errorf("error getting API key from keyring: %w", err) 33 | } 34 | 35 | return apiKey, nil 36 | } 37 | 38 | func (k *APIKeyKeyringStore) SetAPIKey(apiKey string) error { 39 | usr, err := k.usernameRetriever() 40 | if err != nil { 41 | return fmt.Errorf("error getting current user: %w", err) 42 | } 43 | 44 | username := usr.Username 45 | err = keyring.Set(k.serviceName, username, apiKey) 46 | 47 | if err != nil { 48 | return fmt.Errorf("error storing API key in keyring: %w", err) 49 | } 50 | 51 | return nil 52 | } 53 | 54 | func (k *APIKeyKeyringStore) ClearAPIKey() error { 55 | usr, err := k.usernameRetriever() 56 | if err != nil { 57 | return fmt.Errorf("error getting current user: %w", err) 58 | } 59 | 60 | username := usr.Username 61 | err = keyring.Delete(serviceName, username) 62 | 63 | if err != nil { 64 | return fmt.Errorf("error deleting API key from keyring: %w", err) 65 | } 66 | 67 | return nil 68 | } 69 | -------------------------------------------------------------------------------- /pkg/config/apikeyStore.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | type APIKeyStore interface { 4 | GetAPIKey() (string, error) 5 | SetAPIKey(apiKey string) error 6 | ClearAPIKey() error 7 | } 8 | -------------------------------------------------------------------------------- /pkg/config/client.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/sashabaranov/go-openai" 7 | ) 8 | 9 | type ClientProvider interface { 10 | NewClientFromConfig() (*openai.Client, error) 11 | } 12 | 13 | type OpenAIClientProvider struct { 14 | cfg Provider 15 | } 16 | 17 | func NewOpenAIClientProvider(provider Provider) *OpenAIClientProvider { 18 | return &OpenAIClientProvider{cfg: provider} 19 | } 20 | 21 | func (c *OpenAIClientProvider) NewClientFromConfig() (*openai.Client, error) { 22 | cfg, err := c.cfg.NewFromConfigFile() 23 | if err != nil { 24 | return nil, fmt.Errorf("error creating client: %w", err) 25 | } 26 | 27 | client := openai.NewClientWithConfig(cfg) 28 | 29 | return client, nil 30 | } 31 | -------------------------------------------------------------------------------- /pkg/config/client_test.go: -------------------------------------------------------------------------------- 1 | package config_test 2 | 3 | import ( 4 | "errors" 5 | "github.com/intility/cwc/pkg/config" 6 | "testing" 7 | 8 | "github.com/sashabaranov/go-openai" 9 | "github.com/stretchr/testify/assert" 10 | 11 | "github.com/intility/cwc/mocks" 12 | ) 13 | 14 | func TestNewClientFromConfig(t *testing.T) { 15 | 16 | // Define the test cases 17 | type testConfig struct { 18 | cfgProvider *mocks.Provider 19 | clientConfig openai.ClientConfig 20 | } 21 | 22 | tests := []struct { 23 | name string 24 | setupMocks func(testConfig) 25 | wantResult func(t *testing.T, result *openai.Client) 26 | wantErr func(t *testing.T, err error) 27 | }{ 28 | { 29 | name: "success", 30 | setupMocks: func(m testConfig) { 31 | m.cfgProvider.On("NewFromConfigFile").Return(m.clientConfig, nil) 32 | }, 33 | wantResult: func(t *testing.T, result *openai.Client) { 34 | assert.NotNil(t, result) 35 | }, 36 | wantErr: func(t *testing.T, err error) { 37 | assert.NoError(t, err) 38 | }, 39 | }, 40 | { 41 | name: "error loading config", 42 | setupMocks: func(m testConfig) { 43 | m.cfgProvider.On("NewFromConfigFile"). 44 | Return(openai.ClientConfig{}, errors.New("error reading config")) 45 | }, 46 | wantResult: func(t *testing.T, result *openai.Client) { 47 | assert.Nil(t, result) 48 | }, 49 | wantErr: func(t *testing.T, err error) { 50 | assert.Error(t, err) 51 | }, 52 | }, 53 | } 54 | 55 | for _, tt := range tests { 56 | t.Run(tt.name, func(t *testing.T) { 57 | mockConfigProvider := &mocks.Provider{} 58 | cfg := testConfig{cfgProvider: mockConfigProvider, clientConfig: openai.ClientConfig{}} 59 | tt.setupMocks(cfg) 60 | 61 | clientProvider := config.NewOpenAIClientProvider(mockConfigProvider) 62 | res, err := clientProvider.NewClientFromConfig() 63 | 64 | mockConfigProvider.AssertExpectations(t) 65 | tt.wantResult(t, res) 66 | tt.wantErr(t, err) 67 | }) 68 | 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /pkg/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | ) 8 | 9 | const ( 10 | configFileName = "cwc.yaml" // The name of the config file we want to save 11 | configFilePermissions = 0o600 // The permissions we want to set on the config file 12 | apiVersion = "2024-02-01" 13 | ) 14 | 15 | // SanitizeInput trims whitespaces and newlines from a string. 16 | func SanitizeInput(input string) string { 17 | return strings.TrimSpace(input) 18 | } 19 | 20 | type Config struct { 21 | Endpoint string `yaml:"endpoint"` 22 | ModelDeployment string `yaml:"modelDeployment"` 23 | ExcludeGitDir bool `yaml:"excludeGitDir"` 24 | UseGitignore bool `yaml:"useGitignore"` 25 | // Keep APIKey unexported to avoid accidental exposure 26 | apiKey string 27 | } 28 | 29 | // NewConfig creates a new Config object. 30 | func NewConfig(endpoint, modelDeployment string) *Config { 31 | return &Config{ 32 | Endpoint: endpoint, 33 | ModelDeployment: modelDeployment, 34 | ExcludeGitDir: true, 35 | UseGitignore: true, 36 | apiKey: "", 37 | } 38 | } 39 | 40 | // SetAPIKey sets the confidential field apiKey. 41 | func (c *Config) SetAPIKey(apiKey string) { 42 | c.apiKey = apiKey 43 | } 44 | 45 | // APIKey returns the confidential field apiKey. 46 | func (c *Config) APIKey() string { 47 | return c.apiKey 48 | } 49 | 50 | func GetConfigDir() (string, error) { 51 | return XdgConfigPath() 52 | } 53 | 54 | func DefaultConfigPath() (string, error) { 55 | cfgPath, err := GetConfigDir() 56 | if err != nil { 57 | return "", err 58 | } 59 | 60 | return filepath.Join(cfgPath, configFileName), nil 61 | } 62 | 63 | func IsWSL() bool { 64 | _, exists := os.LookupEnv("WSL_DISTRO_NAME") 65 | return exists 66 | } 67 | -------------------------------------------------------------------------------- /pkg/config/fs.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | type OSFileManager struct{} 9 | 10 | func (y *OSFileManager) Read(path string) ([]byte, error) { 11 | _, err := os.Stat(path) 12 | if err != nil { 13 | if os.IsNotExist(err) { 14 | return nil, fmt.Errorf("file does not exist: %w", err) 15 | } 16 | 17 | return nil, fmt.Errorf("error stating file: %w", err) 18 | } 19 | 20 | // even though the path is a variable, it is safe to assume that the file exists 21 | // in a safe location as we are using XDG Base Directory Specification 22 | data, err := os.ReadFile(path) // #nosec 23 | if err != nil { 24 | return nil, fmt.Errorf("error reading file: %w", err) 25 | } 26 | 27 | return data, nil 28 | } 29 | 30 | func (y *OSFileManager) Write(path string, content []byte, perm os.FileMode) error { 31 | err := os.WriteFile(path, content, perm) 32 | if err != nil { 33 | return fmt.Errorf("error writing file: %w", err) 34 | } 35 | 36 | return nil 37 | } 38 | -------------------------------------------------------------------------------- /pkg/config/provider.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/user" 7 | "path/filepath" 8 | 9 | "github.com/sashabaranov/go-openai" 10 | 11 | "github.com/intility/cwc/pkg/errors" 12 | ) 13 | 14 | type Provider interface { 15 | GetConfig() (*Config, error) 16 | NewFromConfigFile() (openai.ClientConfig, error) 17 | GetConfigDir() (string, error) 18 | SaveConfig(config *Config) error 19 | ClearConfig() error 20 | } 21 | 22 | type FileManager interface { 23 | Read(path string) ([]byte, error) 24 | Write(path string, content []byte, perm os.FileMode) error 25 | } 26 | type FileReader func(path string) ([]byte, error) 27 | 28 | type Marshaller interface { 29 | Unmarshal(in []byte, out interface{}) error 30 | Marshal(in interface{}) ([]byte, error) 31 | } 32 | 33 | type Parser func(in []byte, out interface{}) error 34 | 35 | type Validator func(cfg *Config) error 36 | 37 | type DefaultProviderOptions struct { 38 | ConfigPath string 39 | FileManager FileManager 40 | Marshaller Marshaller 41 | Validator Validator 42 | KeyStore APIKeyStore 43 | } 44 | 45 | type DefaultProvider struct { 46 | configPath string 47 | fileManager FileManager 48 | marshaller Marshaller 49 | keyStore APIKeyStore 50 | validate Validator 51 | } 52 | 53 | type optFunc func(*DefaultProviderOptions) 54 | 55 | func NewDefaultProvider(opts ...optFunc) *DefaultProvider { 56 | options := DefaultProviderOptions{ 57 | ConfigPath: "", 58 | FileManager: &OSFileManager{}, 59 | Marshaller: &YamlMarshaller{}, 60 | Validator: DefaultValidator, 61 | KeyStore: NewAPIKeyKeyringStore("cwc", user.Current), 62 | } 63 | 64 | for _, opt := range opts { 65 | opt(&options) 66 | } 67 | 68 | return NewDefaultProviderWithOptions(options) 69 | } 70 | 71 | func WithConfigPath(path string) optFunc { 72 | return func(o *DefaultProviderOptions) { 73 | o.ConfigPath = path 74 | } 75 | } 76 | 77 | func WithFileManager(fm FileManager) optFunc { 78 | return func(o *DefaultProviderOptions) { 79 | o.FileManager = fm 80 | } 81 | } 82 | 83 | func WithMarshaller(m Marshaller) optFunc { 84 | return func(o *DefaultProviderOptions) { 85 | o.Marshaller = m 86 | } 87 | } 88 | 89 | func WithValidator(v Validator) optFunc { 90 | return func(o *DefaultProviderOptions) { 91 | o.Validator = v 92 | } 93 | } 94 | 95 | func WithKeyStore(ks APIKeyStore) optFunc { 96 | return func(o *DefaultProviderOptions) { 97 | o.KeyStore = ks 98 | } 99 | } 100 | 101 | func NewDefaultProviderWithOptions(opts DefaultProviderOptions) *DefaultProvider { 102 | if opts.ConfigPath == "" { 103 | path, err := DefaultConfigPath() 104 | if err != nil { 105 | path = configFileName 106 | } 107 | 108 | opts.ConfigPath = path 109 | } 110 | 111 | if opts.FileManager == nil { 112 | opts.FileManager = &OSFileManager{} 113 | } 114 | 115 | if opts.Marshaller == nil { 116 | opts.Marshaller = &YamlMarshaller{} 117 | } 118 | 119 | if opts.Validator == nil { 120 | opts.Validator = DefaultValidator 121 | } 122 | 123 | if opts.KeyStore == nil { 124 | opts.KeyStore = NewAPIKeyKeyringStore("cwc", user.Current) 125 | } 126 | 127 | return &DefaultProvider{ 128 | configPath: opts.ConfigPath, 129 | fileManager: opts.FileManager, 130 | marshaller: opts.Marshaller, 131 | validate: opts.Validator, 132 | keyStore: opts.KeyStore, 133 | } 134 | } 135 | 136 | func (c *DefaultProvider) GetConfig() (*Config, error) { 137 | if c.configPath == "" { 138 | path, err := DefaultConfigPath() 139 | if err != nil { 140 | return nil, err 141 | } 142 | 143 | c.configPath = path 144 | } 145 | 146 | data, err := c.fileManager.Read(c.configPath) 147 | if err != nil { 148 | return nil, errors.ConfigValidationError{Errors: []string{ 149 | "config file does not exist", 150 | "please run `cwc login` to create a new config file.", 151 | }} 152 | } 153 | 154 | var cfg Config 155 | err = c.marshaller.Unmarshal(data, &cfg) 156 | 157 | if err != nil { 158 | return nil, errors.ConfigValidationError{Errors: []string{ 159 | "invalid config file format", 160 | "please run `cwc login` to create a new config file.", 161 | }} 162 | } 163 | 164 | apiKey, err := c.keyStore.GetAPIKey() 165 | if err != nil { 166 | return nil, errors.ConfigValidationError{Errors: []string{ 167 | err.Error(), 168 | "please run `cwc login` to create a new config file.", 169 | }} 170 | } 171 | 172 | cfg.SetAPIKey(apiKey) 173 | 174 | return &cfg, nil 175 | } 176 | 177 | func (c *DefaultProvider) NewFromConfigFile() (openai.ClientConfig, error) { 178 | cfg, err := c.GetConfig() 179 | if err != nil { 180 | return openai.ClientConfig{}, err 181 | } 182 | 183 | // validate the configuration 184 | err = c.validate(cfg) 185 | if err != nil { 186 | return openai.ClientConfig{}, err 187 | } 188 | 189 | config := openai.DefaultAzureConfig(cfg.APIKey(), cfg.Endpoint) 190 | config.APIVersion = apiVersion 191 | config.AzureModelMapperFunc = func(model string) string { 192 | return cfg.ModelDeployment 193 | } 194 | 195 | return config, nil 196 | } 197 | 198 | func (c *DefaultProvider) SaveConfig(config *Config) error { 199 | // validate the configuration 200 | err := c.validate(config) 201 | if err != nil { 202 | return err 203 | } 204 | 205 | data, err := c.marshaller.Marshal(config) 206 | if err != nil { 207 | return fmt.Errorf("error marshalling config data: %w", err) 208 | } 209 | 210 | err = c.keyStore.SetAPIKey(config.APIKey()) 211 | if err != nil { 212 | return fmt.Errorf("error saving API key in keystore: %w", err) 213 | } 214 | 215 | if c.configPath == "" { 216 | c.configPath, err = DefaultConfigPath() 217 | if err != nil { 218 | return err 219 | } 220 | } 221 | 222 | err = c.fileManager.Write(c.configPath, data, configFilePermissions) 223 | if err != nil { 224 | return fmt.Errorf("error writing config file: %w", err) 225 | } 226 | 227 | return nil 228 | } 229 | 230 | func (c *DefaultProvider) GetConfigDir() (string, error) { 231 | return filepath.Dir(c.configPath), nil 232 | } 233 | 234 | func (c *DefaultProvider) ClearConfig() error { 235 | err := os.Remove(c.configPath) 236 | if err != nil { 237 | return fmt.Errorf("error removing config file: %w", err) 238 | } 239 | 240 | err = c.keyStore.ClearAPIKey() 241 | if err != nil { 242 | return fmt.Errorf("error clearing API key from storage: %w", err) 243 | } 244 | 245 | return nil 246 | } 247 | -------------------------------------------------------------------------------- /pkg/config/validator.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "github.com/intility/cwc/pkg/errors" 4 | 5 | func DefaultValidator(cfg *Config) error { 6 | var validationErrors []string 7 | 8 | if cfg.APIKey() == "" { 9 | validationErrors = append(validationErrors, "apiKey must be provided and not be empty") 10 | } 11 | 12 | if cfg.Endpoint == "" { 13 | validationErrors = append(validationErrors, "endpoint must be provided and not be empty") 14 | } 15 | 16 | if cfg.ModelDeployment == "" { 17 | validationErrors = append(validationErrors, "modelDeployment must be provided and not be empty") 18 | } 19 | 20 | if len(validationErrors) > 0 { 21 | return &errors.ConfigValidationError{Errors: validationErrors} 22 | } 23 | 24 | return nil 25 | } 26 | -------------------------------------------------------------------------------- /pkg/config/xdg.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | ) 8 | 9 | const ( 10 | serviceName = "cwc" // The name of our application 11 | ) 12 | 13 | // helper function to get the XDG config path. 14 | func XdgConfigPath() (string, error) { 15 | xdgConfigHome := os.Getenv("XDG_CONFIG_HOME") 16 | if xdgConfigHome == "" { 17 | // XDG_CONFIG_HOME was not set, use the default "~/.config" 18 | homeDir, err := os.UserHomeDir() 19 | if err != nil { 20 | return "", fmt.Errorf("error getting user home directory: %w", err) 21 | } 22 | 23 | xdgConfigHome = filepath.Join(homeDir, ".config") 24 | } 25 | 26 | configDir := filepath.Join(xdgConfigHome, serviceName) // use serviceName to create a subdirectory for our application 27 | 28 | // Ensure that the config directory exists 29 | err := os.MkdirAll(configDir, os.ModePerm) 30 | if err != nil { 31 | return "", fmt.Errorf("error creating config directory: %w", err) 32 | } 33 | 34 | return configDir, nil 35 | } 36 | -------------------------------------------------------------------------------- /pkg/config/yaml.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | 6 | "gopkg.in/yaml.v3" 7 | ) 8 | 9 | type YamlMarshaller struct{} 10 | 11 | func (y *YamlMarshaller) Marshal(data interface{}) ([]byte, error) { 12 | bytes, err := yaml.Marshal(data) 13 | if err != nil { 14 | return nil, fmt.Errorf("error marshalling data: %w", err) 15 | } 16 | 17 | return bytes, nil 18 | } 19 | 20 | func (y *YamlMarshaller) Unmarshal(data []byte, out interface{}) error { 21 | err := yaml.Unmarshal(data, out) 22 | if err != nil { 23 | return fmt.Errorf("error unmarshalling data: %w", err) 24 | } 25 | 26 | return nil 27 | } 28 | -------------------------------------------------------------------------------- /pkg/errors/errors.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | ) 8 | 9 | type InvalidInputError struct { 10 | Message string 11 | } 12 | 13 | func (e *InvalidInputError) Error() string { 14 | return e.Message 15 | } 16 | 17 | type SaveConfigError struct { 18 | Message string 19 | Err error 20 | } 21 | 22 | func (e *SaveConfigError) Error() string { 23 | return e.Message + ": " + e.Err.Error() 24 | } 25 | 26 | // ConfigValidationError collects all configuration validation errors. 27 | type ConfigValidationError struct { 28 | Errors []string 29 | } 30 | 31 | func (e ConfigValidationError) Error() string { 32 | return "config validation failed: " + strings.Join(e.Errors, ", ") 33 | } 34 | 35 | // AsConfigValidationError attempts to convert an error to a 36 | // *ConfigValidationError and returns it with a boolean indicating success. 37 | func AsConfigValidationError(err error) (*ConfigValidationError, bool) { 38 | var validationErr *ConfigValidationError 39 | if err != nil { 40 | ok := errors.As(err, &validationErr) 41 | return validationErr, ok 42 | } 43 | 44 | return nil, false 45 | } 46 | 47 | func IsConfigValidationError(err error) bool { 48 | var validationErr ConfigValidationError 49 | return errors.As(err, &validationErr) 50 | } 51 | 52 | // FileNotExistError is an error type for when a file does not exist. 53 | type FileNotExistError struct { 54 | FileName string 55 | } 56 | 57 | func (e FileNotExistError) Error() string { 58 | return fmt.Sprintf("file %s does not exist", e.FileName) 59 | } 60 | 61 | func IsFileNotExistError(err error) bool { 62 | var fileDoesNotExistError FileNotExistError 63 | return errors.As(err, &fileDoesNotExistError) 64 | } 65 | 66 | type GitNotInstalledError struct { 67 | Message string 68 | } 69 | 70 | func (e GitNotInstalledError) Error() string { 71 | return e.Message 72 | } 73 | 74 | func IsGitNotInstalledError(err error) bool { 75 | var gitNotInstalledError GitNotInstalledError 76 | return errors.As(err, &gitNotInstalledError) 77 | } 78 | 79 | type NotAGitRepositoryError struct { 80 | Message string 81 | } 82 | 83 | func (e NotAGitRepositoryError) Error() string { 84 | return e.Message 85 | } 86 | 87 | func IsNotAGitRepositoryError(err error) bool { 88 | var notAGitRepositoryError NotAGitRepositoryError 89 | return errors.As(err, ¬AGitRepositoryError) 90 | } 91 | 92 | type NoPromptProvidedError struct { 93 | Message string 94 | } 95 | 96 | func (e NoPromptProvidedError) Error() string { 97 | return e.Message 98 | } 99 | 100 | type TemplateNotFoundError struct { 101 | TemplateName string 102 | } 103 | 104 | func (e TemplateNotFoundError) Error() string { 105 | return "template not found: " + e.TemplateName 106 | } 107 | 108 | func IsTemplateNotFoundError(err error) bool { 109 | var templateNotFoundError TemplateNotFoundError 110 | return errors.As(err, &templateNotFoundError) 111 | } 112 | 113 | type SuppressedError struct{} 114 | 115 | func (e SuppressedError) Error() string { 116 | return "error suppressed" 117 | } 118 | 119 | type ArgParseError struct { 120 | Message string 121 | } 122 | 123 | func (e ArgParseError) Error() string { 124 | return e.Message 125 | } 126 | -------------------------------------------------------------------------------- /pkg/filetree/filetree.go: -------------------------------------------------------------------------------- 1 | package filetree 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "slices" 8 | "sort" 9 | "strings" 10 | 11 | pm "github.com/intility/cwc/pkg/pathmatcher" 12 | cwcui "github.com/intility/cwc/pkg/ui" 13 | ) 14 | 15 | type FileNode struct { 16 | Name string 17 | IsDir bool 18 | Children []*FileNode 19 | } 20 | 21 | type File struct { 22 | Path string 23 | Data []byte 24 | Type string 25 | } 26 | 27 | type FileGatherOptions struct { 28 | IncludeMatcher pm.PathMatcher 29 | ExcludeMatcher pm.PathMatcher 30 | PathScopes []string 31 | } 32 | 33 | func GatherFiles(opts *FileGatherOptions) ([]File, *FileNode, error) { //nolint:funlen,gocognit,cyclop 34 | includeMatcher := opts.IncludeMatcher 35 | excludeMatcher := opts.ExcludeMatcher 36 | pathScopes := opts.PathScopes 37 | ui := cwcui.NewUI() //nolint:varnamelen 38 | 39 | var files []File 40 | 41 | knownLanguage := cachedLanguageChecker() 42 | 43 | rootNode := &FileNode{Name: "/", IsDir: true, Children: []*FileNode{}} 44 | 45 | for _, path := range pathScopes { 46 | err := filepath.Walk(path, func(path string, info os.FileInfo, err error) error { 47 | if err != nil { 48 | return err 49 | } 50 | 51 | normalizedPath := filepath.ToSlash(path) 52 | if !includeMatcher.Match(normalizedPath) || info.IsDir() || excludeMatcher.Match(normalizedPath) { 53 | return nil 54 | } 55 | 56 | fileType, ok := knownLanguage(path) 57 | 58 | if !ok { 59 | ui.PrintMessage("skipping unknown file type: "+path+"\n", cwcui.MessageTypeWarning) 60 | return nil 61 | } 62 | 63 | file := &File{ 64 | Path: path, 65 | Type: fileType, 66 | Data: []byte{}, 67 | } 68 | 69 | codeFile, err := os.OpenFile(path, os.O_RDONLY, 0) // #nosec 70 | if err != nil { 71 | return fmt.Errorf("error opening codeFile: %w", err) 72 | } 73 | 74 | defer func() { 75 | err = codeFile.Close() 76 | if err != nil { 77 | ui.PrintMessage(fmt.Sprintf("error closing codeFile: %s\n", err), cwcui.MessageTypeError) 78 | } 79 | }() 80 | 81 | file.Data, err = os.ReadFile(path) // #nosec 82 | 83 | if err != nil { 84 | return fmt.Errorf("error reading codeFile: %w", err) 85 | } 86 | 87 | files = append(files, *file) 88 | 89 | // Construct the codeFile tree 90 | parts := strings.Split(path, string(os.PathSeparator)) 91 | current := rootNode 92 | 93 | for _, part := range parts[:len(parts)-1] { // Exclude the last part which is the codeFile itself 94 | found := false 95 | 96 | for _, child := range current.Children { 97 | if child.Name == part && child.IsDir { 98 | current = child 99 | found = true 100 | 101 | break 102 | } 103 | } 104 | 105 | if !found { 106 | newNode := &FileNode{Name: part, IsDir: true, Children: []*FileNode{}} 107 | current.Children = append(current.Children, newNode) 108 | current = newNode 109 | } 110 | } 111 | 112 | current.Children = append(current.Children, 113 | &FileNode{Name: parts[len(parts)-1], IsDir: false, Children: []*FileNode{}}) 114 | 115 | return nil 116 | }) 117 | if err != nil { 118 | return nil, nil, fmt.Errorf("error walking the path: %w", err) 119 | } 120 | } 121 | 122 | // Sort the files for consistent output 123 | sort.Slice(files, func(i, j int) bool { 124 | return files[i].Path < files[j].Path 125 | }) 126 | 127 | return files, rootNode, nil 128 | } 129 | 130 | type languageCheckerCache struct { 131 | cache map[string]string 132 | cacheHits int 133 | } 134 | 135 | func (l *languageCheckerCache) Get(ext string) (string, bool) { 136 | val, ok := l.cache[ext] 137 | return val, ok 138 | } 139 | 140 | func (l *languageCheckerCache) Set(ext, lang string) { 141 | l.cache[ext] = lang 142 | } 143 | 144 | func cachedLanguageChecker() func(string) (string, bool) { 145 | cache := &languageCheckerCache{cache: make(map[string]string), cacheHits: 0} 146 | 147 | return func(path string) (string, bool) { 148 | if ext, ok := cache.Get(filepath.Ext(path)); ok { 149 | cache.cacheHits++ 150 | return ext, true 151 | } 152 | // .md should be interpreted as markdown, not lisp 153 | if filepath.Ext(path) == ".md" { 154 | cache.Set(filepath.Ext(path), "markdown") 155 | return "markdown", true 156 | } 157 | 158 | for _, lang := range languages { 159 | if slices.Contains(lang.Extensions, filepath.Ext(path)) { 160 | // cache the extension for faster lookup 161 | cache.Set(filepath.Ext(path), lang.AceMode) 162 | return lang.AceMode, true 163 | } 164 | 165 | if slices.Contains(lang.Filenames, filepath.Base(path)) { 166 | // cache the filename for faster lookup 167 | cache.Set(filepath.Base(path), lang.AceMode) 168 | return lang.AceMode, true 169 | } 170 | } 171 | 172 | return "", false 173 | } 174 | } 175 | 176 | func GenerateFileTree(node *FileNode, indent string, isLast bool) string { 177 | // Handle the case for the root node differently 178 | var tree strings.Builder 179 | 180 | if node.Name == "/" && node.IsDir { 181 | tree.WriteString(".\n") 182 | } else { 183 | // Choose the appropriate prefix 184 | prefix := "├── " 185 | if isLast { 186 | prefix = "└── " 187 | } 188 | 189 | // Print the name of the current node with the correct indentation 190 | tree.WriteString(indent + prefix + node.Name + "\n") 191 | 192 | if node.IsDir && !isLast { 193 | indent += "│ " 194 | } else { 195 | indent += " " 196 | } 197 | } 198 | 199 | if node.IsDir { 200 | // Sort node's children for consistent output 201 | sort.Slice(node.Children, func(i, j int) bool { 202 | return node.Children[i].Name < node.Children[j].Name 203 | }) 204 | // Recursively call `printFileTree` for each child 205 | for i, child := range node.Children { 206 | // Check if the child node is the last in the list 207 | isLastChild := i == len(node.Children)-1 208 | tree.WriteString(GenerateFileTree(child, indent, isLastChild)) 209 | } 210 | } 211 | 212 | return tree.String() 213 | } 214 | -------------------------------------------------------------------------------- /pkg/pathmatcher/compound.go: -------------------------------------------------------------------------------- 1 | package pathmatcher 2 | 3 | type CompoundPathMatcher struct { 4 | matchers []PathMatcher 5 | } 6 | 7 | func NewCompoundPathMatcher(matchers ...PathMatcher) *CompoundPathMatcher { 8 | return &CompoundPathMatcher{ 9 | matchers: matchers, 10 | } 11 | } 12 | 13 | func (c *CompoundPathMatcher) Match(path string) bool { 14 | for _, matcher := range c.matchers { 15 | if matcher.Match(path) { 16 | return true 17 | } 18 | } 19 | 20 | return false 21 | } 22 | 23 | func (c *CompoundPathMatcher) Add(matcher PathMatcher) { 24 | c.matchers = append(c.matchers, matcher) 25 | } 26 | -------------------------------------------------------------------------------- /pkg/pathmatcher/gitignore.go: -------------------------------------------------------------------------------- 1 | package pathmatcher 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os/exec" 7 | "slices" 8 | "strings" 9 | 10 | "github.com/intility/cwc/pkg/errors" 11 | ) 12 | 13 | type GitignorePathMatcher struct { 14 | ignoredPaths []string 15 | } 16 | 17 | func NewGitignorePathMatcher() (*GitignorePathMatcher, error) { 18 | matcher := &GitignorePathMatcher{ 19 | ignoredPaths: make([]string, 0), 20 | } 21 | 22 | err := matcher.gitLsFiles() 23 | 24 | return matcher, err 25 | } 26 | 27 | func (g *GitignorePathMatcher) Match(path string) bool { 28 | return slices.Contains(g.ignoredPaths, path) 29 | } 30 | 31 | func (g *GitignorePathMatcher) Any() bool { 32 | return len(g.ignoredPaths) > 0 33 | } 34 | 35 | func (g *GitignorePathMatcher) gitLsFiles() error { 36 | // git ls-files -o --exclude-standard 37 | buf := new(bytes.Buffer) 38 | errBuf := new(bytes.Buffer) 39 | cmd := exec.Command("git", "ls-files", "-o", "--ignored", "--exclude-standard") 40 | cmd.Stdout = buf 41 | cmd.Stderr = errBuf 42 | 43 | err := cmd.Run() 44 | if err != nil { 45 | errStr := errBuf.String() 46 | 47 | if strings.Contains(err.Error(), "executable file not found in") { 48 | return errors.GitNotInstalledError{Message: "git not found in PATH"} 49 | } 50 | 51 | if strings.Contains(errStr, "fatal: not a git repository") { 52 | return errors.NotAGitRepositoryError{Message: "not a git repository"} 53 | } 54 | 55 | return fmt.Errorf("error running git ls-files: %w", err) 56 | } 57 | 58 | // create a slice of ignored paths and remove the last empty string 59 | ignored := strings.Split(buf.String(), "\n") 60 | ignored = ignored[:len(ignored)-1] 61 | 62 | g.ignoredPaths = append(g.ignoredPaths, ignored...) 63 | 64 | return nil 65 | } 66 | -------------------------------------------------------------------------------- /pkg/pathmatcher/pathmatcher.go: -------------------------------------------------------------------------------- 1 | package pathmatcher 2 | 3 | type PathMatcher interface { 4 | Match(path string) bool 5 | } 6 | -------------------------------------------------------------------------------- /pkg/pathmatcher/regex.go: -------------------------------------------------------------------------------- 1 | package pathmatcher 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | ) 7 | 8 | type RegexPathMatcher struct { 9 | re *regexp.Regexp 10 | } 11 | 12 | func NewRegexPathMatcher(pattern string) (*RegexPathMatcher, error) { 13 | re, err := regexp.Compile(pattern) 14 | if err != nil { 15 | return nil, fmt.Errorf("error compiling regex pattern: %w", err) 16 | } 17 | 18 | return &RegexPathMatcher{re: re}, nil 19 | } 20 | 21 | func (r *RegexPathMatcher) Match(path string) bool { 22 | return r.re.MatchString(path) 23 | } 24 | -------------------------------------------------------------------------------- /pkg/prompting/prompt.go: -------------------------------------------------------------------------------- 1 | package prompting 2 | 3 | import "github.com/intility/cwc/pkg/templates" 4 | 5 | type PromptResolver interface { 6 | ResolvePrompt() string 7 | } 8 | 9 | type ArgsOrTemplatePromptResolver struct { 10 | args []string 11 | templateName string 12 | templateLocator templates.TemplateLocator 13 | } 14 | 15 | func NewArgsOrTemplatePromptResolver( 16 | templateLocator templates.TemplateLocator, 17 | args []string, 18 | tmplName string, 19 | ) *ArgsOrTemplatePromptResolver { 20 | return &ArgsOrTemplatePromptResolver{ 21 | args: args, 22 | templateName: tmplName, 23 | templateLocator: templateLocator, 24 | } 25 | } 26 | 27 | func (r *ArgsOrTemplatePromptResolver) ResolvePrompt() string { 28 | var prompt string 29 | 30 | tmpl, err := r.templateLocator.GetTemplate(r.templateName) 31 | if err == nil && tmpl.DefaultPrompt != "" { 32 | prompt = tmpl.DefaultPrompt 33 | } 34 | 35 | if len(r.args) > 0 { 36 | prompt = r.args[0] 37 | } 38 | 39 | return prompt 40 | } 41 | -------------------------------------------------------------------------------- /pkg/prompting/prompt_test.go: -------------------------------------------------------------------------------- 1 | package prompting_test 2 | 3 | import ( 4 | stdErrors "errors" 5 | "github.com/intility/cwc/pkg/prompting" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | 10 | "github.com/intility/cwc/mocks" 11 | "github.com/intility/cwc/pkg/templates" 12 | ) 13 | 14 | func TestArgsOrTemplatePromptResolver_ResolvePrompt(t *testing.T) { 15 | // Define the test cases 16 | type testConfig struct { 17 | locator *mocks.TemplateLocator 18 | testTemplate *templates.Template 19 | } 20 | 21 | tests := []struct { 22 | name string 23 | args []string 24 | templateName string 25 | setupMocks func(testConfig) 26 | wantResult func(t *testing.T, result string) 27 | }{ 28 | { 29 | name: "template default prompt", 30 | args: []string{}, 31 | templateName: "test", 32 | setupMocks: func(m testConfig) { 33 | m.testTemplate.DefaultPrompt = "foo" 34 | m.locator.On("GetTemplate", "test").Return(m.testTemplate, nil) 35 | }, 36 | wantResult: func(t *testing.T, prompt string) { 37 | assert.Equal(t, "foo", prompt) 38 | }, 39 | }, 40 | { 41 | name: "args prompt when error getting template", 42 | args: []string{"bar"}, 43 | templateName: "test", 44 | setupMocks: func(m testConfig) { 45 | m.locator.On("GetTemplate", "test").Return(nil, stdErrors.New("error")) 46 | }, 47 | wantResult: func(t *testing.T, prompt string) { 48 | assert.Equal(t, "bar", prompt) 49 | }, 50 | }, 51 | { 52 | name: "args prompt overrides template", 53 | args: []string{"bar"}, 54 | templateName: "test", 55 | setupMocks: func(m testConfig) { 56 | m.testTemplate.DefaultPrompt = "foo" 57 | m.locator.On("GetTemplate", "test").Return(m.testTemplate, nil) 58 | }, 59 | wantResult: func(t *testing.T, prompt string) { 60 | assert.Equal(t, "bar", prompt) 61 | }, 62 | }, 63 | } 64 | 65 | for _, tt := range tests { 66 | t.Run(tt.name, func(t *testing.T) { 67 | locator := &mocks.TemplateLocator{} 68 | cfg := testConfig{locator: locator, testTemplate: &templates.Template{}} 69 | tt.setupMocks(cfg) 70 | 71 | resolver := prompting.NewArgsOrTemplatePromptResolver(locator, tt.args, tt.templateName) 72 | prompt := resolver.ResolvePrompt() 73 | 74 | locator.AssertExpectations(t) 75 | tt.wantResult(t, prompt) 76 | }) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /pkg/systemcontext/file.go: -------------------------------------------------------------------------------- 1 | package systemcontext 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/intility/cwc/pkg/config" 7 | "github.com/intility/cwc/pkg/errors" 8 | "github.com/intility/cwc/pkg/filetree" 9 | "github.com/intility/cwc/pkg/pathmatcher" 10 | "github.com/intility/cwc/pkg/ui" 11 | ) 12 | 13 | type FileContextRetriever struct { 14 | ui ui.UI 15 | cfgProvider config.Provider 16 | includePattern string 17 | excludePattern string 18 | searchScopes []string 19 | contextPrinter func(fileTree string, files []filetree.File) 20 | } 21 | 22 | type FileContextRetrieverOptions struct { 23 | CfgProvider config.Provider 24 | IncludePattern string 25 | ExcludePattern string 26 | SearchScopes []string 27 | ContextPrinter func(fileTree string, files []filetree.File) 28 | } 29 | 30 | func NewFileContextRetriever(opts FileContextRetrieverOptions) *FileContextRetriever { 31 | return &FileContextRetriever{ 32 | ui: ui.NewUI(), 33 | cfgProvider: opts.CfgProvider, 34 | includePattern: opts.IncludePattern, 35 | excludePattern: opts.ExcludePattern, 36 | searchScopes: opts.SearchScopes, 37 | contextPrinter: opts.ContextPrinter, 38 | } 39 | } 40 | 41 | func (r *FileContextRetriever) RetrieveContext() (string, error) { 42 | files, rootNode, err := r.gatherContext() 43 | if err != nil { 44 | return "", fmt.Errorf("error gathering context: %w", err) 45 | } 46 | 47 | fileTree := filetree.GenerateFileTree(rootNode, "", true) 48 | 49 | if r.contextPrinter != nil { 50 | r.contextPrinter(fileTree, files) 51 | } 52 | 53 | ctx := r.createContext(fileTree, files) 54 | 55 | return ctx, nil 56 | } 57 | 58 | func (r *FileContextRetriever) createContext(fileTree string, files []filetree.File) string { 59 | contextStr := "File tree:\n\n" 60 | contextStr += "```\n" + fileTree + "```\n\n" 61 | contextStr += "File contents:\n\n" 62 | 63 | for _, file := range files { 64 | // find extension by splitting on ".". if no extension, use 65 | contextStr += fmt.Sprintf("./%s\n```%s\n%s\n```\n\n", file.Path, file.Type, file.Data) 66 | } 67 | 68 | return contextStr 69 | } 70 | 71 | func (r *FileContextRetriever) gatherContext() ([]filetree.File, *filetree.FileNode, error) { 72 | var excludeMatchers []pathmatcher.PathMatcher 73 | 74 | // add exclude flag to excludeMatchers 75 | if r.excludePattern != "" { 76 | excludeMatcher, err := pathmatcher.NewRegexPathMatcher(r.excludePattern) 77 | if err != nil { 78 | return nil, nil, fmt.Errorf("error creating exclude matcher: %w", err) 79 | } 80 | 81 | excludeMatchers = append(excludeMatchers, excludeMatcher) 82 | } 83 | 84 | excludeMatchersFromConfig, err := r.excludeMatchersFromConfig() 85 | if err != nil { 86 | return nil, nil, err 87 | } 88 | 89 | excludeMatchers = append(excludeMatchers, excludeMatchersFromConfig...) 90 | 91 | excludeMatcher := pathmatcher.NewCompoundPathMatcher(excludeMatchers...) 92 | 93 | includeMatcher, err := pathmatcher.NewRegexPathMatcher(r.includePattern) 94 | if err != nil { 95 | return nil, nil, fmt.Errorf("error creating include matcher: %w", err) 96 | } 97 | 98 | files, rootNode, err := filetree.GatherFiles(&filetree.FileGatherOptions{ 99 | IncludeMatcher: includeMatcher, 100 | ExcludeMatcher: excludeMatcher, 101 | PathScopes: r.searchScopes, 102 | }) 103 | if err != nil { 104 | return nil, nil, fmt.Errorf("error gathering files: %w", err) 105 | } 106 | 107 | return files, rootNode, nil 108 | } 109 | 110 | func (r *FileContextRetriever) excludeMatchersFromConfig() ([]pathmatcher.PathMatcher, error) { 111 | var excludeMatchers []pathmatcher.PathMatcher 112 | 113 | cfg, err := r.cfgProvider.GetConfig() 114 | if err != nil { 115 | return excludeMatchers, fmt.Errorf("error loading config: %w", err) 116 | } 117 | 118 | if cfg.UseGitignore { 119 | gitignoreMatcher, err := pathmatcher.NewGitignorePathMatcher() 120 | if err != nil { 121 | switch { 122 | case errors.IsGitNotInstalledError(err): 123 | r.ui.PrintMessage("warning: git not found in PATH, skipping .gitignore\n", ui.MessageTypeWarning) 124 | case errors.IsNotAGitRepositoryError(err): 125 | r.ui.PrintMessage("warning: not a git repository, skipping .gitignore\n", ui.MessageTypeWarning) 126 | default: 127 | return nil, fmt.Errorf("error creating gitignore matcher: %w", err) 128 | } 129 | } 130 | 131 | excludeMatchers = append(excludeMatchers, gitignoreMatcher) 132 | } 133 | 134 | if cfg.ExcludeGitDir { 135 | gitDirMatcher, err := pathmatcher.NewRegexPathMatcher(`^\.git(/|\\)`) 136 | if err != nil { 137 | return nil, fmt.Errorf("error creating git directory matcher: %w", err) 138 | } 139 | 140 | excludeMatchers = append(excludeMatchers, gitDirMatcher) 141 | } 142 | 143 | return excludeMatchers, nil 144 | } 145 | -------------------------------------------------------------------------------- /pkg/systemcontext/ioreader.go: -------------------------------------------------------------------------------- 1 | package systemcontext 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | ) 7 | 8 | type IOReaderContextRetriever struct { 9 | io.Reader 10 | } 11 | 12 | func NewIOReaderContextRetriever(reader io.Reader) *IOReaderContextRetriever { 13 | return &IOReaderContextRetriever{ 14 | Reader: reader, 15 | } 16 | } 17 | 18 | func (r *IOReaderContextRetriever) RetrieveContext() (string, error) { 19 | bytes, err := io.ReadAll(r) 20 | if err != nil { 21 | return "", fmt.Errorf("error reading from io.Reader: %w", err) 22 | } 23 | 24 | return string(bytes), nil 25 | } 26 | -------------------------------------------------------------------------------- /pkg/systemcontext/retriever.go: -------------------------------------------------------------------------------- 1 | package systemcontext 2 | 3 | type ContextRetriever interface { 4 | RetrieveContext() (string, error) 5 | } 6 | -------------------------------------------------------------------------------- /pkg/systemcontext/systemmessage.go: -------------------------------------------------------------------------------- 1 | package systemcontext 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | tt "text/template" 7 | 8 | "github.com/intility/cwc/pkg/errors" 9 | "github.com/intility/cwc/pkg/templates" 10 | ) 11 | 12 | const ( 13 | defaultTemplateName = "default" 14 | ) 15 | 16 | type SystemMessageGenerator interface { 17 | GenerateSystemMessage() (string, error) 18 | } 19 | 20 | type TemplatedSystemMessageGenerator struct { 21 | templateLocator templates.TemplateLocator 22 | templateName string 23 | templateVars map[string]string 24 | contextRetriever ContextRetriever 25 | } 26 | 27 | func NewTemplatedSystemMessageGenerator( 28 | templateLocator templates.TemplateLocator, 29 | templateName string, 30 | templateVars map[string]string, 31 | contextRetriever ContextRetriever, 32 | ) *TemplatedSystemMessageGenerator { 33 | return &TemplatedSystemMessageGenerator{ 34 | templateLocator: templateLocator, 35 | templateName: templateName, 36 | templateVars: templateVars, 37 | contextRetriever: contextRetriever, 38 | } 39 | } 40 | 41 | func (smg *TemplatedSystemMessageGenerator) GenerateSystemMessage() (string, error) { 42 | ctx, err := smg.contextRetriever.RetrieveContext() 43 | if err != nil { 44 | return "", fmt.Errorf("error retrieving context: %w", err) 45 | } 46 | 47 | tmpl, err := smg.templateLocator.GetTemplate(smg.templateName) 48 | 49 | if smg.templateVars == nil { 50 | smg.templateVars = make(map[string]string) 51 | } 52 | 53 | // if no template found, create a basic template as fallback 54 | if err != nil { 55 | if errors.IsTemplateNotFoundError(err) { 56 | // exit with error if the user has requested a custom template and it is not found 57 | if smg.templateName != defaultTemplateName { 58 | return "", fmt.Errorf("template not found: %w", err) 59 | } 60 | 61 | return CreateBuiltinSystemMessageFromContext(ctx), nil 62 | } 63 | 64 | return "", fmt.Errorf("error getting template: %w", err) 65 | } 66 | 67 | // compile the template.SystemMessage as a go template 68 | compiledTemplate, err := tt.New("systemMessage").Parse(tmpl.SystemMessage) 69 | if err != nil { 70 | return "", fmt.Errorf("error parsing template: %w", err) 71 | } 72 | 73 | type valueBag struct { 74 | Context string 75 | Variables map[string]string 76 | } 77 | 78 | // populate the variables map with default values if not provided 79 | for _, v := range tmpl.Variables { 80 | if _, ok := smg.templateVars[v.Name]; !ok { 81 | smg.templateVars[v.Name] = v.DefaultValue 82 | } 83 | } 84 | 85 | values := valueBag{ 86 | Context: ctx, 87 | Variables: smg.templateVars, 88 | } 89 | 90 | writer := &strings.Builder{} 91 | err = compiledTemplate.Execute(writer, values) 92 | 93 | if err != nil { 94 | return "", fmt.Errorf("error executing template: %w", err) 95 | } 96 | 97 | return writer.String(), nil 98 | } 99 | 100 | func CreateBuiltinSystemMessageFromContext(ctx string) string { 101 | var systemMessage strings.Builder 102 | 103 | systemMessage.WriteString("You are a helpful coding assistant. ") 104 | systemMessage.WriteString("Below you will find relevant context to answer the user's question.\n\n") 105 | systemMessage.WriteString("Context:\n") 106 | systemMessage.WriteString(ctx) 107 | systemMessage.WriteString("\n\n") 108 | systemMessage.WriteString("Please follow the users instructions, you can do this!") 109 | 110 | return systemMessage.String() 111 | } 112 | -------------------------------------------------------------------------------- /pkg/systemcontext/systemmessage_test.go: -------------------------------------------------------------------------------- 1 | package systemcontext_test 2 | 3 | import ( 4 | "github.com/intility/cwc/pkg/systemcontext" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | 9 | "github.com/intility/cwc/mocks" 10 | "github.com/intility/cwc/pkg/errors" 11 | "github.com/intility/cwc/pkg/templates" 12 | ) 13 | 14 | func TestTemplatedSystemMessageGenerator_GenerateSystemMessage(t *testing.T) { 15 | type testConfig struct { 16 | locator *mocks.TemplateLocator 17 | testTemplate *templates.Template 18 | templateVars map[string]string 19 | ctxRetriever *mocks.ContextRetriever 20 | } 21 | 22 | tests := []struct { 23 | name string 24 | templateName string 25 | setupMocks func(testConfig) 26 | wantResult func(t *testing.T, result string) 27 | wantErr func(t *testing.T, err error) 28 | }{ 29 | { 30 | name: "use builtin system message if default template not found", 31 | templateName: "default", 32 | setupMocks: func(m testConfig) { 33 | m.ctxRetriever.On("RetrieveContext").Return("test_context", nil) 34 | m.locator.On("GetTemplate", "default"). 35 | Return(nil, errors.TemplateNotFoundError{}) 36 | }, 37 | wantResult: func(t *testing.T, result string) { 38 | builtInMessage := systemcontext.CreateBuiltinSystemMessageFromContext("test_context") 39 | assert.Equal(t, builtInMessage, result) 40 | }, 41 | wantErr: func(t *testing.T, err error) { 42 | assert.NoError(t, err) 43 | }, 44 | }, 45 | { 46 | name: "return error if non-default template not found", 47 | templateName: "test", 48 | setupMocks: func(m testConfig) { 49 | m.ctxRetriever.On("RetrieveContext").Return("test_context", nil) 50 | m.locator.On("GetTemplate", "test"). 51 | Return(nil, errors.TemplateNotFoundError{}) 52 | }, 53 | wantResult: func(t *testing.T, result string) { 54 | assert.Empty(t, result) 55 | }, 56 | wantErr: func(t *testing.T, err error) { 57 | assert.Error(t, err) 58 | }, 59 | }, 60 | { 61 | name: "returns error if template provider fails", 62 | templateName: "test", 63 | setupMocks: func(m testConfig) { 64 | m.ctxRetriever.On("RetrieveContext").Return("test_context", nil) 65 | m.locator.On("GetTemplate", "test"). 66 | Return(nil, assert.AnError) 67 | }, 68 | wantResult: func(t *testing.T, result string) { 69 | assert.Empty(t, result) 70 | }, 71 | wantErr: func(t *testing.T, err error) { 72 | assert.Error(t, err) 73 | }, 74 | }, 75 | { 76 | name: "render template without vars", 77 | templateName: "test", 78 | setupMocks: func(m testConfig) { 79 | m.ctxRetriever.On("RetrieveContext").Return("test_context", nil) 80 | m.testTemplate = &templates.Template{SystemMessage: "test_message"} 81 | m.locator.On("GetTemplate", "test"). 82 | Return(m.testTemplate, nil) 83 | }, 84 | wantResult: func(t *testing.T, result string) { 85 | assert.Equal(t, "test_message", result) 86 | }, 87 | wantErr: func(t *testing.T, err error) { 88 | assert.NoError(t, err) 89 | }, 90 | }, 91 | { 92 | name: "render template with default var values", 93 | templateName: "test", 94 | setupMocks: func(m testConfig) { 95 | m.ctxRetriever.On("RetrieveContext").Return("test_context", nil) 96 | m.testTemplate = &templates.Template{ 97 | SystemMessage: "test_message {{.Variables.foo}}", 98 | Variables: []templates.TemplateVariable{ 99 | {Name: "foo", DefaultValue: "bar"}, 100 | }, 101 | } 102 | m.locator.On("GetTemplate", "test"). 103 | Return(m.testTemplate, nil) 104 | }, 105 | wantResult: func(t *testing.T, result string) { 106 | assert.Equal(t, "test_message bar", result) 107 | }, 108 | wantErr: func(t *testing.T, err error) { 109 | assert.NoError(t, err) 110 | }, 111 | }, 112 | { 113 | name: "render template with replaced var values", 114 | templateName: "test", 115 | setupMocks: func(m testConfig) { 116 | m.ctxRetriever.On("RetrieveContext").Return("test_context", nil) 117 | m.testTemplate = &templates.Template{ 118 | SystemMessage: "test_message {{.Variables.foo}}", 119 | Variables: []templates.TemplateVariable{ 120 | {Name: "foo", DefaultValue: "bar"}, 121 | }, 122 | } 123 | m.locator.On("GetTemplate", "test"). 124 | Return(m.testTemplate, nil) 125 | m.templateVars["foo"] = "baz" 126 | }, 127 | wantResult: func(t *testing.T, result string) { 128 | assert.Equal(t, "test_message baz", result) 129 | }, 130 | wantErr: func(t *testing.T, err error) { 131 | assert.NoError(t, err) 132 | }, 133 | }, 134 | } 135 | 136 | for _, tt := range tests { 137 | t.Run(tt.name, func(t *testing.T) { 138 | locator := &mocks.TemplateLocator{} 139 | ctxRetriever := &mocks.ContextRetriever{} 140 | cfg := testConfig{ 141 | locator: locator, 142 | testTemplate: &templates.Template{}, 143 | templateVars: map[string]string{}, 144 | ctxRetriever: ctxRetriever, 145 | } 146 | 147 | tt.setupMocks(cfg) 148 | 149 | smg := systemcontext.NewTemplatedSystemMessageGenerator( 150 | locator, 151 | tt.templateName, 152 | cfg.templateVars, 153 | ctxRetriever, 154 | ) 155 | 156 | res, err := smg.GenerateSystemMessage() 157 | 158 | locator.AssertExpectations(t) 159 | tt.wantResult(t, res) 160 | tt.wantErr(t, err) 161 | }) 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /pkg/templates/mergedTemplateLocator.go: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | import ( 4 | stdErrors "errors" 5 | "fmt" 6 | 7 | "github.com/intility/cwc/pkg/errors" 8 | ) 9 | 10 | // MergedTemplateLocator is a TemplateLocator that merges templates from multiple locators 11 | // making the last applied locator the one that takes precedence in case of name conflicts. 12 | type MergedTemplateLocator struct { 13 | locators []TemplateLocator 14 | } 15 | 16 | // NewMergedTemplateLocator creates a new MergedTemplateLocator. 17 | func NewMergedTemplateLocator(locators ...TemplateLocator) *MergedTemplateLocator { 18 | return &MergedTemplateLocator{ 19 | locators: locators, 20 | } 21 | } 22 | 23 | // ListTemplates returns a list of available templates. 24 | func (c *MergedTemplateLocator) ListTemplates() ([]Template, error) { 25 | // Merge templates from all locators 26 | templates := make(map[string]Template) 27 | 28 | for _, l := range c.locators { 29 | t, err := l.ListTemplates() 30 | if err != nil { 31 | return nil, fmt.Errorf("error listing templates: %w", err) 32 | } 33 | 34 | for _, template := range t { 35 | templates[template.Name] = template 36 | } 37 | } 38 | 39 | mergedTemplates := make([]Template, 0, len(templates)) 40 | for _, t := range templates { 41 | mergedTemplates = append(mergedTemplates, t) 42 | } 43 | 44 | return mergedTemplates, nil 45 | } 46 | 47 | // GetTemplate returns a template by name. 48 | func (c *MergedTemplateLocator) GetTemplate(name string) (*Template, error) { 49 | // Get template from the last locator that has it 50 | for i := len(c.locators) - 1; i >= 0; i-- { 51 | tmpl, err := c.locators[i].GetTemplate(name) 52 | 53 | // if template not found, continue to the next locator 54 | var templateNotFoundError errors.TemplateNotFoundError 55 | if stdErrors.As(err, &templateNotFoundError) { 56 | continue 57 | } else if err != nil { 58 | return nil, fmt.Errorf("error getting template: %w", err) 59 | } 60 | 61 | return tmpl, nil 62 | } 63 | 64 | return nil, errors.TemplateNotFoundError{TemplateName: name} 65 | } 66 | -------------------------------------------------------------------------------- /pkg/templates/templates.go: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | type Template struct { 4 | // Name is the name of the template 5 | Name string `yaml:"name"` 6 | 7 | // Description is a short description of the template 8 | Description string `yaml:"description"` 9 | 10 | // DefaultPrompt is the prompt that is used if no prompt is provided 11 | DefaultPrompt string `yaml:"defaultPrompt,omitempty"` 12 | 13 | // SystemMessage is the message that primes the conversation 14 | SystemMessage string `yaml:"systemMessage"` 15 | 16 | // Variables is a list of input variables for the template 17 | Variables []TemplateVariable `yaml:"variables"` 18 | } 19 | 20 | type TemplateVariable struct { 21 | // Name is the name of the input variable 22 | Name string `yaml:"name"` 23 | 24 | // Description is a short description of the input variable 25 | Description string `yaml:"description"` 26 | 27 | // DefaultValue is the value used if no override is provided 28 | DefaultValue string `yaml:"defaultValue,omitempty"` 29 | } 30 | 31 | type TemplateLocator interface { 32 | // ListTemplates returns a list of available templates 33 | ListTemplates() ([]Template, error) 34 | 35 | // GetTemplate returns a template by name 36 | GetTemplate(name string) (*Template, error) 37 | } 38 | -------------------------------------------------------------------------------- /pkg/templates/yamlFileTemplateLocator.go: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "gopkg.in/yaml.v3" 8 | 9 | "github.com/intility/cwc/pkg/errors" 10 | ) 11 | 12 | type YamlFileTemplateLocator struct { 13 | // Path is the path to the directory containing the templates 14 | Path string 15 | } 16 | 17 | // configFile is a struct that represents the yaml file containing the templates. 18 | type configFile struct { 19 | Templates []Template `yaml:"templates"` 20 | } 21 | 22 | func NewYamlFileTemplateLocator(path string) *YamlFileTemplateLocator { 23 | return &YamlFileTemplateLocator{ 24 | Path: path, 25 | } 26 | } 27 | 28 | func (y *YamlFileTemplateLocator) ListTemplates() ([]Template, error) { 29 | // no configured templates file is a valid state 30 | // and should not return an error 31 | _, err := os.Stat(y.Path) 32 | if os.IsNotExist(err) { 33 | return []Template{}, nil 34 | } 35 | 36 | file, err := os.Open(y.Path) 37 | if err != nil { 38 | return nil, fmt.Errorf("error opening file: %w", err) 39 | } 40 | 41 | decoder := yaml.NewDecoder(file) 42 | 43 | var cfg configFile 44 | err = decoder.Decode(&cfg) 45 | 46 | if err != nil { 47 | return nil, fmt.Errorf("error decoding file: %w", err) 48 | } 49 | 50 | return cfg.Templates, nil 51 | } 52 | 53 | func (y *YamlFileTemplateLocator) GetTemplate(name string) (*Template, error) { 54 | templates, err := y.ListTemplates() 55 | if err != nil { 56 | return nil, fmt.Errorf("error getting template: %w", err) 57 | } 58 | 59 | for _, tmpl := range templates { 60 | if tmpl.Name == name { 61 | return &tmpl, nil 62 | } 63 | } 64 | 65 | return nil, errors.TemplateNotFoundError{TemplateName: name} 66 | } 67 | -------------------------------------------------------------------------------- /pkg/ui/ui.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | "os" 8 | "slices" 9 | "strings" 10 | ) 11 | 12 | type MessageType int 13 | 14 | const ( 15 | MessageTypeInfo MessageType = iota 16 | MessageTypeWarning 17 | MessageTypeError 18 | MessageTypeNotice 19 | MessageTypeSuccess 20 | ) 21 | 22 | // Define ANSI color codes. 23 | const ( 24 | colorReset = "\033[0m" 25 | colorRed = "\033[31m" 26 | colorYellow = "\033[33m" 27 | colorCyan = "\033[36m" 28 | colorGreen = "\033[32m" 29 | ) 30 | 31 | type UI struct { 32 | Reader io.Reader 33 | Writer io.Writer 34 | } 35 | 36 | type opt func(*UI) 37 | 38 | func WithReader(reader io.Reader) opt { 39 | return func(ui *UI) { 40 | ui.Reader = reader 41 | } 42 | } 43 | 44 | func WithWriter(writer io.Writer) opt { 45 | return func(ui *UI) { 46 | ui.Writer = writer 47 | } 48 | } 49 | 50 | func NewUI(opts ...opt) UI { 51 | ui := UI{ //nolint:varnamelen 52 | Reader: os.Stdin, 53 | Writer: os.Stdout, 54 | } 55 | 56 | for _, opt := range opts { 57 | opt(&ui) 58 | } 59 | 60 | return ui 61 | } 62 | 63 | func (u UI) AskYesNo(prompt string, defaultYes bool) bool { 64 | // default answer should add the correct uppercase to the (Y/n) prompt 65 | if defaultYes { 66 | prompt += " (Y/n)" 67 | } else { 68 | prompt += " (y/N)" 69 | } 70 | 71 | _, err := u.Writer.Write([]byte(prompt + "\n")) 72 | if err != nil { 73 | return false 74 | } 75 | 76 | yesStrings := []string{"Y", "YES", "YEAH", "YEP", "YEA", "YEAH", "YUP"} 77 | 78 | scanner := bufio.NewScanner(u.Reader) 79 | for scanner.Scan() { 80 | answer := strings.ToUpper(scanner.Text()) 81 | if answer == "" { 82 | return defaultYes 83 | } 84 | 85 | if slices.Contains(yesStrings, answer) { 86 | return true 87 | } 88 | } 89 | 90 | return false 91 | } 92 | 93 | // ReadUserInput reads a line of input from the user. 94 | func (u UI) ReadUserInput() string { 95 | scanner := bufio.NewScanner(u.Reader) 96 | scanner.Scan() 97 | userInput := scanner.Text() 98 | 99 | return strings.TrimSpace(userInput) 100 | } 101 | 102 | // PrintMessage prints a message to the user. 103 | func (u UI) PrintMessage(message string, messageType MessageType) { 104 | if messageType == MessageTypeInfo { 105 | fmt.Fprint(u.Writer, message) 106 | return 107 | } 108 | 109 | messageColors := map[MessageType]string{ 110 | MessageTypeWarning: colorYellow, 111 | MessageTypeError: colorRed, 112 | MessageTypeNotice: colorCyan, 113 | MessageTypeSuccess: colorGreen, 114 | } 115 | 116 | color, ok := messageColors[messageType] 117 | if !ok { 118 | // If the messageType is not found in the map, use a default color or no color. 119 | fmt.Fprint(u.Writer, message) 120 | return 121 | } 122 | 123 | // Print the message with color. 124 | fmt.Fprint(u.Writer, color+message+colorReset) 125 | } 126 | -------------------------------------------------------------------------------- /pkg/ui/ui_test.go: -------------------------------------------------------------------------------- 1 | package ui_test 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/google/go-cmp/cmp" 9 | "github.com/intility/cwc/pkg/ui" 10 | ) 11 | 12 | func TestAskYesNo(t *testing.T) { 13 | testCases := []struct { 14 | name string 15 | prompt string 16 | userInput string 17 | want bool 18 | defaultYes bool 19 | }{ 20 | { 21 | name: "Yes, default", 22 | prompt: "Do you want to proceed?", 23 | userInput: "y\n", 24 | defaultYes: true, 25 | want: true, 26 | }, 27 | { 28 | name: "No, default", 29 | prompt: "Do you want to proceed?", 30 | userInput: "n\n", 31 | defaultYes: false, 32 | want: false, 33 | }, 34 | { 35 | name: "Yes, default, uppercase", 36 | prompt: "Do you want to proceed?", 37 | userInput: "Y\n", 38 | defaultYes: true, 39 | want: true, 40 | }, 41 | { 42 | name: "No, default, uppercase", 43 | prompt: "Do you want to proceed?", 44 | userInput: "N\n", 45 | defaultYes: false, 46 | want: false, 47 | }, 48 | { 49 | name: "Yes, not default", 50 | prompt: "Do you want to proceed?", 51 | userInput: "y\n", 52 | defaultYes: false, 53 | want: true, 54 | }, 55 | { 56 | name: "No, not default", 57 | prompt: "Do you want to proceed?", 58 | userInput: "n\n", 59 | defaultYes: false, 60 | want: false, 61 | }, 62 | { 63 | name: "Random input", 64 | prompt: "Do you want to proceed?", 65 | userInput: "kochicomputeren\n", 66 | defaultYes: false, 67 | want: false, 68 | }, 69 | { 70 | name: "Empty input", 71 | prompt: "Do you want to proceed?", 72 | userInput: "\n", 73 | defaultYes: false, 74 | want: false, 75 | }, 76 | { 77 | name: "Empty input, default yes", 78 | prompt: "Do you want to proceed?", 79 | userInput: "\n", 80 | defaultYes: true, 81 | want: true, 82 | }, 83 | } 84 | for _, tc := range testCases { 85 | t.Run(tc.name, func(t *testing.T) { 86 | reader := bytes.NewBufferString(tc.userInput) 87 | buf := &bytes.Buffer{} 88 | ui := ui.NewUI(ui.WithReader(reader), ui.WithWriter(buf)) 89 | 90 | t.Run("Return value is correct", func(t *testing.T) { 91 | got := ui.AskYesNo(tc.prompt, tc.defaultYes) 92 | if got != tc.want { 93 | t.Errorf(cmp.Diff(got, tc.want)) 94 | } 95 | }) 96 | 97 | t.Run("Prompt is correct", func(t *testing.T) { 98 | if tc.defaultYes { 99 | tc.prompt += " (Y/n)" 100 | } else { 101 | tc.prompt += " (y/N)" 102 | } 103 | wantPrompt := fmt.Sprintf("%s\n", tc.prompt) 104 | if buf.String() != wantPrompt { 105 | t.Errorf(cmp.Diff(wantPrompt, buf.String())) 106 | } 107 | }) 108 | }) 109 | } 110 | } 111 | 112 | func TestReadUserInput(t *testing.T) { 113 | testCases := []struct { 114 | name string 115 | userInput string 116 | want string 117 | }{ 118 | { 119 | name: "Simple input", 120 | userInput: "Hello, world!\n", 121 | want: "Hello, world!", 122 | }, 123 | { 124 | name: "Empty input", 125 | userInput: "\n", 126 | want: "", 127 | }, 128 | { 129 | name: "Input with newline", 130 | userInput: "Hello, world!\n", 131 | want: "Hello, world!", 132 | }, 133 | } 134 | for _, tc := range testCases { 135 | t.Run(tc.name, func(t *testing.T) { 136 | reader := bytes.NewBufferString(tc.userInput) 137 | buf := &bytes.Buffer{} 138 | ui := ui.NewUI(ui.WithReader(reader), ui.WithWriter(buf)) 139 | t.Run("Return value is correct", func(t *testing.T) { 140 | got := ui.ReadUserInput() 141 | if got != tc.want { 142 | t.Errorf(cmp.Diff(got, tc.want)) 143 | } 144 | }) 145 | }) 146 | } 147 | } 148 | 149 | func TestPrintMessage(t *testing.T) { 150 | testCases := []struct { 151 | name string 152 | message string 153 | messageType ui.MessageType 154 | want string 155 | }{ 156 | { 157 | name: "Info message", 158 | message: "Just do it!", 159 | messageType: ui.MessageTypeInfo, 160 | want: "Just do it!", 161 | }, 162 | { 163 | name: "Warning message", 164 | message: "Just do it!", 165 | messageType: ui.MessageTypeWarning, 166 | want: "\x1b[33mJust do it!\x1b[0m", 167 | }, 168 | { 169 | name: "Error message", 170 | message: "Just do it!", 171 | messageType: ui.MessageTypeError, 172 | want: "\x1b[31mJust do it!\x1b[0m", 173 | }, 174 | { 175 | name: "Success message", 176 | message: "Just do it!", 177 | messageType: ui.MessageTypeSuccess, 178 | want: "\x1b[32mJust do it!\x1b[0m", 179 | }, 180 | { 181 | name: "Notice message", 182 | message: "Just do it!", 183 | messageType: ui.MessageTypeNotice, 184 | want: "\x1b[36mJust do it!\x1b[0m", 185 | }, 186 | { 187 | name: "Unknown message type", 188 | message: "Just do it!", 189 | messageType: ui.MessageType(42), 190 | want: "Just do it!", 191 | }, 192 | } 193 | for _, tc := range testCases { 194 | t.Run(tc.name, func(t *testing.T) { 195 | reader := bytes.NewBufferString("") 196 | buf := &bytes.Buffer{} 197 | ui := ui.NewUI(ui.WithReader(reader), ui.WithWriter(buf)) 198 | 199 | ui.PrintMessage(tc.message, tc.messageType) 200 | if buf.String() != tc.want { 201 | t.Errorf(cmp.Diff(buf.String(), tc.want)) 202 | } 203 | }) 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /scripts/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Detect the operating system and architecture 4 | OS="unknown" 5 | ARCH=$(uname -m) 6 | 7 | case "$(uname -s)" in 8 | Darwin) OS="darwin";; 9 | Linux) OS="linux";; 10 | CYGWIN*|MINGW32*|MSYS*|MINGW*) OS="windows";; 11 | esac 12 | 13 | # Map architecture names to those used in the releases 14 | case "$ARCH" in 15 | x86_64) ARCH="amd64";; 16 | arm64) ARCH="arm64";; 17 | esac 18 | 19 | # For Windows, change the executable name 20 | EXT="" 21 | if [ "$OS" = "windows" ]; then 22 | EXT=".exe" 23 | fi 24 | 25 | URL="https://github.com/intility/cwc/releases/latest/download/cwc-$OS-$ARCH$EXT" 26 | 27 | # Download the correct binary 28 | if command -v wget > /dev/null; then 29 | wget "$URL" -O "cwc$EXT" 30 | elif command -v curl > /dev/null; then 31 | curl -L "$URL" -o "cwc$EXT" 32 | else 33 | echo "Error: Neither wget nor curl is installed." 34 | exit 1 35 | fi 36 | 37 | # Make it executable (not necessary for Windows) 38 | if [ "$OS" != "windows" ]; then 39 | chmod +x "cwc$EXT" 40 | fi 41 | 42 | echo "Downloaded cwc for $OS-$ARCH" 43 | --------------------------------------------------------------------------------