├── .github ├── images │ ├── demo.gif │ ├── example-docker.gif │ ├── example-ffmpeg.gif │ ├── example-ffmpeg.tape │ ├── example-docker.tape │ └── demo.tape └── scripts │ └── build.mjs ├── go.mod ├── ioctl_windows.go ├── utils.go ├── security.go ├── go.sum ├── ioctl_unix.go ├── prompt.go ├── main.go ├── LICENSE ├── openai.go └── README.md /.github/images/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antonmedv/howto/HEAD/.github/images/demo.gif -------------------------------------------------------------------------------- /.github/images/example-docker.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antonmedv/howto/HEAD/.github/images/example-docker.gif -------------------------------------------------------------------------------- /.github/images/example-ffmpeg.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antonmedv/howto/HEAD/.github/images/example-ffmpeg.gif -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/antonmedv/howto 2 | 3 | go 1.23 4 | 5 | require golang.org/x/term v0.29.0 6 | 7 | require golang.org/x/sys v0.30.0 // indirect 8 | -------------------------------------------------------------------------------- /ioctl_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package main 4 | 5 | import ( 6 | "fmt" 7 | ) 8 | 9 | func insertInput(cmd string) { 10 | fmt.Println(cmd) 11 | } 12 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "runtime" 5 | ) 6 | 7 | func userOs() string { 8 | goos := runtime.GOOS 9 | switch goos { 10 | case "darwin": 11 | return "mac" 12 | } 13 | return goos 14 | } 15 | -------------------------------------------------------------------------------- /security.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | func sanitizeCommand(cmd string) string { 8 | cmd = strings.TrimSpace(cmd) 9 | cmd = strings.Trim(cmd, "`") 10 | return strings.ReplaceAll(cmd, "\n", " ") 11 | } 12 | -------------------------------------------------------------------------------- /.github/images/example-ffmpeg.tape: -------------------------------------------------------------------------------- 1 | Output example-ffmpeg.gif 2 | 3 | Set FontSize 30 4 | Set Width 1200 5 | Set Height 300 6 | Set TypingSpeed 100ms 7 | 8 | Type "ffmpeg -i my-movie.mov convert to output.mp4" 9 | Left@0 44 10 | Type@0 "howto " 11 | Enter 12 | 13 | Sleep 3s 14 | Left 3 15 | Sleep 2s 16 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= 2 | golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 3 | golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= 4 | golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= 5 | -------------------------------------------------------------------------------- /.github/images/example-docker.tape: -------------------------------------------------------------------------------- 1 | Output example-docker.gif 2 | 3 | Set FontSize 30 4 | Set Width 1200 5 | Set Height 300 6 | Set TypingSpeed 100ms 7 | 8 | Type "docker ps hostnames for all containers" 9 | Left@0 38 10 | Type@0 "howto " 11 | Enter 12 | 13 | Sleep 3s 14 | Left 2 15 | Sleep 1s 16 | Left 2 17 | Sleep 1s 18 | 19 | -------------------------------------------------------------------------------- /.github/images/demo.tape: -------------------------------------------------------------------------------- 1 | Output demo.gif 2 | 3 | Set FontSize 32 4 | Set Width 1200 5 | Set Height 600 6 | Set TypingSpeed 200ms 7 | 8 | Type "lsof -tcp 8089" 9 | Left@0 14 10 | Type@0 "howto " 11 | Enter 12 | Sleep 4s 13 | 14 | Enter 15 | Sleep 4s 16 | 17 | Type@0 "howto lsof -tcp 8089" 18 | Type " only PIDs" 19 | Sleep 1s 20 | 21 | Enter 22 | Sleep 4s 23 | 24 | Enter 25 | Sleep 2s 26 | -------------------------------------------------------------------------------- /ioctl_unix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package main 4 | 5 | import ( 6 | "os" 7 | "syscall" 8 | "unsafe" 9 | 10 | "golang.org/x/term" 11 | ) 12 | 13 | func insertInput(cmd string) { 14 | oldState, err := term.MakeRaw(int(os.Stdin.Fd())) 15 | if err != nil { 16 | panic(err) 17 | } 18 | defer term.Restore(int(os.Stdin.Fd()), oldState) 19 | 20 | for _, c := range cmd { 21 | char := byte(c) 22 | syscall.Syscall(syscall.SYS_IOCTL, uintptr(0), syscall.TIOCSTI, uintptr(unsafe.Pointer(&char))) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /prompt.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | func prompt(cmd string) string { 8 | return fmt.Sprintf(` 9 | You are a command line assistant that can help users with their tasks. 10 | User want assistance with the following command: 11 | 12 | %s 13 | 14 | Assistant should respond with a command that can be used to achieve the desired result. 15 | Command shoud be suitable for %s OS. 16 | Output only the command, do not include any additional text. 17 | Do not include any quotes or backticks in the output. 18 | `, cmd, userOs()) 19 | } 20 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | ) 8 | 9 | func main() { 10 | cmd := strings.Join(os.Args[1:], " ") 11 | if cmd == "" { 12 | fmt.Println("usage: howto ") 13 | os.Exit(1) 14 | } 15 | 16 | openAIKey, ok := os.LookupEnv("OPENAI_API_KEY") 17 | if !ok { 18 | panic("OPENAI_API_KEY not set") 19 | } 20 | 21 | openAiModel := "gpt-4o" 22 | model, ok := os.LookupEnv("OPENAI_MODEL") 23 | if ok { 24 | openAiModel = model 25 | } 26 | 27 | response, err := queryOpenAI(openAIKey, openAiModel, prompt(cmd), 1000) 28 | if err != nil { 29 | panic(err) 30 | } 31 | 32 | insertInput(sanitizeCommand(response)) 33 | } 34 | -------------------------------------------------------------------------------- /.github/scripts/build.mjs: -------------------------------------------------------------------------------- 1 | $.verbose = true 2 | 3 | const bin = 'howto' 4 | const repo = 'antonmedv/howto' 5 | const goos = [ 6 | 'linux', 7 | 'darwin', 8 | 'windows', 9 | ] 10 | const goarch = [ 11 | 'amd64', 12 | 'arm64', 13 | ] 14 | 15 | const name = (GOOS, GOARCH) => `${bin}_${GOOS}_${GOARCH}` + (GOOS === 'windows' ? '.exe' : '') 16 | 17 | const resp = await fetch(`https://api.github.com/repos/${repo}/releases/latest`) 18 | const {tag_name: latest} = await resp.json() 19 | 20 | await $`go mod download` 21 | 22 | await Promise.all( 23 | goos.flatMap(GOOS => 24 | goarch.map(GOARCH => 25 | $`GOOS=${GOOS} GOARCH=${GOARCH} go build -o ${name(GOOS, GOARCH)}`))) 26 | 27 | await Promise.all( 28 | goos.flatMap(GOOS => 29 | goarch.map(GOARCH => 30 | $`gh release upload ${latest} ${name(GOOS, GOARCH)}`))) 31 | 32 | await Promise.all( 33 | goos.flatMap(GOOS => 34 | goarch.map(GOARCH => 35 | $`rm ${name(GOOS, GOARCH)}`))) 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Anton Medvedev 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 | -------------------------------------------------------------------------------- /openai.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | ) 10 | 11 | const openAIEndpoint = "https://api.openai.com/v1/chat/completions" 12 | 13 | type OpenAIRequest struct { 14 | Model string `json:"model"` 15 | Messages []Message `json:"messages"` 16 | MaxTokens int `json:"max_tokens"` 17 | } 18 | 19 | type Message struct { 20 | Role string `json:"role"` 21 | Content string `json:"content"` 22 | } 23 | 24 | type OpenAIResponse struct { 25 | Choices []struct { 26 | Message Message `json:"message"` 27 | } `json:"choices"` 28 | } 29 | 30 | func queryOpenAI(apiKey, model, prompt string, maxTokens int) (string, error) { 31 | requestBody := OpenAIRequest{ 32 | Model: model, 33 | Messages: []Message{{Role: "user", Content: prompt}}, 34 | MaxTokens: maxTokens, 35 | } 36 | 37 | jsonData, err := json.Marshal(requestBody) 38 | if err != nil { 39 | return "", err 40 | } 41 | 42 | req, err := http.NewRequest("POST", openAIEndpoint, bytes.NewBuffer(jsonData)) 43 | if err != nil { 44 | return "", err 45 | } 46 | req.Header.Set("Content-Type", "application/json") 47 | req.Header.Set("Authorization", "Bearer "+apiKey) 48 | 49 | client := &http.Client{} 50 | resp, err := client.Do(req) 51 | if err != nil { 52 | return "", err 53 | } 54 | defer resp.Body.Close() 55 | 56 | body, err := io.ReadAll(resp.Body) 57 | if err != nil { 58 | return "", err 59 | } 60 | 61 | var openAIResp OpenAIResponse 62 | if err := json.Unmarshal(body, &openAIResp); err != nil { 63 | return "", err 64 | } 65 | 66 | if len(openAIResp.Choices) > 0 { 67 | return openAIResp.Choices[0].Message.Content, nil 68 | } 69 | 70 | return "", fmt.Errorf("no response from OpenAI") 71 | } 72 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # howto 2 | 3 |

