├── .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 |
2 |
3 | # ShellAI
4 |
5 | A delightfully minimal, yet remarkably powerful AI Shell Assistant.
6 |
7 | 
8 |
9 | > "Ten minutes of Googling is now ten seconds in the terminal."
10 | >
11 | > ~ Joe C.
12 |
13 |
14 |
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 |
--------------------------------------------------------------------------------