├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── codeql-analysis.yml │ └── linter.yml ├── .gitignore ├── .goreleaser.yml ├── LICENSE ├── README.md ├── go.mod ├── go.sum ├── shell ├── __fish_command_not_found_handler.fish └── tii_on_command_not_found.sh └── tii.go /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help improve Tii 4 | title: 'Bug: ' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | 12 | **Description of the bug** 13 | 14 | **Steps to Reproduce** 15 | 16 | **Expected behavior** 17 | 18 | **OS and tii version** 19 | 20 | **Additional info** 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for Tii 4 | title: 'Feature: ' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | 12 | **Is your feature request related to a problem? If so, what is the problem?** 13 | 14 | **Describe the solution/feature you'd like** 15 | 16 | **Describe alternatives you've considered** 17 | 18 | **Additional info** 19 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | name: "CodeQL" 7 | 8 | on: 9 | push: 10 | pull_request: 11 | # The branches below must be a subset of the branches above 12 | branches: [master] 13 | 14 | jobs: 15 | analyze: 16 | name: Analyze 17 | runs-on: ubuntu-latest 18 | 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | # Override automatic language detection by changing the below list 23 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 24 | language: ['go'] 25 | # Learn more... 26 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 27 | 28 | steps: 29 | - name: Checkout repository 30 | uses: actions/checkout@v2 31 | with: 32 | # We must fetch at least the immediate parents so that if this is 33 | # a pull request then we can checkout the head. 34 | fetch-depth: 2 35 | 36 | # If this run was triggered by a pull request event, then checkout 37 | # the head of the pull request instead of the merge commit. 38 | 39 | # codeql no longer needs this and instead recommends not to use it 40 | # - run: git checkout HEAD^2 41 | # if: ${{ github.event_name == 'pull_request' }} 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v1 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | - name: Autobuild 56 | uses: github/codeql-action/autobuild@v1 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 https://git.io/JvXDl 60 | 61 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 62 | # and modify them (or add more) to build your code if your project 63 | # uses a compiled language 64 | 65 | #- run: | 66 | # make bootstrap 67 | # make release 68 | 69 | - name: Perform CodeQL Analysis 70 | uses: github/codeql-action/analyze@v1 71 | -------------------------------------------------------------------------------- /.github/workflows/linter.yml: -------------------------------------------------------------------------------- 1 | --- 2 | ########################### 3 | ########################### 4 | ## Linter GitHub Actions ## 5 | ########################### 6 | ########################### 7 | name: Lint Code Base 8 | 9 | # 10 | # Documentation: 11 | # https://help.github.com/en/articles/workflow-syntax-for-github-actions 12 | # 13 | 14 | ############################# 15 | # Start the job on all push # 16 | ############################# 17 | on: 18 | push: 19 | # branches-ignore: [master] 20 | # # Remove the line above to run when pushing to master 21 | pull_request: 22 | branches: [master] 23 | 24 | ############### 25 | # Set the Job # 26 | ############### 27 | jobs: 28 | build: 29 | # Name the Job 30 | name: Lint Code Base 31 | # Set the agent to run on 32 | runs-on: ubuntu-latest 33 | 34 | ################## 35 | # Load all steps # 36 | ################## 37 | steps: 38 | ########################## 39 | # Checkout the code base # 40 | ########################## 41 | - name: Checkout Code 42 | uses: actions/checkout@v2 43 | with: 44 | # Full git history is needed to get a proper list of changed files within `super-linter` 45 | fetch-depth: 0 46 | 47 | ################################ 48 | # Run Linter against code base # 49 | ################################ 50 | - name: Lint Code Base 51 | uses: github/super-linter@v3 52 | env: 53 | VALIDATE_ALL_CODEBASE: false 54 | DEFAULT_BRANCH: main 55 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | *.DS_Store 18 | __debug_bin 19 | /tii 20 | .idea 21 | dist/ 22 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # This is an example goreleaser.yaml file with some sane defaults. 2 | # Make sure to check the documentation at http://goreleaser.com 3 | before: 4 | hooks: 5 | # You may remove this if you don't use go modules. 6 | - go mod download 7 | builds: 8 | - env: 9 | - CGO_ENABLED=0 10 | goos: 11 | - freebsd 12 | - windows 13 | - darwin 14 | - linux 15 | goarch: 16 | - amd64 17 | - arm 18 | - arm64 19 | - 386 20 | ldflags: 21 | - -s -w -X main.version=v{{.Version}} 22 | archives: 23 | - replacements: 24 | darwin: Darwin 25 | linux: Linux 26 | windows: Windows 27 | 386: 32-bit 28 | amd64: x86_64 29 | format_overrides: 30 | - goos: windows 31 | format: zip 32 | files: 33 | - shell 34 | checksum: 35 | name_template: 'checksums.txt' 36 | snapshot: 37 | name_template: "{{ .Tag }}-next" 38 | changelog: 39 | sort: asc 40 | filters: 41 | exclude: 42 | - '^docs:' 43 | - '^test:' 44 | brews: 45 | - 46 | # Repository to push the tap to. 47 | tap: 48 | owner: quackduck 49 | name: homebrew-tap 50 | 51 | # Your app's homepage. 52 | # Default is empty. 53 | homepage: "https://github.com/quackduck/tii" 54 | 55 | # Your app's description. 56 | # Default is empty. 57 | description: "Command not found? Install it right there!" 58 | caveats: "For bash or zsh, put something like this in a profile file (like ~/.bash_profile or ~/.zshrc):\n. #{etc}/profile.d/tii_on_command_not_found.sh" 59 | install: | 60 | bin.install "tii" 61 | fish_function.install "shell/__fish_command_not_found_handler.fish" 62 | (prefix/"etc/profile.d").install "shell/tii_on_command_not_found.sh" 63 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Ishan Goel 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 | # Tii 2 | 3 | On most GNU/Linux systems, when a command isn't found, a message showing what 4 | to run to install the command is shown. However, macOS doesn't have 5 | this. 6 | 7 | This tool adds a similar function with support for macOS using 8 | the Homebrew package manager. Instead of simply printing the best matches, Tii shows package 9 | descriptions and also offers to run an install command for you. 10 | 11 | [comment]: <> ([![asciicast](https://asciinema.org/a/382511.svg)](https://asciinema.org/a/382511?autoplay=1&speed=2)) 12 | 13 | 14 | demo 15 | 16 | 17 | The name Tii is an acronym for "Then Install It", which is what you'll probably say when shown "Command not found". 18 | 19 | ## Installing 20 | As of now, only macOS is supported 21 | ```shell 22 | brew install quackduck/tap/tii 23 | ``` 24 | 25 | ## Usage, environment and files 26 | 27 | Tii will be automatically triggered if a command is not found and so you usually do not need to directly interact with it. 28 | 29 | ```text 30 | Usage: tii [--help/-h | --version/-v | --refresh-cache/-r | ] 31 | 32 | Examples: 33 | tii fish 34 | tii cowsay 35 | tii --help 36 | 37 | Environment: 38 | TII_DISABLE_INTERACTIVE: If this variable is set to "true", Tii will 39 | disable interactive output (prompting for confirmation) and not install 40 | any packages. 41 | TII_AUTO_INSTALL_EXACT_MATCHES: If this variable is set to "true", Tii will 42 | automatically install exact matches without prompting for confirmation 43 | 44 | Files: 45 | $XDG_DATA_HOME/tii: used to cache package list info. If $XDG_DATA_HOME is 46 | not set, ~/.local/share is used instead. Refresh the cache using the 47 | --refresh-cache option. 48 | ``` 49 | 50 | ## Uninstalling 51 | If you have issues with Tii, head over to [issues](https://github.com/quackduck/tii/issues). 52 | 53 | You can uninstall with: 54 | ```shell 55 | brew uninstall tii 56 | ``` 57 | 58 | Here's a list of all the files Tii uses: 59 | ```text 60 | /usr/local/bin/tii 61 | /usr/local/share/fish/vendor_functions.d/tii_on_command_not_found.fish 62 | /etc/profile.d/tii_on_command_not_found.sh 63 | $XDG_DATA_HOME/tii or ~/.local/share/tii 64 | ``` 65 | 66 | ## Any other business 67 | Have a question, idea or just want to share something? Head over to [Discussions](https://github.com/quackduck/uniclip/discussions) 68 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module tii 2 | 3 | go 1.15 4 | 5 | require ( 6 | github.com/fatih/color v1.15.0 7 | github.com/lithammer/fuzzysearch v1.1.8 8 | github.com/mattn/go-isatty v0.0.19 // indirect 9 | golang.org/x/sys v0.9.0 // indirect 10 | golang.org/x/text v0.10.0 // indirect 11 | ) 12 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= 2 | github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= 3 | github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= 4 | github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= 5 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 6 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 7 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 8 | github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 9 | github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= 10 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 11 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 12 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 13 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 14 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 15 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 16 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 17 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 18 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 19 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 20 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 21 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 22 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 23 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 24 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 25 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 26 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 27 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 28 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 29 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 30 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 31 | golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s= 32 | golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 33 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 34 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 35 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 36 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 37 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 38 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 39 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 40 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 41 | golang.org/x/text v0.10.0 h1:UpjohKhiEgNc0CSauXmwYftY1+LlaC75SJwh0SgCX58= 42 | golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 43 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 44 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 45 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 46 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 47 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 48 | -------------------------------------------------------------------------------- /shell/__fish_command_not_found_handler.fish: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env fish 2 | 3 | function __fish_command_not_found_handler --on-event fish_command_not_found 4 | __fish_default_command_not_found_handler $argv 5 | echo 6 | if status --is-interactive # make sure a human is there to agree or disagree 7 | echo -e "Searching for command with Tii" 8 | tii "$argv[1]" 9 | end 10 | end -------------------------------------------------------------------------------- /shell/tii_on_command_not_found.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | if [ "$ZSH_VERSION" ] && [ -t 0 ]; then # is zsh and is interactive 3 | command_not_found_handler() { 4 | echo "Searching for command with Tii" 5 | tii "$1" 6 | } 7 | elif [ "$BASH_VERSION" ] && [ -t 0 ]; then # is bash and is interactive 8 | command_not_found_handle() { 9 | echo "Searching for command with Tii" 10 | tii "$1" 11 | } 12 | else 13 | echo -e "This is not bash or zsh. Please run $0 only in these shells." 14 | fi 15 | -------------------------------------------------------------------------------- /tii.go: -------------------------------------------------------------------------------- 1 | // This software is distributed under the MIT License. 2 | package main 3 | 4 | import ( 5 | "bufio" 6 | "encoding/json" 7 | "fmt" 8 | "net/http" 9 | "os" 10 | "os/exec" 11 | "sort" 12 | "strconv" 13 | 14 | "github.com/fatih/color" 15 | "github.com/lithammer/fuzzysearch/fuzzy" 16 | ) 17 | 18 | var ( 19 | scanner = bufio.NewScanner(os.Stdin) 20 | helpMsg = `Tii - Instantly install command when not found 21 | 22 | On most GNU/Linux systems, when a command isn't found, a message showing what 23 | to run to install the command is shown. However, macOS doesn't have 24 | this. This tool adds a similar function with support for macOS using 25 | the Homebrew package manager. Instead of simply printing the best matches, Tii 26 | shows package descriptions and also offers to run an install command for you. 27 | 28 | Usage: tii [--help/-h | --version/-v | --refresh-cache/-r | ] 29 | 30 | Examples: 31 | tii fish 32 | tii cowsay 33 | tii --help 34 | 35 | Environment: 36 | TII_DISABLE_INTERACTIVE: If this variable is set to "true", Tii will 37 | disable interactive output (prompting for confirmation) and not install 38 | any packages. 39 | TII_AUTO_INSTALL_EXACT_MATCHES: If this variable is set to "true", Tii will 40 | automatically install exact matches without prompting for confirmation 41 | 42 | Files: 43 | $XDG_DATA_HOME/tii: used to cache package list info. If $XDG_DATA_HOME is 44 | not set, ~/.local/share is used instead. Refresh the cache using the 45 | --refresh-cache option. 46 | 47 | If Tii was installed correctly, using commands which are not found will 48 | automatically trigger it. The name Tii is an acronym for "Then Install It". 49 | 50 | See source and report bugs at github.com/quackduck/tii` 51 | //prefix = "" // set in init() 52 | underline = color.New(color.Underline).SprintFunc() 53 | disablePrompts = os.Getenv("TII_DISABLE_INTERACTIVE") == "true" //nolint // complains about using the literal string "true" 3 times 54 | autoInstallExactMatches = os.Getenv("TII_AUTO_INSTALL_EXACT_MATCHES") == "true" 55 | version = "development" // This will be set at build time using ldflags: go build -ldflags="-s -w -X main.version=$(git describe --tags --abbrev=0)" 56 | formulaURL = "https://formulae.brew.sh/api/formula.json" 57 | caskURL = "https://formulae.brew.sh/api/cask.json" 58 | dataDir = os.Getenv("XDG_DATA_HOME") 59 | dataFile = "pkginfo.json" 60 | ) 61 | 62 | type Formula struct { 63 | Name string `json:"name"` 64 | //FullName string `json:"full_name"` 65 | Desc string `json:"desc"` 66 | } 67 | 68 | type Cask struct { 69 | Name string `json:"token"` 70 | //FullNames []string `json:"name"` 71 | Desc string `json:"desc"` 72 | } 73 | 74 | func main() { 75 | if dataDir == "" { 76 | dataDir = os.Getenv("HOME") + "/.local/share" 77 | } 78 | if _, err := exec.LookPath("brew"); err != nil { 79 | handleErrStr("Homebrew is not installed. Install it to use Tii") 80 | runWithPrompt("Install Homebrew", `/bin/bash -c "\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"`) 81 | return 82 | } 83 | if len(os.Args) > 2 { 84 | handleErrStr("Too many arguments") 85 | fmt.Println(helpMsg) 86 | return 87 | } 88 | if hasOption, _ := argsHaveOption("help", "h"); hasOption || len(os.Args) == 1 { 89 | fmt.Println(helpMsg) 90 | return 91 | } 92 | if hasOption, _ := argsHaveOption("version", "v"); hasOption { 93 | fmt.Println("Tii " + version) 94 | return 95 | } 96 | if hasOption, _ := argsHaveOption("refresh-cache", "r"); hasOption { 97 | _, _, err := getCachedPackageInfo(true) 98 | if err != nil { 99 | handleErr(err) 100 | return 101 | } 102 | fmt.Println("Cache refreshed!") 103 | return 104 | } 105 | if disablePrompts { 106 | fmt.Println("Running Tii in non-interactive mode. ($TII_DISABLE_INTERACTIVE is true)") 107 | } 108 | findPkg(os.Args[1]) 109 | } 110 | 111 | func findPkg(search string) { 112 | list, descriptions, err := getCachedPackageInfo(false) 113 | if err != nil { 114 | handleErr(err) 115 | return 116 | } 117 | matches := fuzzy.RankFindFold(search, list) 118 | sort.Sort(matches) 119 | 120 | if len(matches) > 0 { 121 | if matches[0].Target == search { 122 | fmt.Println("Found exact match: " + color.YellowString(matches[0].Target) + color.HiBlackString(" ("+descriptions[list[matches[0].OriginalIndex]]+")")) 123 | if autoInstallExactMatches { 124 | fmt.Println("Installing it because auto-install is enabled. ($TII_AUTO_INSTALL_EXACT_MATCHES is true)") 125 | run("brew install " + matches[0].Target) 126 | return 127 | } 128 | runWithPrompt("Install it", "brew install "+matches[0].Target) 129 | return 130 | } 131 | 132 | fmt.Println("Presenting fuzzy matches") 133 | for i, match := range matches { 134 | if match.Distance > 10 { 135 | break 136 | } 137 | fmt.Println(color.CyanString(strconv.Itoa(i+1)) + ": " + match.Target + color.HiBlackString(" ("+descriptions[list[match.OriginalIndex]]+")")) 138 | if i == 9 { 139 | fmt.Println("... and more") 140 | break 141 | } 142 | } 143 | if ok, i := promptInt("Enter number to install or press enter to quit", 1, len(matches)); ok { 144 | if runWithPrompt("Install it", "brew install "+matches[i-1].Target) { 145 | return 146 | } 147 | } 148 | } 149 | fmt.Println("No exact matches found for " + color.YellowString(search) + ".") 150 | if promptBool("Refresh package info cache?") { 151 | _, _, err = getCachedPackageInfo(true) 152 | if err != nil { 153 | handleErr(err) 154 | return 155 | } 156 | fmt.Println("Cache refreshed! Try searching again if needed.") 157 | } 158 | } 159 | 160 | func getCachedPackageInfo(forceRefresh bool) ([]string, map[string]string, error) { 161 | if _, err := os.Stat(dataDir + "/tii/" + dataFile); os.IsNotExist(err) || forceRefresh { 162 | err = os.MkdirAll(dataDir+"/tii", 0755) 163 | if err != nil { 164 | return nil, nil, err 165 | } 166 | f, err := os.Create(dataDir + "/tii/" + dataFile) 167 | if err != nil { 168 | return nil, nil, err 169 | } 170 | defer f.Close() 171 | list, descriptions, err := fetchPackageInfo() 172 | if err != nil { 173 | return nil, nil, err 174 | } 175 | err = json.NewEncoder(f).Encode(descriptions) // descriptions also has the full list, so no need to save list 176 | if err != nil { 177 | return nil, nil, err 178 | } 179 | return list, descriptions, nil 180 | } 181 | f, err := os.Open(dataDir + "/tii/" + dataFile) 182 | if err != nil { 183 | return nil, nil, err 184 | } 185 | defer f.Close() 186 | var descriptions map[string]string 187 | err = json.NewDecoder(f).Decode(&descriptions) 188 | if err != nil { 189 | return nil, nil, err 190 | } 191 | var list []string 192 | for name := range descriptions { 193 | list = append(list, name) 194 | } 195 | return list, descriptions, nil 196 | } 197 | 198 | // returns the list, the map to descriptions, and an error 199 | func fetchPackageInfo() ([]string, map[string]string, error) { 200 | var list []string 201 | var formulae []Formula 202 | var casks []Cask 203 | var descriptions = make(map[string]string, 1000) 204 | 205 | resp, err := http.Get(formulaURL) 206 | if err != nil { 207 | handleErr(err) 208 | } 209 | defer resp.Body.Close() 210 | err = json.NewDecoder(resp.Body).Decode(&formulae) 211 | if err != nil { 212 | handleErr(err) 213 | } 214 | resp, err = http.Get(caskURL) 215 | if err != nil { 216 | handleErr(err) 217 | } 218 | defer resp.Body.Close() 219 | err = json.NewDecoder(resp.Body).Decode(&casks) 220 | if err != nil { 221 | handleErr(err) 222 | } 223 | for _, formula := range formulae { 224 | list = append(list, formula.Name) 225 | descriptions[formula.Name] = formula.Desc 226 | } 227 | for _, cask := range casks { 228 | list = append(list, cask.Name) 229 | descriptions[cask.Name] = cask.Desc 230 | } 231 | return list, descriptions, nil 232 | } 233 | 234 | func promptBool(promptStr string) (yes bool) { 235 | if disablePrompts { 236 | return false 237 | } 238 | for { 239 | fmt.Print(underline(promptStr) + " (y/N) > ") 240 | color.Set(color.FgCyan) 241 | if !scanner.Scan() { 242 | break 243 | } 244 | color.Unset() 245 | switch scanner.Text() { 246 | case "y", "Y", "yes", "Yes", "YES", "true", "True", "TRUE": 247 | return true 248 | case "", "n", "N", "no", "No", "NO", "false", "False", "FALSE": 249 | return false 250 | default: 251 | continue 252 | } 253 | } 254 | return true 255 | } 256 | 257 | // quits if user enters enter 258 | func promptInt(promptStr string, lowerLimit int, upperLimit int) (bool, int) { 259 | if disablePrompts { 260 | return false, 0 261 | } 262 | for { 263 | fmt.Print(underline(promptStr) + ": ") 264 | color.Set(color.FgCyan) 265 | if !scanner.Scan() { 266 | break 267 | } 268 | color.Unset() 269 | if scanner.Text() == "" { 270 | break 271 | } 272 | if i, err := strconv.Atoi(scanner.Text()); err == nil && lowerLimit <= i && i <= upperLimit { 273 | return true, i 274 | } 275 | } 276 | return false, 0 277 | } 278 | 279 | func runWithPrompt(promptStr string, command string) (ran bool) { 280 | yes := promptBool(promptStr + " with " + color.YellowString(command) + "?") 281 | if yes { 282 | run(command) 283 | } 284 | return yes 285 | } 286 | 287 | func run(command string) { 288 | // run it with the users shell 289 | cmd := exec.Command(os.Getenv("SHELL"), "-c", command) //nolint //"Subprocess launched with function call as argument or cmd arguments" 290 | cmd.Stdout = os.Stdout 291 | cmd.Stderr = os.Stderr 292 | cmd.Stdin = os.Stdin 293 | if err := cmd.Run(); err != nil { 294 | handleErrStr("An error occurred while trying to run " + command) 295 | handleErr(err) 296 | } 297 | cmd.Stderr = nil 298 | cmd.Stdout = nil 299 | cmd.Stdin = nil 300 | } 301 | 302 | func argsHaveOption(long string, short string) (hasOption bool, foundAt int) { 303 | for i, arg := range os.Args { 304 | if arg == "--"+long || arg == "-"+short { 305 | return true, i 306 | } 307 | } 308 | return false, 0 309 | } 310 | 311 | func handleErr(err error) { 312 | handleErrStr(err.Error()) 313 | } 314 | 315 | func handleErrStr(str string) { 316 | _, _ = fmt.Fprintln(os.Stderr, color.RedString("Error: ")+str) 317 | } 318 | --------------------------------------------------------------------------------