├── .github └── workflows │ └── release.yaml ├── .gitignore ├── LICENSE ├── README.md ├── go.mod ├── go.sum ├── main.go ├── pkg ├── constants.go ├── generate.go ├── openai.go ├── secret.go ├── setup.go ├── state.go └── utils.go └── scripts └── get_latest.sh /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | # .github/workflows/release.yaml 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | releases-matrix: 9 | name: Release Go Binary 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | # build and publish in parallel: linux/386, linux/amd64, linux/arm64, windows/386, windows/amd64, darwin/amd64, darwin/arm64 14 | goos: [linux, windows, darwin] 15 | goarch: ["386", amd64, arm64] 16 | exclude: 17 | - goarch: "386" 18 | goos: darwin 19 | - goarch: arm64 20 | goos: windows 21 | steps: 22 | - uses: actions/checkout@v3 23 | - uses: wangyoucao577/go-release-action@v1.32 24 | with: 25 | github_token: ${{ secrets.GITHUB_TOKEN }} 26 | goos: ${{ matrix.goos }} 27 | goarch: ${{ matrix.goarch }} 28 | goversion: "https://dl.google.com/go/go1.19.1.linux-amd64.tar.gz" 29 | extra_files: LICENSE README.md 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | howto 3 | 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Created by https://www.toptal.com/developers/gitignore/api/macos 21 | # Edit at https://www.toptal.com/developers/gitignore?templates=macos 22 | 23 | ### macOS ### 24 | # General 25 | .DS_Store 26 | .AppleDouble 27 | .LSOverride 28 | 29 | # Icon must end with two \r 30 | Icon 31 | 32 | 33 | # Thumbnails 34 | ._* 35 | 36 | # Files that might appear in the root of a volume 37 | .DocumentRevisions-V100 38 | .fseventsd 39 | .Spotlight-V100 40 | .TemporaryItems 41 | .Trashes 42 | .VolumeIcon.icns 43 | .com.apple.timemachine.donotpresent 44 | 45 | # Directories potentially created on remote AFP share 46 | .AppleDB 47 | .AppleDesktop 48 | Network Trash Folder 49 | Temporary Items 50 | .apdisk 51 | 52 | ### macOS Patch ### 53 | # iCloud generated files 54 | *.icloud 55 | 56 | # End of https://www.toptal.com/developers/gitignore/api/macos 57 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Vlad Lialin 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 | # Howto 2 | Howto is a GPT-3/Codex-powered shell tool that allows you to talk with your shell in natural language. 3 | 4 | 5 | [![Howto demo](https://user-images.githubusercontent.com/2821124/197363019-b038d31e-fde0-45a5-b347-3e87e0c260a6.gif)](https://youtu.be/VwP9eZdTrGY) 6 | 7 | Forgot how to create a conda environment? 8 | ```bash 9 | % howto create conda env 10 | conda create -n python=3.6 11 | ``` 12 | 13 | Forgot how to add a new env to Jupyter? 14 | ```bash 15 | % howto add kernel to jupyter 16 | python -m ipykernel install --user --name= 17 | ``` 18 | 19 | Want to download the biggest Rick Astley's hit? 20 | ```bash 21 | % howto download youtube video for never give you up 22 | youtube-dl -f 18 https://www.youtube.com/watch?v=dQw4w9WgXcQ 23 | ``` 24 | 25 | Howto can also suggest how to be a nicer person 26 | ```bash 27 | % howto be a nicer person 28 | alias please='sudo' 29 | ``` 30 | 31 | It works by sending requests to [OpenAI API](http://openai.com/api/). Lookup [Environment Variables](https://github.com/Guitaricet/howto#environment-variables) section on how to set up the API key. 32 | 33 | # Installation 34 | 35 | ## Two-liner 36 | 37 | ```bash 38 | /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Guitaricet/howto/main/scripts/get_latest.sh)" 39 | sudo mv howto /usr/local/bin/ # howto.exe on windows 40 | ``` 41 | 42 | When first calling `howto` it will ask you to set up the API key. Get your OpenAI API key [here](https://beta.openai.com/docs/quickstart/add-your-api-key). 43 | 44 | ## Download the binary from Github 45 | 46 | | OS | Architecture | Link | 47 | | --- | --- | --- | 48 | | Linux | x86_64 | [howto-linux-x86_64](https://github.com/Guitaricet/howto/releases/download/v2.0.1/howto-v2.0.1-linux-386.tar.gz) | 49 | | MacOS | x86_64 (Intel) | [howto-darwin-x86_64](https://github.com/Guitaricet/howto/releases/download/v2.0.1/howto-v2.0.1-darwin-amd64.tar.gz) | 50 | | MacOS | arm64 (M1) | [howto-darwin-arm64](https://github.com/Guitaricet/howto/releases/download/v2.0.1/howto-v2.0.1-darwin-arm64.tar.gz) | 51 | | Windows | x86_64 | [howto-windows-x86_64](https://github.com/Guitaricet/howto/releases/download/v2.0.1/howto-v2.0.1-windows-amd64.zip) | 52 | 53 | Full list of architectures can be found on the [release page](https://github.com/Guitaricet/howto/releases/latest). 54 | 55 | Then untar it and add it to your `PATH`. For example: 56 | ```bash 57 | tar -xvf howto-v2.0.1-darwin-amd64.tar.gz 58 | mv howto /usr/local/bin/ 59 | ``` 60 | 61 | > moving the binary to `/usr/local/bin` can require sudo rights 62 | 63 | ## Build from source 64 | 65 | If you have Go installed, you can build the binary from source. 66 | 67 | ```bash 68 | go build 69 | ``` 70 | 71 | Then move the binary to your path, e.g., `mv howto /usr/local/bin/` 72 | 73 | # Disclaimer 74 | 75 | Howto suggestions are generated by an AI model and are not guaranteed to be safe to execute or to be executable at all. Please use common sense when using the suggested commands. 76 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/guitaricet/howto 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/zalando/go-keyring v0.2.3 7 | golang.org/x/term v0.10.0 8 | ) 9 | 10 | require ( 11 | github.com/alessio/shellescape v1.4.1 // indirect 12 | github.com/danieljoos/wincred v1.2.0 // indirect 13 | github.com/godbus/dbus/v5 v5.1.0 // indirect 14 | golang.org/x/sys v0.10.0 // indirect 15 | ) 16 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0= 2 | github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= 3 | github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE= 4 | github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec= 5 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 6 | github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= 7 | github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 8 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 9 | github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= 10 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 11 | github.com/zalando/go-keyring v0.2.3 h1:v9CUu9phlABObO4LPWycf+zwMG7nlbb3t/B5wa97yms= 12 | github.com/zalando/go-keyring v0.2.3/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk= 13 | golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA= 14 | golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 15 | golang.org/x/term v0.10.0 h1:3R7pNqamzBraeqj/Tj8qt1aQ2HpmlC+Cx/qL/7hn4/c= 16 | golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= 17 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 18 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | "strings" 8 | "time" 9 | 10 | howto "github.com/guitaricet/howto/pkg" 11 | ) 12 | 13 | const VERSION = "2.0.2" 14 | 15 | func main() { 16 | flag.Usage = func() { 17 | fmt.Println("Usage: howto ") 18 | fmt.Println("To use howto, pass it a prompt to complete. For example: " + howto.GetRandomUsageExample()) 19 | fmt.Println("Options:") 20 | 21 | flag.PrintDefaults() 22 | } 23 | 24 | do_setup := flag.Bool("setup", false, "Set up howto for the first time") 25 | do_config := flag.Bool("config", false, "Show the current configuration") 26 | do_change_prompt := flag.Bool("change-prompt", false, "Change the system message prompt") 27 | flag.Parse() 28 | 29 | if *do_config { 30 | howto.PrintEnvInfo() 31 | return 32 | } 33 | if *do_change_prompt { 34 | howto.ChangeSystemMessage() 35 | return 36 | } 37 | 38 | config, err := howto.GetConfig() 39 | 40 | config_does_not_exist := os.IsNotExist(err) 41 | if *do_setup || config_does_not_exist { 42 | time.Sleep(1 * time.Second) 43 | howto.Setup(VERSION) 44 | return 45 | } 46 | 47 | if err != nil && !config_does_not_exist { 48 | fmt.Println("Error reading config file: " + err.Error()) 49 | response := howto.AskQuestion(howto.QuestionOptions{ 50 | Question: "Do you want to delete your config file and run `howto --setup` again? (y/n) ", 51 | ValidationRegex: "y|n", 52 | Secure: false, 53 | }) 54 | if response == "y" { 55 | os.Remove(howto.GetConfigPath()) 56 | howto.Setup(VERSION) 57 | } 58 | } 59 | 60 | input := strings.Join(os.Args[1:], " ") 61 | 62 | if len(input) == 0 { 63 | fmt.Println("Usage: howto ") 64 | fmt.Println("To use howto, pass it a prompt to complete. For example: " + howto.GetRandomUsageExample()) 65 | return 66 | } 67 | 68 | var command string 69 | command, err = howto.GenerateShellCommand(input, config) 70 | 71 | if err != nil { 72 | fmt.Println("Error generating command: " + err.Error()) 73 | os.Exit(1) 74 | } 75 | 76 | if len(command) == 0 { 77 | fmt.Println("Generated command is empty. Please try to rephrase your prompt.") 78 | } 79 | 80 | fmt.Println(command) 81 | } 82 | -------------------------------------------------------------------------------- /pkg/constants.go: -------------------------------------------------------------------------------- 1 | package howto 2 | 3 | const DEFAULT_SYSTEM_MESSAGE = "You are a CLI tool that converts user requests to shell commands or short scripts. E.g., for `bash command to tar file without compression:`, you should reply `tar -cf file.tar file`. Avoid natural language. If you have to use it, be extremely concise. Less than 5 words." 4 | const SERVICE_NAME = "howto" 5 | 6 | const ( 7 | OwnerReadWrite = 0600 8 | AllRead = 0644 9 | ) 10 | 11 | var examples = []string{ 12 | "howto tar without compression", 13 | "howto oneline install conda", 14 | "howto du -hs hidden files", 15 | "howto donwload from gcp bucket", 16 | "howto pull from upstream", 17 | "howto push if the only update is the tag", 18 | "howto get ubuntu version", 19 | "howto undo make", 20 | "howto connect to mongo running inside docker", 21 | "howto check if something is running on my port 27017", 22 | "howto get user id for user vlialin", 23 | "howto create user vlialin with user IDs 5030 and GID 4030 and assign them a home directory in /mnt/shared_home", 24 | "howto tree withiout node_modules", 25 | "howto 'grep my zsh history and print all examples containing howto (with trailing space)'", 26 | } 27 | -------------------------------------------------------------------------------- /pkg/generate.go: -------------------------------------------------------------------------------- 1 | // contains rule-based logic for generating answer 2 | // including caching and conversation tracking 3 | package howto 4 | 5 | import ( 6 | "fmt" 7 | "time" 8 | ) 9 | 10 | func GenerateShellCommand(command string, config HowtoConfig) (string, error) { 11 | state, err := GetHowtoState() 12 | if err != nil { 13 | return "", fmt.Errorf("error getting state: %w", err) 14 | } 15 | 16 | messages := []OpenAiMessage{ 17 | {Role: "system", Content: config.SystemMessage}, 18 | } 19 | 20 | time_delta := time.Since(state.LastConversationUpdate) 21 | if time_delta.Minutes() <= 1 { 22 | messages = append(messages, state.Conversation...) 23 | // we append command, because prompt does not make sense in a conversation-style request 24 | messages = append(messages, OpenAiMessage{Role: "user", Content: command}) 25 | } else { 26 | prompt := fmt.Sprintf("%s command to %s", config.Shell, command) 27 | messages = append(messages, OpenAiMessage{Role: "user", Content: prompt}) 28 | } 29 | 30 | response, err := GenerateShellCommandOpenAI(messages, config) 31 | 32 | messages = append(messages, OpenAiMessage{Role: "assistant", Content: response}) 33 | 34 | state.Conversation = messages[1:] 35 | state.LastConversationUpdate = time.Now() 36 | state.Save(GetStatePath()) 37 | 38 | return response, err 39 | } 40 | -------------------------------------------------------------------------------- /pkg/openai.go: -------------------------------------------------------------------------------- 1 | package howto 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | type Choice struct { 12 | Message struct { 13 | Role string `json:"role"` 14 | Content string `json:"content"` 15 | } `json:"message"` 16 | FinishReason string `json:"finish_reason"` 17 | } 18 | 19 | type OpenAiResponse struct { 20 | Id string `json:"id"` 21 | Object string `json:"object"` 22 | Choices []Choice `json:"choices"` 23 | Usage struct { 24 | PromptTokens int `json:"prompt_tokens"` 25 | CompletionTokens int `json:"completion_tokens"` 26 | TotalTokens int `json:"total_tokens"` 27 | } `json:"usage"` 28 | } 29 | 30 | type OpenAiMessage struct { 31 | Role string `json:"role"` 32 | Content string `json:"content"` 33 | } 34 | 35 | func getBodyOpenAI(messages []OpenAiMessage, config HowtoConfig) (string, error) { 36 | body := map[string]interface{}{ 37 | "model": config.Model, 38 | "messages": messages, 39 | "temperature": 0, 40 | "max_tokens": config.MaxTokens, 41 | "top_p": 1, 42 | "frequency_penalty": 0, 43 | "presence_penalty": 0, 44 | } 45 | 46 | jsonBody, err := json.Marshal(body) 47 | if err != nil { 48 | return "", err 49 | } 50 | 51 | return string(jsonBody), nil 52 | } 53 | 54 | // generateShellCommandAI makes the command via requesting generate from OpenAI 55 | func GenerateShellCommandOpenAI(messages []OpenAiMessage, config HowtoConfig) (string, error) { 56 | body, err := getBodyOpenAI(messages, config) 57 | 58 | if err != nil { 59 | fmt.Println("Error creating request body: " + err.Error()) 60 | return "", err 61 | } 62 | 63 | req, err := http.NewRequest("POST", "https://api.openai.com/v1/chat/completions", strings.NewReader(body)) 64 | if err != nil { 65 | fmt.Println("Error creating request: ", err) 66 | return "", err 67 | } 68 | 69 | api_key, err := GetOpenAiApiKey() 70 | if err != nil { 71 | fmt.Println("Error getting OpenAI API key: ", err) 72 | return "", err 73 | } 74 | 75 | req.Header.Set("Content-Type", "application/json") 76 | req.Header.Set("Authorization", "Bearer "+api_key) 77 | 78 | client := &http.Client{Timeout: time.Second * 10} 79 | resp, err := client.Do(req) 80 | if err != nil { 81 | fmt.Println("Error making request: ", err) 82 | return "", err 83 | } 84 | 85 | defer resp.Body.Close() 86 | 87 | var openaiResponse OpenAiResponse 88 | err = json.NewDecoder(resp.Body).Decode(&openaiResponse) 89 | if err != nil { 90 | fmt.Println("Error decoding response: ", err) 91 | return "", err 92 | } 93 | 94 | choices := openaiResponse.Choices 95 | if len(choices) == 0 { 96 | fmt.Println("OpenAI API didn't respont correctly. Did you correctly set OPENAI_API_KEY?") 97 | fmt.Println("Response body: ", string(body)) 98 | fmt.Println("Response: ", resp) 99 | return "", err 100 | } 101 | 102 | command := openaiResponse.Choices[0].Message.Content 103 | command = strings.Trim(command, "\n") 104 | 105 | return command, nil 106 | } 107 | -------------------------------------------------------------------------------- /pkg/secret.go: -------------------------------------------------------------------------------- 1 | package howto 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "runtime" 8 | 9 | "github.com/zalando/go-keyring" 10 | ) 11 | 12 | func SetOpenAiApiKey(apiKey string) error { 13 | err := keyring.Set(SERVICE_NAME, "openai_api_key", apiKey) 14 | if err != nil { 15 | log.Fatal(err) 16 | return err 17 | } 18 | 19 | return nil 20 | } 21 | 22 | func GetOpenAiApiKey() (string, error) { 23 | secret, err := keyring.Get(SERVICE_NAME, "openai_api_key") 24 | 25 | if runtime.GOOS == "darwin" { 26 | // check if it exists at all 27 | if err == keyring.ErrNotFound { 28 | fmt.Println("OpenAI API key not found. Please run `howto --setup` to set it in keyring.") 29 | return "", err 30 | } 31 | } else { 32 | // many issues with keyring on Linux, use env var 33 | secret = os.Getenv("OPENAI_API_KEY") 34 | err = nil 35 | } 36 | 37 | // check if it's valid 38 | if secret[:3] != "sk-" { 39 | fmt.Println("OpenAI API key is invalid. Please run `howto config` to set it.") 40 | return secret, err 41 | } 42 | 43 | return secret, nil 44 | } 45 | -------------------------------------------------------------------------------- /pkg/setup.go: -------------------------------------------------------------------------------- 1 | package howto 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "runtime" 9 | ) 10 | 11 | func Setup(version string) error { 12 | configPath := GetConfigPath() 13 | // check if config file exists 14 | _, err := os.Stat(configPath) 15 | config_exists := !os.IsNotExist(err) 16 | if config_exists { 17 | fmt.Println("Config file already exists at " + configPath) 18 | PrintConfig() 19 | response := AskQuestion(QuestionOptions{ 20 | Question: "Do you want to overwrite it? (y/n) ", 21 | ValidationRegex: "y|n", 22 | Secure: false, 23 | }) 24 | if response == "n" { 25 | fmt.Println("Howto is all set up! Try `howto tar without compression`") 26 | return nil 27 | } 28 | } 29 | 30 | fmt.Print("Setting up howto") 31 | if !config_exists { 32 | fmt.Print(" for the first time") 33 | } 34 | fmt.Println("...") 35 | 36 | configDir := filepath.Dir(configPath) 37 | if err := os.MkdirAll(configDir, os.ModePerm); err != nil { 38 | fmt.Println("Error creating config directory: " + err.Error()) 39 | fmt.Println("This should never happen. Please report this bug at https://github.com/guitaricet/howto/issues") 40 | fmt.Println("Please include the following information:") 41 | fmt.Println("OS: " + runtime.GOOS) 42 | fmt.Println("Config path: " + configPath) 43 | fmt.Println("Config dir: " + configDir) 44 | return err 45 | } 46 | 47 | openai_api_key := os.Getenv("OPENAI_API_KEY") 48 | if openai_api_key != "" { 49 | fmt.Println("Detected OPENAI_API_KEY environment variable.") 50 | if runtime.GOOS == "darwin" { 51 | // if MacOS, use keychain 52 | response := AskQuestion(QuestionOptions{ 53 | Question: "Do you want to use your OPENAI_API_KEY with howto? (y/n) ", 54 | ValidationRegex: "y|n", 55 | Secure: false, 56 | }) 57 | if response == "y" { 58 | fmt.Println("Setting OPNEAI_API_KEY in keychain") 59 | } else { 60 | openai_api_key = AskQuestion(QuestionOptions{ 61 | Question: "Please enter your OpenAI API key: ", 62 | ValidationRegex: "sk-[a-zA-Z0-9]{32}", 63 | Secure: true, 64 | }) 65 | } 66 | } else { 67 | fmt.Println("OPENAI_API_KEY will be used with howto") 68 | } 69 | } 70 | 71 | if openai_api_key == "" { 72 | fmt.Println("Please set your OpenAI API key to OPENAI_API_KEY environment variable. You can get it from https://beta.openai.com/account/api-keys") 73 | os.Exit(1) 74 | } 75 | 76 | shell := AskQuestion(QuestionOptions{ 77 | Question: "What shell do you use. Just provide the name, e.g. fish (default: bash)? ", 78 | ValidationRegex: ".+", 79 | Secure: false, 80 | }) 81 | if shell == "" { 82 | shell = "bash" 83 | } 84 | 85 | model := AskQuestion(QuestionOptions{ 86 | Question: "What model do you want to use? (default: gpt-3.5-turbo) ", 87 | ValidationRegex: "", 88 | Secure: false, 89 | }) 90 | if model == "" { 91 | model = "gpt-3.5-turbo" 92 | } 93 | 94 | if runtime.GOOS == "darwin" { 95 | SetOpenAiApiKey(openai_api_key) 96 | } 97 | config := HowtoConfig{ 98 | Version: version, 99 | Model: model, 100 | Shell: shell, 101 | MaxTokens: 512, 102 | SystemMessage: DEFAULT_SYSTEM_MESSAGE, 103 | } 104 | 105 | file, err := os.Create(configPath) 106 | if err != nil { 107 | fmt.Println("Error creating config file: " + err.Error()) 108 | return err 109 | } 110 | defer file.Close() 111 | 112 | encoder := json.NewEncoder(file) 113 | encoder.SetIndent("", " ") 114 | err = encoder.Encode(config) 115 | if err != nil { 116 | fmt.Println("Error writing config file: " + err.Error()) 117 | return err 118 | } 119 | 120 | fmt.Printf("\nSetup complete. Try `howto tar without compression`\n\n") 121 | return nil 122 | } 123 | 124 | func ChangeSystemMessage() error { 125 | new_message := AskQuestion(QuestionOptions{ 126 | Question: "What do you want the system message to be? ", 127 | ValidationRegex: ".+", 128 | Secure: false, 129 | }) 130 | 131 | config, err := GetConfig() 132 | if err != nil { 133 | fmt.Println("Error reading config file: " + err.Error()) 134 | return err 135 | } 136 | config.SystemMessage = new_message 137 | 138 | configPath := GetConfigPath() 139 | file, err := os.Create(configPath) 140 | if err != nil { 141 | fmt.Println("Error creating config file: " + err.Error()) 142 | return err 143 | } 144 | defer file.Close() 145 | 146 | encoder := json.NewEncoder(file) 147 | encoder.SetIndent("", " ") 148 | err = encoder.Encode(config) 149 | if err != nil { 150 | fmt.Println("Error writing config file: " + err.Error()) 151 | return err 152 | } 153 | 154 | fmt.Printf("\nSystem message changed to `%s`\n\n", new_message) 155 | return nil 156 | } 157 | -------------------------------------------------------------------------------- /pkg/state.go: -------------------------------------------------------------------------------- 1 | package howto 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "os" 8 | "path/filepath" 9 | "runtime" 10 | "time" 11 | ) 12 | 13 | // State is used to keep track of the most recent messages 14 | type HowToState struct { 15 | Version string `json:"version"` 16 | Conversation []OpenAiMessage `json:"conversation"` 17 | LastConversationUpdate time.Time `json:"lastConversationUpdate"` 18 | } 19 | 20 | func GetStatePath() string { 21 | return filepath.Join(GetHowtoDir(), "state.json") 22 | } 23 | 24 | func InitializeState() error { 25 | statePath := GetStatePath() 26 | stateDir := filepath.Dir(statePath) 27 | if err := os.MkdirAll(stateDir, os.ModePerm); err != nil { 28 | fmt.Println("Error creating state directory: " + err.Error()) 29 | fmt.Println("This should never happen. Please report this bug at https://github.com/guitaricet/howto/issues") 30 | fmt.Println("Please include the following information:") 31 | fmt.Println("OS: " + runtime.GOOS) 32 | fmt.Println("State path: " + statePath) 33 | fmt.Println("State dir: " + stateDir) 34 | return fmt.Errorf("error creating state directory: %w", err) 35 | } 36 | config, err := GetConfig() 37 | if err != nil { 38 | return fmt.Errorf("error getting config: %w", err) 39 | } 40 | 41 | state := HowToState{ 42 | Version: config.Version, 43 | Conversation: []OpenAiMessage{}, 44 | LastConversationUpdate: time.Now(), 45 | } 46 | 47 | err = state.Save(GetStatePath()) 48 | if err != nil { 49 | log.Fatalf("Error saving state: %s", err) 50 | return fmt.Errorf("error saving state: %w", err) 51 | } 52 | return err 53 | } 54 | 55 | func GetHowtoState() (HowToState, error) { 56 | var state HowToState 57 | statePath := GetStatePath() 58 | 59 | file, err := os.Open(statePath) 60 | if os.IsNotExist(err) { 61 | InitializeState() 62 | file, err = os.Open(statePath) 63 | } 64 | if err != nil { 65 | return state, fmt.Errorf("error opening state file at path %s: %w", statePath, err) 66 | } 67 | 68 | decoder := json.NewDecoder(file) 69 | err = decoder.Decode(&state) 70 | if err != nil { 71 | return state, fmt.Errorf("error decoding state file at path %s: %w", statePath, err) 72 | } 73 | 74 | return state, nil 75 | } 76 | 77 | func (h HowToState) Save(savePath string) error { 78 | file, err := os.Create(savePath) 79 | if err != nil { 80 | return fmt.Errorf("could not create state file: %w", err) 81 | } 82 | defer file.Close() 83 | 84 | encoder := json.NewEncoder(file) 85 | encoder.SetIndent("", " ") 86 | 87 | err = encoder.Encode(h) 88 | if err != nil { 89 | return fmt.Errorf("could not encode state: %w", err) 90 | } 91 | return nil 92 | } 93 | -------------------------------------------------------------------------------- /pkg/utils.go: -------------------------------------------------------------------------------- 1 | package howto 2 | 3 | import ( 4 | "bufio" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "math/rand" 9 | "os" 10 | "path/filepath" 11 | "regexp" 12 | "runtime" 13 | "strings" 14 | 15 | "golang.org/x/term" 16 | ) 17 | 18 | type HowtoConfig struct { 19 | Version string `json:"version"` 20 | Model string `json:"model"` 21 | Shell string `json:"shell"` 22 | MaxTokens int `json:"max_tokens"` 23 | SystemMessage string `json:"system_message"` 24 | } 25 | 26 | type QuestionOptions struct { 27 | Question string 28 | ValidationRegex string 29 | Secure bool 30 | } 31 | 32 | func GetRandomUsageExample() string { 33 | example := examples[rand.Intn(len(examples))] 34 | return example 35 | } 36 | 37 | func GetHowtoDir() string { 38 | if runtime.GOOS == "windows" { 39 | return filepath.Join(os.Getenv("APPDATA"), "howto") 40 | } else { 41 | return filepath.Join(os.Getenv("HOME"), ".howto") 42 | } 43 | } 44 | 45 | func GetConfigPath() string { 46 | return filepath.Join(GetHowtoDir(), "config.json") 47 | } 48 | 49 | func GetConfig() (HowtoConfig, error) { 50 | var config HowtoConfig 51 | 52 | configPath := GetConfigPath() 53 | 54 | file, err := os.Open(configPath) 55 | if err != nil { 56 | return config, err 57 | } 58 | 59 | decoder := json.NewDecoder(file) 60 | err = decoder.Decode(&config) 61 | if err != nil { 62 | return config, err 63 | } 64 | 65 | return config, nil 66 | } 67 | 68 | func PrintConfig() { 69 | config, err := GetConfig() 70 | if err != nil { 71 | fmt.Println("Can't print config: " + err.Error()) 72 | return 73 | } 74 | fmt.Println("Config:") 75 | 76 | jsonData, err := json.MarshalIndent(config, "", " ") 77 | if err != nil { 78 | log.Fatalf("Config JSON marshaling failed: %s", err) 79 | fmt.Printf("%+v\n", config) 80 | return 81 | } 82 | fmt.Println(string(jsonData)) 83 | } 84 | 85 | func PrintEnvInfo() { 86 | fmt.Println("OS: " + runtime.GOOS) 87 | fmt.Println("Config path: " + GetConfigPath()) 88 | PrintConfig() 89 | } 90 | 91 | func AskQuestion(opts QuestionOptions) string { 92 | for { 93 | fmt.Print(opts.Question) 94 | 95 | var input string 96 | if opts.Secure { 97 | bytePassword, err := term.ReadPassword(int(os.Stdin.Fd())) 98 | if err != nil { 99 | fmt.Println("Reading error: " + err.Error()) 100 | fmt.Println("Please try again") 101 | continue 102 | } 103 | input = string(bytePassword) 104 | fmt.Println() 105 | } else { 106 | reader := bufio.NewReader(os.Stdin) 107 | input, _ = reader.ReadString('\n') 108 | input = strings.TrimSpace(input) 109 | } 110 | 111 | if !isValidResponse(input, opts.ValidationRegex) { 112 | fmt.Println("\nInvalid choice, please try again. The answer should match: " + opts.ValidationRegex) 113 | continue 114 | } 115 | 116 | return input 117 | } 118 | } 119 | 120 | func isValidResponse(input string, regex string) bool { 121 | if regex == "" { 122 | return true 123 | } 124 | 125 | matched, err := regexp.MatchString(regex, input) 126 | if err != nil { 127 | fmt.Println("Error matching regex: " + err.Error()) 128 | return false 129 | } 130 | return matched 131 | } 132 | -------------------------------------------------------------------------------- /scripts/get_latest.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Determine the platform 4 | if [[ "$(uname)" == "Darwin" ]]; then 5 | PLATFORM="darwin" 6 | elif [[ "$(uname)" == "Linux" ]]; then 7 | PLATFORM="linux" 8 | elif [[ "$(uname)" =~ "MINGW" ]]; then 9 | PLATFORM="windows" 10 | else 11 | echo "Unsupported platform: $(uname)" 12 | exit 1 13 | fi 14 | 15 | # Determine the architecture 16 | if [[ "$(uname -m)" == "x86_64" ]]; then 17 | ARCH="amd64" 18 | elif [[ "$(uname -m)" == "arm64" ]]; then 19 | ARCH="arm64" 20 | else 21 | echo "Unsupported architecture: $(uname -m)" 22 | exit 1 23 | fi 24 | 25 | # Query GitHub API for the latest release 26 | LATEST_RELEASE=$(curl --silent "https://api.github.com/repos/Guitaricet/howto/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') 27 | 28 | # Download and install the correct binary 29 | if [[ $PLATFORM == "windows" ]]; then 30 | URL="https://github.com/Guitaricet/howto/releases/download/$LATEST_RELEASE/howto-$LATEST_RELEASE-$PLATFORM-$ARCH.zip" 31 | curl -L $URL -o howto.zip 32 | unzip howto.zip 33 | else 34 | URL="https://github.com/Guitaricet/howto/releases/download/$LATEST_RELEASE/howto-$LATEST_RELEASE-$PLATFORM-$ARCH.tar.gz" 35 | curl -L $URL | tar xz 36 | fi 37 | 38 | # Print success message 39 | echo "Downloaded howto $LATEST_RELEASE to $(pwd)" 40 | echo "Disclaimer: Howto suggestions are generated by an AI model and are not guaranteed to be safe to execute or to be executable at all. Please use common sense when using the suggested commands." 41 | --------------------------------------------------------------------------------