├── .github └── workflows │ └── goreleaser.yml ├── .gitignore ├── .goreleaser.yaml ├── LICENSE ├── README.md ├── cli └── cli.go ├── config ├── cli.go ├── config.go └── config.yaml ├── go.mod ├── go.sum ├── install.ps1 ├── install.sh ├── llm └── llm.go ├── main.go ├── types └── types.go └── util └── util.go /.github/workflows/goreleaser.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | goreleaser: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | 20 | - name: Set up Go 21 | uses: actions/setup-go@v5 22 | with: 23 | go-version: "1.22" 24 | 25 | - name: Run GoReleaser 26 | uses: goreleaser/goreleaser-action@v6 27 | with: 28 | distribution: goreleaser 29 | version: "~> v2" 30 | args: release --clean 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.PUBLISHER_TOKEN }} 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | 3 | dist/ 4 | 5 | q 6 | q.exe -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | # This is an example .goreleaser.yml file with some sensible defaults. 4 | # Make sure to check the documentation at https://goreleaser.com 5 | before: 6 | hooks: 7 | # You may remove this if you don't use go modules. 8 | - go mod tidy 9 | 10 | builds: 11 | - env: 12 | - CGO_ENABLED=0 13 | goos: 14 | - linux 15 | - windows 16 | - darwin 17 | 18 | brews: 19 | - name: shell-ai 20 | homepage: "https://github.com/ibigio/shell-ai" 21 | repository: 22 | owner: ibigio 23 | name: homebrew-tap 24 | commit_author: 25 | name: ibigio 26 | email: ilanbigio@gmail.com 27 | install: | 28 | bin.install "shell-ai" 29 | bin.install_symlink "shell-ai" => "q" 30 | 31 | archives: 32 | - format: tar.gz 33 | # this name template makes the OS and Arch compatible with the results of uname. 34 | name_template: >- 35 | {{ .ProjectName }}_ 36 | {{- title .Os }}_ 37 | {{- if eq .Arch "amd64" }}x86_64 38 | {{- else if eq .Arch "386" }}i386 39 | {{- else }}{{ .Arch }}{{ end }} 40 | {{- if .Arm }}v{{ .Arm }}{{ end }} 41 | # use zip for windows archives 42 | format_overrides: 43 | - goos: windows 44 | format: zip 45 | 46 | checksum: 47 | name_template: "checksums.txt" 48 | 49 | snapshot: 50 | name_template: "{{ incpatch .Version }}-next" 51 | 52 | changelog: 53 | sort: asc 54 | filters: 55 | exclude: 56 | - "^docs:" 57 | - "^test:" 58 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 59 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Ilan Bigio 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Frame 7 2 | 3 | # ShellAI 4 | 5 | A delightfully minimal, yet remarkably powerful AI Shell Assistant. 6 | 7 | ![shell-ai-demo](https://github.com/ibigio/shell-ai/assets/25421602/f480db5d-3787-49d8-b1bc-a027f65858e6) 8 | 9 | > "Ten minutes of Googling is now ten seconds in the terminal." 10 | > 11 | > ~ Joe C. 12 | 13 |

14 | Twitter Follow 15 |

16 | 17 | # About 18 | 19 | For developers, referencing things online is inevitable – but one can only look up "how to do [X] in git" so many times before losing your mind. 20 | 21 |

22 | 23 |

