├── .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]: <> ([](https://asciinema.org/a/382511?autoplay=1&speed=2))
12 |
13 |
14 |
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 |
--------------------------------------------------------------------------------