4 |
5 | walk demo 6 |
7 |

8 | 9 | **Howto** is a terminal helper which queries OpenAI API and inserts the result into the current terminal input. 10 | 11 | Simply press ctrl+g to call **howto**. **Howto** replaces your command with a correct command from LLM. 12 | 13 | ## Install 14 | 15 | ``` 16 | go install github.com/antonmedv/howto@latest 17 | ``` 18 | 19 | Or download [prebuild binaries](https://github.com/antonmedv/howto/releases). 20 | 21 | ### Setup 22 | 23 | Add a key binding to **.zshrc** or a similar config: 24 | 25 | 26 | 27 | 28 | 29 | 30 | 37 | 38 |
Zsh
31 | 32 | ```bash 33 | bindkey -s "\C-g" "\C-ahowto \C-j" 34 | ``` 35 | 36 |
39 | 40 | ## Usage 41 | 42 | Write a command in terminal and press ctrl+g to send current command to OpenAI API. 43 | LLM response will be inserted into the current input. You can **modify** the returned command, 44 | before executing it. 45 | 46 | Recall previous command from history and to adjust the prompt. 47 | 48 | ## Examples 49 | 50 | Use **howto** to list all container's hostnames: 51 | 52 | howto example 53 | 54 | Use **howto** to convert a movie to mp4: 55 | 56 | howto example 57 | 58 | ## Related 59 | 60 | - [walk](https://github.com/antonmedv/walk) – terminal file manager 61 | - [fx](https://github.com/antonmedv/fx) – terminal JSON viewer 62 | - [countdown](https://github.com/antonmedv/countdown) – terminal countdown timer 63 | 64 | 65 | ## License 66 | 67 | [MIT](LICENSE) 68 | --------------------------------------------------------------------------------