24 | 25 | **ShellAI** is meant to be a faster and smarter alternative to online reference: for shell commands, code examples, error outputs, and high-level explanations. We believe tools should be beautiful, minimal, and convenient, to let you get back to what you were doing as quickly and pleasantly as possible. That is the purpose of ShellAI. 26 | 27 | _New: ShellAI now supports local models! See [Custom Model Configuration](#custom-model-configuration-new) for more._ 28 | 29 | # Install 30 | 31 | ### Homebrew 32 | 33 | ```bash 34 | brew tap ibigio/tap 35 | brew install shell-ai 36 | ``` 37 | 38 | ### Linux 39 | 40 | ```bash 41 | curl https://raw.githubusercontent.com/ibigio/shell-ai/main/install.sh | bash 42 | ``` 43 | 44 | # Usage 45 | 46 | Type `q` followed by a description of a shell command, code snippet, or general question! 47 | 48 | ### Features 49 | 50 | - Generate shell commands from a description. 51 | - Reference code snippets for any programming language. 52 | - Fast, syntax-highlighted, minimal UI. 53 | - Auto-extract code from response and copy to clipboard. 54 | - Follow up to refine command or explanation. 55 | - Concise, helpful responses. 56 | - Built-in support for GPT 3.5 and GPT 4. 57 | - Support for [other providers and open source models](#custom-model-configuration-new)! 58 | 59 | ### Configuration 60 | 61 | Set your [OpenAI API key](https://platform.openai.com/account/api-keys). 62 | 63 | ```bash 64 | export OPENAI_API_KEY=[your key] 65 | ``` 66 | 67 | For more options (like setting the default model), run: 68 | 69 | ```bash 70 | q config 71 | ``` 72 | 73 | (For more advanced config, like configuring open source models, check out the [Custom Model Configuration](#custom-model-configuration-new) section) 74 | 75 | # Examples 76 | 77 | ### Shell Commands 78 | 79 | `$ q make a new git branch` 80 | 81 | ``` 82 | git branch new-branch 83 | ``` 84 | 85 | `$ q find files that contain "administrative" in the name` 86 | 87 | ``` 88 | find /path/to/directory -type f -name "*administrative*" 89 | ``` 90 | 91 | ### Code Snippets 92 | 93 | `$ q initialize a static map in golang` 94 | 95 | ```golang 96 | var staticMap = map[string]int{ 97 | "key1": 1, 98 | "key2": 2, 99 | "key3": 3, 100 | } 101 | ``` 102 | 103 | `$ q create a generator function in python for dates` 104 | 105 | ```python 106 | def date_generator(start_date, end_date): 107 | current_date = start_date 108 | while current_date <= end_date: 109 | yield current_date 110 | current_date += datetime.timedelta(days=1) 111 | ``` 112 | 113 | # Custom Model Configuration (New!) 114 | 115 | You can now configure model prompts and even add your own model setups in the `~/.shell-ai/config.yaml` file! ShellAI _should_ support any model that can be accessed through a chat-like endpoint... including local OSS models. 116 | 117 | (I'm working on making config entirely possible through `q config`, but until then you'll have to edit the file directly.) 118 | 119 | ### Config File Syntax 120 | 121 | ````yaml 122 | preferences: 123 | default_model: gpt-4-1106-preview 124 | 125 | models: 126 | - name: gpt-4-1106-preview 127 | endpoint: https://api.openai.com/v1/chat/completions 128 | auth_env_var: OPENAI_API_KEY 129 | org_env_var: OPENAI_ORG_ID 130 | prompt: 131 | [ 132 | { 133 | role: "system", 134 | content: "You are a terminal assistant. Turn the natural language instructions into a terminal command. By default always only output code, and in a code block. However, if the user is clearly asking a question then answer it very briefly and well.", 135 | }, 136 | { role: "user", content: "print hi" }, 137 | { role: "assistant", content: "```bash\necho \"hi\"\n```" }, 138 | ] 139 | # other models ... 140 | 141 | config_format_version: "1" 142 | ```` 143 | 144 | **Note:** The `auth_env_var` is set to `OPENAI_API_KEY` verbatim, not the key itself, so as to not keep sensitive information in the config file. 145 | 146 | ### Setting Up a Local Model 147 | 148 | As a proof of concept I set up `stablelm-zephyr-3b.Q8_0` on my MacBook Pro (16GB) and it works decently well. (Mostly some formatting oopsies here and there.) 149 | 150 | Here's what I did: 151 | 152 | 1. I cloned and set up `llama.cpp` ([repo](https://github.com/ggerganov/llama.cpp)). (Just follow the instructions.) 153 | 2. Then I downloaded the [`stablelm-zephyr-3b.Q8_0`](https://huggingface.co/TheBloke/stablelm-zephyr-3b-GGUF/blob/main/stablelm-zephyr-3b.Q8_0.gguf) GGUF [from hugging face](https://huggingface.co/TheBloke/stablelm-zephyr-3b-GGUF) (thanks, TheBloke) and saved it under `llama.cpp/models/`. 154 | 3. Then I ran the model in server mode with chat syntax (from `llama.cpp/`): 155 | 156 | ```bash 157 | ./server -m models/stablelm-zephyr-3b.Q8_0.gguf --host 0.0.0.0 --port 8080 158 | ``` 159 | 160 | 4. Finally I added the new `model` config to my `~/.shell-ai/config.yaml`, and wrestled with the prompt until it worked – bet you can do better. (As you can see, YAML is [flexible](https://docs.ansible.com/ansible/latest/reference_appendices/YAMLSyntax.html).) 161 | 162 | ````yaml 163 | models: 164 | - name: stablelm-zephyr-3b.Q8_0 165 | endpoint: http://127.0.0.1:8080/v1/chat/completions 166 | auth_env_var: OPENAI_API_KEY 167 | org_env_var: OPENAI_ORG_ID 168 | prompt: 169 | - role: system 170 | content: 171 | You are a terminal assistant. Turn the natural language instructions 172 | into a terminal command. By default always only output code, and in a code block. 173 | DO NOT OUTPUT ADDITIONAL REMARKS ABOUT THE CODE YOU OUTPUT. Do not repeat the 174 | question the users asks. Do not add explanations for your code. Do not output 175 | any non-code words at all. Just output the code. Short is better. However, if 176 | the user is clearly asking a general question then answer it very briefly and 177 | well. Indent code correctly. 178 | - role: user 179 | content: get the current time from some website 180 | - role: assistant 181 | content: |- 182 | ```bash 183 | curl -s http://worldtimeapi.org/api/ip | jq '.datetime' 184 | ``` 185 | - role: user 186 | content: print hi 187 | - role: assistant 188 | content: |- 189 | ```bash 190 | echo "hi" 191 | ``` 192 | 193 | # other models ... 194 | ```` 195 | 196 | and also updated the default model (which you can also do from `q config`): 197 | 198 | ```yaml 199 | preferences: 200 | default_model: stablelm-zephyr-3b.Q8_0 201 | ``` 202 | 203 | And huzzah! You can now use ShellAI on a plane. 204 | 205 | (Fun fact, I implemented a good bit of the initial config TUI on a plane using this exact local model.) 206 | 207 | ### Setting Up Azure OpenAI endpoint 208 | 209 | Define `AZURE_OPENAI_API_KEY` environment variable and make few changes to the config file. 210 | 211 | ```yaml 212 | models: 213 | - name: azure-gpt-4 214 | endpoint: https://.openai.azure.com/openai/deployments//chat/completions?api-version= 215 | auth_env_var: AZURE_OPENAI_API_KEY 216 | ``` 217 | 218 | ### I Fucked Up The Config File 219 | 220 | Great! Means you're having fun. 221 | 222 | `q config revert` will revert it back to the latest working version you had. (ShellAI automatically saves backups on successful updates.) Wish more tools had this. 223 | 224 | `q config reset` will nuke it to the (latest) default config. 225 | 226 | # Contributing 227 | 228 | Now that `~/.shell-ai/config.yaml` is set up, there's so much to do! I'm open to any feature ideas you might want to add, but am generally focused on two efforts: 229 | 230 | 1. Building out the config TUI so you never have to edit the file directly (along with other nice features like re-using prompts, etc), and 231 | 2. Setting up model install templates – think an (immutable) templates file where people can configure the model config and install steps, so someone can just go like `go config` -> `Install Model` -> pick one, and start using it. 232 | 233 | Like I said, if you have other ideas, or just want to say hi, go ahead and reach out! [@ilanbigio](https://twitter.com/ilanbigio) :) 234 | -------------------------------------------------------------------------------- /cli/cli.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "q/config" 7 | "q/llm" 8 | . "q/types" 9 | "q/util" 10 | 11 | "runtime" 12 | "strings" 13 | 14 | "github.com/atotto/clipboard" 15 | "github.com/charmbracelet/bubbles/spinner" 16 | "github.com/charmbracelet/bubbles/textinput" 17 | tea "github.com/charmbracelet/bubbletea" 18 | "github.com/charmbracelet/glamour" 19 | "github.com/charmbracelet/lipgloss" 20 | "github.com/spf13/cobra" 21 | ) 22 | 23 | type State int 24 | 25 | const ( 26 | Loading State = iota 27 | RecevingInput 28 | ReceivingResponse 29 | ) 30 | 31 | type model struct { 32 | client *llm.LLMClient 33 | markdownRenderer *glamour.TermRenderer 34 | p *tea.Program 35 | 36 | textInput textinput.Model 37 | spinner spinner.Model 38 | 39 | state State 40 | query string 41 | latestCommandResponse string 42 | latestCommandIsCode bool 43 | 44 | formattedPartialResponse string 45 | 46 | maxWidth int 47 | 48 | runWithArgs bool 49 | err error 50 | } 51 | 52 | type responseMsg struct { 53 | response string 54 | err error 55 | } 56 | type partialResponseMsg struct { 57 | content string 58 | err error 59 | } 60 | type setPMsg struct{ p *tea.Program } 61 | 62 | // === Commands === // 63 | 64 | func makeQuery(client *llm.LLMClient, query string) tea.Cmd { 65 | return func() tea.Msg { 66 | response, err := client.Query(query) 67 | return responseMsg{response: response, err: err} 68 | } 69 | } 70 | 71 | // === Msg Handlers === // 72 | 73 | func (m model) handleKeyEnter() (tea.Model, tea.Cmd) { 74 | if m.state != RecevingInput { 75 | return m, nil 76 | } 77 | v := m.textInput.Value() 78 | 79 | // No input, copy and quit. 80 | if v == "" { 81 | if m.latestCommandResponse == "" { 82 | return m, tea.Quit 83 | } 84 | err := clipboard.WriteAll(m.latestCommandResponse) 85 | if err != nil { 86 | fmt.Println("Failed to copy text to clipboard:", err) 87 | return m, tea.Quit 88 | } 89 | placeholderStyle := lipgloss.NewStyle().Faint(true) 90 | message := "Copied to clipboard." 91 | if !m.latestCommandIsCode { 92 | message = "Copied only code to clipboard." 93 | } 94 | message = placeholderStyle.Render(message) 95 | return m, tea.Sequence(tea.Printf("%s", message), tea.Quit) 96 | } 97 | // Input, run query. 98 | m.textInput.SetValue("") 99 | m.query = v 100 | m.state = Loading 101 | placeholderStyle := lipgloss.NewStyle().Faint(true).Width(m.maxWidth) 102 | message := placeholderStyle.Render(fmt.Sprintf("> %s", v)) 103 | return m, tea.Sequence(tea.Printf("%s", message), tea.Batch(m.spinner.Tick, makeQuery(m.client, m.query))) 104 | } 105 | 106 | func (m model) formatResponse(response string, isCode bool) (string, error) { 107 | 108 | // format nicely 109 | formatted, err := m.markdownRenderer.Render(response) 110 | if err != nil { 111 | // TODO: handle error 112 | panic(err) 113 | } 114 | 115 | // trim preceding and trailing newlines 116 | formatted = strings.TrimPrefix(formatted, "\n") 117 | formatted = strings.TrimSuffix(formatted, "\n") 118 | 119 | // Add newline for non-code blocks (hacky) 120 | if !isCode { 121 | formatted = "\n" + formatted 122 | } 123 | return formatted, nil 124 | } 125 | 126 | // TODO: parse the model endpoint to infer whether it's openai, other, or local. 127 | // for local, suggest it may not be running, and how to run it 128 | func (m model) getConnectionError(err error) string { 129 | styleRed := lipgloss.NewStyle().Foreground(lipgloss.Color("9")) 130 | styleGreen := lipgloss.NewStyle().Foreground(lipgloss.Color("2")) 131 | styleDim := lipgloss.NewStyle().Faint(true).Width(m.maxWidth).PaddingLeft(2) 132 | message := fmt.Sprintf("\n %v\n\n%v\n", 133 | styleRed.Render("Error: Failed to connect to OpenAI."), 134 | styleDim.Render(err.Error())) 135 | if util.IsLikelyBillingError(err.Error()) { 136 | message = fmt.Sprintf("%v\n %v %v\n\n %v%v\n\n", 137 | message, 138 | styleGreen.Render("Hint:"), 139 | "You may need to set up billing. You can do so here:", 140 | styleGreen.Render("->"), 141 | styleDim.Render("https://platform.openai.com/account/billing"), 142 | ) 143 | } 144 | return message 145 | } 146 | 147 | func (m model) handleResponseMsg(msg responseMsg) (tea.Model, tea.Cmd) { 148 | m.formattedPartialResponse = "" 149 | 150 | // error handling 151 | if msg.err != nil { 152 | m.state = RecevingInput 153 | message := m.getConnectionError(msg.err) 154 | return m, tea.Sequence(tea.Printf("%s", message), textinput.Blink) 155 | } 156 | 157 | // parse out the code block 158 | content, isOnlyCode := util.ExtractFirstCodeBlock(msg.response) 159 | if content != "" { 160 | m.latestCommandResponse = content 161 | } 162 | 163 | formatted, err := m.formatResponse(msg.response, util.StartsWithCodeBlock(msg.response)) 164 | if err != nil { 165 | // TODO: handle error 166 | panic(err) 167 | } 168 | 169 | m.textInput.Placeholder = "Follow up, ENTER to copy & quit, CTRL+C to quit" 170 | if !isOnlyCode { 171 | m.textInput.Placeholder = "Follow up, ENTER to copy (code only), CTRL+C to quit" 172 | } 173 | if m.latestCommandResponse == "" { 174 | m.textInput.Placeholder = "Follow up, ENTER or CTRL+C to quit" 175 | } 176 | 177 | m.state = RecevingInput 178 | m.latestCommandIsCode = isOnlyCode 179 | message := formatted 180 | return m, tea.Sequence(tea.Printf("%s", message), textinput.Blink) 181 | } 182 | 183 | func (m model) handlePartialResponseMsg(msg partialResponseMsg) (tea.Model, tea.Cmd) { 184 | m.state = ReceivingResponse 185 | isCode := util.StartsWithCodeBlock(msg.content) 186 | formatted, err := m.formatResponse(msg.content, isCode) 187 | if err != nil { 188 | // TODO: handle error 189 | panic(err) 190 | } 191 | m.formattedPartialResponse = formatted 192 | return m, nil 193 | } 194 | 195 | // === Init, Update, View === // 196 | 197 | func (m model) Init() tea.Cmd { 198 | if m.runWithArgs { 199 | return tea.Batch(m.spinner.Tick, makeQuery(m.client, m.query)) 200 | } 201 | return textinput.Blink 202 | } 203 | 204 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 205 | var cmd tea.Cmd 206 | 207 | switch msg := msg.(type) { 208 | case tea.KeyMsg: 209 | switch msg.Type { 210 | case tea.KeyCtrlC, tea.KeyEsc, tea.KeyCtrlD: 211 | return m, tea.Quit 212 | 213 | case tea.KeyEnter: 214 | return m.handleKeyEnter() 215 | } 216 | 217 | case responseMsg: 218 | return m.handleResponseMsg(msg) 219 | 220 | case partialResponseMsg: 221 | return m.handlePartialResponseMsg(msg) 222 | 223 | case setPMsg: 224 | m.p = msg.p 225 | return m, nil 226 | 227 | case error: 228 | m.err = msg 229 | return m, nil 230 | } 231 | // Update spinner or cursor. 232 | switch m.state { 233 | case Loading: 234 | m.spinner, cmd = m.spinner.Update(msg) 235 | return m, cmd 236 | case RecevingInput: 237 | m.textInput, cmd = m.textInput.Update(msg) 238 | return m, cmd 239 | } 240 | return m, nil 241 | } 242 | 243 | func (m model) View() string { 244 | switch m.state { 245 | case Loading: 246 | return m.spinner.View() 247 | case RecevingInput: 248 | return m.textInput.View() 249 | case ReceivingResponse: 250 | return m.formattedPartialResponse + "\n" 251 | } 252 | return "" 253 | } 254 | 255 | // === Initial Model Setup === // 256 | 257 | func initialModel(prompt string, client *llm.LLMClient) model { 258 | maxWidth := util.GetTermSafeMaxWidth() 259 | ti := textinput.New() 260 | ti.Placeholder = "Describe a shell command, or ask a question." 261 | ti.Focus() 262 | ti.Width = maxWidth 263 | 264 | s := spinner.New() 265 | s.Spinner = spinner.Dot 266 | s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) 267 | 268 | runWithArgs := prompt != "" 269 | 270 | r, _ := glamour.NewTermRenderer( 271 | glamour.WithAutoStyle(), 272 | glamour.WithWordWrap(int(maxWidth)), 273 | ) 274 | model := model{ 275 | client: client, 276 | markdownRenderer: r, 277 | textInput: ti, 278 | spinner: s, 279 | state: RecevingInput, 280 | query: "", 281 | latestCommandResponse: "", 282 | latestCommandIsCode: false, 283 | maxWidth: maxWidth, 284 | runWithArgs: false, 285 | err: nil, 286 | } 287 | 288 | if runWithArgs { 289 | model.runWithArgs = true 290 | model.state = Loading 291 | model.query = prompt 292 | } 293 | return model 294 | } 295 | 296 | // === Main === // 297 | 298 | func printAPIKeyNotSetMessage(modelConfig ModelConfig) { 299 | auth := modelConfig.Auth 300 | r, _ := glamour.NewTermRenderer( 301 | glamour.WithAutoStyle(), 302 | ) 303 | 304 | profileScriptName := ".zshrc or.bashrc" 305 | shellSyntax := "\n```bash\nexport OPENAI_API_KEY=[your key]\n```" 306 | if runtime.GOOS == "windows" { 307 | profileScriptName = "$profile" 308 | shellSyntax = "\n```powershell\n$env:OPENAI_API_KEY = \"[your key]\"\n```" 309 | } 310 | 311 | styleRed := lipgloss.NewStyle().Foreground(lipgloss.Color("9")) 312 | 313 | switch auth { 314 | case "OPENAI_API_KEY": 315 | msg1 := styleRed.Render("OPENAI_API_KEY environment variable not set.") 316 | 317 | // make it platform agnostic 318 | message_string := fmt.Sprintf(` 319 | 1. Generate your API key at https://platform.openai.com/account/api-keys 320 | 2. Add your credit card in the API (for the free trial) 321 | 3. Set your key by running: 322 | %s 323 | 4. (Recommended) Add that ^ line to your %s file.`, shellSyntax, profileScriptName) 324 | 325 | msg2, _ := r.Render(message_string) 326 | fmt.Printf("\n %v%v\n", msg1, msg2) 327 | default: 328 | msg := styleRed.Render(auth + " environment variable not set.") 329 | fmt.Printf("\n %v", msg) 330 | } 331 | } 332 | 333 | func streamHandler(p *tea.Program) func(content string, err error) { 334 | return func(content string, err error) { 335 | p.Send(partialResponseMsg{content, err}) 336 | } 337 | } 338 | 339 | func getModelConfig(appConfig config.AppConfig) (ModelConfig, error) { 340 | if len(appConfig.Models) == 0 { 341 | return ModelConfig{}, fmt.Errorf("no models available") 342 | } 343 | for _, model := range appConfig.Models { 344 | if model.ModelName == appConfig.Preferences.DefaultModel { 345 | return model, nil 346 | } 347 | } 348 | // If the preferred model is not found, return the first model 349 | return appConfig.Models[0], nil 350 | } 351 | 352 | func runQProgram(prompt string) { 353 | appConfig, err := config.LoadAppConfig() 354 | if err != nil { 355 | config.PrintConfigErrorMessage(err) 356 | os.Exit(1) 357 | } 358 | 359 | modelConfig, err := getModelConfig(appConfig) 360 | if err != nil { 361 | config.PrintConfigErrorMessage(err) 362 | os.Exit(1) 363 | } 364 | auth := os.Getenv(modelConfig.Auth) 365 | if auth == "" || os.Getenv(modelConfig.Auth) == "" { 366 | printAPIKeyNotSetMessage(modelConfig) 367 | os.Exit(1) 368 | } 369 | // everything checks out, save the config 370 | // TODO: maybe add a validating function 371 | config.SaveAppConfig(appConfig) 372 | 373 | orgID := os.Getenv(modelConfig.OrgID) 374 | modelConfig.Auth = auth 375 | modelConfig.OrgID = orgID 376 | 377 | c := llm.NewLLMClient(modelConfig) 378 | p := tea.NewProgram(initialModel(prompt, c)) 379 | c.StreamCallback = streamHandler(p) 380 | if _, err := p.Run(); err != nil { 381 | fmt.Printf("Alas, there's been an error: %v", err) 382 | os.Exit(1) 383 | } 384 | } 385 | 386 | var RootCmd = &cobra.Command{ 387 | Use: "q [request]", 388 | Short: "A command line interface for natural language queries", 389 | Run: func(cmd *cobra.Command, args []string) { 390 | // join args into a single string separated by spaces 391 | prompt := strings.Join((args), " ") 392 | if len(args) > 0 && args[0] == "config" { 393 | config.RunConfigProgram(args) 394 | return 395 | } 396 | runQProgram(prompt) 397 | 398 | }, 399 | } 400 | -------------------------------------------------------------------------------- /config/cli.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | "os" 8 | "os/exec" 9 | "q/types" 10 | "q/util" 11 | "strings" 12 | 13 | "github.com/charmbracelet/bubbles/list" 14 | tea "github.com/charmbracelet/bubbletea" 15 | "github.com/charmbracelet/glamour" 16 | "github.com/charmbracelet/lipgloss" 17 | ) 18 | 19 | const listHeight = 12 20 | 21 | var ( 22 | styleRed = lipgloss.NewStyle().Foreground(lipgloss.Color("9")) 23 | greyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) 24 | titleStyle = lipgloss.NewStyle().MarginLeft(2).Foreground(lipgloss.Color("240")) 25 | itemStyle = lipgloss.NewStyle().PaddingLeft(4) 26 | selectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("170")) 27 | paginationStyle = list.DefaultStyles().PaginationStyle.PaddingLeft(4) 28 | helpStyle = list.DefaultStyles().HelpStyle.PaddingLeft(4).PaddingBottom(1) 29 | quitTextStyle = lipgloss.NewStyle().Faint(true).Margin(1, 0, 2, 4) 30 | ) 31 | 32 | // type item string 33 | 34 | // func (i item) FilterValue() string { return "" } 35 | 36 | type itemDelegate struct{} 37 | 38 | func (d itemDelegate) Height() int { return 1 } 39 | func (d itemDelegate) Spacing() int { return 0 } 40 | func (d itemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } 41 | func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { 42 | i, ok := listItem.(menuItem) 43 | if !ok { 44 | return 45 | } 46 | 47 | fn := itemStyle.Render 48 | if index == m.Index() { 49 | fn = func(s ...string) string { 50 | 51 | return selectedItemStyle.Render("> " + strings.Join(s, " ")) 52 | } 53 | } 54 | text := fn(i.title) 55 | if i.data != "" { 56 | text = fmt.Sprintf("%s %s", text, greyStyle.Render("("+i.data+")")) 57 | } 58 | 59 | fmt.Fprint(w, text) 60 | } 61 | 62 | func quit() tea.Cmd { 63 | return func() tea.Msg { 64 | return quitMsg{} 65 | } 66 | } 67 | 68 | type quitMsg struct{} 69 | 70 | type setMenuMsg struct { 71 | state state 72 | menu menuFunc 73 | } 74 | 75 | type setStateMsg struct { 76 | state state 77 | } 78 | 79 | func setMenu(menu menuFunc) tea.Cmd { 80 | return func() tea.Msg { return setMenuMsg{menu: menu} } 81 | } 82 | 83 | type backMsg struct{} 84 | 85 | func back() tea.Cmd { 86 | return func() tea.Msg { return backMsg{} } 87 | } 88 | 89 | type updateConfigMsg struct { 90 | appConfig AppConfig 91 | } 92 | 93 | type editorFinishedMsg struct{ err error } 94 | 95 | func openEditor() tea.Cmd { 96 | fullPath, err := FullFilePath(configFilePath) 97 | if err != nil { 98 | return tea.Cmd(func() tea.Msg { return editorFinishedMsg{err} }) 99 | } 100 | editor := os.Getenv("EDITOR") 101 | if editor == "" { 102 | editor = "vim" 103 | } 104 | c := exec.Command(editor, fullPath) //nolint:gosec 105 | return tea.ExecProcess(c, func(err error) tea.Msg { 106 | return editorFinishedMsg{err} 107 | }) 108 | } 109 | 110 | func openBrowser(url string) tea.Cmd { 111 | return func() tea.Msg { 112 | util.OpenBrowser(url) 113 | return nil 114 | } 115 | } 116 | 117 | func openGithubRepo() tea.Cmd { 118 | return func() tea.Msg { 119 | util.OpenBrowser("https://github.com/ibigio/shell-ai?tab=readme-ov-file#contributing") 120 | return nil 121 | } 122 | } 123 | 124 | type configSavedMsg struct{} 125 | 126 | func saveConfig(config AppConfig) tea.Cmd { 127 | return func() tea.Msg { 128 | SaveAppConfig(config) 129 | return configSavedMsg{} 130 | } 131 | } 132 | 133 | func updateConfig(config AppConfig) tea.Cmd { 134 | return func() tea.Msg { return updateConfigMsg{config} } 135 | } 136 | 137 | type setDefaultModelMsg struct { 138 | model string 139 | } 140 | 141 | func setDefaultModel(model string) tea.Cmd { 142 | return func() tea.Msg { return setDefaultModelMsg{model} } 143 | } 144 | 145 | // func saveConfig(appConfig AppConfig) tea.Cmd { 146 | // return func() tea.Msg { 147 | // return saveConfigMsg{} 148 | // } 149 | // } 150 | 151 | type menuItem struct { 152 | title string 153 | selectCmd tea.Cmd 154 | data string 155 | } 156 | 157 | func (i menuItem) FilterValue() string { return i.title } 158 | 159 | type menuFunc func(config AppConfig) list.Model 160 | 161 | type menuModel struct { 162 | title string 163 | items []menuItem 164 | lastSelectedIndex int 165 | } 166 | 167 | func (m menuModel) ListItems() []list.Item { 168 | menuItems := m.items 169 | listItems := make([]list.Item, len(menuItems)) 170 | 171 | for i, item := range menuItems { 172 | listItems[i] = item 173 | } 174 | 175 | return listItems 176 | } 177 | 178 | type page int 179 | 180 | const ( 181 | ListPage page = iota 182 | ) 183 | 184 | type state struct { 185 | page page 186 | menu menuFunc 187 | listIndex int 188 | model string 189 | } 190 | 191 | type model struct { 192 | state state 193 | 194 | list list.Model 195 | 196 | dirty bool 197 | backstack []state 198 | 199 | appConfig AppConfig 200 | 201 | quitting bool 202 | } 203 | 204 | func (m model) Init() tea.Cmd { 205 | return nil 206 | } 207 | 208 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 209 | switch msg := msg.(type) { 210 | case quitMsg: 211 | m.quitting = true 212 | return m, tea.Quit 213 | 214 | case tea.KeyMsg: 215 | if msg.Type == tea.KeyCtrlC || msg.Type == tea.KeyCtrlD { 216 | return m, quit() 217 | } 218 | case backMsg: 219 | if len(m.backstack) > 0 { 220 | m.state = m.backstack[len(m.backstack)-1] 221 | m.backstack = m.backstack[:len(m.backstack)-1] 222 | m.list = m.state.menu(m.appConfig) 223 | m.list.Select(m.state.listIndex) 224 | } 225 | return m, nil 226 | } 227 | 228 | switch msg := msg.(type) { 229 | case tea.KeyMsg: 230 | if msg.Type == tea.KeyRunes && len(msg.Runes) > 0 && msg.Runes[0] == 'q' { 231 | m.quitting = true 232 | return m, tea.Quit 233 | } 234 | switch msg.Type { 235 | case tea.KeyCtrlC, tea.KeyCtrlD: 236 | return m, quit() 237 | 238 | case tea.KeyEsc: 239 | if len(m.backstack) > 0 { 240 | return m, back() 241 | } 242 | return m, quit() 243 | 244 | case tea.KeyEnter: 245 | i, _ := m.list.SelectedItem().(menuItem) 246 | return m, i.selectCmd 247 | 248 | case tea.KeyBackspace: 249 | return m, back() 250 | } 251 | case quitMsg: 252 | m.quitting = true 253 | return m, tea.Quit 254 | 255 | case setMenuMsg: 256 | m.backstack = append(m.backstack, m.state) 257 | m.list = msg.menu(m.appConfig) 258 | m.state = state{page: ListPage, menu: msg.menu} 259 | 260 | case setDefaultModelMsg: 261 | m.appConfig.Preferences.DefaultModel = msg.model 262 | // fmt.Println("Config:", m.appConfig.Preferences.DefaultModel) 263 | return m, tea.Sequence(saveConfig(m.appConfig), back()) 264 | 265 | case editorFinishedMsg: 266 | if msg.err != nil { 267 | return m, quit() 268 | } 269 | } 270 | 271 | var cmd tea.Cmd 272 | if !m.quitting { 273 | m.list, cmd = m.list.Update(msg) 274 | } 275 | m.state.listIndex = m.list.Index() 276 | return m, cmd 277 | } 278 | 279 | func (m *model) handleSelect() { 280 | 281 | } 282 | 283 | func (m model) View() string { 284 | if m.quitting { 285 | return "" 286 | // return quitTextStyle.Render("Changes saved to ~/.shell-ai/config.yaml") 287 | } 288 | return "\n" + m.list.View() 289 | } 290 | 291 | func listFromMenu(m menuModel) list.Model { 292 | l := list.New(m.ListItems(), itemDelegate{}, 20, listHeight) 293 | l.Title = m.title 294 | l.SetShowStatusBar(false) 295 | l.SetFilteringEnabled(false) 296 | l.Styles.Title = titleStyle 297 | l.SetWidth(100) 298 | l.Styles.PaginationStyle = paginationStyle 299 | l.Styles.HelpStyle = helpStyle 300 | l.SetShowHelp(false) 301 | l.Select(m.lastSelectedIndex) 302 | return l 303 | } 304 | 305 | func defaultList(title string, items []menuItem) list.Model { 306 | listItems := make([]list.Item, len(items)) 307 | for i, item := range items { 308 | listItems[i] = item 309 | } 310 | l := list.New(listItems, itemDelegate{}, 20, listHeight) 311 | l.Title = title 312 | l.SetShowStatusBar(false) 313 | l.SetFilteringEnabled(false) 314 | l.Styles.Title = titleStyle 315 | l.SetWidth(100) 316 | l.Styles.PaginationStyle = paginationStyle 317 | l.Styles.HelpStyle = helpStyle 318 | l.SetShowHelp(false) 319 | return l 320 | } 321 | 322 | // func (m menuModel) 323 | 324 | func mainMenu(appConfig AppConfig) list.Model { 325 | items := []menuItem{ 326 | { 327 | title: "Change Default Model", 328 | data: appConfig.Preferences.DefaultModel, 329 | selectCmd: setMenu(defaultModelSelectMenu), 330 | }, 331 | { 332 | title: "Edit Config File", 333 | data: "~/.shell-ai/config.yaml", 334 | selectCmd: openEditor(), 335 | }, 336 | { 337 | title: "Configure Models", 338 | selectCmd: setMenu(configureModelsMenu), 339 | }, 340 | { 341 | title: "Contribute", 342 | selectCmd: openBrowser("https://github.com/ibigio/shell-ai#contributing"), 343 | }, 344 | { 345 | title: "Quit", 346 | data: "esc", 347 | selectCmd: quit(), 348 | }, 349 | } 350 | return defaultList("ShellAI Config", items) 351 | } 352 | 353 | func defaultModelSelectMenu(appConfig AppConfig) list.Model { 354 | var modelItems []menuItem 355 | for _, model := range appConfig.Models { 356 | model := model 357 | modelItems = append(modelItems, menuItem{ 358 | title: model.ModelName, 359 | selectCmd: tea.Sequence(setDefaultModel(model.ModelName), back()), 360 | }) 361 | } 362 | return defaultList("Choose Default Model", modelItems) 363 | } 364 | 365 | func configureModelsMenu(appConfig AppConfig) list.Model { 366 | var modelItems []menuItem 367 | for _, model := range appConfig.Models { 368 | model := model 369 | modelItems = append(modelItems, menuItem{ 370 | title: model.ModelName, 371 | selectCmd: setMenu(modelDetailsMenu(model)), 372 | }) 373 | } 374 | modelItems = append(modelItems, menuItem{ 375 | title: "Add Model", 376 | data: "coming soon!", 377 | selectCmd: openBrowser("https://github.com/ibigio/shell-ai#custom-model-configuration-new"), 378 | }) 379 | modelItems = append(modelItems, menuItem{ 380 | title: "Install Model", 381 | data: "coming soon!", 382 | selectCmd: openBrowser("https://github.com/ibigio/shell-ai#custom-model-configuration-new"), 383 | }) 384 | return defaultList("Configure Models (coming soon!)", modelItems) 385 | } 386 | 387 | func modelDetailsMenu(modelConfig types.ModelConfig) menuFunc { 388 | return func(c AppConfig) list.Model { 389 | return modelDetailsForModelMenu(c, modelConfig) 390 | } 391 | } 392 | 393 | func modelDetailsForModelMenu(appConfig AppConfig, modelConfig types.ModelConfig) list.Model { 394 | items := []menuItem{ 395 | { 396 | title: "Name: " + modelConfig.ModelName, 397 | }, 398 | { 399 | title: "Endpoint: " + modelConfig.Endpoint, 400 | }, 401 | { 402 | title: "Auth: " + modelConfig.Auth, 403 | }, 404 | { 405 | title: "Auth: " + modelConfig.Auth, 406 | }, 407 | { 408 | title: "Prompt", 409 | }, 410 | } 411 | return defaultList(modelConfig.ModelName+"(editing coming soon!)", items) 412 | } 413 | 414 | func PrintConfigErrorMessage(err error) { 415 | maxWidth := util.GetTermSafeMaxWidth() 416 | styleRed := lipgloss.NewStyle().Foreground(lipgloss.Color("9")).PaddingLeft(2) 417 | styleDim := lipgloss.NewStyle().Faint(true).Width(maxWidth).PaddingLeft(2) 418 | 419 | r, _ := glamour.NewTermRenderer( 420 | glamour.WithAutoStyle(), 421 | ) 422 | 423 | msg1 := styleRed.Render("Failed to load config file.") 424 | 425 | filePath, _ := FullFilePath(configFilePath) 426 | msg2 := styleDim.Render(err.Error()) 427 | revertConfigCmd := "q config revert" 428 | resetConfigCmd := "q config reset" 429 | 430 | // Concatenate message string with backticks 431 | message_string := fmt.Sprintf( 432 | "---\n"+ 433 | "# Options:\n\n"+ 434 | "1. Run `%s` to load the automatic backup - you're welcome.\n"+ 435 | "2. Nuke it. Run `%s` to reset the config to default.\n"+ 436 | "3. DIY - take a look at the config and fix the errors. It's at:\n\n"+ 437 | " `%s`\n\n", 438 | revertConfigCmd, resetConfigCmd, filePath) 439 | 440 | msg3, _ := r.Render(message_string) 441 | 442 | fmt.Printf("\n%s\n\n%s%s", msg1, msg2, msg3) 443 | } 444 | 445 | func handleConfigResets(args []string) { 446 | if len(args) < 2 { 447 | return 448 | } 449 | 450 | greyStylePadded := greyStyle.PaddingLeft(2) 451 | reader := bufio.NewReader(os.Stdin) 452 | 453 | warningMessage, confirmationMessage := getMessages(args[1], greyStylePadded) 454 | fmt.Print("\n" + styleRed.PaddingLeft(2).Render(warningMessage) + "\n\n" + confirmationMessage + " ") 455 | 456 | response, _ := reader.ReadString('\n') 457 | response = strings.ToLower(strings.TrimSpace(response)) 458 | 459 | if response == "yes" || response == "y" { 460 | handleResetOrRevert(args[1]) 461 | } else { 462 | fmt.Println("\n" + styleRed.PaddingLeft(2).Render("Operation cancelled.\n")) 463 | } 464 | os.Exit(0) 465 | } 466 | 467 | func getMessages(arg string, greyStylePadded lipgloss.Style) (string, string) { 468 | warningMessage := "WARNING: You are about to " 469 | confirmationMessage := greyStylePadded.Render("Do you want to continue? (y/N):") 470 | 471 | switch arg { 472 | case "reset": 473 | warningMessage += "reset the config file to the default." 474 | case "revert": 475 | warningMessage += "revert the config file to the last working automatic backup." 476 | } 477 | 478 | return warningMessage, confirmationMessage 479 | } 480 | 481 | func handleResetOrRevert(arg string) { 482 | var ( 483 | err error 484 | message string 485 | ) 486 | 487 | switch arg { 488 | case "reset": 489 | err = ResetAppConfigToDefault() 490 | message = "Config reset to default.\n" 491 | case "revert": 492 | err = RevertAppConfigToBackup() 493 | message = "Config reverted to backup.\n" 494 | } 495 | 496 | if err == nil { 497 | fmt.Println("\n" + greyStyle.PaddingLeft(2).Render(message)) 498 | } else { 499 | fmt.Println("\n" + styleRed.PaddingLeft(2).Render("Operation failed.\n")) 500 | fmt.Println("\n" + styleRed.PaddingLeft(2).Render(fmt.Sprintf("Error: %s\n", err))) 501 | } 502 | } 503 | 504 | func RunConfigProgram(args []string) { 505 | 506 | handleConfigResets(args) 507 | 508 | appConfig, err := LoadAppConfig() 509 | if err != nil { 510 | PrintConfigErrorMessage(err) 511 | os.Exit(1) 512 | } 513 | 514 | m := model{ 515 | appConfig: appConfig, 516 | list: mainMenu(appConfig), 517 | state: state{page: ListPage, menu: mainMenu}, 518 | } 519 | 520 | if _, err := tea.NewProgram(m).Run(); err != nil { 521 | fmt.Println("Error running program:", err) 522 | os.Exit(1) 523 | } 524 | } 525 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | . "q/types" 8 | 9 | _ "embed" 10 | 11 | "gopkg.in/yaml.v2" 12 | ) 13 | 14 | type AppConfig struct { 15 | Models []ModelConfig `yaml:"models"` 16 | Preferences Preferences `yaml:"preferences"` 17 | Version string `yaml:"config_format_version"` 18 | } 19 | 20 | // //go:embed config.yaml 21 | // var embeddedConfigFile []byte 22 | 23 | //go:embed config.yaml 24 | var embeddedConfigFile []byte 25 | var configFilePath string = ".shell-ai/config.yaml" 26 | var backupConfigFilePath string = ".shell-ai/.backup-config.yaml" 27 | 28 | func FullFilePath(relativeFilePath string) (string, error) { 29 | homeDir, err := os.UserHomeDir() 30 | if err != nil { 31 | return "", fmt.Errorf("error getting home directory: %s", err) 32 | } 33 | configFilePath := filepath.Join(homeDir, relativeFilePath) 34 | return configFilePath, nil 35 | } 36 | 37 | func LoadAppConfig() (config AppConfig, err error) { 38 | filePath, err := FullFilePath(configFilePath) 39 | if err != nil { 40 | return config, fmt.Errorf("error getting config file path: %s", err) 41 | } 42 | 43 | // if file doesn't exist, create it with defaults 44 | if _, err := os.Stat(filePath); os.IsNotExist(err) { 45 | return createConfigWithDefaults(filePath) 46 | } 47 | return loadExistingConfig(filePath) 48 | } 49 | 50 | func SaveAppConfig(config AppConfig) error { 51 | return writeConfigToFile(config) 52 | } 53 | 54 | func ResetAppConfigToDefault() error { 55 | _, err := createConfigWithDefaults(configFilePath) 56 | return err 57 | } 58 | 59 | func RevertAppConfigToBackup() error { 60 | fullConfigPath, _ := FullFilePath(configFilePath) 61 | fullBackupConfigPath, _ := FullFilePath(backupConfigFilePath) 62 | 63 | // delete the file if it exists 64 | if err := os.Remove(fullConfigPath); !os.IsNotExist(err) && err != nil { 65 | return err 66 | } 67 | config, err := loadExistingConfig(fullBackupConfigPath) 68 | if err != nil { 69 | return err 70 | } 71 | return writeConfigToFile(config) 72 | } 73 | 74 | func createConfigWithDefaults(filePath string) (AppConfig, error) { 75 | config := AppConfig{} 76 | err := yaml.Unmarshal(embeddedConfigFile, &config) 77 | if err != nil { 78 | return config, fmt.Errorf("error unmarshalling embedded config: %s", err) 79 | } 80 | // set default model to legacy option (for backwards compat) 81 | modelOverride := os.Getenv("OPENAI_MODEL_OVERRIDE") 82 | if modelOverride != "" { 83 | config.Preferences.DefaultModel = modelOverride 84 | } 85 | 86 | return config, writeConfigToFile(config) 87 | } 88 | 89 | func loadExistingConfig(filePath string) (AppConfig, error) { 90 | config := AppConfig{} 91 | yamlFile, err := os.ReadFile(filePath) 92 | if err != nil { 93 | return config, fmt.Errorf("error reading config file: %s", err) 94 | } 95 | err = yaml.Unmarshal(yamlFile, &config) 96 | if err != nil { 97 | return config, fmt.Errorf("error unmarshalling config file: %s", err) 98 | } 99 | return config, nil 100 | } 101 | 102 | func SaveBackupConfig(config AppConfig) error { 103 | filePath, err := FullFilePath(backupConfigFilePath) 104 | if err != nil { 105 | return err 106 | } 107 | configData, err := yaml.Marshal(config) 108 | if err != nil { 109 | return fmt.Errorf("error marshalling config: %s", err) 110 | } 111 | 112 | err = os.WriteFile(filePath, configData, 0644) 113 | if err != nil { 114 | return fmt.Errorf("error writing config to file: %s", err) 115 | } 116 | return nil 117 | } 118 | 119 | func writeConfigToFile(config AppConfig) error { 120 | filePath, _ := FullFilePath(configFilePath) 121 | // Create all directories in the filepath 122 | dir := filepath.Dir(filePath) 123 | if err := os.MkdirAll(dir, 0755); err != nil { 124 | return fmt.Errorf("error creating directories: %s", err) 125 | } 126 | configData, err := yaml.Marshal(config) 127 | if err != nil { 128 | return fmt.Errorf("error marshalling config: %s", err) 129 | } 130 | 131 | err = os.WriteFile(filePath, configData, 0644) 132 | if err != nil { 133 | return fmt.Errorf("error writing config to file: %s", err) 134 | } 135 | SaveBackupConfig(config) 136 | return nil 137 | } 138 | -------------------------------------------------------------------------------- /config/config.yaml: -------------------------------------------------------------------------------- 1 | preferences: 2 | default_model: gpt-4.1 3 | 4 | models: 5 | - name: gpt-4.1 6 | endpoint: https://api.openai.com/v1/chat/completions 7 | auth_env_var: OPENAI_API_KEY 8 | org_env_var: OPENAI_ORG_ID 9 | prompt: 10 | - role: system 11 | content: You are a terminal assistant. Turn the natural language instructions into a terminal command. By default always only output code, and in a code block. However, if the user is clearly asking a question then answer it very briefly and well. Consider when the user request references a previous request. 12 | - role: user 13 | content: print hi 14 | - role: assistant 15 | content: "```bash\necho \"hi\"\n```" 16 | 17 | - name: gpt-4.1-mini 18 | endpoint: https://api.openai.com/v1/chat/completions 19 | auth_env_var: OPENAI_API_KEY 20 | org_env_var: OPENAI_ORG_ID 21 | prompt: 22 | - role: system 23 | content: You are a terminal assistant. Turn the natural language instructions into a terminal command. By default always only output code, and in a code block. DO NOT OUTPUT ADDITIONAL REMARKS ABOUT THE CODE YOU OUTPUT. Do not repeat the question the users asks. Do not add explanations for your code. Do not output any non-code words at all. Just output the code. Short is better. However, if the user is clearly asking a general question then answer it very briefly and well. Consider when the user request references a previous request. 24 | - role: user 25 | content: get the current time from some website 26 | - role: assistant 27 | content: "```bash\ncurl -s http://worldtimeapi.org/api/ip | jq '.datetime'\n```" 28 | - role: user 29 | content: print hi 30 | - role: assistant 31 | content: "```bash\necho \"hi\"\n```" 32 | 33 | config_format_version: "1" 34 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module q 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/atotto/clipboard v0.1.4 7 | github.com/charmbracelet/bubbles v0.17.1 8 | github.com/charmbracelet/bubbletea v0.25.0 9 | github.com/charmbracelet/glamour v0.6.0 10 | github.com/charmbracelet/lipgloss v0.9.1 11 | github.com/mattn/go-tty v0.0.5 12 | github.com/spf13/cobra v1.7.0 13 | ) 14 | 15 | require github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f // indirect 16 | 17 | require ( 18 | github.com/alecthomas/chroma v0.10.0 // indirect 19 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 20 | github.com/aymerick/douceur v0.2.0 // indirect 21 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect 22 | github.com/dlclark/regexp2 v1.4.0 // indirect 23 | github.com/gorilla/css v1.0.0 // indirect 24 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 25 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 26 | github.com/mattn/go-isatty v0.0.18 // indirect 27 | github.com/mattn/go-localereader v0.0.1 // indirect 28 | github.com/mattn/go-runewidth v0.0.15 // indirect 29 | github.com/microcosm-cc/bluemonday v1.0.21 // indirect 30 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect 31 | github.com/muesli/cancelreader v0.2.2 // indirect 32 | github.com/muesli/reflow v0.3.0 // indirect 33 | github.com/muesli/termenv v0.15.2 // indirect 34 | github.com/olekukonko/tablewriter v0.0.5 // indirect 35 | github.com/rivo/uniseg v0.2.0 // indirect 36 | github.com/spf13/pflag v1.0.5 // indirect 37 | github.com/yuin/goldmark v1.5.2 // indirect 38 | github.com/yuin/goldmark-emoji v1.0.1 // indirect 39 | golang.org/x/net v0.0.0-20221002022538-bcab6841153b // indirect 40 | golang.org/x/sync v0.1.0 // indirect 41 | golang.org/x/sys v0.12.0 // indirect 42 | golang.org/x/term v0.6.0 // indirect 43 | golang.org/x/text v0.3.8 // indirect 44 | gopkg.in/yaml.v2 v2.4.0 45 | ) 46 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek= 2 | github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s= 3 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 4 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 5 | github.com/aymanbagabas/go-osc52 v1.0.3 h1:DTwqENW7X9arYimJrPeGZcV0ln14sGMt3pHZspWD+Mg= 6 | github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= 7 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 8 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 9 | github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 10 | github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 11 | github.com/charmbracelet/bubbles v0.15.0 h1:c5vZ3woHV5W2b8YZI1q7v4ZNQaPetfHuoHzx+56Z6TI= 12 | github.com/charmbracelet/bubbles v0.15.0/go.mod h1:Y7gSFbBzlMpUDR/XM9MhZI374Q+1p1kluf1uLl8iK74= 13 | github.com/charmbracelet/bubbles v0.17.1 h1:0SIyjOnkrsfDo88YvPgAWvZMwXe26TP6drRvmkjyUu4= 14 | github.com/charmbracelet/bubbles v0.17.1/go.mod h1:9HxZWlkCqz2PRwsCbYl7a3KXvGzFaDHpYbSYMJ+nE3o= 15 | github.com/charmbracelet/bubbletea v0.23.1/go.mod h1:JAfGK/3/pPKHTnAS8JIE2u9f61BjWTQY57RbT25aMXU= 16 | github.com/charmbracelet/bubbletea v0.24.0 h1:l8PHrft/GIeikDPCUhQe53AJrDD8xGSn0Agirh8xbe8= 17 | github.com/charmbracelet/bubbletea v0.24.0/go.mod h1:rK3g/2+T8vOSEkNHvtq40umJpeVYDn6bLaqbgzhL/hg= 18 | github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= 19 | github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= 20 | github.com/charmbracelet/glamour v0.6.0 h1:wi8fse3Y7nfcabbbDuwolqTqMQPMnVPeZhDM273bISc= 21 | github.com/charmbracelet/glamour v0.6.0/go.mod h1:taqWV4swIMMbWALc0m7AfE9JkPSU8om2538k9ITBxOc= 22 | github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= 23 | github.com/charmbracelet/lipgloss v0.6.0 h1:1StyZB9vBSOyuZxQUcUwGr17JmojPNm87inij9N3wJY= 24 | github.com/charmbracelet/lipgloss v0.6.0/go.mod h1:tHh2wr34xcHjC2HCXIlGSG1jaDF0S0atAUvBMP6Ppuk= 25 | github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= 26 | github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= 27 | github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= 28 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= 29 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= 30 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 31 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 32 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 33 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 34 | github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E= 35 | github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= 36 | github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= 37 | github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= 38 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 39 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 40 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 41 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 42 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 43 | github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= 44 | github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 45 | github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= 46 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 47 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 48 | github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 49 | github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= 50 | github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 51 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 52 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 53 | github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 54 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 55 | github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 56 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 57 | github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 58 | github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= 59 | github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 60 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= 61 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 62 | github.com/mattn/go-tty v0.0.5 h1:s09uXI7yDbXzzTTfw3zonKFzwGkyYlgU3OMjqA0ddz4= 63 | github.com/mattn/go-tty v0.0.5/go.mod h1:u5GGXBtZU6RQoKV8gY5W6UhMudbR5vXnUe7j3pxse28= 64 | github.com/microcosm-cc/bluemonday v1.0.21 h1:dNH3e4PSyE4vNX+KlRGHT5KrSvjeUkoNPwEORjffHJg= 65 | github.com/microcosm-cc/bluemonday v1.0.21/go.mod h1:ytNkv4RrDrLJ2pqlsSI46O6IVXmZOBBD4SaJyDwwTkM= 66 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= 67 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= 68 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 69 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 70 | github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= 71 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 72 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 73 | github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= 74 | github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4YJK5e2bc= 75 | github.com/muesli/termenv v0.15.1 h1:UzuTb/+hhlBugQz28rpzey4ZuKcZ03MeKsoG7IJZIxs= 76 | github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4ANqrZs2sQ= 77 | github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= 78 | github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= 79 | github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= 80 | github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= 81 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 82 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 83 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 84 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= 85 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 86 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 87 | github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 88 | github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f h1:MvTmaQdww/z0Q4wrYjDSCcZ78NoftLQyHBSLW/Cx79Y= 89 | github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 90 | github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= 91 | github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= 92 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 93 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 94 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 95 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 96 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 97 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 98 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 99 | github.com/yuin/goldmark v1.5.2 h1:ALmeCk/px5FSm1MAcFBAsVKZjDuMVj8Tm7FFIlMJnqU= 100 | github.com/yuin/goldmark v1.5.2/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 101 | github.com/yuin/goldmark-emoji v1.0.1 h1:ctuWEyzGBwiucEqxzwe0SOYDXPAucOrE9NQC18Wa1os= 102 | github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ= 103 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 104 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 105 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 106 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 107 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 108 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 109 | golang.org/x/net v0.0.0-20221002022538-bcab6841153b h1:6e93nYa3hNqAvLr0pD4PN1fFS+gKzp2zAXqrnTCstqU= 110 | golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= 111 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 112 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 113 | golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= 114 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 115 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 116 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 117 | golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 118 | golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 119 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 120 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 121 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 122 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 123 | golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 124 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 125 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 126 | golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 127 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 128 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 129 | golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= 130 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 131 | golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= 132 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 133 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 134 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 135 | golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= 136 | golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= 137 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 138 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 139 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 140 | golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= 141 | golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 142 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 143 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 144 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 145 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 146 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 147 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 148 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 149 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 150 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 151 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 152 | -------------------------------------------------------------------------------- /install.ps1: -------------------------------------------------------------------------------- 1 | param( 2 | [string]$repoowner = "ibigio", 3 | [string]$reponame = "shell-ai", 4 | [string]$toolname = "shell-ai", 5 | [string]$toolsymlink = "q", 6 | [switch]$help 7 | ) 8 | 9 | if ($help) { 10 | Write-Host "shell-ai Installer Help!" 11 | Write-Host " Usage: " 12 | Write-Host " shell-ai -help " 13 | Write-Host " shell-ai -repoowner " 14 | Write-Host " shell-ai -reponame " 15 | Write-Host " shell-ai -toolname " 16 | Write-Host " shell-ai -toolsymlink " 17 | 18 | exit 0 19 | } 20 | 21 | # if user isnt admin then quit 22 | function IsUserAdministrator { 23 | $user = [Security.Principal.WindowsIdentity]::GetCurrent() 24 | $principal = New-Object Security.Principal.WindowsPrincipal($user) 25 | return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) 26 | } 27 | 28 | if (-not (IsUserAdministrator)) { 29 | Write-Host "Please run as administrator" 30 | exit 1 31 | } 32 | 33 | # Detect the platform (architecture and OS) 34 | $ARCH = $null 35 | $OS = "Windows" 36 | 37 | 38 | if ($env:PROCESSOR_ARCHITECTURE -eq "AMD64") { 39 | $ARCH = "x86_64" 40 | } elseif ($env:PROCESSOR_ARCHITECTURE -eq "arm64") { 41 | $ARCH = "arm64" 42 | } else { 43 | $ARCH = "i386" 44 | } 45 | 46 | if ($env:OS -notmatch "Windows") { 47 | Write-Host "You are running the powershell script on a non-windows platform. Please use the install.sh script instead." 48 | } 49 | 50 | # Fetch the latest release tag from GitHub API 51 | $API_URL = "https://api.github.com/repos/$repoowner/$reponame/releases/latest" 52 | $LATEST_TAG = (Invoke-RestMethod -Uri $API_URL).tag_name 53 | 54 | # Set the download URL based on the platform and latest release tag 55 | $DOWNLOAD_URL = "https://github.com/$repoowner/$reponame/releases/download/$LATEST_TAG/${toolname}_${OS}_${ARCH}.zip" 56 | 57 | Write-Host $DOWNLOAD_URL 58 | 59 | # Download the ZIP file 60 | Invoke-WebRequest -Uri $DOWNLOAD_URL -OutFile "${toolname}.zip" 61 | 62 | # Extract the ZIP file 63 | $extractedDir = "${toolname}-temp" 64 | Expand-Archive -Path "${toolname}.zip" -DestinationPath $extractedDir -Force 65 | 66 | # check if the file already exists 67 | $toolPath = "C:\Program Files\shell-ai\${toolsymlink}.exe" 68 | if (Test-Path $toolPath) { 69 | Remove-Item $toolPath 70 | } else { 71 | New-Item -ItemType Directory -Path "C:\Program Files\shell-ai\" 72 | } 73 | 74 | # Add the file to path 75 | $currentPath = [System.Environment]::GetEnvironmentVariable("PATH", "User") 76 | 77 | # Append the desired path to the current PATH value if it's not already present 78 | if (-not ($currentPath -split ";" | Select-String -SimpleMatch "C:\Program Files\shell-ai\")) { 79 | $updatedPath = $currentPath + ";" + "C:\Program Files\shell-ai\" 80 | 81 | # Set the updated PATH value 82 | [System.Environment]::SetEnvironmentVariable("PATH", $updatedPath, "User") # Use "User" instead of "Machine" for user-level PATH 83 | 84 | Write-Host "The path has been added to the PATH variable. You may need to restart applications to see the changes." -ForegroundColor Red 85 | } 86 | 87 | # Make the binary executable 88 | Move-Item "${extractedDir}/${toolname}.exe" $toolPath 89 | Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy Unrestricted 90 | 91 | # Clean up 92 | Remove-Item -Recurse -Force "${extractedDir}" 93 | Remove-Item -Force "${toolname}.zip" 94 | 95 | # Print success message 96 | Write-Host "The $toolname has been installed successfully (version: $LATEST_TAG)." -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Replace these values with your tool's information: 4 | REPO_OWNER="ibigio" 5 | REPO_NAME="shell-ai" 6 | TOOL_NAME="shell-ai" 7 | TOOL_SYMLINK="q" 8 | 9 | # Detect the platform (architecture and OS) 10 | ARCH="$(uname -m)" 11 | OS="$(uname -s | tr '[:upper:]' '[:lower:]')" 12 | 13 | # Fetch the latest release tag from GitHub API 14 | API_URL="https://api.github.com/repos/$REPO_OWNER/$REPO_NAME/releases/latest" 15 | LATEST_TAG=$(curl --silent $API_URL | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') 16 | 17 | # Set the download URL based on the platform and latest release tag 18 | DOWNLOAD_URL="https://github.com/$REPO_OWNER/$REPO_NAME/releases/download/$LATEST_TAG/${TOOL_NAME}_${OS}_${ARCH}.tar.gz" 19 | 20 | echo $DOWNLOAD_URL 21 | 22 | # # Download and extract the binary 23 | curl -L "$DOWNLOAD_URL" -o "${TOOL_NAME}.tar.gz" 24 | mkdir -p "${TOOL_NAME}-temp" 25 | tar xzf "${TOOL_NAME}.tar.gz" -C "${TOOL_NAME}-temp" 26 | 27 | # Make the binary executable 28 | mv "${TOOL_NAME}-temp/${TOOL_NAME}" "/usr/local/bin/${TOOL_SYMLINK}" 29 | chmod +x /usr/local/bin/"${TOOL_SYMLINK}" 30 | 31 | # # Clean up 32 | rm -rf "${TOOL_NAME}-temp" 33 | rm "${TOOL_NAME}.tar.gz" 34 | 35 | # Print success message 36 | echo "The $TOOL_NAME has been installed successfully (version: $LATEST_TAG)." -------------------------------------------------------------------------------- /llm/llm.go: -------------------------------------------------------------------------------- 1 | package llm 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "encoding/json" 7 | "fmt" 8 | "net/http" 9 | . "q/types" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | type LLMClient struct { 15 | config ModelConfig 16 | messages []Message 17 | 18 | StreamCallback func(string, error) 19 | 20 | httpClient *http.Client 21 | } 22 | 23 | func NewLLMClient(config ModelConfig) *LLMClient { 24 | return &LLMClient{ 25 | config: config, 26 | messages: append([]Message(nil), config.Prompt...), 27 | 28 | httpClient: &http.Client{ 29 | Timeout: time.Second * 120, 30 | }, 31 | } 32 | } 33 | 34 | func (c *LLMClient) createRequest(payload Payload) (*http.Request, error) { 35 | payloadBytes, err := json.Marshal(payload) 36 | if err != nil { 37 | return nil, fmt.Errorf("failed to marshal payload: %w", err) 38 | } 39 | req, err := http.NewRequest("POST", c.config.Endpoint, bytes.NewBuffer(payloadBytes)) 40 | if err != nil { 41 | return nil, fmt.Errorf("failed to create request: %w", err) 42 | } 43 | if strings.Contains(c.config.Endpoint, "openai.azure.com") { 44 | req.Header.Set("Api-Key", c.config.Auth) 45 | } else { 46 | req.Header.Set("Authorization", "Bearer "+c.config.Auth) 47 | } 48 | if c.config.OrgID != "" { 49 | req.Header.Set("OpenAI-Organization", c.config.OrgID) 50 | } 51 | req.Header.Set("Content-Type", "application/json") 52 | return req, nil 53 | } 54 | 55 | func (c *LLMClient) Query(query string) (string, error) { 56 | messages := c.messages 57 | messages = append(messages, Message{Role: "user", Content: query}) 58 | 59 | payload := Payload{ 60 | Model: c.config.ModelName, 61 | Messages: messages, 62 | Temperature: 0, 63 | Stream: true, 64 | } 65 | 66 | message, err := c.callStream(payload) 67 | if err != nil { 68 | return "", err 69 | } 70 | c.messages = append(c.messages, message) 71 | return message.Content, nil 72 | } 73 | 74 | func (c *LLMClient) processStream(resp *http.Response) (string, error) { 75 | counter := 0 76 | streamReader := bufio.NewReader(resp.Body) 77 | totalData := "" 78 | for { 79 | line, err := streamReader.ReadString('\n') 80 | if err != nil { 81 | break 82 | } 83 | line = strings.TrimSpace(line) 84 | if line == "data: [DONE]" { 85 | break 86 | } 87 | if strings.HasPrefix(line, "data:") { 88 | payload := strings.TrimPrefix(line, "data:") 89 | 90 | var responseData ResponseData 91 | err = json.Unmarshal([]byte(payload), &responseData) 92 | if err != nil { 93 | fmt.Println("Error parsing data:", err) 94 | continue 95 | } 96 | if len(responseData.Choices) == 0 { 97 | continue 98 | } 99 | content := responseData.Choices[0].Delta.Content 100 | if counter < 2 && strings.Count(content, "\n") > 0 { 101 | continue 102 | } 103 | totalData += content 104 | c.StreamCallback(totalData, nil) 105 | counter++ 106 | } 107 | } 108 | return totalData, nil 109 | } 110 | 111 | func (c *LLMClient) callStream(payload Payload) (Message, error) { 112 | req, err := c.createRequest(payload) 113 | if err != nil { 114 | return Message{}, fmt.Errorf("failed to create the request: %w", err) 115 | } 116 | resp, err := c.httpClient.Do(req) 117 | if err != nil { 118 | return Message{}, fmt.Errorf("failed to make the API request: %w", err) 119 | } 120 | defer resp.Body.Close() 121 | 122 | if resp.StatusCode != 200 { 123 | return Message{}, fmt.Errorf("API request failed: %s", resp.Status) 124 | } 125 | content, err := c.processStream(resp) 126 | return Message{Role: "assistant", Content: content}, err 127 | } 128 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "q/cli" 5 | ) 6 | 7 | func main() { 8 | if err := cli.RootCmd.Execute(); err != nil { 9 | panic(err) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /types/types.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | type ModelConfig struct { 4 | ModelName string `yaml:"name"` 5 | Endpoint string `yaml:"endpoint"` 6 | Auth string `yaml:"auth_env_var"` 7 | OrgID string `yaml:"org_env_var,omitempty"` 8 | Prompt []Message `yaml:"prompt"` 9 | } 10 | 11 | type Message struct { 12 | Role string `yaml:"role" json:"role"` 13 | Content string `yaml:"content" json:"content"` 14 | } 15 | 16 | type Preferences struct { 17 | DefaultModel string `yaml:"default_model"` 18 | } 19 | 20 | type Payload struct { 21 | Model string `json:"model"` 22 | Prompt string `json:"prompt,omitempty"` 23 | MaxTokens int `json:"max_tokens,omitempty"` 24 | Temperature float32 `json:"temperature,omitempty"` 25 | Messages []Message `json:"messages"` 26 | Stream bool `json:"stream,omitempty"` 27 | } 28 | 29 | type ResponseData struct { 30 | ID string `json:"id"` 31 | Object string `json:"object"` 32 | Created int `json:"created"` 33 | Model string `json:"model"` 34 | Usage struct { 35 | PromptTokens int `json:"prompt_tokens"` 36 | CompletionTokens int `json:"completion_tokens"` 37 | TotalTokens int `json:"total_tokens"` 38 | } `json:"usage"` 39 | Choices []struct { 40 | Delta struct { 41 | Content string `json:"content"` 42 | } `json:"delta"` 43 | Index int `json:"index"` 44 | FinishReason string `json:"finish_reason"` 45 | } `json:"choices"` 46 | } 47 | -------------------------------------------------------------------------------- /util/util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "os/exec" 5 | "runtime" 6 | "strings" 7 | 8 | "github.com/mattn/go-tty" 9 | ) 10 | 11 | const ( 12 | TermMaxWidth = 100 13 | TermSafeZonePadding = 10 14 | ) 15 | 16 | func StartsWithCodeBlock(s string) bool { 17 | if len(s) <= 3 { 18 | return strings.Repeat("`", len(s)) == s 19 | } 20 | return strings.HasPrefix(s, "```") 21 | } 22 | 23 | func ExtractFirstCodeBlock(s string) (content string, isOnlyCode bool) { 24 | isOnlyCode = true 25 | if len(s) <= 3 { 26 | return "", false 27 | } 28 | start := strings.Index(s, "```") 29 | if start == -1 { 30 | return "", false 31 | } 32 | if start != 0 { 33 | isOnlyCode = false 34 | } 35 | fromStart := s[start:] 36 | content = strings.TrimPrefix(fromStart, "```") 37 | // Find newline after the first ``` 38 | newlinePos := strings.Index(content, "\n") 39 | if newlinePos != -1 { 40 | // Check if there's a word immediately after the first ``` 41 | if content[0:newlinePos] == strings.TrimSpace(content[0:newlinePos]) { 42 | // If so, remove that part from the content 43 | content = content[newlinePos+1:] 44 | } 45 | } 46 | // Strip final ``` if present 47 | end := strings.Index(content, "```") 48 | if end < len(content)-3 { 49 | isOnlyCode = false 50 | } 51 | if end != -1 { 52 | content = content[:end] 53 | } 54 | if len(content) == 0 { 55 | return "", false 56 | } 57 | // Strip the final newline, if present 58 | if content[len(content)-1] == '\n' { 59 | content = content[:len(content)-1] 60 | } 61 | return 62 | } 63 | 64 | func GetTermSafeMaxWidth() int { 65 | maxWidth := TermMaxWidth 66 | termWidth, err := getTermWidth() 67 | if err != nil || termWidth < maxWidth { 68 | maxWidth = termWidth - TermSafeZonePadding 69 | } 70 | return maxWidth 71 | } 72 | 73 | func getTermWidth() (width int, err error) { 74 | t, err := tty.Open() 75 | if err != nil { 76 | return 0, err 77 | } 78 | defer t.Close() 79 | width, _, err = t.Size() 80 | return width, err 81 | } 82 | 83 | func IsLikelyBillingError(s string) bool { 84 | return strings.Contains(s, "429 Too Many Requests") 85 | } 86 | 87 | func OpenBrowser(url string) error { 88 | var cmd *exec.Cmd 89 | 90 | switch runtime.GOOS { 91 | case "windows": 92 | cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) 93 | case "darwin": 94 | cmd = exec.Command("open", url) 95 | default: // For Linux or anything else 96 | cmd = exec.Command("xdg-open", url) 97 | } 98 | 99 | return cmd.Start() 100 | } 101 | --------------------------------------------------------------------------------