├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ ├── build.yml │ ├── codeql-analysis.yml │ ├── mkdocs-dev.yml │ ├── mkdocs-latest.yml │ └── release.yml ├── .gitignore ├── .gitpod.yml ├── .golangci.yml ├── .goreleaser.yml ├── .pre-commit-config.yaml ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── cmd ├── assistant.go ├── assistant_demo_flags.go ├── asssitant_test.go ├── browse.go ├── browse_test.go ├── config.go ├── config_test.go ├── copy.go ├── copy_test.go ├── exec.go ├── exec_test.go ├── export.go ├── export_test.go ├── helper_test.go ├── info.go ├── info_test.go ├── manager.go ├── manager_test.go ├── print.go ├── print_test.go ├── root.go ├── root_test.go ├── sync.go └── sync_test.go ├── docs ├── assistant │ ├── gemini.md │ ├── index.md │ └── openai.md ├── configuration │ ├── overview.md │ └── themes.md ├── getting-started │ ├── fzf.md │ ├── overview.md │ ├── parameters.md │ └── power-setup.md ├── images │ ├── assistant │ │ ├── assistant-choose.gif │ │ ├── assistant-save.gif │ │ ├── assistant-zip.gif │ │ └── assistant.gif │ ├── demo.gif │ ├── logo.png │ ├── param-example-password.gif │ ├── param-example-path.gif │ ├── param-example-predefined-values.gif │ └── themes │ │ ├── default-dark-form.png │ │ ├── default-dark-lookup.png │ │ ├── default-light-form.png │ │ ├── default-light-lookup.png │ │ ├── simple-form.png │ │ └── simple-lookup.png ├── index.md └── managers │ ├── fslibrary.md │ ├── githubgist.md │ ├── overview.md │ ├── pet.md │ ├── pictarinesnip.md │ └── snippetslab.md ├── go.mod ├── go.sum ├── internal ├── app │ ├── app.go │ ├── app_assistant.go │ ├── app_assistant_test.go │ ├── app_exec.go │ ├── app_exec_test.go │ ├── app_export.go │ ├── app_export_test.go │ ├── app_info.go │ ├── app_info_test.go │ ├── app_lookup.go │ ├── app_lookup_test.go │ ├── app_manager.go │ ├── app_manager_test.go │ ├── app_print.go │ ├── app_print_test.go │ ├── app_test.go │ └── helper_test.go ├── assistant │ ├── assistant.go │ ├── assistant_test.go │ ├── client.go │ ├── client_provider.go │ ├── client_provider_test.go │ ├── config.go │ ├── gemini │ │ ├── client.go │ │ ├── client_test.go │ │ ├── config.go │ │ ├── config_test.go │ │ ├── description.go │ │ ├── description_test.go │ │ ├── model.go │ │ └── options.go │ ├── model.go │ ├── openai │ │ ├── client.go │ │ ├── client_test.go │ │ ├── config.go │ │ ├── config_test.go │ │ ├── description.go │ │ ├── description_test.go │ │ ├── model.go │ │ └── options.go │ ├── options.go │ ├── prompts │ │ ├── prompt.md │ │ └── prompts.go │ ├── util.go │ └── util_test.go ├── cache │ ├── cache.go │ ├── data.go │ ├── data_test.go │ ├── secrets.go │ └── secrets_test.go ├── config │ ├── config.go │ ├── configtest │ │ └── test_helpers.go │ ├── create.go │ ├── create_test.go │ ├── errors.go │ ├── errors_test.go │ ├── migrations │ │ ├── migrations.go │ │ ├── migrations_test.go │ │ ├── v110 │ │ │ ├── mapper.go │ │ │ └── mapper_test.go │ │ ├── v111 │ │ │ ├── mapper.go │ │ │ └── mapper_test.go │ │ └── v120 │ │ │ ├── mapper.go │ │ │ └── mapper_test.go │ ├── options.go │ ├── service.go │ ├── service_test.go │ └── testdata │ │ ├── example-config.yaml │ │ ├── migrations │ │ ├── config-1-0-0.yaml │ │ ├── config-1-1-0.yaml │ │ ├── config-1-1-1-fslibrary.yaml │ │ ├── config-1-1-1.yaml │ │ ├── config-1-2-0-fslibrary.yaml │ │ └── config-1-2-0.yaml │ │ └── testdata.go ├── managers │ ├── config.go │ ├── fslibrary │ │ ├── config.go │ │ ├── constants.go │ │ ├── description.go │ │ ├── manager.go │ │ ├── manager_test.go │ │ ├── model.go │ │ ├── parser.go │ │ ├── parser_test.go │ │ ├── utils.go │ │ └── utils_test.go │ ├── githubgist │ │ ├── client.go │ │ ├── client_test.go │ │ ├── config.go │ │ ├── config_test.go │ │ ├── constants.go │ │ ├── description.go │ │ ├── description_test.go │ │ ├── manager.go │ │ ├── manager_test.go │ │ ├── model.go │ │ ├── options.go │ │ ├── parser.go │ │ ├── parser_test.go │ │ ├── store.go │ │ ├── store_test.go │ │ └── testdata │ │ │ ├── github_device_code.json │ │ │ ├── github_get_user_gists_response.json │ │ │ └── github_oauth_access_token.json │ ├── manager.go │ ├── masscode │ │ ├── config.go │ │ ├── config_test.go │ │ ├── constants.go │ │ ├── constants_test.go │ │ ├── description.go │ │ ├── description_test.go │ │ ├── manager.go │ │ ├── manager_test.go │ │ ├── model.go │ │ ├── parser_v1.go │ │ ├── parser_v1_test.go │ │ ├── parser_v2.go │ │ ├── parser_v2_test.go │ │ └── testdata │ │ │ ├── userhome-v1 │ │ │ └── massCode │ │ │ │ ├── masscode.db │ │ │ │ ├── snippets.db │ │ │ │ └── tags.db │ │ │ └── userhome-v2 │ │ │ └── massCode │ │ │ └── db.json │ ├── pet │ │ ├── config.go │ │ ├── config_test.go │ │ ├── constants.go │ │ ├── description.go │ │ ├── description_test.go │ │ ├── manager.go │ │ ├── manager_test.go │ │ ├── model.go │ │ ├── parser.go │ │ ├── parser_test.go │ │ └── testdata │ │ │ └── userhome │ │ │ └── .config │ │ │ └── pet │ │ │ ├── config.toml │ │ │ └── snippet.toml │ ├── pictarinesnip │ │ ├── config.go │ │ ├── config_test.go │ │ ├── constants.go │ │ ├── description.go │ │ ├── manager.go │ │ ├── manager_test.go │ │ ├── model.go │ │ ├── parser.go │ │ ├── parser_test.go │ │ └── testdata │ │ │ ├── pref-with-user-defined-library-path.plist │ │ │ └── userhome │ │ │ └── Library │ │ │ └── Containers │ │ │ └── com.pictarine.Snip │ │ │ └── Data │ │ │ └── Library │ │ │ └── Application Support │ │ │ └── Snip │ │ │ └── snippets │ ├── provider.go │ ├── provider_test.go │ └── snippetslab │ │ ├── config.go │ │ ├── config_test.go │ │ ├── constants.go │ │ ├── constants_test.go │ │ ├── description.go │ │ ├── manager.go │ │ ├── manager_test.go │ │ ├── model.go │ │ ├── parser.go │ │ ├── parser_test.go │ │ ├── testdata │ │ ├── pref-with-user-defined-library-path.plist │ │ └── userhome │ │ │ └── Library │ │ │ └── Containers │ │ │ └── com.renfei.SnippetsLab │ │ │ └── Data │ │ │ └── Library │ │ │ ├── Application Support │ │ │ └── com.renfei.SnippetsLab │ │ │ │ └── main.snippetslablibrary │ │ │ │ └── Database │ │ │ │ ├── Snippets │ │ │ │ ├── 84A08C4A-B2BE-4964-A521-180550BDA7B3.data │ │ │ │ └── B3EDC3BE-6FE1-489E-9EB8-C400D4CF1B54.data │ │ │ │ ├── deletion.data │ │ │ │ ├── folders.data │ │ │ │ ├── shortcuts.data │ │ │ │ ├── smart groups.data │ │ │ │ └── tags.data │ │ │ └── Preferences │ │ │ └── com.renfei.SnippetsLab.plist │ │ ├── urls.go │ │ └── urls_test.go ├── model │ ├── assistant.go │ ├── info.go │ ├── language.go │ ├── manager.go │ ├── parameter.go │ ├── snippet.go │ └── sync.go ├── parser │ ├── parser.go │ └── parser_test.go ├── ui │ ├── assistant │ │ ├── prompt │ │ │ ├── prompt.go │ │ │ └── prompt_test.go │ │ └── wizard │ │ │ ├── wizard.go │ │ │ └── wizard_test.go │ ├── config.go │ ├── config_test.go │ ├── confirm │ │ ├── confirm.go │ │ ├── confirm_test.go │ │ └── keys.go │ ├── finder │ │ ├── finder.go │ │ └── utils.go │ ├── form │ │ ├── field.go │ │ ├── form.go │ │ ├── form_test.go │ │ ├── keys.go │ │ ├── options.go │ │ ├── util.go │ │ └── util_test.go │ ├── helper_test.go │ ├── lookup.go │ ├── lookup_test.go │ ├── message_printer.go │ ├── picker │ │ ├── picker.go │ │ └── picker_test.go │ ├── spinner │ │ ├── spinner.go │ │ └── spinner_test.go │ ├── style │ │ ├── colors.go │ │ ├── style.go │ │ ├── theme.go │ │ └── utils.go │ ├── sync │ │ ├── options.go │ │ ├── sync.go │ │ └── sync_test.go │ ├── testdata │ │ ├── github-device-code.json │ │ └── test-custom.yaml │ ├── themes.go │ ├── themes_test.go │ ├── tui.go │ ├── tui_form_test.go │ ├── tui_test.go │ └── uimsg │ │ ├── templates │ │ ├── assistant_none_enabled.gotmpl │ │ ├── assistant_snippet_saved.gotmpl │ │ ├── assistant_update_config_result.gotmpl │ │ ├── config_file_create_confirm.gotmpl │ │ ├── config_file_create_result.gotmpl │ │ ├── config_file_delete_confirm.gotmpl │ │ ├── config_file_delete_result.gotmpl │ │ ├── config_file_migration_confirm.gotmpl │ │ ├── config_file_migration_result.gotmpl │ │ ├── config_needs_migration.gotmpl │ │ ├── config_not_found.gotmpl │ │ ├── exec_confirm.gotmpl │ │ ├── exec_print.gotmpl │ │ ├── home_dir_still_exists.gotmpl │ │ ├── manager_add_config_confirm.gotmpl │ │ ├── manager_add_config_result.gotmpl │ │ ├── manager_oauth_device_flow.gotmpl │ │ ├── themes_delete_confirm.gotmpl │ │ └── themes_delete_result.gotmpl │ │ ├── uimsg.go │ │ └── uimsg_test.go └── utils │ ├── assertutil │ └── assert_util.go │ ├── errorutil │ └── app_error.go │ ├── httputil │ └── http_util.go │ ├── idutil │ └── id_util.go │ ├── json │ ├── json_util.go │ └── json_util_test.go │ ├── logutil │ └── log_util.go │ ├── sliceutil │ └── slice_util.go │ ├── stringutil │ ├── string_set.go │ ├── string_set_test.go │ ├── string_utils.go │ └── string_utils_test.go │ ├── system │ ├── errors.go │ ├── errors_test.go │ ├── system.go │ └── system_test.go │ ├── tagutil │ ├── tag_util.go │ └── tag_util_test.go │ ├── termtest │ └── termtest.go │ ├── termutil │ └── stdio.go │ ├── testutil │ ├── mockutil │ │ ├── env.go │ │ └── method_names.go │ ├── model.go │ ├── strings.go │ └── system.go │ ├── titleheader │ ├── title_header.go │ └── title_header_test.go │ └── tmpdir │ ├── tmpdir.go │ └── tmpdir_test.go ├── main.go ├── misc ├── example-snippets │ └── echo-something.sh └── fzf.sh ├── mkdocs.yml ├── themes ├── data.go ├── default.dark.yaml ├── default.light.yaml └── simple.yaml └── tools ├── go.mod ├── go.sum └── tools.go /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | *.{cmd,[cC][mM][dD]} text eol=crlf 3 | *.{bat,[bB][aA][tT]} text eol=crlf 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | # Maintain dependencies for Go 9 | - package-ecosystem: "gomod" 10 | directory: "/" 11 | schedule: 12 | interval: "monthly" 13 | 14 | # Maintain dependencies for build tools 15 | - package-ecosystem: "gomod" 16 | directory: "/tools" 17 | schedule: 18 | interval: "monthly" 19 | 20 | # Maintain dependencies for GitHub Actions 21 | - package-ecosystem: "github-actions" 22 | directory: "/" 23 | schedule: 24 | interval: "monthly" 25 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: [ "*" ] 6 | pull_request: 7 | branches: [ main, "*" ] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | ci-linux: 12 | runs-on: ubuntu-20.04 13 | env: 14 | DEBIAN_FRONTEND: noninteractive 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-go@v5 18 | with: 19 | go-version: '1.23' 20 | - name: Install clipboard utilities 21 | run: sudo apt-get update && sudo apt-get install -y xsel wl-clipboard 22 | - name: Build 23 | run: | 24 | # https://github.com/jaraco/keyring/blob/main/README.rst#using-keyring-on-headless-linux-systems 25 | make ci 26 | - name: Upload coverage to Codecov 27 | uses: codecov/codecov-action@v4.5.0 28 | with: 29 | file: ./coverage.out 30 | flags: ${{ runner.os }} 31 | env: 32 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 33 | 34 | ci-macos: 35 | runs-on: macos-latest 36 | defaults: 37 | run: 38 | shell: bash 39 | steps: 40 | - uses: actions/checkout@v4 41 | - uses: actions/setup-go@v5 42 | with: 43 | go-version: '1.23' 44 | - name: Build 45 | run: make ci 46 | - name: Upload coverage to Codecov 47 | uses: codecov/codecov-action@v4.5.0 48 | with: 49 | file: ./coverage.out 50 | flags: ${{ runner.os }} 51 | env: 52 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 53 | 54 | release-test: 55 | runs-on: ubuntu-20.04 56 | steps: 57 | - uses: actions/checkout@v4 58 | with: 59 | fetch-depth: 0 60 | 61 | - uses: actions/setup-go@v5 62 | with: 63 | go-version: '1.23' 64 | 65 | - name: Release test 66 | run: make build 67 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [ main ] 9 | schedule: 10 | - cron: '11 0 * * 5' 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | language: [ 'go' ] 21 | 22 | steps: 23 | - name: Checkout repository 24 | uses: actions/checkout@v4 25 | 26 | # Initializes the CodeQL tools for scanning. 27 | - name: Initialize CodeQL 28 | uses: github/codeql-action/init@v3 29 | with: 30 | languages: ${{ matrix.language }} 31 | # If you wish to specify custom queries, you can do so here or in a config file. 32 | # By default, queries listed here will override any specified in a config file. 33 | # Prefix the list here with "+" to use these queries and those in the config file. 34 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 35 | 36 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 37 | # If this step fails, then you should remove it and run the build manually (see below) 38 | - name: Autobuild 39 | uses: github/codeql-action/autobuild@v3 40 | 41 | # ℹ️ Command-line programs to run using the OS shell. 42 | # 📚 https://git.io/JvXDl 43 | 44 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 45 | # and modify them (or add more) to build your code if your project 46 | # uses a compiled language 47 | 48 | #- run: | 49 | # make bootstrap 50 | # make release 51 | 52 | - name: Perform CodeQL Analysis 53 | uses: github/codeql-action/analyze@v3 54 | -------------------------------------------------------------------------------- /.github/workflows/mkdocs-dev.yml: -------------------------------------------------------------------------------- 1 | name: mkdocs-dev 2 | on: 3 | push: 4 | paths: 5 | - 'docs/**' 6 | - mkdocs.yml 7 | branches: 8 | - docs 9 | - main 10 | jobs: 11 | deploy: 12 | name: mkdocs-dev 13 | runs-on: ubuntu-20.04 14 | steps: 15 | - name: Checkout main 16 | uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | persist-credentials: true 20 | - uses: actions/setup-python@v5 21 | with: 22 | python-version: 3.x 23 | - name: Install dependencies 24 | run: | 25 | pip install mike 26 | pip install mkdocs-material 27 | env: 28 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | - name: Configure the git user 30 | run: | 31 | git config user.name "Github Actions mkdocs Bot" 32 | git config user.email "actions-mkdocs-bot@github.com" 33 | - name: Deploy the dev documents 34 | run: mike deploy --push dev 35 | -------------------------------------------------------------------------------- /.github/workflows/mkdocs-latest.yml: -------------------------------------------------------------------------------- 1 | name: mkdocs-latest 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | version: 6 | description: Version to be deployed 7 | required: true 8 | push: 9 | tags: 10 | - "v*" 11 | jobs: 12 | deploy: 13 | name: mkdocs-latest 14 | runs-on: ubuntu-20.04 15 | steps: 16 | - name: Checkout main 17 | uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | persist-credentials: true 21 | - uses: actions/setup-python@v5 22 | with: 23 | python-version: 3.x 24 | - name: Install dependencies 25 | run: | 26 | pip install mike 27 | pip install mkdocs-material 28 | env: 29 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | - name: Configure the git user 31 | run: | 32 | git config user.name "Github Actions mkdocs Bot" 33 | git config user.email "actions-mkdocs-bot@github.com" 34 | - name: Deploy the latest documents from new tag push 35 | if: ${{ github.event.inputs.version == '' }} 36 | run: | 37 | VERSION=$(echo ${{ github.ref }} | sed -e "s#refs/tags/##g") 38 | mike deploy --push --update-aliases $VERSION latest 39 | - name: Deploy the latest documents from manual trigger 40 | if: ${{ github.event.inputs.version != '' }} 41 | run: mike deploy --push --update-aliases ${{ github.event.inputs.version }} latest 42 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | goreleaser: 10 | runs-on: ubuntu-20.04 11 | steps: 12 | - uses: actions/checkout@v4 13 | with: 14 | fetch-depth: 0 15 | 16 | - uses: actions/setup-go@v5 17 | with: 18 | go-version: '1.23' 19 | 20 | - name: "Docker login" 21 | run: docker login docker.pkg.github.com -u docker -p ${{ secrets.GITHUB_TOKEN }} 22 | 23 | - name: Release 24 | run: make release 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }} 28 | 29 | - name: Publish to apt 30 | run: | 31 | for i in dist/*.deb; do 32 | curl --fail -F package=@${i} "https://${PUSH_TOKEN}@push.fury.io/lemoony/" 33 | done 34 | env: 35 | PUSH_TOKEN: ${{ secrets.GEMFURY_PUSH_TOKEN }} 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # MacOS DS_Store files 2 | .DS_Store 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 | coverage.html 17 | 18 | # Dependency directories (remove the comment below to include it) 19 | vendor/ 20 | tools/vendor 21 | 22 | mocks/ 23 | !mocks/method_names.go 24 | 25 | # Output of GoReleaser 26 | dist/ 27 | 28 | # Visual Studio Code files 29 | .vscode/* 30 | !.vscode/settings.json 31 | !.vscode/tasks.json 32 | !.vscode/launch.json 33 | !.vscode/extensions.json 34 | *.code-workspace 35 | 36 | # Local History for Visual Studio Code 37 | .history/ 38 | 39 | # GoLand and IntelliJ IDEA files 40 | .idea/ 41 | 42 | # env files that usually contain secrets or local config 43 | .env 44 | .envrc 45 | .dev_config 46 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | tasks: 2 | - name: Setup 3 | init: | 4 | pip install pre-commit mkdocs mkdocs-material && pre-commit install 5 | command: | 6 | make install generate 7 | ports: 8 | - port: 8000 9 | onOpen: open-preview 10 | vscode: 11 | extensions: 12 | - golang.Go 13 | - eamodio.gitlens 14 | - shd101wyy.markdown-preview-enhanced 15 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters-settings: 2 | gci: 3 | sections: [standard, default, Prefix(github.com/lemoony/snipkit)] 4 | goimports: 5 | local-prefixes: github.com/lemoony/snipkit 6 | revive: 7 | confidence: 0.8 8 | gocyclo: 9 | min-complexity: 15 10 | govet: 11 | enable-all: true 12 | disable: 13 | - fieldalignment 14 | settings: 15 | shadow: 16 | strict: true 17 | misspell: 18 | locale: US 19 | nolintlint: 20 | allow-leading-space: false # require machine-readable nolint directives (with no leading space) 21 | allow-unused: false # report any unused nolint directives 22 | require-explanation: true # require an explanation for nolint directives 23 | require-specific: false # don't require nolint directives to be specific about which linter is being skipped 24 | stylecheck: 25 | checks: [ "all", "-ST1000" ] 26 | mnd: 27 | checks: [argument,case,condition,operation,return,assign] 28 | ignored-numbers: ['2'] 29 | # ignored-files: magic_.*.go 30 | # ignored-functions: math.* 31 | 32 | 33 | linters: 34 | # please, do not use `enable-all`: it's deprecated and will be removed soon. 35 | # inverted configuration with `enable-all` and `disable` is not scalable during updates of golangci-lint 36 | disable-all: true 37 | enable: 38 | - errcheck 39 | - gosimple 40 | - govet 41 | - ineffassign 42 | - staticcheck 43 | - typecheck 44 | - unused 45 | - bodyclose 46 | - dupl 47 | - copyloopvar 48 | - funlen 49 | - gci 50 | - gocognit 51 | - goconst 52 | - gocritic 53 | - gocyclo 54 | - godot 55 | - gofumpt 56 | - revive 57 | - mnd 58 | - goprintffuncname 59 | - gosec 60 | - misspell 61 | - noctx 62 | - nolintlint 63 | - rowserrcheck 64 | - sqlclosecheck 65 | - stylecheck 66 | - thelper 67 | - tparallel 68 | - unconvert 69 | - unparam 70 | - whitespace 71 | - unused 72 | # - errorlint 73 | # - goerr113 74 | # - wrapcheck 75 | # - forcetypeassert 76 | issues: 77 | # enable issues excluded by default 78 | exclude-use-default: false 79 | exclude-rules: 80 | - linters: [gosec] 81 | path: _test\.go 82 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | project_name: snipkit 2 | before: 3 | hooks: 4 | - go mod download 5 | builds: 6 | - id: macos 7 | env: 8 | - CGO_ENABLED=0 9 | goos: [ darwin ] 10 | goarch: [ amd64, arm64 ] 11 | flags: 12 | - "-tags={{ if index .Env \"BUILD_TAGS\" }}{{ .Env.BUILD_TAGS }}{{ else }}\"\"{{ end }}" 13 | - id: linux 14 | env: 15 | - CGO_ENABLED=0 16 | goos: [ linux ] 17 | goarch: [ amd64, arm64, 386, arm ] 18 | checksum: 19 | name_template: "{{ .ProjectName }}_checksums.txt" 20 | brews: 21 | - repository: 22 | owner: lemoony 23 | name: homebrew-tap 24 | token: "{{ .Env.TAP_GITHUB_TOKEN }}" 25 | directory: Formula 26 | homepage: https://github.com/lemoony/snipkit 27 | description: Access snippets from your favorite snippet manager without leaving the terminal 28 | # skip_upload: auto 29 | test: | 30 | system "#{bin}/snipkit", "--version" 31 | nfpms: 32 | - 33 | builds: [linux] 34 | homepage: https://github.com/lemoony/snipkit 35 | maintainer: Philipp Sessler 36 | description: Access snippets from your favorite snippet manager without leaving the terminal. 37 | license: Apache 2.0 38 | formats: 39 | - deb 40 | - apk 41 | - rpm 42 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/dnephin/pre-commit-golang 3 | rev: v0.5.1 4 | hooks: 5 | - id: go-fmt 6 | - id: golangci-lint 7 | - id: go-unit-tests 8 | - id: go-mod-tidy 9 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions to the project are highly welcome! There are several ways to help out, e.g.: 4 | 5 | - Create an issue on GitHub, if you have found a bug 6 | - Create a feature request, if you have something on your mind 7 | - Create a pull request, if you 8 | - have fixed something 9 | - have added a new feature 10 | - want to add your own theme 11 | - Contribute to the documentation 12 | 13 | ## Local development 14 | 15 | ### Setting up a dev environment 16 | 17 | Setting up a test environment involves the following steps: 18 | 19 | * Install [go](https://go.dev/doc/install) 20 | * Install [pre-commit](https://pre-commit.com/) 21 | * Run `pre-commit install` 22 | * For working on the documentation: 23 | * Install [mkdcos](https://www.mkdocs.org/) 24 | * Install [mkdocs-material](https://github.com/squidfunk/mkdocs-material) 25 | 26 | After this, you'll be able to test any change. Alternatively, you can open the project via Gitpod: 27 | 28 | [![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/lemoony/snipkit) 29 | 30 | ### Commands 31 | 32 | Check if everything works as expected: 33 | 34 | ```bash 35 | make ci 36 | ``` 37 | 38 | This command will run all tests as well as the linter to check if there are any issues. 39 | 40 | During development, the following commands may also be beneficial to you: 41 | 42 | ```bash 43 | make build # Build the binary files 44 | make test # Run all tests 45 | make lint # Run the linter to detect any issues 46 | make mocks # (Re-)generate the mock files 47 | pre-commit run --all-files # Run all pre-commit hooks manually 48 | ``` 49 | 50 | ## Features and bugs 51 | 52 | Please file feature requests and bugs at the [issue tracker][tracker]. 53 | 54 | [tracker]: https://github.com/lemoony/snipkit/issues 55 | 56 | ## Submitting Changes 57 | 58 | Push your changes to a topic branch in your fork of the repository. Submit a pull request to the repository on github, with 59 | the correct target branch. 60 | -------------------------------------------------------------------------------- /cmd/assistant.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | var ( 10 | assistantDemoScriptFlag []string 11 | assistantDemoWaitFlag int 12 | ) 13 | 14 | var assistantCmd = &cobra.Command{ 15 | Use: "assistant", 16 | Short: "SnipKit assistant helps to write executable CLI snippets.", 17 | Long: `SnipKit assistant generates a script by means of AI and allows to execute it directly.`, 18 | } 19 | 20 | var generateCmd = &cobra.Command{ 21 | Use: "generate", 22 | Short: "Generate a script based on a user prompt.", 23 | Long: `Generate a script based on a user prompt and either copy it to the clipboard or execute it directly.`, 24 | Aliases: []string{"ai", "create"}, 25 | Run: func(cmd *cobra.Command, args []string) { 26 | getAppFromContext(cmd.Context()).GenerateSnippetWithAssistant(assistantDemoScriptFlag, time.Duration(assistantDemoWaitFlag)*time.Second) 27 | }, 28 | } 29 | 30 | var choose = &cobra.Command{ 31 | Use: "choose", 32 | Short: "Choose a specific assistant provider.", 33 | Long: `Enables a specific assistant provider (LLM) by modifying the SnipKit config.`, 34 | Aliases: []string{"switch", "enable"}, 35 | Run: func(cmd *cobra.Command, args []string) { 36 | getAppFromContext(cmd.Context()).EnableAssistant() 37 | }, 38 | } 39 | 40 | func init() { 41 | rootCmd.AddCommand(assistantCmd) 42 | rootCmd.AddCommand(generateCmd) 43 | assistantCmd.AddCommand(generateCmd) 44 | assistantCmd.AddCommand(choose) 45 | } 46 | -------------------------------------------------------------------------------- /cmd/assistant_demo_flags.go: -------------------------------------------------------------------------------- 1 | //go:build demo 2 | 3 | package cmd 4 | 5 | func init() { 6 | generateCmd.PersistentFlags().StringArrayVar( 7 | &assistantDemoScriptFlag, 8 | "demo-script", 9 | []string{}, 10 | "Path to a fixed script (demo purposes only)", 11 | ) 12 | 13 | generateCmd.PersistentFlags().IntVar( 14 | &assistantDemoWaitFlag, 15 | "demo-wait-seconds", 16 | 0, 17 | "Seconds to wait before showing the script (demo purposes only)", 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /cmd/asssitant_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/mock" 7 | 8 | mocks "github.com/lemoony/snipkit/mocks/app" 9 | ) 10 | 11 | func Test_Assistant_GenerateCmd(t *testing.T) { 12 | defer resetCommand(execCmd) 13 | 14 | app := mocks.App{} 15 | app.On("GenerateSnippetWithAssistant", mock.Anything, mock.Anything).Return(nil) 16 | 17 | runExecuteTest(t, []string{"assistant", "generate"}, withApp(&app)) 18 | 19 | app.AssertNumberOfCalls(t, "GenerateSnippetWithAssistant", 1) 20 | } 21 | 22 | func Test_Assistant_Choose(t *testing.T) { 23 | defer resetCommand(execCmd) 24 | 25 | app := mocks.App{} 26 | app.On("EnableAssistant").Return(nil) 27 | 28 | runExecuteTest(t, []string{"assistant", "choose"}, withApp(&app)) 29 | 30 | app.AssertNumberOfCalls(t, "EnableAssistant", 1) 31 | } 32 | -------------------------------------------------------------------------------- /cmd/browse.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | var browseCmd = &cobra.Command{ 8 | Use: "browse", 9 | Short: "Browse all snippets without executing them", 10 | Long: `Browse all available snippets without executing them after pressing enter. This is a way to explore your library 11 | in a safe way in case executing some scripts by accident would have undesirable effects.`, 12 | Run: func(cmd *cobra.Command, args []string) { 13 | app := getAppFromContext(cmd.Context()) 14 | _, _ = app.LookupSnippet() 15 | }, 16 | } 17 | 18 | func init() { 19 | rootCmd.AddCommand(browseCmd) 20 | } 21 | -------------------------------------------------------------------------------- /cmd/browse_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "testing" 5 | 6 | mocks "github.com/lemoony/snipkit/mocks/app" 7 | ) 8 | 9 | func Test_Browse(t *testing.T) { 10 | app := mocks.App{} 11 | app.On("LookupSnippet").Return(nil, nil) 12 | 13 | runExecuteTest(t, []string{"browse"}, withApp(&app)) 14 | 15 | app.AssertNumberOfCalls(t, "LookupSnippet", 1) 16 | } 17 | -------------------------------------------------------------------------------- /cmd/config_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "testing" 5 | 6 | mocks "github.com/lemoony/snipkit/mocks/config" 7 | ) 8 | 9 | func Test_ConfigInit(t *testing.T) { 10 | configService := mocks.ConfigService{} 11 | configService.On("Create").Return() 12 | 13 | runExecuteTest(t, []string{"config", "init"}, withConfigService(&configService)) 14 | 15 | configService.AssertNumberOfCalls(t, "Create", 1) 16 | } 17 | 18 | func Test_ConfigClean(t *testing.T) { 19 | configService := mocks.ConfigService{} 20 | configService.On("Clean").Return(nil) 21 | 22 | runExecuteTest(t, []string{"config", "clean"}, withConfigService(&configService)) 23 | 24 | configService.AssertNumberOfCalls(t, "Clean", 1) 25 | } 26 | 27 | func Test_ConfigEdit(t *testing.T) { 28 | configService := mocks.ConfigService{} 29 | configService.On("Edit").Return(nil) 30 | 31 | runExecuteTest(t, []string{"config", "edit"}, withConfigService(&configService)) 32 | 33 | configService.AssertNumberOfCalls(t, "Edit", 1) 34 | } 35 | 36 | func Test_ConfigMigrate(t *testing.T) { 37 | configService := mocks.ConfigService{} 38 | configService.On("Migrate").Return(nil) 39 | 40 | runExecuteTest(t, []string{"config", "migrate"}, withConfigService(&configService)) 41 | 42 | configService.AssertNumberOfCalls(t, "Migrate", 1) 43 | } 44 | -------------------------------------------------------------------------------- /cmd/copy.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "emperror.dev/errors" 5 | "github.com/atotto/clipboard" 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | var copyCmd = &cobra.Command{ 10 | Use: "copy", 11 | Aliases: []string{"cp"}, 12 | Short: "Copies the snippet to the clipboard", 13 | Long: `Copies the selected snippet to the clipboard for manual execution.`, 14 | Run: func(cmd *cobra.Command, args []string) { 15 | app := getAppFromContext(cmd.Context()) 16 | if ok, snippet := app.LookupAndCreatePrintableSnippet(); ok { 17 | copyToClipboard(snippet) 18 | } 19 | }, 20 | } 21 | 22 | func copyToClipboard(snippet string) { 23 | if err := clipboard.WriteAll(snippet); err != nil { 24 | panic(errors.Wrap(errors.WithStack(err), "failed to write to clipboard")) 25 | } 26 | } 27 | 28 | func init() { 29 | rootCmd.AddCommand(copyCmd) 30 | } 31 | -------------------------------------------------------------------------------- /cmd/copy_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "testing" 5 | 6 | mocks "github.com/lemoony/snipkit/mocks/app" 7 | ) 8 | 9 | func Test_Copy(t *testing.T) { 10 | app := mocks.App{} 11 | app.On("LookupAndCreatePrintableSnippet"). 12 | Return(true, "snippet-printed") 13 | 14 | runExecuteTest(t, []string{"copy"}, withApp(&app)) 15 | 16 | app.AssertNumberOfCalls(t, "LookupAndCreatePrintableSnippet", 1) 17 | 18 | assertClipboardContent(t, "snippet-printed") 19 | } 20 | -------------------------------------------------------------------------------- /cmd/exec.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "regexp" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/lemoony/snipkit/internal/model" 9 | ) 10 | 11 | var ( 12 | execCmdPrintFlag = false 13 | execCmdConfirmFlag = false 14 | execCmdIDFlag string 15 | execCmdParametersFlag []string 16 | 17 | parameterValueRegex = regexp.MustCompile(`^(?P[a-zA-Z_][a-zA-Z0-9_]*)=(?P.*)$`) 18 | ) 19 | 20 | var execCmd = &cobra.Command{ 21 | Use: "exec", 22 | Short: "Execute a snippet directly from the terminal", 23 | Long: `Execute a snippet directly from the terminal. The output of the commands will be visibile in the terminal.`, 24 | Run: func(cmd *cobra.Command, args []string) { 25 | app := getAppFromContext(cmd.Context()) 26 | 27 | if execCmdIDFlag == "" { 28 | app.LookupAndExecuteSnippet(execCmdConfirmFlag, execCmdPrintFlag) 29 | } else { 30 | app.FindScriptAndExecuteWithParameters(execCmdIDFlag, toParameterValues(execCmdParametersFlag), execCmdConfirmFlag, execCmdPrintFlag) 31 | } 32 | }, 33 | } 34 | 35 | func toParameterValues(flagValues []string) []model.ParameterValue { 36 | result := make([]model.ParameterValue, len(flagValues)) 37 | for i, v := range flagValues { 38 | match := parameterValueRegex.FindAllStringSubmatch(v, -1) 39 | if match == nil || len(match) != 1 { 40 | panic("Invalid parameter value: " + v) 41 | } 42 | result[i] = model.ParameterValue{Key: match[0][1], Value: match[0][2]} 43 | } 44 | return result 45 | } 46 | 47 | func init() { 48 | execCmd.PersistentFlags().BoolVar( 49 | &execCmdPrintFlag, 50 | "print", 51 | false, 52 | "print the command before execution on stdout", 53 | ) 54 | 55 | execCmd.PersistentFlags().BoolVar( 56 | &execCmdConfirmFlag, 57 | "confirm", 58 | false, 59 | "the command is printed on stdout before execution for confirmation", 60 | ) 61 | 62 | execCmd.PersistentFlags().StringVar( 63 | &execCmdIDFlag, 64 | "id", 65 | "", 66 | "ID of the snippet to execute", 67 | ) 68 | 69 | execCmd.PersistentFlags().StringArrayVarP( 70 | &execCmdParametersFlag, 71 | "param", 72 | "p", 73 | []string{}, 74 | "Parameter values to be passed to the snippet", 75 | ) 76 | 77 | rootCmd.AddCommand(execCmd) 78 | } 79 | -------------------------------------------------------------------------------- /cmd/exec_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/lemoony/snipkit/internal/model" 7 | mocks "github.com/lemoony/snipkit/mocks/app" 8 | ) 9 | 10 | func Test_Exec(t *testing.T) { 11 | defer resetCommand(execCmd) 12 | 13 | app := mocks.App{} 14 | app.On("LookupAndExecuteSnippet", false, false).Return(nil) 15 | 16 | runExecuteTest(t, []string{"exec"}, withApp(&app)) 17 | 18 | app.AssertNumberOfCalls(t, "LookupAndExecuteSnippet", 1) 19 | app.AssertCalled(t, "LookupAndExecuteSnippet", false, false) 20 | } 21 | 22 | func Test_Exec_WithFlags(t *testing.T) { 23 | defer resetCommand(execCmd) 24 | 25 | app := mocks.App{} 26 | app.On( 27 | "FindScriptAndExecuteWithParameters", 28 | "foo", 29 | []model.ParameterValue{{Key: "KEY1", Value: "VALUE1"}, {Key: "KEY2", Value: "VALUE2"}}, 30 | false, 31 | false, 32 | ).Return(nil) 33 | 34 | runExecuteTest(t, []string{"exec", "--id", "foo", "--param", "KEY1=VALUE1", "--param=KEY2=VALUE2"}, withApp(&app)) 35 | 36 | app.AssertNumberOfCalls(t, "FindScriptAndExecuteWithParameters", 1) 37 | } 38 | -------------------------------------------------------------------------------- /cmd/export.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/lemoony/snipkit/internal/app" 9 | ) 10 | 11 | var ( 12 | exportFormatFlag string 13 | exportFormatMap = map[string]app.ExportFormat{ 14 | "json": app.ExportFormatJSON, 15 | "json-pretty": app.ExportFormatPrettyJSON, 16 | "xml": app.ExportFormatXML, 17 | } 18 | 19 | exportFieldsFlag []string 20 | exportFieldMap = map[string]app.ExportField{ 21 | "id": app.ExportFieldID, 22 | "title": app.ExportFieldTitle, 23 | "content": app.ExportFieldContent, 24 | "parameters": app.ExportFieldParameters, 25 | } 26 | ) 27 | 28 | var exportCmd = &cobra.Command{ 29 | Use: "export", 30 | Short: "Exports snippets on stdout", 31 | Long: `Exports all snippets on stdout as JSON including parsed meta information like parameters.`, 32 | Run: func(cmd *cobra.Command, args []string) { 33 | app := getAppFromContext(cmd.Context()) 34 | fmt.Println(app.ExportSnippets(exportedFields(), exportFormat())) 35 | }, 36 | } 37 | 38 | func exportedFields() []app.ExportField { 39 | result := make([]app.ExportField, len(exportFieldsFlag)) 40 | for i, v := range exportFieldsFlag { 41 | if exportField, ok := exportFieldMap[v]; ok { 42 | result[i] = exportField 43 | } else { 44 | panic("Unsupported field: " + v) 45 | } 46 | } 47 | return result 48 | } 49 | 50 | func exportFormat() app.ExportFormat { 51 | if format, ok := exportFormatMap[exportFormatFlag]; ok { 52 | return format 53 | } 54 | panic("Unsupported export format: " + exportFormatFlag) 55 | } 56 | 57 | func init() { 58 | rootCmd.AddCommand(exportCmd) 59 | 60 | exportCmd.PersistentFlags().StringSliceVarP( 61 | &exportFieldsFlag, 62 | "fields", 63 | "f", 64 | []string{"id", "title", "content", "parameters"}, 65 | "Fields to be exported", 66 | ) 67 | 68 | exportCmd.PersistentFlags().StringVarP( 69 | &exportFormatFlag, 70 | "output", 71 | "o", 72 | "json", 73 | "Output format. One of: json,json-pretty,xml", 74 | ) 75 | } 76 | -------------------------------------------------------------------------------- /cmd/export_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/mock" 8 | 9 | appx "github.com/lemoony/snipkit/internal/app" 10 | mocks "github.com/lemoony/snipkit/mocks/app" 11 | ) 12 | 13 | func Test_Export(t *testing.T) { 14 | app := mocks.App{} 15 | app.On("ExportSnippets", mock.AnythingOfType("[]app.ExportField"), mock.AnythingOfType("app.ExportFormat")). 16 | Return("{}") 17 | 18 | runExecuteTest(t, []string{"export", "-f=id,title", "--output", "json-pretty"}, withApp(&app)) 19 | 20 | app.AssertNumberOfCalls(t, "ExportSnippets", 1) 21 | 22 | exportFields := app.Calls[0].Arguments.Get(0).([]appx.ExportField) 23 | assert.Len(t, exportFields, 2) 24 | assert.Equal(t, appx.ExportFieldID, exportFields[0]) 25 | assert.Equal(t, appx.ExportFieldTitle, exportFields[1]) 26 | 27 | assert.Equal(t, appx.ExportFormatPrettyJSON, app.Calls[0].Arguments.Get(1).(appx.ExportFormat)) 28 | } 29 | -------------------------------------------------------------------------------- /cmd/info.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | var infoCmd = &cobra.Command{ 8 | Use: "info", 9 | Short: "Provides useful information about the snipkit configuration", 10 | Long: `This command is useful to view the current configuration of SnipKit, 11 | helping to debug any issues you may experience`, 12 | Run: func(cmd *cobra.Command, args []string) { 13 | getAppFromContextWithConfigMigrationCheck(cmd.Context(), false).Info() 14 | }, 15 | } 16 | 17 | func init() { 18 | rootCmd.AddCommand(infoCmd) 19 | } 20 | -------------------------------------------------------------------------------- /cmd/info_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/spf13/viper" 7 | 8 | "github.com/lemoony/snipkit/internal/config/configtest" 9 | "github.com/lemoony/snipkit/internal/model" 10 | "github.com/lemoony/snipkit/internal/utils/termtest" 11 | "github.com/lemoony/snipkit/internal/utils/testutil" 12 | mocks "github.com/lemoony/snipkit/mocks/managers" 13 | ) 14 | 15 | func Test_Info(t *testing.T) { 16 | configtest.ResetSnipkitHome(t) 17 | 18 | system := testutil.NewTestSystem() 19 | cfgFilePath := configtest.NewTestConfigFilePath(t, system.Fs) 20 | 21 | manager := mocks.Manager{} 22 | manager.On("Info").Return([]model.InfoLine{{Key: "Some-Key", Value: "Some-Value", IsError: false}}) 23 | 24 | v := viper.New() 25 | v.SetFs(system.Fs) 26 | v.SetConfigFile(cfgFilePath) 27 | 28 | ts := setup{ 29 | system: system, 30 | v: v, 31 | provider: testProviderForManager(&manager), 32 | } 33 | 34 | runTerminalTest(t, []string{"info"}, ts, false, func(c *termtest.Console) { 35 | c.ExpectString("Config path: " + cfgFilePath) 36 | c.ExpectString("SNIPKIT_HOME: Not set") 37 | c.ExpectString("Theme: default") 38 | c.ExpectString("Some-Key: Some-Value") 39 | }) 40 | } 41 | -------------------------------------------------------------------------------- /cmd/manager.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | var managerCmd = &cobra.Command{ 8 | Use: "manager", 9 | Short: "Manage the snippet managers snipkit connects to", 10 | } 11 | 12 | var managerAddCommand = &cobra.Command{ 13 | Use: "add", 14 | Short: "Add a new snippet manager", 15 | Long: `Add a new snippet manager to your config. SnipKit will connect to it and provide all snippets to you 16 | which meet certain criteria.`, 17 | Run: func(cmd *cobra.Command, args []string) { 18 | getAppFromContext(cmd.Context()).AddManager() 19 | }, 20 | } 21 | 22 | var managerSyncCommand = &cobra.Command{ 23 | Use: "sync", 24 | Short: "Synchronizes all snippet managers", 25 | Long: `Synchronizes all snippet managers. This updates the cache in case a specific manager requires caching of the 26 | snippets.`, 27 | Run: func(cmd *cobra.Command, args []string) { 28 | getAppFromContext(cmd.Context()).SyncManager() 29 | }, 30 | } 31 | 32 | func init() { 33 | rootCmd.AddCommand(managerCmd) 34 | 35 | managerCmd.AddCommand(managerAddCommand) 36 | managerCmd.AddCommand(managerSyncCommand) 37 | } 38 | -------------------------------------------------------------------------------- /cmd/manager_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "testing" 5 | 6 | mocks "github.com/lemoony/snipkit/mocks/app" 7 | ) 8 | 9 | func Test_ManagerSync(t *testing.T) { 10 | app := mocks.App{} 11 | app.On("SyncManager").Return(nil, nil) 12 | 13 | runExecuteTest(t, []string{"manager", "sync"}, withApp(&app)) 14 | 15 | app.AssertNumberOfCalls(t, "SyncManager", 1) 16 | } 17 | -------------------------------------------------------------------------------- /cmd/print_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/lemoony/snipkit/internal/model" 7 | mocks "github.com/lemoony/snipkit/mocks/app" 8 | ) 9 | 10 | func Test_Print(t *testing.T) { 11 | defer resetCommand(printCmd) 12 | 13 | app := mocks.App{} 14 | app.On("LookupAndCreatePrintableSnippet"). 15 | Return(true, "snippet-printed") 16 | 17 | runExecuteTest(t, []string{"print", "--copy"}, withApp(&app)) 18 | 19 | app.AssertNumberOfCalls(t, "LookupAndCreatePrintableSnippet", 1) 20 | assertClipboardContent(t, "snippet-printed") 21 | } 22 | 23 | func Test_Print_WithCmdFlags(t *testing.T) { 24 | defer resetCommand(printCmd) 25 | 26 | app := mocks.App{} 27 | app.On("FindSnippetAndPrint", "foo", []model.ParameterValue{{Key: "KEY1", Value: "VALUE1"}, {Key: "KEY2", Value: "VALUE2"}}). 28 | Return(true, "snippet-printed") 29 | 30 | runExecuteTest(t, []string{"print", "--copy", "--id", "foo", "--param", "KEY1=VALUE1", "--param=KEY2=VALUE2"}, withApp(&app)) 31 | 32 | app.AssertNumberOfCalls(t, "FindSnippetAndPrint", 1) 33 | assertClipboardContent(t, "snippet-printed") 34 | } 35 | 36 | func Test_Print_WithArgsFlags(t *testing.T) { 37 | defer resetCommand(printCmd) 38 | 39 | app := mocks.App{} 40 | app.On("LookupSnippetArgs"). 41 | Return(true, "foo-id", []model.ParameterValue{{Key: "Key1", Value: "Val1"}}) 42 | 43 | runExecuteTest(t, []string{"print", "--args", "--copy"}, withApp(&app)) 44 | 45 | app.AssertNumberOfCalls(t, "LookupSnippetArgs", 1) 46 | assertClipboardContent(t, "snipkit exec --id foo-id --param Key1=Val1") 47 | } 48 | -------------------------------------------------------------------------------- /cmd/sync.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | // syncCmd is a short alias for managerSyncCommand. 8 | var syncCmd = &cobra.Command{ 9 | Use: "sync", 10 | Short: "Synchronizes all snippet managers", 11 | Long: `Synchronizes all snippet managers. This updates the cache in case a specific manager requires caching of the 12 | snippets. Alias for manager sync.`, 13 | Run: managerSyncCommand.Run, 14 | } 15 | 16 | func init() { 17 | rootCmd.AddCommand(syncCmd) 18 | } 19 | -------------------------------------------------------------------------------- /cmd/sync_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "testing" 5 | 6 | mocks "github.com/lemoony/snipkit/mocks/app" 7 | ) 8 | 9 | func Test_Sync(t *testing.T) { 10 | app := mocks.App{} 11 | app.On("SyncManager").Return(nil, nil) 12 | 13 | runExecuteTest(t, []string{"sync"}, withApp(&app)) 14 | 15 | app.AssertNumberOfCalls(t, "SyncManager", 1) 16 | } 17 | -------------------------------------------------------------------------------- /docs/assistant/gemini.md: -------------------------------------------------------------------------------- 1 | # Gemini Assistant 2 | 3 | ## Configuration 4 | 5 | ```yaml title="config.yaml" 6 | version: 1.2.0 7 | config: 8 | assistant: 9 | gemini: 10 | # If set to false, Gemini will not be used as an AI assistant. 11 | enabled: true 12 | # Gemini API endpoint. 13 | endpoint: https://generativelanguage.googleapis.com 14 | # Gemini Model to be used (e.g., openai/gpt-4o) 15 | model: gemini-1.5-flash 16 | # The name of the environment variable holding the Gemini API key. 17 | apiKeyEnv: SNIPKIT_GEMINI_API_KEY 18 | ``` 19 | 20 | !!! info 21 | For this configuration, you will need to provide the API key for the Gemini API via the environment variable `SNIPKIT_GEMINI_API_KEY`. -------------------------------------------------------------------------------- /docs/assistant/openai.md: -------------------------------------------------------------------------------- 1 | # OpenAI Assistant 2 | 3 | ## Configuration 4 | 5 | ```yaml title="config.yaml" 6 | version: 1.2.0 7 | config: 8 | assistant: 9 | openai: 10 | # If set to false, OpenAI will not be used as an AI assistant. 11 | enabled: true 12 | # OpenAI API endpoint. 13 | endpoint: https://api.openai.co 14 | # OpenAI Model to be used (e.g., openai/gpt-4o) 15 | model: openai/gpt-4o 16 | # The name of the environment variable holding the OpenAI API key. 17 | apiKeyEnv: SNIPKIT_OPENAI_API_KEY 18 | ``` 19 | 20 | !!! info 21 | For this configuration, you will need to provide the API key for the OpenAI API via the environment variable `SNIPKIT_OPENAI_API_KEY`. -------------------------------------------------------------------------------- /docs/getting-started/power-setup.md: -------------------------------------------------------------------------------- 1 | # Power setup 2 | 3 | !!! tip "Customize Snipkit" 4 | Also have a look at [fzf][fzf] to get an understanding of how to customize Snipkit even more to fit your needs. 5 | 6 | ### Alias 7 | 8 | Always typing the full name `snipkit` in order to open the manager might be too 9 | cumbersome for you. Instead, define an alias (e.g. in your `.zshrc` file): 10 | 11 | ```bash 12 | # SnipKit alias 13 | sn () { 14 | snipkit exec "$@" 15 | } 16 | ``` 17 | 18 | Then you can just type `sn` instead of `snipkit` to execute a script via SnipKit. 19 | 20 | ### Inline command for ZSH 21 | 22 | The `print -z` command in Zsh is used to push a command onto the Zsh input buffer, which effectively allows you to 23 | simulate typing a command into the terminal. 24 | 25 | The specified command appears as if you had typed it at the prompt, but it's not executed immediately; instead, it 26 | waits for you to press Enter. This can be used as an alternative to SnipKit confirmation mechanism (via the 27 | `--confirm` flag). For ease of convenience, define another alias: 28 | 29 | ```bash 30 | # SnipKit alias 31 | sn () { 32 | print -z $(snipkit print) 33 | } 34 | ``` 35 | 36 | ### Default Root Command 37 | 38 | Most of the time, you want to call the same subcommand, e.g. `print` or `exec`. You 39 | can configure `snipkit` so that this command gets executed by default by editing the config: 40 | 41 | *Example:* 42 | 43 | ```yaml 44 | # snipkit config edit 45 | defaultRootCommand: "exec" 46 | ``` 47 | 48 | With this setup, calling `sn` will yield the same result as `snipkit exec`. If you want to call 49 | the `print` command instead, type `sn print`. 50 | 51 | [fzf]: ./fzf.md 52 | -------------------------------------------------------------------------------- /docs/images/assistant/assistant-choose.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lemoony/snipkit/bca1be1f6647f6d1db5d39815be5d6c388888ddc/docs/images/assistant/assistant-choose.gif -------------------------------------------------------------------------------- /docs/images/assistant/assistant-save.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lemoony/snipkit/bca1be1f6647f6d1db5d39815be5d6c388888ddc/docs/images/assistant/assistant-save.gif -------------------------------------------------------------------------------- /docs/images/assistant/assistant-zip.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lemoony/snipkit/bca1be1f6647f6d1db5d39815be5d6c388888ddc/docs/images/assistant/assistant-zip.gif -------------------------------------------------------------------------------- /docs/images/assistant/assistant.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lemoony/snipkit/bca1be1f6647f6d1db5d39815be5d6c388888ddc/docs/images/assistant/assistant.gif -------------------------------------------------------------------------------- /docs/images/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lemoony/snipkit/bca1be1f6647f6d1db5d39815be5d6c388888ddc/docs/images/demo.gif -------------------------------------------------------------------------------- /docs/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lemoony/snipkit/bca1be1f6647f6d1db5d39815be5d6c388888ddc/docs/images/logo.png -------------------------------------------------------------------------------- /docs/images/param-example-password.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lemoony/snipkit/bca1be1f6647f6d1db5d39815be5d6c388888ddc/docs/images/param-example-password.gif -------------------------------------------------------------------------------- /docs/images/param-example-path.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lemoony/snipkit/bca1be1f6647f6d1db5d39815be5d6c388888ddc/docs/images/param-example-path.gif -------------------------------------------------------------------------------- /docs/images/param-example-predefined-values.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lemoony/snipkit/bca1be1f6647f6d1db5d39815be5d6c388888ddc/docs/images/param-example-predefined-values.gif -------------------------------------------------------------------------------- /docs/images/themes/default-dark-form.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lemoony/snipkit/bca1be1f6647f6d1db5d39815be5d6c388888ddc/docs/images/themes/default-dark-form.png -------------------------------------------------------------------------------- /docs/images/themes/default-dark-lookup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lemoony/snipkit/bca1be1f6647f6d1db5d39815be5d6c388888ddc/docs/images/themes/default-dark-lookup.png -------------------------------------------------------------------------------- /docs/images/themes/default-light-form.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lemoony/snipkit/bca1be1f6647f6d1db5d39815be5d6c388888ddc/docs/images/themes/default-light-form.png -------------------------------------------------------------------------------- /docs/images/themes/default-light-lookup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lemoony/snipkit/bca1be1f6647f6d1db5d39815be5d6c388888ddc/docs/images/themes/default-light-lookup.png -------------------------------------------------------------------------------- /docs/images/themes/simple-form.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lemoony/snipkit/bca1be1f6647f6d1db5d39815be5d6c388888ddc/docs/images/themes/simple-form.png -------------------------------------------------------------------------------- /docs/images/themes/simple-lookup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lemoony/snipkit/bca1be1f6647f6d1db5d39815be5d6c388888ddc/docs/images/themes/simple-lookup.png -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | hide: 3 | - navigation 4 | - toc 5 | --- 6 | 7 | # SnipKit 8 | 9 | ![Demo](./images/demo.gif){ align=left } 10 | 11 | ## Execute the scripts saved in your favorite snippet manager without even leaving the terminal. 12 | 13 | Retrieve snippets from a list of external snippet managers, so you can keep your scripts all at one place and manage 14 | them more easily: 15 | 16 | * Search for snippets by typing. 17 | 18 | * Execute them with parameters. Define default values or a list of pre-defined values to pick from. 19 | 20 | * Use SnipKit Assistant to generate scripts with the help of AI. 21 | 22 | * Customize SnipKit with a theme to match the look of your terminal. 23 | 24 | [Getting started](./getting-started/overview.md){ .md-button .md-button--primary } 25 | -------------------------------------------------------------------------------- /docs/managers/overview.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | Managers are the actual provider of snippets. 4 | 5 | ## Supported managers 6 | 7 | - [SnippetsLab](https://www.renfei.org/snippets-lab/) 8 | - [Snip](https://github.com/Pictarine/macos-snippets) 9 | - GitHub Gist ([Example gist](https://gist.github.com/lemoony/4905e7468b8f0a7991d6122d7d09e40d)) 10 | - [Pet](https://github.com/knqyf263/pet) 11 | - [MassCode](https://masscode.io/) 12 | 13 | Moreover, SnipKit allows you to provide snippets via a simple [file system directory][fslibrary]. 14 | 15 | ## Adding a manager 16 | 17 | Adding a manager means that SnipKit will retrieve snippets from it each time it is started. 18 | 19 | This command lets you add a manager to your [configuration][configuration]: 20 | 21 | ```sh 22 | snipkit manager add 23 | ``` 24 | 25 | It represents a list of all supported managers that have not been added or enabled in your configuration. It will try to 26 | detect the path to the manager and configure everything automatically. If SnipKit thinks it has found the 27 | corresponding manager and everything looks good so far, it will be enabled. Otherwise, all required config options will 28 | be added to your config file, however, the manager will be disabled. 29 | 30 | 31 | ## Enabling & Disabling 32 | 33 | Each manager can be enabled or disabled. By default, all managers are disabled: 34 | 35 | ```yaml title="config.yaml" 36 | manager: 37 | : 38 | # If set to false, the is disabled 39 | enabled: true 40 | ``` 41 | 42 | If a manager does not work, SnipKit refuses to startup. In this case, disable the manager by setting `enabled: false` or 43 | fix the configuration. 44 | 45 | 46 | [configuration]: ../configuration/overview.md 47 | [fslibrary]: ./fslibrary.md 48 | -------------------------------------------------------------------------------- /docs/managers/pet.md: -------------------------------------------------------------------------------- 1 | # Pet 2 | 3 | Available for: macOS, Linux 4 | 5 | [Repository](https://github.com/knqyf263/pet) 6 | 7 | ## Configuration 8 | 9 | The configuration for Pet may look similar to this: 10 | 11 | ```yaml title="config.yaml" 12 | manager: 13 | pet: 14 | # Set to true if you want to use pet. 15 | enabled: true 16 | # List of pet snippet files. 17 | libraryPaths: 18 | - /Users/testuser/.config/pet/snippet.toml 19 | # If this list is not empty, only those snippets that match the listed tags will be provided to you. 20 | includeTags: 21 | - snipkit 22 | - othertag 23 | ``` 24 | 25 | Upon adding Pet as a manager, SnipKit will try to detect a default `librayPath` automatically. If the library file was not 26 | found, `enabled` will be set to `false`. 27 | 28 | With this example configuration, SnipKit gets all snippets from Pet which are tagged `snipkit` or `othertag`. All other 29 | snippets will not be presented to you. If you don't want to filter for tags, set `includeTags: []`. 30 | 31 | ## Parameter 32 | 33 | Pet comes with its own parameter syntax in the form of ``, `` or ``. 34 | SnipKit supports this syntax and you should have no problems using your Pet snippets the same way in SnipKit. 35 | 36 | !!! tip 37 | While being easy to use, Pet's parameter syntax is less expressive than the one of SnipKit. 38 | Migrate to the [file system directory][fslibrary] manager if you want to take advantage of the additional SnipKit 39 | features like multiple value options or parameter descriptions. 40 | 41 | 42 | [fslibrary]: ./fslibrary.md 43 | -------------------------------------------------------------------------------- /docs/managers/pictarinesnip.md: -------------------------------------------------------------------------------- 1 | # Snip 2 | 3 | Available for: macOS 4 | 5 | [Homepage](https://snip.picta-hub.io/) 6 | 7 | [Repository](https://github.com/Pictarine/macos-snippets) 8 | 9 | ## Configuration 10 | 11 | The configuration for Snip may look similar to this: 12 | 13 | ```yaml title="config.yaml" 14 | manager: 15 | pictarineSnip: 16 | # Set to true if you want to use Snip. 17 | enabled: true 18 | # Path to the snippets file. 19 | libraryPath: /Users//Library/Containers/com.pictarine.Snip/Data/Library/Application Support/Snip/snippets 20 | # If this list is not empty, only those snippets that match the listed tags will be provided to you. 21 | includeTags: 22 | - snipkit 23 | - othertag 24 | ``` 25 | 26 | Upon adding Snip as a manager, SnipKit will try to detect the `librayPath` automatically. If the library file was not 27 | found, `enabled` will be set to `false`. 28 | 29 | With this example configuration, SnipKit gets all snippets from Snip which are tagged `snipkit` or `othertag`. All other 30 | snippets will not be presented to you. If you don't want to filter for tags, set `includeTags: []`. 31 | -------------------------------------------------------------------------------- /docs/managers/snippetslab.md: -------------------------------------------------------------------------------- 1 | # SnippetsLab 2 | 3 | Available for: macOS 4 | 5 | [Homepeage](https://www.renfei.org/snippets-lab/) 6 | 7 | ## Configuration 8 | 9 | The configuration for SnippetsLab may look similar to this: 10 | 11 | ```yaml title="config.yaml" 12 | manager: 13 | snippetsLab: 14 | # Set to true if you want to use SnippetsLab. 15 | enabled: true 16 | # Path to your *.snippetslablibrary file. 17 | # SnipKit will try to detect this file automatically when generating the config. 18 | libraryPath: /path/to/main.snippetslablibrary 19 | # If this list is not empty, only those snippets that match the listed tags will be provided to you. 20 | includeTags: 21 | - snipkit 22 | - othertag 23 | ``` 24 | 25 | With this example configuration, SnipKit gets all snippets from SnippetsLab which are tagged with `snipkit` or `othertag`. 26 | All other snippets will not be presented to you. If you don't want to filter for tags, set `includeTags: []`. 27 | 28 | ## Library path 29 | 30 | Snipkit will try to automatically detect the path to the currently configured `*.snippetslablibrary` file. 31 | 32 | If you have iCloud sync enabled, the path will be similar to: 33 | 34 | ``` 35 | /Users//Library/Containers/com.renfei.SnippetsLab/Data/Library/Application Support/com.renfei.SnippetsLab/main.snippetslablibrary 36 | ``` 37 | SnippetsLab lets you configure a custom library path. In this case, SnipKit will try to detect the preferences 38 | file of SnippetsLab: 39 | 40 | ``` 41 | /Users//Library/Containers/com.renfei.SnippetsLab/Data/Library/Preferences/com.renfei.SnippetsLab.plist 42 | ``` 43 | 44 | The preferences file holds the path to the current `*.snippetslablibrary` file (if iCloud sync is turned off). 45 | -------------------------------------------------------------------------------- /internal/app/app_info.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/lemoony/snipkit/internal/model" 7 | ) 8 | 9 | func (a *appImpl) Info() { 10 | a.printInfo(a.configService.Info()) 11 | for _, manager := range a.managers { 12 | a.printInfo(manager.Info()) 13 | } 14 | } 15 | 16 | func (a *appImpl) printInfo(info []model.InfoLine) { 17 | for _, line := range info { 18 | if line.IsError { 19 | a.tui.PrintError(fmt.Sprintf("%s: %s", line.Key, line.Value)) 20 | } else { 21 | a.tui.PrintMessage(fmt.Sprintf("%s: %s", line.Key, line.Value)) 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /internal/app/app_info_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/mock" 7 | 8 | "github.com/lemoony/snipkit/internal/config/configtest" 9 | "github.com/lemoony/snipkit/internal/model" 10 | "github.com/lemoony/snipkit/internal/utils/testutil/mockutil" 11 | configMocks "github.com/lemoony/snipkit/mocks/config" 12 | managerMocks "github.com/lemoony/snipkit/mocks/managers" 13 | uiMocks "github.com/lemoony/snipkit/mocks/ui" 14 | ) 15 | 16 | func Test_App_Info(t *testing.T) { 17 | tui := uiMocks.TUI{} 18 | tui.On(mockutil.ApplyConfig, mock.Anything, mock.Anything).Return() 19 | 20 | cfg := configtest.NewTestConfig().Config 21 | 22 | cfgService := configMocks.ConfigService{} 23 | cfgService.On("LoadConfig").Return(cfg, nil) 24 | cfgService.On("NeedsMigration").Return(false, "") 25 | cfgService.On("Info").Return([]model.InfoLine{ 26 | {Key: "Some-Config-Key", Value: "Some-Value", IsError: false}, 27 | }) 28 | 29 | tui.On(mockutil.PrintMessage, mock.Anything) 30 | tui.On(mockutil.PrintError, mock.Anything) 31 | 32 | manager := managerMocks.Manager{} 33 | manager.On("Info").Return([]model.InfoLine{ 34 | {Key: "Some-Key", Value: "Some-Value", IsError: false}, 35 | {Key: "Some-Error", Value: "Some-Error", IsError: true}, 36 | }) 37 | 38 | app := NewApp( 39 | WithTUI(&tui), WithConfigService(&cfgService), withManager(&manager), 40 | ) 41 | 42 | app.Info() 43 | 44 | tui.AssertCalled(t, mockutil.PrintMessage, "Some-Config-Key: Some-Value") 45 | tui.AssertCalled(t, mockutil.PrintMessage, "Some-Key: Some-Value") 46 | tui.AssertCalled(t, mockutil.PrintError, "Some-Error: Some-Error") 47 | } 48 | -------------------------------------------------------------------------------- /internal/app/app_lookup.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "github.com/lemoony/snipkit/internal/model" 5 | ) 6 | 7 | func (a *appImpl) LookupSnippet() (bool, model.Snippet) { 8 | snippets := a.getAllSnippets() 9 | if len(snippets) == 0 { 10 | panic(ErrNoSnippetsAvailable) 11 | } 12 | 13 | if index := a.tui.ShowLookup(snippets, a.config.FuzzySearch); index < 0 { 14 | return false, nil 15 | } else { 16 | return true, snippets[index] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /internal/app/app_lookup_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/mock" 7 | 8 | "github.com/lemoony/snipkit/internal/config/configtest" 9 | "github.com/lemoony/snipkit/internal/model" 10 | "github.com/lemoony/snipkit/internal/utils/assertutil" 11 | "github.com/lemoony/snipkit/internal/utils/testutil/mockutil" 12 | uiMocks "github.com/lemoony/snipkit/mocks/ui" 13 | ) 14 | 15 | func Test_Lookup_ErrNoSnippetsAvailable(t *testing.T) { 16 | var snippets []model.Snippet 17 | 18 | tui := uiMocks.TUI{} 19 | tui.On(mockutil.ApplyConfig, mock.Anything, mock.Anything).Return() 20 | tui.On("ShowLookup", mock.Anything).Return(1) 21 | 22 | app := NewApp( 23 | WithTUI(&tui), WithConfig(configtest.NewTestConfig().Config), withManagerSnippets(snippets), 24 | ) 25 | 26 | _ = assertutil.AssertPanicsWithError(t, ErrNoSnippetsAvailable, func() { 27 | app.LookupSnippet() 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /internal/app/app_print.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "github.com/lemoony/snipkit/internal/model" 5 | "github.com/lemoony/snipkit/internal/ui" 6 | ) 7 | 8 | func (a *appImpl) LookupAndCreatePrintableSnippet() (bool, string) { 9 | if ok, snippet := a.LookupSnippet(); ok { 10 | parameters := snippet.GetParameters() 11 | if parameterValues, paramOk := a.tui.ShowParameterForm(parameters, nil, ui.OkButtonPrint); paramOk { 12 | return true, snippet.Format(parameterValues, formatOptions(a.config.Script)) 13 | } 14 | } 15 | 16 | return false, "" 17 | } 18 | 19 | func (a *appImpl) LookupSnippetArgs() (bool, string, []model.ParameterValue) { 20 | if ok, snippet := a.LookupSnippet(); ok { 21 | parameters := snippet.GetParameters() 22 | if parameterValues, paramOk := a.tui.ShowParameterForm(parameters, nil, ui.OkButtonPrint); paramOk { 23 | return true, snippet.GetID(), matchParameterToValues(parameters, parameterValues) 24 | } 25 | } 26 | return false, "", nil 27 | } 28 | 29 | func (a *appImpl) FindSnippetAndPrint(id string, paramValues []model.ParameterValue) (bool, string) { 30 | if snippetFound, snippet := a.getSnippet(id); !snippetFound { 31 | panic(ErrSnippetIDNotFound) 32 | } else if paramOk, parameters := matchParameters(paramValues, snippet.GetParameters()); paramOk { 33 | return true, snippet.Format(parameters, formatOptions(a.config.Script)) 34 | } else if selectedParams, formOk := a.tui.ShowParameterForm(snippet.GetParameters(), paramValues, ui.OkButtonExecute); formOk { 35 | return true, snippet.Format(selectedParams, formatOptions(a.config.Script)) 36 | } 37 | return false, "" 38 | } 39 | 40 | func matchParameterToValues(parameters []model.Parameter, values []string) []model.ParameterValue { 41 | result := make([]model.ParameterValue, len(parameters)) 42 | for i := range parameters { 43 | result[i] = model.ParameterValue{Key: parameters[i].Key, Value: values[i]} 44 | } 45 | return result 46 | } 47 | -------------------------------------------------------------------------------- /internal/app/helper_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "github.com/stretchr/testify/mock" 5 | 6 | "github.com/lemoony/snipkit/internal/managers" 7 | "github.com/lemoony/snipkit/internal/model" 8 | managerMocks "github.com/lemoony/snipkit/mocks/managers" 9 | ) 10 | 11 | var testSnippetContent = `# ${VAR1} Name: First Output 12 | # ${VAR1} Description: What to print on the tui first 13 | echo "${VAR1}` 14 | 15 | func withManagerSnippets(snippets []model.Snippet) Option { 16 | return optionFunc(func(a *appImpl) { 17 | manager := managerMocks.Manager{} 18 | manager.On("GetSnippets").Return(snippets, nil) 19 | 20 | provider := managerMocks.Provider{} 21 | provider.On("CreateManager", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]managers.Manager{&manager}, nil) 22 | a.provider = &provider 23 | }) 24 | } 25 | 26 | func withManager(m ...managers.Manager) Option { 27 | return optionFunc(func(a *appImpl) { 28 | providerBuilder := managerMocks.Provider{} 29 | providerBuilder.On("CreateManager", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(m, nil) 30 | a.provider = &providerBuilder 31 | }) 32 | } 33 | -------------------------------------------------------------------------------- /internal/assistant/client.go: -------------------------------------------------------------------------------- 1 | package assistant 2 | 3 | type Client interface { 4 | Query(string) (string, error) 5 | } 6 | -------------------------------------------------------------------------------- /internal/assistant/client_provider.go: -------------------------------------------------------------------------------- 1 | package assistant 2 | 3 | import ( 4 | "emperror.dev/errors" 5 | 6 | "github.com/lemoony/snipkit/internal/assistant/gemini" 7 | "github.com/lemoony/snipkit/internal/assistant/openai" 8 | ) 9 | 10 | type ClientProvider interface { 11 | GetClient(config Config) (Client, error) 12 | } 13 | 14 | type clientProviderImpl struct{} 15 | 16 | func (p clientProviderImpl) GetClient(config Config) (Client, error) { 17 | key, err := config.ClientKey() 18 | if err != nil { 19 | return nil, err 20 | } 21 | 22 | switch key { 23 | case openai.Key: 24 | return openai.NewClient(openai.WithConfig(*config.OpenAI)) 25 | case gemini.Key: 26 | return gemini.NewClient(gemini.WithConfig(*config.Gemini)) 27 | default: 28 | return nil, errors.Errorf("Unsupported assistant key %s", key) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /internal/assistant/client_provider_test.go: -------------------------------------------------------------------------------- 1 | package assistant 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/lemoony/snipkit/internal/assistant/gemini" 9 | "github.com/lemoony/snipkit/internal/assistant/openai" 10 | ) 11 | 12 | func Test_clientProviderImpl_GetClient(t *testing.T) { 13 | tests := []struct { 14 | name string 15 | config Config 16 | expectedError bool 17 | expectedClientType interface{} 18 | }{ 19 | { 20 | name: "openai", 21 | config: Config{OpenAI: &openai.Config{Enabled: true}}, 22 | expectedError: false, 23 | expectedClientType: &openai.Client{}, 24 | }, 25 | { 26 | name: "gemini", 27 | config: Config{Gemini: &gemini.Config{Enabled: true}}, 28 | expectedError: false, 29 | expectedClientType: &gemini.Client{}, 30 | }, 31 | { 32 | name: "multiple enabled - error", 33 | config: Config{OpenAI: &openai.Config{Enabled: true}, Gemini: &gemini.Config{Enabled: true}}, 34 | expectedError: true, 35 | }, 36 | { 37 | name: "none enabled - error", 38 | config: Config{OpenAI: &openai.Config{Enabled: false}, Gemini: &gemini.Config{Enabled: false}}, 39 | expectedError: true, 40 | }, 41 | } 42 | for _, tt := range tests { 43 | t.Run(tt.name, func(t *testing.T) { 44 | client, err := clientProviderImpl{}.GetClient(tt.config) 45 | if tt.expectedError { 46 | assert.NotNil(t, err) 47 | } else { 48 | assert.NotNil(t, client) 49 | assert.IsType(t, tt.expectedClientType, client) 50 | } 51 | }) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /internal/assistant/config.go: -------------------------------------------------------------------------------- 1 | package assistant 2 | 3 | import ( 4 | "emperror.dev/errors" 5 | 6 | "github.com/lemoony/snipkit/internal/assistant/gemini" 7 | "github.com/lemoony/snipkit/internal/assistant/openai" 8 | "github.com/lemoony/snipkit/internal/model" 9 | ) 10 | 11 | type SaveMode string 12 | 13 | const ( 14 | SaveModeNever = SaveMode("NEVER") 15 | SaveModeFsLibrary = SaveMode("FS_LIBRARY") 16 | 17 | noopKey = model.AssistantKey("noop") 18 | ) 19 | 20 | var ErrorNoAssistantEnabled = errors.New("No assistant configured or enabled") 21 | 22 | type Config struct { 23 | SaveMode SaveMode `yaml:"saveMode" mapstructure:"saveMode" head_comment:"Defines if you want to save the snippets created by the assistant. Possible values: NEVER | FS_LIBRARY"` 24 | OpenAI *openai.Config `yaml:"openai,omitempty" mapstructure:"openai"` 25 | Gemini *gemini.Config `yaml:"gemini,omitempty" mapstructure:"gemini"` 26 | } 27 | 28 | func (c Config) moreThanOneEnabled() bool { 29 | openAIEnabled := c.OpenAI != nil && c.OpenAI.Enabled 30 | geminiEnabled := c.Gemini != nil && c.Gemini.Enabled 31 | return openAIEnabled && geminiEnabled 32 | } 33 | 34 | func (c Config) ClientKey() (model.AssistantKey, error) { 35 | switch { 36 | case c.moreThanOneEnabled(): 37 | return noopKey, errors.New("Invalid config - more than one assistant is enabled") 38 | case c.OpenAI != nil && c.OpenAI.Enabled: 39 | return openai.Key, nil 40 | case c.Gemini != nil && c.Gemini.Enabled: 41 | return gemini.Key, nil 42 | default: 43 | return noopKey, ErrorNoAssistantEnabled 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /internal/assistant/gemini/config.go: -------------------------------------------------------------------------------- 1 | package gemini 2 | 3 | type Config struct { 4 | Enabled bool `yaml:"enabled" head_comment:"If set to false, Gemnini will not be used as an AI assistant."` 5 | Endpoint string `yaml:"endpoint" head_comment:"Gemini API endpoint."` 6 | Model string `yaml:"model" head_comment:"Gemini Model to be used (e.g., gemini-1.5-flash)"` 7 | APIKeyEnv string `yaml:"apiKeyEnv" head_comment:"The name of the environment variable holding the Gemini API key."` 8 | } 9 | 10 | func AutoDiscoveryConfig(config *Config) Config { 11 | if config != nil { 12 | result := *config 13 | result.Enabled = true 14 | return result 15 | } 16 | 17 | return Config{ 18 | Enabled: true, 19 | Endpoint: "https://generativelanguage.googleapis.com", 20 | Model: "gemini-1.5-flash", 21 | APIKeyEnv: "SNIPKIT_GEMINI_API_KEY", 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /internal/assistant/gemini/config_test.go: -------------------------------------------------------------------------------- 1 | package gemini 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestAutoDiscoveryConfig(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | input *Config 13 | expected Config 14 | }{ 15 | {name: "nil config", input: nil, expected: Config{Enabled: true}}, 16 | {name: "non nil config", input: &Config{Enabled: false}, expected: Config{Enabled: true}}, 17 | } 18 | for _, tt := range tests { 19 | t.Run(tt.name, func(t *testing.T) { 20 | config := AutoDiscoveryConfig(tt.input) 21 | assert.Equal(t, config.Enabled, tt.expected.Enabled) 22 | }) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /internal/assistant/gemini/description.go: -------------------------------------------------------------------------------- 1 | package gemini 2 | 3 | import "github.com/lemoony/snipkit/internal/model" 4 | 5 | const Key = model.AssistantKey("Gemini") 6 | 7 | func Description(config *Config) model.AssistantDescription { 8 | return model.AssistantDescription{ 9 | Key: Key, 10 | Name: "Gemini", 11 | Description: "Use Google Gemini as an assistant AI", 12 | Enabled: config != nil && config.Enabled, 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /internal/assistant/gemini/description_test.go: -------------------------------------------------------------------------------- 1 | package gemini 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestDescription(t *testing.T) { 10 | description := Description(&Config{ 11 | Enabled: true, 12 | }) 13 | 14 | assert.Equal(t, Key, description.Key) 15 | assert.Equal(t, true, description.Enabled) 16 | } 17 | -------------------------------------------------------------------------------- /internal/assistant/gemini/model.go: -------------------------------------------------------------------------------- 1 | package gemini 2 | 3 | type Request struct { 4 | SystemInstruction Instruction `json:"system_instruction"` 5 | Contents []ContentParts `json:"contents"` 6 | SafetySettings []SafetySetting `json:"safetySettings"` 7 | } 8 | 9 | type Instruction struct { 10 | Parts TextPart `json:"parts"` 11 | } 12 | 13 | type SafetySetting struct { 14 | Category string `json:"category"` 15 | Threshold string `json:"threshold"` 16 | } 17 | 18 | type Response struct { 19 | Candidates []Candidate `json:"candidates"` 20 | } 21 | 22 | type Candidate struct { 23 | Content ContentParts `json:"content"` 24 | } 25 | 26 | type ContentParts struct { 27 | Role string `json:"role"` 28 | Parts []TextPart `json:"parts"` 29 | } 30 | 31 | type TextPart struct { 32 | Text string `json:"text"` 33 | } 34 | -------------------------------------------------------------------------------- /internal/assistant/gemini/options.go: -------------------------------------------------------------------------------- 1 | package gemini 2 | 3 | import "github.com/lemoony/snipkit/internal/utils/httputil" 4 | 5 | // Option configures a Manager. 6 | type Option interface { 7 | apply(client *Client) 8 | } 9 | 10 | // optionFunc wraps a func so that it satisfies the Option interface. 11 | type optionFunc func(client *Client) 12 | 13 | func (f optionFunc) apply(client *Client) { 14 | f(client) 15 | } 16 | 17 | func WithConfig(config Config) Option { 18 | return optionFunc(func(client *Client) { 19 | client.config = config 20 | }) 21 | } 22 | 23 | func WithHTTPClient(httpClient httputil.HTTPClient) Option { 24 | return optionFunc(func(c *Client) { 25 | c.httpClient = httpClient 26 | }) 27 | } 28 | -------------------------------------------------------------------------------- /internal/assistant/model.go: -------------------------------------------------------------------------------- 1 | package assistant 2 | 3 | import ( 4 | "github.com/lemoony/snipkit/internal/model" 5 | "github.com/lemoony/snipkit/internal/parser" 6 | ) 7 | 8 | type snippetImpl struct { 9 | id string 10 | path string 11 | content string 12 | tags []string 13 | titleFunc func() string 14 | } 15 | 16 | func (s snippetImpl) GetID() string { 17 | return s.id 18 | } 19 | 20 | func (s snippetImpl) GetTitle() string { 21 | return s.titleFunc() 22 | } 23 | 24 | func (s snippetImpl) GetTags() []string { 25 | return s.tags 26 | } 27 | 28 | func (s snippetImpl) GetContent() string { 29 | return s.content 30 | } 31 | 32 | func (s snippetImpl) GetLanguage() model.Language { 33 | return model.LanguageBash 34 | } 35 | 36 | func (s snippetImpl) GetParameters() []model.Parameter { 37 | return parser.ParseParameters(s.GetContent()) 38 | } 39 | 40 | func (s snippetImpl) Format(values []string, options model.SnippetFormatOptions) string { 41 | return parser.CreateSnippet(s.GetContent(), s.GetParameters(), values, options) 42 | } 43 | -------------------------------------------------------------------------------- /internal/assistant/openai/config.go: -------------------------------------------------------------------------------- 1 | package openai 2 | 3 | type Config struct { 4 | Enabled bool `yaml:"enabled" head_comment:"If set to false, OpenAI will not be used as an AI assistant."` 5 | Endpoint string `yaml:"endpoint" head_comment:"OpenAI API endpoint."` 6 | Model string `yaml:"model" head_comment:"OpenAI Model to be used (e.g., openai/gpt-4o)"` 7 | APIKeyEnv string `yaml:"apiKeyEnv" head_comment:"The name of the environment variable holding the OpenAI API key."` 8 | } 9 | 10 | func AutoDiscoveryConfig(config *Config) Config { 11 | if config != nil { 12 | result := *config 13 | result.Enabled = true 14 | return result 15 | } 16 | 17 | return Config{ 18 | Enabled: true, 19 | Endpoint: "https://api.openai.com", 20 | Model: "openai/gpt-4o", 21 | APIKeyEnv: "SNIPKIT_OPENAI_API_KEY", 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /internal/assistant/openai/config_test.go: -------------------------------------------------------------------------------- 1 | package openai 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestAutoDiscoveryConfig(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | input *Config 13 | expected Config 14 | }{ 15 | {name: "nil config", input: nil, expected: Config{Enabled: true}}, 16 | {name: "non nil config", input: &Config{Enabled: false}, expected: Config{Enabled: true}}, 17 | } 18 | for _, tt := range tests { 19 | t.Run(tt.name, func(t *testing.T) { 20 | config := AutoDiscoveryConfig(tt.input) 21 | assert.Equal(t, config.Enabled, tt.expected.Enabled) 22 | }) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /internal/assistant/openai/description.go: -------------------------------------------------------------------------------- 1 | package openai 2 | 3 | import "github.com/lemoony/snipkit/internal/model" 4 | 5 | const Key = model.AssistantKey("Openai") 6 | 7 | func Description(config *Config) model.AssistantDescription { 8 | return model.AssistantDescription{ 9 | Key: Key, 10 | Name: "OpenAI", 11 | Description: "Use OpenAI as an assistant AI", 12 | Enabled: config != nil && config.Enabled, 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /internal/assistant/openai/description_test.go: -------------------------------------------------------------------------------- 1 | package openai 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestDescription(t *testing.T) { 10 | description := Description(&Config{ 11 | Enabled: true, 12 | }) 13 | 14 | assert.Equal(t, Key, description.Key) 15 | assert.Equal(t, true, description.Enabled) 16 | } 17 | -------------------------------------------------------------------------------- /internal/assistant/openai/model.go: -------------------------------------------------------------------------------- 1 | package openai 2 | 3 | type Request struct { 4 | Model string `json:"model"` 5 | Messages []Message `json:"messages"` 6 | } 7 | 8 | type Message struct { 9 | Role string `json:"role"` 10 | Content string `json:"content"` 11 | } 12 | 13 | type Response struct { 14 | Choices []struct { 15 | Message Message `json:"message"` 16 | } `json:"choices"` 17 | } 18 | -------------------------------------------------------------------------------- /internal/assistant/openai/options.go: -------------------------------------------------------------------------------- 1 | package openai 2 | 3 | import "github.com/lemoony/snipkit/internal/utils/httputil" 4 | 5 | // Option configures a Manager. 6 | type Option interface { 7 | apply(client *Client) 8 | } 9 | 10 | // optionFunc wraps a func so that it satisfies the Option interface. 11 | type optionFunc func(client *Client) 12 | 13 | func (f optionFunc) apply(client *Client) { 14 | f(client) 15 | } 16 | 17 | func WithConfig(config Config) Option { 18 | return optionFunc(func(client *Client) { 19 | client.config = config 20 | }) 21 | } 22 | 23 | func WithHTTPClient(httpClient httputil.HTTPClient) Option { 24 | return optionFunc(func(c *Client) { 25 | c.httpClient = httpClient 26 | }) 27 | } 28 | -------------------------------------------------------------------------------- /internal/assistant/options.go: -------------------------------------------------------------------------------- 1 | package assistant 2 | 3 | import "time" 4 | 5 | type DemoConfig struct { 6 | ScriptPaths []string 7 | QueryDuration time.Duration 8 | } 9 | 10 | // Option configures a Manager. 11 | type Option interface { 12 | apply(a *assistantImpl) 13 | } 14 | 15 | // optionFunc wraps a func so that it satisfies the Option interface. 16 | type optionFunc func(a *assistantImpl) 17 | 18 | func (f optionFunc) apply(provider *assistantImpl) { 19 | f(provider) 20 | } 21 | 22 | func withClientProvider(provider ClientProvider) Option { 23 | return optionFunc(func(a *assistantImpl) { 24 | a.provider = provider 25 | }) 26 | } 27 | 28 | func WithDemoConfig(demo DemoConfig) Option { 29 | return optionFunc(func(a *assistantImpl) { 30 | a.demo = demo 31 | }) 32 | } 33 | -------------------------------------------------------------------------------- /internal/assistant/prompts/prompt.md: -------------------------------------------------------------------------------- 1 | You're an assistant to write snippets for SnipKit based on a provided user prompt. Only provide the snippet without any introduction or outro. 2 | 3 | In order to support snippet parameters, SnipKit requires some special parameter syntax in your scripts. 4 | 5 | All parameters are described by the usage of bash comments. All parameters must be defined at the beginning of the script. The scripts remain functional even if executed without SnipKit. 6 | 7 | Example: 8 | ```sh 9 | # ${VAR} Name: <> 10 | # ${VAR} Description: <> 11 | # ${VAR} Type: either PASSWORD, PATH, TEXT 12 | # ${VAR} Values: <> 13 | # ${VAR} Default: <> 14 | echo "${VAR1}" 15 | ``` 16 | 17 | Ensure that only supported values for `${VAR1} Type` are used (PASSWORD|PATH|TEXT). 18 | 19 | Examples: 20 | 21 | ```sh 22 | # ${VAR1} Name: First Output 23 | # ${VAR1} Description: What to print on the terminal first 24 | # ${VAR1} Default: Hello World! 25 | # ${VAR1} Values: One + some more, "Two",Three 26 | echo "${VAR1}" 27 | ``` 28 | 29 | 30 | ```sh 31 | # ${PW} Name: Login password 32 | # ${PW} Type: PASSWORD 33 | login ${PW} 34 | ``` 35 | 36 | ```sh 37 | # ${FILE} Name: File path 38 | # ${FILE} Type: PATH 39 | git ls-files "${FILE}" | xargs wc -l 40 | ``` 41 | 42 | Provide a shebang and include this comment right after the shebang and replace the placeholders: 43 | 44 | ```sh 45 | <> 46 | 47 | # 48 | # Snippet Title: <> 49 | # Filename: <> 50 | # 51 | ``` 52 | 53 | -------------------------------------------------------------------------------- /internal/assistant/prompts/prompts.go: -------------------------------------------------------------------------------- 1 | package prompts 2 | 3 | import _ "embed" 4 | 5 | //go:embed prompt.md 6 | var DefaultPrompt string 7 | -------------------------------------------------------------------------------- /internal/cache/cache.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "github.com/lemoony/snipkit/internal/utils/system" 5 | ) 6 | 7 | type ( 8 | SecretKey string 9 | DataKey string 10 | ) 11 | 12 | type Cache interface { 13 | GetSecret(key SecretKey, account string) (string, bool) 14 | PutSecret(key SecretKey, account string, secret string) 15 | DeleteSecret(key SecretKey, account string) 16 | PutData(key DataKey, data []byte) 17 | GetData(key DataKey) ([]byte, bool) 18 | EnablePlainFileSecrets() 19 | } 20 | 21 | type cacheImpl struct { 22 | system *system.System 23 | plainFileSecretsEnabled bool 24 | } 25 | 26 | func New(s *system.System) Cache { 27 | return &cacheImpl{system: s} 28 | } 29 | 30 | func (c *cacheImpl) EnablePlainFileSecrets() { 31 | c.plainFileSecretsEnabled = true 32 | } 33 | -------------------------------------------------------------------------------- /internal/cache/data.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "path/filepath" 5 | 6 | "github.com/spf13/afero" 7 | ) 8 | 9 | func (c *cacheImpl) PutData(key DataKey, data []byte) { 10 | path := c.cacheFilepath(key) 11 | c.system.CreatePath(path) 12 | c.system.WriteFile(path, data) 13 | } 14 | 15 | func (c *cacheImpl) GetData(key DataKey) ([]byte, bool) { 16 | bytes, err := afero.ReadFile(c.system.Fs, c.cacheFilepath(key)) 17 | if err != nil { 18 | return nil, false 19 | } 20 | 21 | return bytes, true 22 | } 23 | 24 | func (c *cacheImpl) cacheFilepath(key DataKey) string { 25 | return filepath.Join(c.system.HomeDir(), ".cache", string(key)) 26 | } 27 | -------------------------------------------------------------------------------- /internal/cache/data_test.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/lemoony/snipkit/internal/utils/testutil" 9 | ) 10 | 11 | func Test_PutAndGetData(t *testing.T) { 12 | cache := New(testutil.NewTestSystem()) 13 | 14 | const testKey = DataKey("test_key_1") 15 | 16 | data, ok := cache.GetData(testKey) 17 | assert.False(t, ok) 18 | assert.Nil(t, data) 19 | 20 | cache.PutData(testKey, []byte("foo")) 21 | 22 | data, ok = cache.GetData(testKey) 23 | assert.True(t, ok) 24 | assert.Equal(t, []byte("foo"), data) 25 | } 26 | -------------------------------------------------------------------------------- /internal/cache/secrets.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "fmt" 5 | "path" 6 | 7 | "github.com/phuslu/log" 8 | "github.com/spf13/afero" 9 | "github.com/zalando/go-keyring" 10 | ) 11 | 12 | const noSecret = "" 13 | 14 | func (c *cacheImpl) GetSecret(key SecretKey, account string) (string, bool) { 15 | if c.plainFileSecretsEnabled { 16 | return c.getSecretFromFile(key, account) 17 | } else { 18 | return c.getSecretFromKeyring(key, account) 19 | } 20 | } 21 | 22 | func (c *cacheImpl) getSecretFromKeyring(key SecretKey, account string) (string, bool) { 23 | value, err := keyring.Get(key.service(), account) 24 | switch { 25 | case err == keyring.ErrNotFound: 26 | return noSecret, false 27 | case err != nil: 28 | panic(err) 29 | default: 30 | return value, true 31 | } 32 | } 33 | 34 | func (c *cacheImpl) getSecretFromFile(key SecretKey, account string) (string, bool) { 35 | value, err := afero.ReadFile(c.system.Fs, c.secretFilePath(key, account)) 36 | if err != nil { 37 | return noSecret, false 38 | } 39 | return string(value), true 40 | } 41 | 42 | func (c *cacheImpl) PutSecret(key SecretKey, account, secret string) { 43 | if c.plainFileSecretsEnabled { 44 | secretPath := c.secretFilePath(key, account) 45 | c.system.CreatePath(secretPath) 46 | c.system.WriteFile(secretPath, []byte(secret)) 47 | } else { 48 | if err := keyring.Set(key.service(), account, secret); err != nil { 49 | panic(err) 50 | } 51 | } 52 | } 53 | 54 | func (c *cacheImpl) DeleteSecret(key SecretKey, account string) { 55 | if c.plainFileSecretsEnabled { 56 | if err := c.system.Fs.RemoveAll(c.secretFilePath(key, account)); err != nil { 57 | log.Warn().Str("key", string(key)).Err(err).Msg("Failed to delete secret") 58 | } 59 | } else { 60 | if err := keyring.Delete(key.service(), account); err != nil && err != keyring.ErrNotFound { 61 | panic(err) 62 | } 63 | } 64 | } 65 | 66 | func (c *cacheImpl) secretFilePath(key SecretKey, account string) string { 67 | return path.Join(c.system.HomeDir(), ".secrets", account, string(key)) 68 | } 69 | 70 | func (s SecretKey) service() string { 71 | return fmt.Sprintf("Snipkit %s", string(s)) 72 | } 73 | -------------------------------------------------------------------------------- /internal/config/errors.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "emperror.dev/errors" 5 | 6 | "github.com/lemoony/snipkit/internal/ui/style" 7 | "github.com/lemoony/snipkit/internal/ui/uimsg" 8 | ) 9 | 10 | var ErrInvalidConfig = errors.New("invalid config file") 11 | 12 | type ErrConfigNotFound struct { 13 | cfgPath string 14 | } 15 | 16 | func (e ErrConfigNotFound) Error() string { 17 | return uimsg.ConfigNotFound(e.cfgPath).RenderWith(style.NoopStyle) 18 | } 19 | 20 | func (e ErrConfigNotFound) Is(target error) bool { 21 | _, ok := target.(ErrConfigNotFound) 22 | return ok 23 | } 24 | -------------------------------------------------------------------------------- /internal/config/errors_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | 9 | "github.com/lemoony/snipkit/internal/ui/style" 10 | "github.com/lemoony/snipkit/internal/ui/uimsg" 11 | ) 12 | 13 | func Test_ErrConfigNotFound_string(t *testing.T) { 14 | assert.Equal(t, uimsg.ConfigNotFound("path").RenderWith(style.NoopStyle), ErrConfigNotFound{"path"}.Error()) 15 | } 16 | 17 | func Test_ErrConfigNotFound_Is(t *testing.T) { 18 | assert.True(t, errors.Is(ErrConfigNotFound{"foo"}, ErrConfigNotFound{})) 19 | assert.False(t, errors.Is(errors.New("foo error"), ErrConfigNotFound{})) 20 | } 21 | -------------------------------------------------------------------------------- /internal/config/migrations/migrations.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "emperror.dev/errors" 5 | "gopkg.in/yaml.v3" 6 | 7 | configV110 "github.com/lemoony/snipkit/internal/config/migrations/v110" 8 | configV111 "github.com/lemoony/snipkit/internal/config/migrations/v111" 9 | configV120 "github.com/lemoony/snipkit/internal/config/migrations/v120" 10 | ) 11 | 12 | const ( 13 | Latest = configV120.VersionTo 14 | ) 15 | 16 | func Migrate(config []byte) []byte { 17 | for { 18 | var configMap map[string]interface{} 19 | if err := yaml.Unmarshal(config, &configMap); err != nil { 20 | panic(err) 21 | } 22 | 23 | currentVersion := configMap["version"] 24 | 25 | switch currentVersion { 26 | case configV110.VersionFrom: 27 | config = configV110.Migrate(config) 28 | case configV111.VersionFrom: 29 | config = configV111.Migrate(config) 30 | case configV120.VersionFrom: 31 | config = configV120.Migrate(config) 32 | case Latest: 33 | return config 34 | default: 35 | panic(errors.Errorf("Unsupported config version: %s", currentVersion)) 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /internal/config/migrations/migrations_test.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | 9 | "github.com/lemoony/snipkit/internal/config/testdata" 10 | ) 11 | 12 | func Test_Migrate(t *testing.T) { 13 | tests := []struct { 14 | from testdata.ConfigVersion 15 | to testdata.ConfigVersion 16 | }{ 17 | { 18 | from: testdata.ConfigV100, 19 | to: testdata.ConfigV120, 20 | }, 21 | } 22 | 23 | for _, tt := range tests { 24 | t.Run(fmt.Sprintf("Migrate_%s-%s", tt.from, tt.to), func(t *testing.T) { 25 | input := testdata.ConfigBytes(t, tt.from) 26 | expected := testdata.ConfigBytes(t, tt.to) 27 | 28 | actual := Migrate(input) 29 | assert.YAMLEq(t, string(actual), string(expected)) 30 | }) 31 | } 32 | } 33 | 34 | func Test_Migrate_invalidYamlPanic(t *testing.T) { 35 | assert.Panics(t, func() { 36 | Migrate([]byte("{")) 37 | }) 38 | } 39 | 40 | func Test_Migrate_invalidConfigVersion(t *testing.T) { 41 | assert.Panics(t, func() { 42 | Migrate([]byte("version: 3.0.0")) 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /internal/config/migrations/v110/mapper.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "emperror.dev/errors" 5 | "gopkg.in/yaml.v3" 6 | ) 7 | 8 | const ( 9 | VersionFrom = "1.0.0" 10 | VersionTo = "1.1.0" 11 | ) 12 | 13 | func Migrate(old []byte) []byte { 14 | var config versionWrapper 15 | 16 | if err := yaml.Unmarshal(old, &config); err != nil { 17 | panic(err) 18 | } 19 | 20 | if config.Version != VersionFrom { 21 | panic(errors.Errorf("Invalid version for migration to v1.1.0: %s", config.Version)) 22 | } 23 | 24 | config.Version = VersionTo 25 | 26 | styleCfg := config.Config.Style 27 | styleCfg["hideKeyMap"] = true 28 | 29 | config.Config.Script = map[string]interface{}{} 30 | config.Config.Script["shell"] = "/bin/zsh" 31 | config.Config.Script["parameterMode"] = "SET" 32 | config.Config.Script["removeComments"] = true 33 | 34 | configBytes, err := yaml.Marshal(config) 35 | if err != nil { 36 | panic(err) 37 | } 38 | return configBytes 39 | } 40 | 41 | type versionWrapper struct { 42 | Version string `yaml:"version"` 43 | Config configV110 `yaml:"config"` 44 | } 45 | 46 | type configV110 struct { 47 | Style map[string]interface{} `yaml:"style"` 48 | Editor string `yaml:"editor"` 49 | DefaultRootCommand string `yaml:"defaultRootCommand"` 50 | Script map[string]interface{} `yaml:"scripts"` 51 | Manager map[string]interface{} `yaml:"manager"` 52 | } 53 | -------------------------------------------------------------------------------- /internal/config/migrations/v110/mapper_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/lemoony/snipkit/internal/config/testdata" 9 | ) 10 | 11 | func Test_Migrate(t *testing.T) { 12 | oldCfg := testdata.ConfigBytes(t, testdata.ConfigV100) 13 | newCfg := testdata.ConfigBytes(t, testdata.ConfigV110) 14 | actualCfg := Migrate(oldCfg) 15 | assert.YAMLEq(t, string(newCfg), string(actualCfg)) 16 | } 17 | 18 | func Test_Migrate_invalidYamlPanic(t *testing.T) { 19 | assert.Panics(t, func() { 20 | Migrate([]byte("{")) 21 | }) 22 | } 23 | 24 | func Test_Migrate_invalidConfigVersion(t *testing.T) { 25 | assert.Panics(t, func() { 26 | Migrate([]byte("version: 3.0.0")) 27 | }) 28 | } 29 | -------------------------------------------------------------------------------- /internal/config/migrations/v111/mapper.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "emperror.dev/errors" 5 | "gopkg.in/yaml.v3" 6 | ) 7 | 8 | const ( 9 | VersionFrom = "1.1.0" 10 | VersionTo = "1.1.1" 11 | ) 12 | 13 | func Migrate(old []byte) []byte { 14 | var config versionWrapper 15 | 16 | if err := yaml.Unmarshal(old, &config); err != nil { 17 | panic(err) 18 | } 19 | 20 | if config.Version != VersionFrom { 21 | panic(errors.Errorf("Invalid version for migration to v1.1.1: %s", config.Version)) 22 | } 23 | 24 | config.Version = VersionTo 25 | config.Config.FuzzySearch = true 26 | config.Config.Script["execConfirm"] = false 27 | config.Config.Script["execPrint"] = false 28 | 29 | configBytes, err := yaml.Marshal(config) 30 | if err != nil { 31 | panic(err) 32 | } 33 | return configBytes 34 | } 35 | 36 | type versionWrapper struct { 37 | Version string `yaml:"version"` 38 | Config configV111 `yaml:"config"` 39 | } 40 | 41 | type configV111 struct { 42 | Style map[string]interface{} `yaml:"style"` 43 | Editor string `yaml:"editor"` 44 | DefaultRootCommand string `yaml:"defaultRootCommand"` 45 | FuzzySearch bool `yaml:"fuzzySearch"` 46 | Script map[string]interface{} `yaml:"scripts"` 47 | Manager map[string]interface{} `yaml:"manager"` 48 | } 49 | -------------------------------------------------------------------------------- /internal/config/migrations/v111/mapper_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/lemoony/snipkit/internal/config/testdata" 9 | ) 10 | 11 | func Test_Migrate(t *testing.T) { 12 | oldCfg := testdata.ConfigBytes(t, testdata.ConfigV110) 13 | newCfg := testdata.ConfigBytes(t, testdata.ConfigV111) 14 | actualCfg := Migrate(oldCfg) 15 | assert.YAMLEq(t, string(newCfg), string(actualCfg)) 16 | } 17 | 18 | func Test_Migrate_invalidYamlPanic(t *testing.T) { 19 | assert.Panics(t, func() { 20 | Migrate([]byte("{")) 21 | }) 22 | } 23 | 24 | func Test_Migrate_invalidConfigVersion(t *testing.T) { 25 | assert.Panics(t, func() { 26 | Migrate([]byte("version: 3.0.0")) 27 | }) 28 | } 29 | -------------------------------------------------------------------------------- /internal/config/migrations/v120/mapper.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "emperror.dev/errors" 5 | "gopkg.in/yaml.v3" 6 | ) 7 | 8 | const ( 9 | VersionFrom = "1.1.1" 10 | VersionTo = "1.2.0" 11 | ) 12 | 13 | func Migrate(old []byte) []byte { 14 | var config versionWrapper 15 | 16 | if err := yaml.Unmarshal(old, &config); err != nil { 17 | panic(err) 18 | } 19 | 20 | if config.Version != VersionFrom { 21 | panic(errors.Errorf("Invalid version for migration to v1.2.0: %s", config.Version)) 22 | } 23 | 24 | config.Version = VersionTo 25 | config.Config.SecretStorage = "KEYRING" 26 | config.Config.Assistant = map[string]interface{}{ 27 | "saveMode": "NEVER", 28 | } 29 | 30 | if fslibConfig, ok := config.Config.Manager["fsLibrary"]; ok { 31 | if fslibConfigMap, mapOk := fslibConfig.(map[string]interface{}); mapOk { 32 | fslibConfigMap["assistantLibraryPathIndex"] = 0 33 | } 34 | } 35 | 36 | configBytes, err := yaml.Marshal(config) 37 | if err != nil { 38 | panic(err) 39 | } 40 | return configBytes 41 | } 42 | 43 | type versionWrapper struct { 44 | Version string `yaml:"version"` 45 | Config configV112 `yaml:"config"` 46 | } 47 | 48 | type configV112 struct { 49 | Style map[string]interface{} `yaml:"style"` 50 | Editor string `yaml:"editor"` 51 | DefaultRootCommand string `yaml:"defaultRootCommand"` 52 | FuzzySearch bool `yaml:"fuzzySearch"` 53 | SecretStorage string `yaml:"secretStorage"` 54 | Script map[string]interface{} `yaml:"scripts"` 55 | Assistant map[string]interface{} `yaml:"assistant"` 56 | Manager map[string]interface{} `yaml:"manager"` 57 | } 58 | -------------------------------------------------------------------------------- /internal/config/migrations/v120/mapper_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/lemoony/snipkit/internal/config/testdata" 9 | ) 10 | 11 | func Test_Migrate(t *testing.T) { 12 | oldCfg := testdata.ConfigBytes(t, testdata.ConfigV111) 13 | newCfg := testdata.ConfigBytes(t, testdata.Latest) 14 | actualCfg := Migrate(oldCfg) 15 | assert.YAMLEq(t, string(newCfg), string(actualCfg)) 16 | } 17 | 18 | func Test_Migrate_FsLibrary(t *testing.T) { 19 | oldCfg := testdata.ConfigBytes(t, testdata.ConfigV111FsLibrary) 20 | newCfg := testdata.ConfigBytes(t, testdata.ConfigV120FsLibrary) 21 | actualCfg := Migrate(oldCfg) 22 | assert.YAMLEq(t, string(newCfg), string(actualCfg)) 23 | } 24 | 25 | func Test_Migrate_invalidYamlPanic(t *testing.T) { 26 | assert.Panics(t, func() { 27 | Migrate([]byte("{")) 28 | }) 29 | } 30 | 31 | func Test_Migrate_invalidConfigVersion(t *testing.T) { 32 | assert.Panics(t, func() { 33 | Migrate([]byte("version: 3.0.0")) 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /internal/config/options.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/spf13/viper" 5 | 6 | "github.com/lemoony/snipkit/internal/ui" 7 | "github.com/lemoony/snipkit/internal/utils/system" 8 | ) 9 | 10 | // Option configures an App. 11 | type Option interface { 12 | apply(s *serviceImpl) 13 | } 14 | 15 | // terminalOptionFunc wraps a func so that it satisfies the Option interface. 16 | type optionFunc func(s *serviceImpl) 17 | 18 | func (f optionFunc) apply(s *serviceImpl) { 19 | f(s) 20 | } 21 | 22 | // WithTerminal sets the tui for the Service. 23 | func WithTerminal(t ui.TUI) Option { 24 | return optionFunc(func(s *serviceImpl) { 25 | s.tui = t 26 | }) 27 | } 28 | 29 | // WithViper sets the viper instance for the Service. 30 | func WithViper(v *viper.Viper) Option { 31 | return optionFunc(func(s *serviceImpl) { 32 | s.v = v 33 | }) 34 | } 35 | 36 | // WithSystem sets the system instance for the Service. 37 | func WithSystem(system *system.System) Option { 38 | return optionFunc(func(s *serviceImpl) { 39 | s.system = system 40 | }) 41 | } 42 | -------------------------------------------------------------------------------- /internal/config/testdata/migrations/config-1-0-0.yaml: -------------------------------------------------------------------------------- 1 | version: 1.0.0 2 | config: 3 | style: 4 | # The theme defines the terminal colors used by Snipkit. 5 | # Available themes:default(.light|.dark),simple. 6 | theme: simple 7 | # Your preferred editor to open the config file when typing 'snipkit config edit'. 8 | editor: foo-editor # Defaults to a reasonable value for your operation system when empty. 9 | # The path to the shell to execute scripts with. If not set or empty, $SHELL will be used instead. Fallback is '/bin/bash'. 10 | shell: /bin/zsh 11 | # The command which should run if you don't provide any subcommand. 12 | defaultRootCommand: "" # If not set, the help text will be shown. 13 | manager: {} 14 | -------------------------------------------------------------------------------- /internal/config/testdata/migrations/config-1-1-0.yaml: -------------------------------------------------------------------------------- 1 | version: 1.1.0 2 | config: 3 | style: 4 | # The theme defines the terminal colors used by Snipkit. 5 | # Available themes:default(.light|.dark),simple. 6 | theme: simple 7 | # If set to true, the key map won't be displayed. Default value: false 8 | hideKeyMap: true 9 | # Your preferred editor to open the config file when typing 'snipkit config edit'. 10 | editor: foo-editor # Defaults to a reasonable value for your operation system when empty. 11 | # The command which should run if you don't provide any subcommand. 12 | defaultRootCommand: "" # If not set, the help text will be shown. 13 | scripts: 14 | # The path to the shell to execute scripts with. If not set or empty, $SHELL will be used instead. Fallback is '/bin/bash'. 15 | shell: /bin/zsh 16 | # Defines how parameters are handled. Allowed values: SET (sets the parameter value as shell variable) and REPLACE (replaces all occurrences of the variable with the actual value) 17 | parameterMode: SET 18 | # If set to true, any comments in your scripts will be removed upon executing or printing. 19 | removeComments: true 20 | manager: {} 21 | -------------------------------------------------------------------------------- /internal/config/testdata/migrations/config-1-1-1.yaml: -------------------------------------------------------------------------------- 1 | version: 1.1.1 2 | config: 3 | style: 4 | # The theme defines the terminal colors used by Snipkit. 5 | # Available themes:default(.light|.dark),simple. 6 | theme: simple 7 | # If set to true, the key map won't be displayed. Default value: false 8 | hideKeyMap: true 9 | # Your preferred editor to open the config file when typing 'snipkit config edit'. 10 | editor: foo-editor # Defaults to a reasonable value for your operation system when empty. 11 | # The command which should run if you don't provide any subcommand. 12 | defaultRootCommand: "" # If not set, the help text will be shown. 13 | # Enable fuzzy searching for snippet titles. 14 | fuzzySearch: true 15 | scripts: 16 | # The path to the shell to execute scripts with. If not set or empty, $SHELL will be used instead. Fallback is '/bin/bash'. 17 | shell: /bin/zsh 18 | # Defines how parameters are handled. Allowed values: SET (sets the parameter value as shell variable) and REPLACE (replaces all occurrences of the variable with the actual value) 19 | parameterMode: SET 20 | # If set to true, any comments in your scripts will be removed upon executing or printing. 21 | removeComments: true 22 | # If set to true, the executed command is always printed on stdout before execution for confirmation (same functionality as providing flag -c/--confirm). 23 | execConfirm: false 24 | # If set to true, the executed command is always printed on stdout (same functionality as providing flag -p/--print). 25 | execPrint: false 26 | manager: {} 27 | -------------------------------------------------------------------------------- /internal/config/testdata/migrations/config-1-2-0.yaml: -------------------------------------------------------------------------------- 1 | version: 1.2.0 2 | config: 3 | style: 4 | # The theme defines the terminal colors used by Snipkit. 5 | # Available themes:default(.light|.dark),simple. 6 | theme: simple 7 | # If set to true, the key map won't be displayed. Default value: false 8 | hideKeyMap: true 9 | # Your preferred editor to open the config file when typing 'snipkit config edit'. 10 | editor: foo-editor # Defaults to a reasonable value for your operation system when empty. 11 | # The command which should run if you don't provide any subcommand. 12 | defaultRootCommand: "" # If not set, the help text will be shown. 13 | # Enable fuzzy searching for snippet titles. 14 | fuzzySearch: true 15 | # How secrets like access tokens are stored (see https://lemoony.github.io/snipkit/latest/configuration/overview/#secret-storage). 16 | secretStorage: KEYRING 17 | scripts: 18 | # The path to the shell to execute scripts with. If not set or empty, $SHELL will be used instead. Fallback is '/bin/bash'. 19 | shell: /bin/zsh 20 | # Defines how parameters are handled. Allowed values: SET (sets the parameter value as shell variable) and REPLACE (replaces all occurrences of the variable with the actual value) 21 | parameterMode: SET 22 | # If set to true, any comments in your scripts will be removed upon executing or printing. 23 | removeComments: true 24 | # If set to true, the executed command is always printed on stdout before execution for confirmation (same functionality as providing flag -c/--confirm). 25 | execConfirm: false 26 | # If set to true, the executed command is always printed on stdout (same functionality as providing flag -p/--print). 27 | execPrint: false 28 | assistant: 29 | # Defines if you want to save the snippets created by the assistant. Possible values: NEVER | FS_LIBRARY 30 | saveMode: NEVER 31 | manager: {} 32 | -------------------------------------------------------------------------------- /internal/config/testdata/testdata.go: -------------------------------------------------------------------------------- 1 | package testdata 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path" 7 | "path/filepath" 8 | "runtime" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | type ConfigVersion string 16 | 17 | const ( 18 | ConfigV100 = ConfigVersion("1.0.0") 19 | ConfigV110 = ConfigVersion("1.1.0") 20 | ConfigV111 = ConfigVersion("1.1.1") 21 | ConfigV111FsLibrary = ConfigVersion("1.1.1-fslibrary") 22 | ConfigV120 = ConfigVersion("1.2.0") 23 | ConfigV120FsLibrary = ConfigVersion("1.2.0-fslibrary") 24 | Latest = ConfigV120 25 | 26 | Example = ConfigVersion("example-config.yaml") 27 | ) 28 | 29 | func ConfigPath(t *testing.T, cfgVersion ConfigVersion) string { 30 | if cfgVersion == Example { 31 | return path.Join(absolutePath(t), "example-config.yaml") 32 | } 33 | 34 | return path.Join( 35 | absolutePath(t), 36 | "migrations", 37 | fmt.Sprintf("config-%s.yaml", strings.ReplaceAll(string(cfgVersion), ".", "-")), 38 | ) 39 | } 40 | 41 | func ConfigBytes(t *testing.T, cfgVersion ConfigVersion) []byte { 42 | bytes, err := os.ReadFile(ConfigPath(t, cfgVersion)) 43 | assert.NoError(t, err) 44 | return bytes 45 | } 46 | 47 | func absolutePath(t *testing.T) string { 48 | _, filename, _, ok := runtime.Caller(1) 49 | assert.True(t, ok) 50 | return filepath.Dir(filename) 51 | } 52 | -------------------------------------------------------------------------------- /internal/managers/config.go: -------------------------------------------------------------------------------- 1 | package managers 2 | 3 | import ( 4 | "github.com/lemoony/snipkit/internal/managers/fslibrary" 5 | "github.com/lemoony/snipkit/internal/managers/githubgist" 6 | "github.com/lemoony/snipkit/internal/managers/masscode" 7 | "github.com/lemoony/snipkit/internal/managers/pet" 8 | "github.com/lemoony/snipkit/internal/managers/pictarinesnip" 9 | "github.com/lemoony/snipkit/internal/managers/snippetslab" 10 | ) 11 | 12 | type Config struct { 13 | SnippetsLab *snippetslab.Config `yaml:"snippetsLab,omitempty" mapstructure:"snippetsLab"` 14 | PictarineSnip *pictarinesnip.Config `yaml:"pictarineSnip,omitempty" mapstructure:"pictarineSnip"` 15 | Pet *pet.Config `yaml:"pet,omitempty" mapstructure:"pet"` 16 | MassCode *masscode.Config `yaml:"massCode,omitempty" mapstructure:"massCode"` 17 | GithubGist *githubgist.Config `yaml:"githubGist,omitempty" mapstructure:"githubGist"` 18 | FsLibrary *fslibrary.Config `yaml:"fsLibrary,omitempty" mapstructure:"fsLibrary"` 19 | } 20 | -------------------------------------------------------------------------------- /internal/managers/fslibrary/config.go: -------------------------------------------------------------------------------- 1 | package fslibrary 2 | 3 | import ( 4 | "github.com/lemoony/snipkit/internal/utils/system" 5 | ) 6 | 7 | type Config struct { 8 | Enabled bool `yaml:"enabled" head_comment:"If set to false, the files specified via libraryPath will not be provided to you."` 9 | LibraryPath []string `yaml:"libraryPath" head_comment:"Paths directories that hold snippets files (use absolute paths!). Each file must hold one snippet only."` 10 | AssistantLibraryPathIndex int `yaml:"assistantLibraryPathIndex" head_comment:"Index of library path where to store snippets created by the assistant."` 11 | SuffixRegex []string `yaml:"suffixRegex" head_comment:"Only files with endings which match one of the listed suffixes will be considered."` 12 | LazyOpen bool `yaml:"lazyOpen" head_comment:"If set to true, the files will not be parsed in advance. This means, only the filename can be used as the snippet name."` 13 | HideTitleInPreview bool `yaml:"hideTitleInPreview" head_comment:"If set to true, the title comment will not be shown in the preview window."` 14 | } 15 | 16 | func AutoDiscoveryConfig(system *system.System) *Config { 17 | return &Config{ 18 | Enabled: false, 19 | LibraryPath: []string{"/path/to/file/system/library"}, 20 | AssistantLibraryPathIndex: 0, 21 | SuffixRegex: []string{".sh"}, 22 | LazyOpen: false, 23 | HideTitleInPreview: false, 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /internal/managers/fslibrary/constants.go: -------------------------------------------------------------------------------- 1 | package fslibrary 2 | 3 | import "github.com/lemoony/snipkit/internal/utils/idutil" 4 | 5 | const ( 6 | idPrefix idutil.IDPrefix = "fsl" 7 | ) 8 | -------------------------------------------------------------------------------- /internal/managers/fslibrary/description.go: -------------------------------------------------------------------------------- 1 | package fslibrary 2 | 3 | import "github.com/lemoony/snipkit/internal/model" 4 | 5 | const Key = model.ManagerKey("fslibrary") 6 | 7 | func Description(config *Config) model.ManagerDescription { 8 | return model.ManagerDescription{ 9 | Key: Key, 10 | Name: "File System Library", 11 | Description: "Use snippets form a local directory which holds snippet files", 12 | Enabled: config != nil && config.Enabled, 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /internal/managers/fslibrary/model.go: -------------------------------------------------------------------------------- 1 | package fslibrary 2 | 3 | import ( 4 | "path/filepath" 5 | 6 | "github.com/lemoony/snipkit/internal/model" 7 | "github.com/lemoony/snipkit/internal/parser" 8 | ) 9 | 10 | type snippetImpl struct { 11 | id string 12 | path string 13 | tags []string 14 | titleFunc func() string 15 | contentFunc func() string 16 | } 17 | 18 | func (s snippetImpl) GetID() string { 19 | return s.id 20 | } 21 | 22 | func (s snippetImpl) GetTitle() string { 23 | return s.titleFunc() 24 | } 25 | 26 | func (s snippetImpl) GetTags() []string { 27 | return s.tags 28 | } 29 | 30 | func (s snippetImpl) GetContent() string { 31 | return s.contentFunc() 32 | } 33 | 34 | func (s snippetImpl) GetLanguage() model.Language { 35 | return LanguageForSuffix(filepath.Ext(s.path)) 36 | } 37 | 38 | func (s snippetImpl) GetParameters() []model.Parameter { 39 | return parser.ParseParameters(s.GetContent()) 40 | } 41 | 42 | func (s snippetImpl) Format(values []string, options model.SnippetFormatOptions) string { 43 | return parser.CreateSnippet(s.GetContent(), s.GetParameters(), values, options) 44 | } 45 | -------------------------------------------------------------------------------- /internal/managers/fslibrary/parser.go: -------------------------------------------------------------------------------- 1 | package fslibrary 2 | 3 | import ( 4 | "io" 5 | "path/filepath" 6 | 7 | "github.com/lemoony/snipkit/internal/utils/system" 8 | "github.com/lemoony/snipkit/internal/utils/titleheader" 9 | ) 10 | 11 | func getSnippetName(system *system.System, filePath string) string { 12 | contents := string(system.ReadFile(filePath)) 13 | if title, ok := titleheader.ParseTitleFromHeader(contents); ok { 14 | return title 15 | } 16 | return filepath.Base(filePath) 17 | } 18 | 19 | func pruneTitleHeader(r io.Reader) string { 20 | all, err := io.ReadAll(r) 21 | if err != nil { 22 | panic(err) 23 | } 24 | return titleheader.PruneTitleHeader(string(all)) 25 | } 26 | -------------------------------------------------------------------------------- /internal/managers/fslibrary/parser_test.go: -------------------------------------------------------------------------------- 1 | package fslibrary 2 | 3 | import ( 4 | "path/filepath" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/spf13/afero" 9 | "github.com/stretchr/testify/assert" 10 | 11 | "github.com/lemoony/snipkit/internal/utils/testutil" 12 | ) 13 | 14 | func Test_pruneTitleComment(t *testing.T) { 15 | tests := []struct { 16 | name string 17 | snippet string 18 | expected string 19 | }{ 20 | {name: "example1", snippet: "#\r\n# Hello\r\n#\n\r\nfoo", expected: "foo"}, 21 | {name: "example2", snippet: "#/bin/bash\n#\n#title\n#", expected: "#/bin/bash"}, 22 | } 23 | 24 | for _, tt := range tests { 25 | t.Run(tt.name, func(t *testing.T) { 26 | assert.Equal(t, tt.expected, pruneTitleHeader(strings.NewReader(tt.snippet))) 27 | }) 28 | } 29 | } 30 | 31 | func Test_getSnippetName(t *testing.T) { 32 | tests := []struct { 33 | title string 34 | content string 35 | ok bool 36 | }{ 37 | {title: "title 1", content: "#\n# title 1\n#", ok: true}, 38 | {title: "title 2", content: "#title 2\n#", ok: false}, 39 | } 40 | 41 | system := testutil.NewTestSystem() 42 | 43 | for _, tt := range tests { 44 | t.Run(tt.title, func(t *testing.T) { 45 | file, err := afero.TempFile(system.Fs, t.TempDir(), "*.sh") 46 | 47 | assert.NoError(t, err) 48 | if _, err = file.Write([]byte(tt.content)); err != nil { 49 | assert.NoError(t, err) 50 | } 51 | 52 | name := getSnippetName(system, file.Name()) 53 | if tt.ok { 54 | assert.Equal(t, tt.title, name) 55 | } else { 56 | assert.Equal(t, filepath.Base(file.Name()), name) 57 | } 58 | }) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /internal/managers/fslibrary/utils.go: -------------------------------------------------------------------------------- 1 | package fslibrary 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "io" 8 | "strings" 9 | ) 10 | 11 | func formatSnippet(script string, title string) string { 12 | var output bytes.Buffer 13 | reader := bufio.NewReader(strings.NewReader(script)) 14 | 15 | shebangLine := "" 16 | firstLine, err := reader.ReadString('\n') 17 | if err == nil && strings.HasPrefix(firstLine, "#!/") { 18 | shebangLine = firstLine 19 | } else { 20 | // If the first line is not a shebang, reset the reader to include the first line in the script 21 | reader = bufio.NewReader(strings.NewReader(firstLine + script[len(firstLine):])) 22 | } 23 | 24 | if shebangLine != "" { 25 | output.WriteString(shebangLine) 26 | output.WriteString("\n") 27 | } 28 | 29 | snippetComment := fmt.Sprintf(`# 30 | # %s 31 | # 32 | `, title) 33 | output.WriteString(snippetComment) 34 | output.WriteString("\n") 35 | 36 | // Write the rest of the script 37 | for { 38 | line, readErr := reader.ReadString('\n') 39 | output.WriteString(line) 40 | if readErr == io.EOF { 41 | break 42 | } 43 | } 44 | 45 | return output.String() 46 | } 47 | -------------------------------------------------------------------------------- /internal/managers/fslibrary/utils_test.go: -------------------------------------------------------------------------------- 1 | package fslibrary 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func Test_formatSnippet(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | script string 13 | title string 14 | expected string 15 | }{ 16 | { 17 | name: "Script with shebang", 18 | script: "#!/bin/bash\necho \"Hello, World!\"", 19 | title: "Hello World Script", 20 | expected: `#!/bin/bash 21 | 22 | # 23 | # Hello World Script 24 | # 25 | 26 | echo "Hello, World!"`, 27 | }, 28 | { 29 | name: "Script without shebang", 30 | script: "echo \"Hello, World!\"", 31 | title: "Hello World Script", 32 | expected: `# 33 | # Hello World Script 34 | # 35 | 36 | echo "Hello, World!"`, 37 | }, 38 | } 39 | 40 | for _, tt := range tests { 41 | t.Run(tt.name, func(t *testing.T) { 42 | formatted := formatSnippet(tt.script, tt.title) 43 | assert.Equal(t, tt.expected, formatted) 44 | }) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /internal/managers/githubgist/config_test.go: -------------------------------------------------------------------------------- 1 | package githubgist 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func Test_AutoDiscoveryConfig(t *testing.T) { 10 | cfg := AutoDiscoveryConfig() 11 | assert.NotNil(t, cfg) 12 | assert.False(t, cfg.Enabled) 13 | assert.Len(t, cfg.Gists, 1) 14 | 15 | gistConfig := cfg.Gists[0] 16 | assert.Equal(t, "gist.github.com/lemoony", gistConfig.URL) 17 | assert.Equal(t, "https://api.github.com/users/lemoony/gists", gistConfig.apiURL()) 18 | 19 | assert.Equal(t, &gistConfig, cfg.getGistConfig("gist.github.com/lemoony")) 20 | } 21 | 22 | func Test_getGistConfig_unknown(t *testing.T) { 23 | assert.Nil(t, AutoDiscoveryConfig().getGistConfig("gist.github.com/foouser")) 24 | } 25 | 26 | func Test_config_getAPIUrl(t *testing.T) { 27 | gistConfig := GistConfig{URL: "gist.github.com/"} 28 | gistConfig.URL = "http://someurl.com" 29 | assert.Panics(t, func() { 30 | gistConfig.apiURL() 31 | }) 32 | } 33 | 34 | func Test_config_invalidURL(t *testing.T) { 35 | gistConfig := GistConfig{URL: "someurl.com"} 36 | gistConfig.URL = "http://someurl.com" 37 | assert.Panics(t, func() { 38 | gistConfig.apiURL() 39 | }) 40 | } 41 | -------------------------------------------------------------------------------- /internal/managers/githubgist/constants.go: -------------------------------------------------------------------------------- 1 | package githubgist 2 | 3 | import "github.com/lemoony/snipkit/internal/utils/idutil" 4 | 5 | const ( 6 | idPrefix idutil.IDPrefix = "ghg" 7 | ) 8 | -------------------------------------------------------------------------------- /internal/managers/githubgist/description.go: -------------------------------------------------------------------------------- 1 | package githubgist 2 | 3 | import "github.com/lemoony/snipkit/internal/model" 4 | 5 | var Key = model.ManagerKey("GitHub Gist") 6 | 7 | func Description(config *Config) model.ManagerDescription { 8 | return model.ManagerDescription{ 9 | Key: Key, 10 | Name: "Github Gist", 11 | Description: "Use snippets form Github Gist", 12 | Enabled: config != nil && config.Enabled, 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /internal/managers/githubgist/description_test.go: -------------------------------------------------------------------------------- 1 | package githubgist 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func Test_Description(t *testing.T) { 10 | cfg := &Config{Enabled: true} 11 | 12 | description := Description(cfg) 13 | assert.NotNil(t, description) 14 | assert.Equal(t, "Github Gist", description.Name) 15 | assert.True(t, description.Enabled) 16 | } 17 | -------------------------------------------------------------------------------- /internal/managers/githubgist/model.go: -------------------------------------------------------------------------------- 1 | package githubgist 2 | 3 | import ( 4 | "github.com/lemoony/snipkit/internal/model" 5 | "github.com/lemoony/snipkit/internal/parser" 6 | ) 7 | 8 | type snippetImpl struct { 9 | id string 10 | tags []string 11 | title string 12 | content string 13 | language model.Language 14 | } 15 | 16 | func (s snippetImpl) GetID() string { 17 | return s.id 18 | } 19 | 20 | func (s snippetImpl) GetTitle() string { 21 | return s.title 22 | } 23 | 24 | func (s snippetImpl) GetTags() []string { 25 | return s.tags 26 | } 27 | 28 | func (s snippetImpl) GetContent() string { 29 | return s.content 30 | } 31 | 32 | func (s snippetImpl) GetLanguage() model.Language { 33 | return s.language 34 | } 35 | 36 | func (s snippetImpl) GetParameters() []model.Parameter { 37 | return parser.ParseParameters(s.content) 38 | } 39 | 40 | func (s snippetImpl) Format(values []string, options model.SnippetFormatOptions) string { 41 | return parser.CreateSnippet(s.GetContent(), s.GetParameters(), values, options) 42 | } 43 | -------------------------------------------------------------------------------- /internal/managers/githubgist/options.go: -------------------------------------------------------------------------------- 1 | package githubgist 2 | 3 | import ( 4 | "github.com/lemoony/snipkit/internal/cache" 5 | "github.com/lemoony/snipkit/internal/utils/system" 6 | ) 7 | 8 | // Option configures a Manager. 9 | type Option interface { 10 | apply(p *Manager) 11 | } 12 | 13 | // optionFunc wraps a func so that it satisfies the Option interface. 14 | type optionFunc func(manager *Manager) 15 | 16 | func (f optionFunc) apply(manager *Manager) { 17 | f(manager) 18 | } 19 | 20 | // WithSystem sets the utils.System instance to be used by Manager. 21 | func WithSystem(system *system.System) Option { 22 | return optionFunc(func(p *Manager) { 23 | p.system = system 24 | }) 25 | } 26 | 27 | func WithConfig(config Config) Option { 28 | return optionFunc(func(p *Manager) { 29 | p.config = config 30 | }) 31 | } 32 | 33 | func WithCache(cache cache.Cache) Option { 34 | return optionFunc(func(p *Manager) { 35 | p.cache = cache 36 | }) 37 | } 38 | -------------------------------------------------------------------------------- /internal/managers/githubgist/store.go: -------------------------------------------------------------------------------- 1 | package githubgist 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/phuslu/log" 7 | 8 | "github.com/lemoony/snipkit/internal/cache" 9 | ) 10 | 11 | const ( 12 | storeKey = cache.DataKey("github_gist_cache") 13 | storeVersion = "1.0" 14 | ) 15 | 16 | type store struct { 17 | Version string `json:"version"` 18 | Gists []gistStore `json:"gists"` 19 | } 20 | 21 | type gistStore struct { 22 | URL string `json:"url"` 23 | ETag string `json:"ETag"` 24 | RawSnippets []rawSnippet `json:"RawSnippets"` 25 | } 26 | 27 | type rawSnippet struct { 28 | ID string `json:"id"` 29 | Filename string `json:"filename"` 30 | Content []byte `json:"content"` 31 | ETag string `json:"etag"` 32 | Pubic bool `json:"public"` 33 | Description string `json:"description"` 34 | Language string `json:"language"` 35 | FilesInGist int `json:"filesInGist"` 36 | } 37 | 38 | func (m *Manager) getStoreFromCache() *store { 39 | result := &store{} 40 | if raw, ok := m.cache.GetData(storeKey); ok { 41 | result.deserialize(raw) 42 | } 43 | return result 44 | } 45 | 46 | func (m *Manager) storeInCache(s *store) { 47 | m.cache.PutData(storeKey, s.serialize()) 48 | } 49 | 50 | func (c *store) serialize() []byte { 51 | if bytes, err := json.Marshal(c); err != nil { 52 | panic(err) 53 | } else { 54 | return bytes 55 | } 56 | } 57 | 58 | func (c *store) deserialize(bytes []byte) { 59 | if err := json.Unmarshal(bytes, c); err != nil { 60 | log.Warn().Err(err).Msg("store invalid") 61 | } 62 | } 63 | 64 | func (c *store) getGists(cfg GistConfig) *gistStore { 65 | for i := range c.Gists { 66 | if c.Gists[i].URL == cfg.URL { 67 | return &c.Gists[i] 68 | } 69 | } 70 | return nil 71 | } 72 | -------------------------------------------------------------------------------- /internal/managers/githubgist/testdata/github_device_code.json: -------------------------------------------------------------------------------- 1 | { 2 | "device_code": "3584d83530557fdd1f46af8289938c8ef79f9dc5", 3 | "user_code": "WDJB-MJHT", 4 | "verification_uri": "https://github.com/login/device", 5 | "expires_in": 900, 6 | "interval": 0 7 | } 8 | -------------------------------------------------------------------------------- /internal/managers/githubgist/testdata/github_oauth_access_token.json: -------------------------------------------------------------------------------- 1 | { 2 | "access_token": "%s", 3 | "token_type": "bearer", 4 | "scope": "gist" 5 | } 6 | -------------------------------------------------------------------------------- /internal/managers/manager.go: -------------------------------------------------------------------------------- 1 | package managers 2 | 3 | import "github.com/lemoony/snipkit/internal/model" 4 | 5 | type Manager interface { 6 | Key() model.ManagerKey 7 | Info() []model.InfoLine 8 | GetSnippets() []model.Snippet 9 | Sync(model.SyncEventChannel) 10 | SaveAssistantSnippet(snippetTitle string, filename string, contents []byte) 11 | } 12 | -------------------------------------------------------------------------------- /internal/managers/masscode/config.go: -------------------------------------------------------------------------------- 1 | package masscode 2 | 3 | import ( 4 | "path/filepath" 5 | 6 | "github.com/lemoony/snipkit/internal/utils/system" 7 | ) 8 | 9 | type Version string 10 | 11 | const ( 12 | version1 = Version("v1") 13 | version2 = Version("v2") 14 | ) 15 | 16 | type Config struct { 17 | Enabled bool `yaml:"enabled" head_comment:"Set to true if you want to use pet."` 18 | MassCodeHome string `yaml:"massCodeHome" head_comment:"Path to the massCode directory containing the db files."` 19 | Version Version `version:"Version of massCode. Allowed values: v1, v2."` 20 | IncludeTags []string `yaml:"includeTags" head_comment:"If this list is not empty, only those Snippets that match the listed Tags will be provided to you."` 21 | } 22 | 23 | type autoDetectResult struct { 24 | found bool 25 | version Version 26 | path string 27 | } 28 | 29 | func AutoDiscoveryConfig(system *system.System) *Config { 30 | autoDetect := findMassCodeHome(system) 31 | return &Config{ 32 | Enabled: autoDetect.found, 33 | Version: autoDetect.version, 34 | MassCodeHome: autoDetect.path, 35 | IncludeTags: []string{}, 36 | } 37 | } 38 | 39 | func findMassCodeHome(sys *system.System) autoDetectResult { 40 | defaultHome := filepath.Join(sys.UserHome(), defaultMassCodeHomePath) 41 | 42 | result := autoDetectResult{ 43 | found: false, 44 | version: version1, 45 | path: defaultHome, 46 | } 47 | 48 | if v2DBFile := filepath.Join(defaultHome, v2DatabaseFile); sys.FileExists(v2DBFile) { 49 | result.found = true 50 | result.version = version2 51 | } else if v1DBFile := filepath.Join(defaultHome, v1SnippetsFile); sys.FileExists(v1DBFile) { 52 | result.found = true 53 | } 54 | 55 | return result 56 | } 57 | -------------------------------------------------------------------------------- /internal/managers/masscode/config_test.go: -------------------------------------------------------------------------------- 1 | package masscode 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | 9 | "github.com/lemoony/snipkit/internal/utils/system" 10 | "github.com/lemoony/snipkit/internal/utils/testutil" 11 | ) 12 | 13 | func Test_AutoDiscoveryConfig(t *testing.T) { 14 | tests := []struct { 15 | name string 16 | userHomeDir string 17 | expected Config 18 | }{ 19 | { 20 | name: "found v1", 21 | userHomeDir: testDataUserHomeV1, 22 | expected: Config{ 23 | Enabled: true, 24 | MassCodeHome: fmt.Sprintf("%s/%s", testDataUserHomeV1, defaultMassCodeHomePath), 25 | Version: version1, 26 | IncludeTags: []string{}, 27 | }, 28 | }, 29 | { 30 | name: "found v2", 31 | userHomeDir: testDataUserHomeV2, 32 | expected: Config{ 33 | Enabled: true, 34 | MassCodeHome: fmt.Sprintf("%s/%s", testDataUserHomeV2, defaultMassCodeHomePath), 35 | Version: version2, 36 | IncludeTags: []string{}, 37 | }, 38 | }, 39 | { 40 | name: "not found", 41 | userHomeDir: "testdata/userhome-not-found", 42 | expected: Config{ 43 | Enabled: false, 44 | MassCodeHome: "testdata/userhome-not-found/massCode", 45 | Version: version1, 46 | IncludeTags: []string{}, 47 | }, 48 | }, 49 | } 50 | 51 | for _, tt := range tests { 52 | t.Run(tt.name, func(t *testing.T) { 53 | s := testutil.NewTestSystem(system.WithUserHome(tt.userHomeDir)) 54 | cfg := AutoDiscoveryConfig(s) 55 | assert.Equal(t, tt.expected, *cfg) 56 | }) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /internal/managers/masscode/constants.go: -------------------------------------------------------------------------------- 1 | package masscode 2 | 3 | import "github.com/lemoony/snipkit/internal/utils/idutil" 4 | 5 | const ( 6 | defaultMassCodeHomePath = "massCode" 7 | 8 | v1SnippetsFile = "snippets.db" 9 | v1TagsFile = "tags.db" 10 | 11 | v2DatabaseFile = "db.json" 12 | 13 | idPrefix idutil.IDPrefix = "mass" 14 | ) 15 | -------------------------------------------------------------------------------- /internal/managers/masscode/constants_test.go: -------------------------------------------------------------------------------- 1 | package masscode 2 | 3 | import "path/filepath" 4 | 5 | const ( 6 | testDataUserHomeV1 = "testdata/userhome-v1" 7 | testDataUserHomeV2 = "testdata/userhome-v2" 8 | ) 9 | 10 | var ( 11 | testDataMassCodeV1Path = filepath.Join(testDataUserHomeV1, defaultMassCodeHomePath) 12 | testDataMassCodeV2Path = filepath.Join(testDataUserHomeV2, defaultMassCodeHomePath) 13 | testDataLibraryV2Path = filepath.Join(testDataUserHomeV2, defaultMassCodeHomePath, v2DatabaseFile) 14 | ) 15 | -------------------------------------------------------------------------------- /internal/managers/masscode/description.go: -------------------------------------------------------------------------------- 1 | package masscode 2 | 3 | import "github.com/lemoony/snipkit/internal/model" 4 | 5 | const Key = model.ManagerKey("massCode") 6 | 7 | func Description(config *Config) model.ManagerDescription { 8 | return model.ManagerDescription{ 9 | Key: Key, 10 | Name: "massCode", 11 | Description: "Use Snippets form massCode - a free and open source code Snippets manager.", 12 | Enabled: config != nil && config.Enabled, 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /internal/managers/masscode/description_test.go: -------------------------------------------------------------------------------- 1 | package masscode 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func Test_Description(t *testing.T) { 10 | cfg := &Config{Enabled: true} 11 | 12 | description := Description(cfg) 13 | assert.NotNil(t, description) 14 | assert.Equal(t, "massCode", description.Name) 15 | assert.True(t, description.Enabled) 16 | } 17 | -------------------------------------------------------------------------------- /internal/managers/masscode/model.go: -------------------------------------------------------------------------------- 1 | package masscode 2 | 3 | import ( 4 | "github.com/lemoony/snipkit/internal/model" 5 | "github.com/lemoony/snipkit/internal/parser" 6 | ) 7 | 8 | type snippetImpl struct { 9 | id string 10 | tags []string 11 | title string 12 | content string 13 | language model.Language 14 | } 15 | 16 | func (s snippetImpl) GetID() string { 17 | return s.id 18 | } 19 | 20 | func (s snippetImpl) GetTitle() string { 21 | return s.title 22 | } 23 | 24 | func (s snippetImpl) GetTags() []string { 25 | return s.tags 26 | } 27 | 28 | func (s snippetImpl) GetContent() string { 29 | return s.content 30 | } 31 | 32 | func (s snippetImpl) GetLanguage() model.Language { 33 | return s.language 34 | } 35 | 36 | func (s snippetImpl) GetParameters() []model.Parameter { 37 | return parser.ParseParameters(s.content) 38 | } 39 | 40 | func (s snippetImpl) Format(values []string, options model.SnippetFormatOptions) string { 41 | return parser.CreateSnippet(s.GetContent(), s.GetParameters(), values, options) 42 | } 43 | -------------------------------------------------------------------------------- /internal/managers/masscode/parser_v1.go: -------------------------------------------------------------------------------- 1 | package masscode 2 | 3 | import ( 4 | "encoding/json" 5 | "path/filepath" 6 | 7 | "github.com/lemoony/snipkit/internal/model" 8 | "github.com/lemoony/snipkit/internal/utils/idutil" 9 | "github.com/lemoony/snipkit/internal/utils/system" 10 | ) 11 | 12 | func parseDBFileV1(sys *system.System, massCodePath string) []model.Snippet { 13 | var result []model.Snippet 14 | 15 | tagMap := parseRawTagMapV1(sys, filepath.Join(massCodePath, v1TagsFile)) 16 | snippetsMap := parseRawSnippetsV1(sys, filepath.Join(massCodePath, v1SnippetsFile)) 17 | 18 | for _, raw := range snippetsMap { 19 | result = append(result, &snippetImpl{ 20 | id: idutil.FormatSnippetID(raw.ID, idPrefix), 21 | title: raw.Name, 22 | tags: toTagNames(raw.Tags, tagMap), 23 | content: raw.Content[0].Value, 24 | language: mapLanguage(raw.Content[0].Language), 25 | }) 26 | } 27 | return result 28 | } 29 | 30 | func parseRawSnippetsV1(sys *system.System, path string) map[string]rawSnippet { 31 | snippets := map[string]rawSnippet{} 32 | 33 | file, err := sys.Fs.Open(path) 34 | if err != nil { 35 | panic(err) 36 | } 37 | 38 | dc := json.NewDecoder(file) 39 | 40 | var snippet rawSnippet 41 | for err = dc.Decode(&snippet); err == nil; err = dc.Decode(&snippet) { 42 | if snippet.Deleted || snippet.IsInTrash { 43 | delete(snippets, snippet.ID) 44 | } else { 45 | snippets[snippet.ID] = snippet 46 | } 47 | snippet = rawSnippet{} 48 | } 49 | 50 | return snippets 51 | } 52 | 53 | func parseRawTagMapV1(sys *system.System, path string) map[string]string { 54 | tags := map[string]string{} 55 | 56 | file, err := sys.Fs.Open(path) 57 | if err != nil { 58 | panic(err) 59 | } 60 | 61 | dc := json.NewDecoder(file) 62 | 63 | var tag rawTag 64 | for err = dc.Decode(&tag); err == nil; err = dc.Decode(&tag) { 65 | if tag.Deleted { 66 | delete(tags, tag.ID) 67 | } else { 68 | tags[tag.ID] = tag.Name 69 | } 70 | } 71 | 72 | return tags 73 | } 74 | -------------------------------------------------------------------------------- /internal/managers/masscode/parser_v2_test.go: -------------------------------------------------------------------------------- 1 | package masscode 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/lemoony/snipkit/internal/model" 9 | "github.com/lemoony/snipkit/internal/utils/idutil" 10 | "github.com/lemoony/snipkit/internal/utils/testutil" 11 | ) 12 | 13 | func Test_parseDBFileV2(t *testing.T) { 14 | sys := testutil.NewTestSystem() 15 | 16 | snippets := parseDBFileV2(sys, testDataLibraryV2Path) 17 | assert.Len(t, snippets, 3) 18 | 19 | assert.Equal(t, idutil.FormatSnippetID("176c30e0-2e5d-4be8-a2f2-970eba03901c", idPrefix), snippets[0].GetID()) 20 | assert.Equal(t, "Another", snippets[0].GetTitle()) 21 | assert.Equal(t, model.LanguageText, snippets[0].GetLanguage()) 22 | assert.Equal(t, "echo Hello world", snippets[0].GetContent()) 23 | 24 | assert.Equal(t, "Echo something", snippets[1].GetTitle()) 25 | assert.Equal(t, model.LanguageBash, snippets[1].GetLanguage()) 26 | assert.Equal(t, []string{"snipkit"}, snippets[1].GetTags()) 27 | assert.Len(t, snippets[1].GetParameters(), 3) 28 | 29 | assert.Equal(t, "markdown file", snippets[2].GetTitle()) 30 | assert.Equal(t, model.LanguageMarkdown, snippets[2].GetLanguage()) 31 | } 32 | -------------------------------------------------------------------------------- /internal/managers/masscode/testdata/userhome-v1/massCode/masscode.db: -------------------------------------------------------------------------------- 1 | {"list":[{"id":"d5g7plMwA","name":"Default","open":false,"defaultLanguage":"text"}],"_id":"folders"} 2 | {"list":[{"id":"d5g7plMwA","name":"Default","open":false,"defaultLanguage":"text"},{"id":"tiZse8A03","name":"Untitled","open":false,"defaultLanguage":"text"}],"_id":"folders"} 3 | {"list":[{"id":"d5g7plMwA","name":"Default","open":false,"defaultLanguage":"text"},{"id":"tiZse8A03","name":"TestFolder","open":false,"defaultLanguage":"text"}],"_id":"folders"} 4 | {"list":[{"id":"d5g7plMwA","name":"Default","open":false,"defaultLanguage":"text"},{"id":"tiZse8A03","name":"TestFolder","open":false,"defaultLanguage":"text"},{"id":"V4mG6aRu0","name":"Untitled","open":false,"defaultLanguage":"text"}],"_id":"folders"} 5 | {"list":[{"id":"d5g7plMwA","name":"Default","open":false,"defaultLanguage":"text"},{"id":"tiZse8A03","name":"TestFolder","open":false,"defaultLanguage":"text"},{"id":"V4mG6aRu0","name":"FooFolder","open":false,"defaultLanguage":"text"}],"_id":"folders"} 6 | -------------------------------------------------------------------------------- /internal/managers/masscode/testdata/userhome-v1/massCode/tags.db: -------------------------------------------------------------------------------- 1 | {"name":"snipkit","_id":"HJPPsaOIjc5GaDZe"} 2 | {"name":"test","_id":"k28o4TQnyYcODPgW"} 3 | {"$$deleted":true,"_id":"k28o4TQnyYcODPgW"} 4 | -------------------------------------------------------------------------------- /internal/managers/pet/config.go: -------------------------------------------------------------------------------- 1 | package pet 2 | 3 | import ( 4 | "github.com/phuslu/log" 5 | 6 | "github.com/lemoony/snipkit/internal/utils/system" 7 | ) 8 | 9 | type Config struct { 10 | Enabled bool `yaml:"enabled" head_comment:"Set to true if you want to use pet."` 11 | LibraryPaths []string `yaml:"libraryPaths" head_comment:"List of pet snippet files."` 12 | IncludeTags []string `yaml:"includeTags" head_comment:"If this list is not empty, only those snippets that match the listed tags will be provided to you."` 13 | } 14 | 15 | func AutoDiscoveryConfig(system *system.System) *Config { 16 | snippetFilePaths, err := parseSnippetFilePaths(system) 17 | if err != nil { 18 | log.Info().Err(err).Msg("failed to discover pet snippet file paths") 19 | } 20 | 21 | found := len(snippetFilePaths) > 0 22 | if !found { 23 | snippetFilePaths = []string{"~/.config/pet/snippet.toml"} 24 | } 25 | 26 | return &Config{ 27 | Enabled: found, 28 | LibraryPaths: snippetFilePaths, 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /internal/managers/pet/config_test.go: -------------------------------------------------------------------------------- 1 | package pet 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/lemoony/snipkit/internal/utils/system" 9 | "github.com/lemoony/snipkit/internal/utils/testutil" 10 | ) 11 | 12 | func Test_AutoDiscoveryConfig(t *testing.T) { 13 | tests := []struct { 14 | name string 15 | userHomeDir string 16 | enabled bool 17 | }{ 18 | {name: "found", userHomeDir: testDataUserHome, enabled: true}, 19 | {name: "not found", userHomeDir: "testdata/not-found-dir", enabled: false}, 20 | } 21 | 22 | for _, tt := range tests { 23 | t.Run(tt.name, func(t *testing.T) { 24 | s := testutil.NewTestSystem(system.WithUserHome(tt.userHomeDir)) 25 | cfg := AutoDiscoveryConfig(s) 26 | assert.Equal(t, tt.enabled, cfg.Enabled) 27 | }) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /internal/managers/pet/constants.go: -------------------------------------------------------------------------------- 1 | package pet 2 | 3 | import "github.com/lemoony/snipkit/internal/utils/idutil" 4 | 5 | const ( 6 | defaultConfigPath = ".config/pet/config.toml" 7 | idPrefix idutil.IDPrefix = "pet" 8 | ) 9 | -------------------------------------------------------------------------------- /internal/managers/pet/description.go: -------------------------------------------------------------------------------- 1 | package pet 2 | 3 | import "github.com/lemoony/snipkit/internal/model" 4 | 5 | const Key = model.ManagerKey("pet") 6 | 7 | func Description(config *Config) model.ManagerDescription { 8 | return model.ManagerDescription{ 9 | Key: Key, 10 | Name: "pet - CLI Snippet Manager", 11 | Description: "Use snippets form pet - a simple command-line snippet manager", 12 | Enabled: config != nil && config.Enabled, 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /internal/managers/pet/description_test.go: -------------------------------------------------------------------------------- 1 | package pet 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func Test_Description(t *testing.T) { 10 | cfg := &Config{Enabled: true} 11 | 12 | description := Description(cfg) 13 | assert.NotNil(t, description) 14 | assert.Equal(t, "pet - CLI Snippet Manager", description.Name) 15 | assert.True(t, description.Enabled) 16 | } 17 | -------------------------------------------------------------------------------- /internal/managers/pet/model.go: -------------------------------------------------------------------------------- 1 | package pet 2 | 3 | import ( 4 | "github.com/lemoony/snipkit/internal/model" 5 | ) 6 | 7 | type snippetImpl struct { 8 | id string 9 | tags []string 10 | title string 11 | content string 12 | language model.Language 13 | } 14 | 15 | func (s snippetImpl) GetID() string { 16 | return "unused" 17 | } 18 | 19 | func (s snippetImpl) GetTitle() string { 20 | return s.title 21 | } 22 | 23 | func (s snippetImpl) GetTags() []string { 24 | return s.tags 25 | } 26 | 27 | func (s snippetImpl) GetContent() string { 28 | return s.content 29 | } 30 | 31 | func (s snippetImpl) GetLanguage() model.Language { 32 | return s.language 33 | } 34 | 35 | func (s snippetImpl) GetParameters() []model.Parameter { 36 | return parseParameters(s.content) 37 | } 38 | 39 | func (s snippetImpl) Format(values []string, _ model.SnippetFormatOptions) string { 40 | return formatContent(s.content, values) 41 | } 42 | -------------------------------------------------------------------------------- /internal/managers/pet/testdata/userhome/.config/pet/config.toml: -------------------------------------------------------------------------------- 1 | [General] 2 | snippetfile = "testdata/userhome/.config/pet/snippet.toml" 3 | editor = "code" 4 | column = 40 5 | selectcmd = "fzf" 6 | backend = "gist" 7 | sortby = "" 8 | 9 | [Gist] 10 | file_name = "pet-snippet.toml" 11 | access_token = "" 12 | gist_id = "" 13 | public = false 14 | auto_sync = false 15 | 16 | [GitLab] 17 | file_name = "pet-snippet.toml" 18 | access_token = "" 19 | url = "" 20 | id = "" 21 | visibility = "private" 22 | auto_sync = false 23 | skip_ssl = false 24 | -------------------------------------------------------------------------------- /internal/managers/pet/testdata/userhome/.config/pet/snippet.toml: -------------------------------------------------------------------------------- 1 | [[snippets]] 2 | description = "Echo something" 3 | command = "echo " 4 | output = "" 5 | 6 | [[snippets]] 7 | description = "Watches Kubernetes pods with refresh" 8 | command = "watch -n 5 'kubectl get pods | grep '" 9 | tag = ["tag1", "tag2"] 10 | output = "" 11 | -------------------------------------------------------------------------------- /internal/managers/pictarinesnip/config.go: -------------------------------------------------------------------------------- 1 | package pictarinesnip 2 | 3 | import ( 4 | "path/filepath" 5 | 6 | "github.com/spf13/afero" 7 | 8 | "github.com/lemoony/snipkit/internal/utils/system" 9 | ) 10 | 11 | type Config struct { 12 | Enabled bool `yaml:"enabled" head_comment:"Set to true if you want to use Snip."` 13 | LibraryPath string `yaml:"libraryPath" head_comment:"Path to the snippets file."` 14 | IncludeTags []string `yaml:"includeTags" head_comment:"If this list is not empty, only those snippets that match the listed tags will be provided to you."` 15 | } 16 | 17 | func AutoDiscoveryConfig(system *system.System) *Config { 18 | libPath := findDefaultSnippetsLibrary(system) 19 | found := libPath != "" 20 | 21 | if libPath == "" { 22 | libPath = "/path/to/snippets-file" 23 | } 24 | 25 | return &Config{ 26 | Enabled: found, 27 | LibraryPath: libPath, 28 | } 29 | } 30 | 31 | func findDefaultSnippetsLibrary(s *system.System) string { 32 | containersHome := s.UserContainersHome() 33 | path := filepath.Join(containersHome, defaultPathContainersLibrary) 34 | if exists, _ := afero.Exists(s.Fs, path); exists { 35 | return path 36 | } 37 | return "" 38 | } 39 | -------------------------------------------------------------------------------- /internal/managers/pictarinesnip/config_test.go: -------------------------------------------------------------------------------- 1 | package pictarinesnip 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/lemoony/snipkit/internal/utils/system" 9 | "github.com/lemoony/snipkit/internal/utils/testutil" 10 | ) 11 | 12 | func Test_findDefaultSnippetsLibrary(t *testing.T) { 13 | tests := []struct { 14 | name string 15 | userContainersDir string 16 | expected string 17 | }{ 18 | {name: "found", userContainersDir: "testdata/userhome/Library/Containers", expected: testDataDefaultLibraryPath}, 19 | {name: "not found", userContainersDir: "testdata/other", expected: ""}, 20 | } 21 | 22 | for _, tt := range tests { 23 | t.Run(tt.name, func(t *testing.T) { 24 | s := testutil.NewTestSystem(system.WithUserContainersDir(tt.userContainersDir)) 25 | path := findDefaultSnippetsLibrary(s) 26 | assert.Equal(t, tt.expected, path) 27 | }) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /internal/managers/pictarinesnip/constants.go: -------------------------------------------------------------------------------- 1 | package pictarinesnip 2 | 3 | import "github.com/lemoony/snipkit/internal/utils/idutil" 4 | 5 | const ( 6 | appID = "com.pictarine.Snip" 7 | defaultPathContainersLibrary = appID + "/Data/Library/Application Support/Snip/snippets" 8 | idPrefix idutil.IDPrefix = "pictn" 9 | ) 10 | -------------------------------------------------------------------------------- /internal/managers/pictarinesnip/description.go: -------------------------------------------------------------------------------- 1 | package pictarinesnip 2 | 3 | import "github.com/lemoony/snipkit/internal/model" 4 | 5 | const Key = model.ManagerKey("pictarinesnip") 6 | 7 | func Description(config *Config) model.ManagerDescription { 8 | return model.ManagerDescription{ 9 | Key: Key, 10 | Name: "Pictarine Snip - Snippet Manager", 11 | Description: "Use snippets form Snip Snippets Manager (Pictarine)", 12 | Enabled: config != nil && config.Enabled, 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /internal/managers/pictarinesnip/model.go: -------------------------------------------------------------------------------- 1 | package pictarinesnip 2 | 3 | import ( 4 | "github.com/lemoony/snipkit/internal/model" 5 | "github.com/lemoony/snipkit/internal/parser" 6 | ) 7 | 8 | type snippetImpl struct { 9 | id string 10 | tags []string 11 | title string 12 | language model.Language 13 | content string 14 | } 15 | 16 | func (s snippetImpl) GetID() string { 17 | return s.id 18 | } 19 | 20 | func (s snippetImpl) GetTitle() string { 21 | return s.title 22 | } 23 | 24 | func (s snippetImpl) GetTags() []string { 25 | return s.tags 26 | } 27 | 28 | func (s snippetImpl) GetContent() string { 29 | return s.content 30 | } 31 | 32 | func (s snippetImpl) GetLanguage() model.Language { 33 | return s.language 34 | } 35 | 36 | func (s snippetImpl) GetParameters() []model.Parameter { 37 | return parser.ParseParameters(s.content) 38 | } 39 | 40 | func (s snippetImpl) Format(values []string, options model.SnippetFormatOptions) string { 41 | return parser.CreateSnippet(s.GetContent(), s.GetParameters(), values, options) 42 | } 43 | -------------------------------------------------------------------------------- /internal/managers/pictarinesnip/parser.go: -------------------------------------------------------------------------------- 1 | package pictarinesnip 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/lemoony/snipkit/internal/model" 7 | "github.com/lemoony/snipkit/internal/utils/idutil" 8 | "github.com/lemoony/snipkit/internal/utils/stringutil" 9 | "github.com/lemoony/snipkit/internal/utils/system" 10 | "github.com/lemoony/snipkit/internal/utils/tagutil" 11 | ) 12 | 13 | var languageMapping = map[string]model.Language{ 14 | "shell": model.LanguageBash, 15 | "yaml": model.LanguageYAML, 16 | "markdown": model.LanguageMarkdown, 17 | } 18 | 19 | type picatrineSnippet struct { 20 | ID string `json:"id"` 21 | Name string `json:"name"` 22 | Tags []string `json:"tags"` 23 | Snippet string `json:"snippet"` 24 | Mode struct { 25 | Name string `json:"name"` 26 | } `json:"mode"` 27 | } 28 | 29 | func parseLibrary(path string, system *system.System, tags *stringutil.StringSet) []model.Snippet { 30 | file, err := system.Fs.Open(path) 31 | if err != nil { 32 | panic(err) 33 | } 34 | 35 | var snippets []picatrineSnippet 36 | if err = json.NewDecoder(file).Decode(&snippets); err != nil { 37 | panic(err) 38 | } 39 | 40 | return mapToModel(snippets, tags) 41 | } 42 | 43 | func mapToModel(rawSnippets []picatrineSnippet, tags *stringutil.StringSet) []model.Snippet { 44 | var result []model.Snippet 45 | 46 | for i := range rawSnippets { 47 | raw := rawSnippets[i] 48 | 49 | if !tagutil.HasValidTag(*tags, raw.Tags) { 50 | continue 51 | } 52 | 53 | result = append(result, snippetImpl{ 54 | id: idutil.FormatSnippetID(raw.ID, idPrefix), 55 | title: raw.Name, 56 | tags: raw.Tags, 57 | language: mapToLanguage(raw.Mode.Name), 58 | content: raw.Snippet, 59 | }) 60 | } 61 | return result 62 | } 63 | 64 | // https://github.com/Pictarine/macos-snippets/blob/aeb70a4b0e04025be9b511ea5810dd41671d89e7/Snip/Model/Mode.swift 65 | func mapToLanguage(name string) model.Language { 66 | if entry, ok := languageMapping[name]; ok { 67 | return entry 68 | } 69 | return model.LanguageUnknown 70 | } 71 | -------------------------------------------------------------------------------- /internal/managers/pictarinesnip/parser_test.go: -------------------------------------------------------------------------------- 1 | package pictarinesnip 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/lemoony/snipkit/internal/model" 9 | "github.com/lemoony/snipkit/internal/utils/idutil" 10 | "github.com/lemoony/snipkit/internal/utils/stringutil" 11 | "github.com/lemoony/snipkit/internal/utils/testutil" 12 | ) 13 | 14 | func Test_parseLibrary(t *testing.T) { 15 | system := testutil.NewTestSystem() 16 | snippets := parseLibrary(testDataDefaultLibraryPath, system, &stringutil.StringSet{}) 17 | 18 | assert.Len(t, snippets, 2) 19 | 20 | snippet1 := snippets[0] 21 | assert.Equal(t, idutil.FormatSnippetID("88235A31-F0AD-4206-96DE-19E0EDEE79B2", idPrefix), snippet1.GetID()) 22 | assert.Equal(t, "Echo something", snippet1.GetTitle()) 23 | assert.Regexp(t, "^# some comment.*", snippet1.GetContent()) 24 | assert.Equal(t, model.LanguageBash, snippet1.GetLanguage()) 25 | assert.Equal(t, []string{"snipkit"}, snippet1.GetTags()) 26 | assert.Len(t, snippet1.GetParameters(), 3) 27 | assert.NotEqual(t, snippet1.GetContent(), snippet1.Format([]string{"one", "two", "three"}, model.SnippetFormatOptions{})) 28 | 29 | snippet2 := snippets[1] 30 | assert.Equal(t, idutil.FormatSnippetID("B3473DF8-6ED6-4589-BFFC-C75F73B1B522", idPrefix), snippet2.GetID()) 31 | assert.Equal(t, "Another snippet", snippet2.GetTitle()) 32 | assert.Equal(t, "echo \"Hello\"", snippet2.GetContent()) 33 | assert.Equal(t, model.LanguageUnknown, snippet2.GetLanguage()) 34 | assert.Equal(t, []string{}, snippet2.GetTags()) 35 | assert.Empty(t, snippet2.GetParameters()) 36 | assert.Equal(t, snippet2.GetContent(), snippet2.Format([]string{}, model.SnippetFormatOptions{})) 37 | } 38 | -------------------------------------------------------------------------------- /internal/managers/pictarinesnip/testdata/pref-with-user-defined-library-path.plist: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lemoony/snipkit/bca1be1f6647f6d1db5d39815be5d6c388888ddc/internal/managers/pictarinesnip/testdata/pref-with-user-defined-library-path.plist -------------------------------------------------------------------------------- /internal/managers/pictarinesnip/testdata/userhome/Library/Containers/com.pictarine.Snip/Data/Library/Application Support/Snip/snippets: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "88235A31-F0AD-4206-96DE-19E0EDEE79B2", 4 | "lastUpdateDate": 663946440.240493, 5 | "name": "Echo something", 6 | "isFavorite": false, 7 | "remoteURL": null, 8 | "gistNodeId": null, 9 | "tags": [ 10 | "snipkit" 11 | ], 12 | "snippet": "# some comment\n# ${VAR1} Name: First Output\n# ${VAR1} Description: What to print on the terminal first\necho \"${VAR1}\"\n\n# ${VAR2} Name: Second Output\n# ${VAR2} Description: What to print on the terminal second\n# ${VAR2} Default: friend\necho \"${VAR2}\"\n\n# ${VAR3} Name: Third Output\n# ${VAR3} Description: hat to print on the terminal third\n# ${VAR3} Values: One + some more, \"Two\",Three, ,\n# ${VAR3} Values: Four\\, and some more, Five\n# ${VAR3} Default: Three\necho \"${VAR3}\"\n", 13 | "gistId": null, 14 | "mode": { 15 | "name": "shell", 16 | "mimeType": "application/x-sh" 17 | }, 18 | "syncState": 0, 19 | "gistURL": null, 20 | "kind": 1, 21 | "creationDate": 663946416.148411, 22 | "content": [] 23 | }, 24 | { 25 | "id": "B3473DF8-6ED6-4589-BFFC-C75F73B1B522", 26 | "lastUpdateDate": 663947994.338797, 27 | "name": "Another snippet", 28 | "isFavorite": false, 29 | "remoteURL": null, 30 | "gistNodeId": null, 31 | "tags": [], 32 | "snippet": "echo \"Hello\"", 33 | "gistId": null, 34 | "mode": { 35 | "name": "text", 36 | "mimeType": "text/plain-text" 37 | }, 38 | "syncState": 0, 39 | "gistURL": null, 40 | "kind": 1, 41 | "creationDate": 663947934.156118, 42 | "content": [] 43 | } 44 | ] -------------------------------------------------------------------------------- /internal/managers/snippetslab/config.go: -------------------------------------------------------------------------------- 1 | package snippetslab 2 | 3 | import ( 4 | "github.com/lemoony/snipkit/internal/utils/system" 5 | ) 6 | 7 | type Config struct { 8 | Enabled bool `yaml:"enabled" head_comment:"Set to true if you want to use SnippetsLab."` 9 | LibraryPath string `yaml:"libraryPath" head_comment:"Path to your *.snippetslablibrary file.\nSnipKit will try to detect this file automatically when generating the config."` 10 | IncludeTags []string `yaml:"includeTags" head_comment:"If this list is not empty, only those snippets that match the listed tags will be provided to you."` 11 | } 12 | 13 | func AutoDiscoveryConfig(system *system.System) *Config { 14 | result := Config{ 15 | Enabled: false, 16 | LibraryPath: "/path/to/main.snippetslablibrary", 17 | } 18 | 19 | var libraryURL snippetsLabLibrary 20 | preferencesFilePath, _ := findPreferencesPath(system) 21 | libraryURL = findLibraryURL(system, preferencesFilePath) 22 | 23 | if ok, err := libraryURL.validate(); err != nil || !ok { 24 | return &result 25 | } else if basePath, err2 := libraryURL.basePath(); err2 == nil { 26 | result.Enabled = true 27 | result.LibraryPath = basePath 28 | } 29 | 30 | return &result 31 | } 32 | -------------------------------------------------------------------------------- /internal/managers/snippetslab/config_test.go: -------------------------------------------------------------------------------- 1 | package snippetslab 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/lemoony/snipkit/internal/utils/system" 9 | ) 10 | 11 | func Test_AutoDiscoveryConfig_NoSnippetLab(t *testing.T) { 12 | system := system.NewSystem(system.WithUserContainersDir("path/does/not/exist")) 13 | config := AutoDiscoveryConfig(system) 14 | assert.False(t, config.Enabled) 15 | } 16 | 17 | func Test_AutoDiscoveryConfig_Available(t *testing.T) { 18 | system := system.NewSystem(system.WithUserContainersDir(testdataContainersPath)) 19 | config := AutoDiscoveryConfig(system) 20 | assert.True(t, config.Enabled) 21 | assert.Equal(t, testDataDefaultLibraryPath, config.LibraryPath) 22 | } 23 | -------------------------------------------------------------------------------- /internal/managers/snippetslab/constants.go: -------------------------------------------------------------------------------- 1 | package snippetslab 2 | 3 | import ( 4 | "net/url" 5 | "path" 6 | 7 | "github.com/lemoony/snipkit/internal/utils/idutil" 8 | ) 9 | 10 | type snippetsLabLibrary string 11 | 12 | func (t *snippetsLabLibrary) basePath() (string, error) { 13 | libURL, err := url.Parse(string(*t)) 14 | if err != nil { 15 | return "", err 16 | } 17 | return libURL.Path, nil 18 | } 19 | 20 | func (t *snippetsLabLibrary) tagsFilePath() (string, error) { 21 | if libURL, err := url.Parse(string(*t)); err != nil { 22 | return "", err 23 | } else { 24 | return path.Join(libURL.Path, tagsSubPath), nil 25 | } 26 | } 27 | 28 | func (t *snippetsLabLibrary) snippetsFilePath() (string, error) { 29 | if libURL, err := url.Parse(string(*t)); err != nil { 30 | return "", err 31 | } else { 32 | return path.Join(libURL.Path, snippetsSubPath), nil 33 | } 34 | } 35 | 36 | func (t *snippetsLabLibrary) validate() (bool, error) { 37 | if _, err := parseTags(*t); err != nil { 38 | return false, err 39 | } 40 | return true, nil 41 | } 42 | 43 | const ( 44 | SnippetTitle = "com.renfei.SnippetsLab.Key.SnippetTitle" 45 | SnippetParts = "com.renfei.SnippetsLab.Key.SnippetParts" 46 | SnippetPartContent = "com.renfei.SnippetsLab.Key.SnippetPartContent" 47 | SnippetPartLanguage = "com.renfei.SnippetsLab.Key.SnippetPartLanguage" 48 | SnippetUUID = "com.renfei.SnippetsLab.Key.SnippetUUID" 49 | SnippetTagUUIDs = "com.renfei.SnippetsLab.Key.SnippetTagUUIDs" 50 | 51 | SnippetTagsTagUUID = "com.renfei.SnippetsLab.Key.TagUUID" 52 | SnippetTagsTagTitle = "com.renfei.SnippetsLab.Key.TagTitle" 53 | 54 | appID = "com.renfei.SnippetsLab" 55 | preferencesFile = appID + ".plist" 56 | 57 | databaseSubPath = "Database" 58 | tagsSubPath = databaseSubPath + "/tags.data" 59 | snippetsSubPath = databaseSubPath + "/Snippets" 60 | 61 | defaultPathContaninersLibrary = appID + "/Data/Library/Application Support/" + appID + "/main.snippetslablibrary" 62 | userDesignatedLibraryPathString = "User DesignatedLibraryPathString" 63 | 64 | invalidSnippetsLabLibrary = snippetsLabLibrary("") 65 | 66 | idPrefix idutil.IDPrefix = "spl" 67 | ) 68 | -------------------------------------------------------------------------------- /internal/managers/snippetslab/constants_test.go: -------------------------------------------------------------------------------- 1 | package snippetslab 2 | 3 | const ( 4 | testdataContainersPath = "testdata/userhome/Library/Containers" 5 | 6 | testDataPreferencesPath = testdataContainersPath + "/com.renfei.SnippetsLab/Data/Library/Preferences/com.renfei.SnippetsLab.plist" 7 | testDataDefaultLibraryPath = testdataContainersPath + "/com.renfei.SnippetsLab/Data/Library/Application Support/com.renfei.SnippetsLab/main.snippetslablibrary" 8 | testDataPreferencesWithUserDefinedLibraryPath = "testdata/pref-with-user-defined-library-path.plist" 9 | ) 10 | -------------------------------------------------------------------------------- /internal/managers/snippetslab/description.go: -------------------------------------------------------------------------------- 1 | package snippetslab 2 | 3 | import "github.com/lemoony/snipkit/internal/model" 4 | 5 | var Key = model.ManagerKey("snippetslab") 6 | 7 | func Description(config *Config) model.ManagerDescription { 8 | return model.ManagerDescription{ 9 | Key: Key, 10 | Name: "SnippetsLab", 11 | Description: "Use snippets form SnippetsLab", 12 | Enabled: config != nil && config.Enabled, 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /internal/managers/snippetslab/model.go: -------------------------------------------------------------------------------- 1 | package snippetslab 2 | 3 | import ( 4 | "github.com/lemoony/snipkit/internal/model" 5 | "github.com/lemoony/snipkit/internal/parser" 6 | ) 7 | 8 | type snippetImpl struct { 9 | id string 10 | tags []string 11 | title string 12 | content string 13 | language model.Language 14 | } 15 | 16 | func (s snippetImpl) GetID() string { 17 | return s.id 18 | } 19 | 20 | func (s snippetImpl) GetTitle() string { 21 | return s.title 22 | } 23 | 24 | func (s snippetImpl) GetTags() []string { 25 | return s.tags 26 | } 27 | 28 | func (s snippetImpl) GetContent() string { 29 | return s.content 30 | } 31 | 32 | func (s snippetImpl) GetLanguage() model.Language { 33 | return s.language 34 | } 35 | 36 | func (s snippetImpl) GetParameters() []model.Parameter { 37 | return parser.ParseParameters(s.content) 38 | } 39 | 40 | func (s snippetImpl) Format(values []string, options model.SnippetFormatOptions) string { 41 | return parser.CreateSnippet(s.GetContent(), s.GetParameters(), values, options) 42 | } 43 | -------------------------------------------------------------------------------- /internal/managers/snippetslab/parser_test.go: -------------------------------------------------------------------------------- 1 | package snippetslab 2 | 3 | import ( 4 | "sort" 5 | "testing" 6 | 7 | "github.com/corbym/gocrest/is" 8 | "github.com/corbym/gocrest/then" 9 | "github.com/stretchr/testify/assert" 10 | 11 | "github.com/lemoony/snipkit/internal/model" 12 | "github.com/lemoony/snipkit/internal/utils/idutil" 13 | ) 14 | 15 | func Test_parseTags(t *testing.T) { 16 | tags, err := parseTags(testDataDefaultLibraryPath) 17 | assert.NoError(t, err) 18 | assert.Len(t, tags, 1) 19 | assert.Equal(t, tags["2DA8009E-7BE7-420D-AD57-E7F9BB3ADCBE"], "snipkit") 20 | } 21 | 22 | func Test_parseSnippets(t *testing.T) { 23 | library := snippetsLabLibrary(testDataDefaultLibraryPath) 24 | 25 | snippets, err := parseSnippets(library) 26 | assert.NoError(t, err) 27 | assert.Len(t, snippets, 2) 28 | 29 | sort.Slice(snippets, func(i, j int) bool { 30 | return snippets[i].GetID() > snippets[j].GetID() 31 | }) 32 | 33 | snippet1 := snippets[0] 34 | assert.Equal(t, idutil.FormatSnippetID("84A08C4A-B2BE-4964-A521-180550BDA7B3", idPrefix), snippet1.GetID()) 35 | assert.Empty(t, snippet1.GetTags()) 36 | assert.Equal(t, model.LanguageBash, snippet1.GetLanguage()) 37 | assert.Len(t, snippet1.GetParameters(), 2) 38 | assert.NotEqual(t, snippet1.Format([]string{"one", "two"}, model.SnippetFormatOptions{}), snippet1.GetContent()) 39 | then.AssertThat(t, snippet1.GetContent(), is.MatchForPattern("^# some comment.*")) 40 | then.AssertThat(t, snippet1.GetTitle(), is.AnyOf(is.EqualTo("Simple echo"))) 41 | 42 | snippet2 := snippets[1] 43 | assert.Equal(t, idutil.FormatSnippetID("B3EDC3BE-6FE1-489E-9EB8-C400D4CF1B54", idPrefix), snippet2.GetID()) 44 | assert.Equal(t, []string{"2DA8009E-7BE7-420D-AD57-E7F9BB3ADCBE"}, snippet2.GetTags()) 45 | assert.Equal(t, model.LanguageBash, snippet2.GetLanguage()) 46 | assert.Empty(t, snippet2.GetParameters()) 47 | assert.NotEqual(t, snippet2.Format([]string{}, model.SnippetFormatOptions{}), snippet1.GetContent()) 48 | then.AssertThat(t, snippet2.GetContent(), is.MatchForPattern("echo \"Foo!\"")) 49 | then.AssertThat(t, snippet2.GetTitle(), is.AnyOf(is.EqualTo("Foos script"))) 50 | } 51 | -------------------------------------------------------------------------------- /internal/managers/snippetslab/testdata/pref-with-user-defined-library-path.plist: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lemoony/snipkit/bca1be1f6647f6d1db5d39815be5d6c388888ddc/internal/managers/snippetslab/testdata/pref-with-user-defined-library-path.plist -------------------------------------------------------------------------------- /internal/managers/snippetslab/testdata/userhome/Library/Containers/com.renfei.SnippetsLab/Data/Library/Application Support/com.renfei.SnippetsLab/main.snippetslablibrary/Database/Snippets/84A08C4A-B2BE-4964-A521-180550BDA7B3.data: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lemoony/snipkit/bca1be1f6647f6d1db5d39815be5d6c388888ddc/internal/managers/snippetslab/testdata/userhome/Library/Containers/com.renfei.SnippetsLab/Data/Library/Application Support/com.renfei.SnippetsLab/main.snippetslablibrary/Database/Snippets/84A08C4A-B2BE-4964-A521-180550BDA7B3.data -------------------------------------------------------------------------------- /internal/managers/snippetslab/testdata/userhome/Library/Containers/com.renfei.SnippetsLab/Data/Library/Application Support/com.renfei.SnippetsLab/main.snippetslablibrary/Database/Snippets/B3EDC3BE-6FE1-489E-9EB8-C400D4CF1B54.data: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lemoony/snipkit/bca1be1f6647f6d1db5d39815be5d6c388888ddc/internal/managers/snippetslab/testdata/userhome/Library/Containers/com.renfei.SnippetsLab/Data/Library/Application Support/com.renfei.SnippetsLab/main.snippetslablibrary/Database/Snippets/B3EDC3BE-6FE1-489E-9EB8-C400D4CF1B54.data -------------------------------------------------------------------------------- /internal/managers/snippetslab/testdata/userhome/Library/Containers/com.renfei.SnippetsLab/Data/Library/Application Support/com.renfei.SnippetsLab/main.snippetslablibrary/Database/deletion.data: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lemoony/snipkit/bca1be1f6647f6d1db5d39815be5d6c388888ddc/internal/managers/snippetslab/testdata/userhome/Library/Containers/com.renfei.SnippetsLab/Data/Library/Application Support/com.renfei.SnippetsLab/main.snippetslablibrary/Database/deletion.data -------------------------------------------------------------------------------- /internal/managers/snippetslab/testdata/userhome/Library/Containers/com.renfei.SnippetsLab/Data/Library/Application Support/com.renfei.SnippetsLab/main.snippetslablibrary/Database/folders.data: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lemoony/snipkit/bca1be1f6647f6d1db5d39815be5d6c388888ddc/internal/managers/snippetslab/testdata/userhome/Library/Containers/com.renfei.SnippetsLab/Data/Library/Application Support/com.renfei.SnippetsLab/main.snippetslablibrary/Database/folders.data -------------------------------------------------------------------------------- /internal/managers/snippetslab/testdata/userhome/Library/Containers/com.renfei.SnippetsLab/Data/Library/Application Support/com.renfei.SnippetsLab/main.snippetslablibrary/Database/shortcuts.data: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lemoony/snipkit/bca1be1f6647f6d1db5d39815be5d6c388888ddc/internal/managers/snippetslab/testdata/userhome/Library/Containers/com.renfei.SnippetsLab/Data/Library/Application Support/com.renfei.SnippetsLab/main.snippetslablibrary/Database/shortcuts.data -------------------------------------------------------------------------------- /internal/managers/snippetslab/testdata/userhome/Library/Containers/com.renfei.SnippetsLab/Data/Library/Application Support/com.renfei.SnippetsLab/main.snippetslablibrary/Database/smart groups.data: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lemoony/snipkit/bca1be1f6647f6d1db5d39815be5d6c388888ddc/internal/managers/snippetslab/testdata/userhome/Library/Containers/com.renfei.SnippetsLab/Data/Library/Application Support/com.renfei.SnippetsLab/main.snippetslablibrary/Database/smart groups.data -------------------------------------------------------------------------------- /internal/managers/snippetslab/testdata/userhome/Library/Containers/com.renfei.SnippetsLab/Data/Library/Application Support/com.renfei.SnippetsLab/main.snippetslablibrary/Database/tags.data: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lemoony/snipkit/bca1be1f6647f6d1db5d39815be5d6c388888ddc/internal/managers/snippetslab/testdata/userhome/Library/Containers/com.renfei.SnippetsLab/Data/Library/Application Support/com.renfei.SnippetsLab/main.snippetslablibrary/Database/tags.data -------------------------------------------------------------------------------- /internal/managers/snippetslab/testdata/userhome/Library/Containers/com.renfei.SnippetsLab/Data/Library/Preferences/com.renfei.SnippetsLab.plist: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lemoony/snipkit/bca1be1f6647f6d1db5d39815be5d6c388888ddc/internal/managers/snippetslab/testdata/userhome/Library/Containers/com.renfei.SnippetsLab/Data/Library/Preferences/com.renfei.SnippetsLab.plist -------------------------------------------------------------------------------- /internal/model/assistant.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type AssistantKey string 4 | 5 | type AssistantDescription struct { 6 | Key AssistantKey 7 | Name string 8 | Description string 9 | Enabled bool 10 | } 11 | -------------------------------------------------------------------------------- /internal/model/info.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type InfoLine struct { 4 | Key string 5 | Value string 6 | IsError bool 7 | } 8 | -------------------------------------------------------------------------------- /internal/model/language.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type Language int 4 | 5 | const ( 6 | LanguageUnknown = Language(0) 7 | LanguageBash = Language(1) 8 | LanguageYAML = Language(2) 9 | LanguageMarkdown = Language(3) 10 | LanguageText = Language(4) 11 | LanguageTOML = Language(5) 12 | ) 13 | -------------------------------------------------------------------------------- /internal/model/manager.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type ManagerKey string 4 | 5 | type ManagerDescription struct { 6 | Key ManagerKey 7 | Name string 8 | Description string 9 | Enabled bool 10 | } 11 | -------------------------------------------------------------------------------- /internal/model/parameter.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type ParameterType int 4 | 5 | const ( 6 | ParameterTypeValue = ParameterType(0) 7 | ParameterTypePath = ParameterType(1) 8 | ParameterTypePassword = ParameterType(2) 9 | ) 10 | 11 | type Parameter struct { 12 | Key string 13 | Name string 14 | Type ParameterType 15 | Description string 16 | DefaultValue string 17 | Values []string 18 | } 19 | 20 | type ParameterValue struct { 21 | Key string 22 | Value string 23 | } 24 | -------------------------------------------------------------------------------- /internal/model/snippet.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type SnippetParamMode int 4 | 5 | const ( 6 | SnippetParamModeSet = 0 7 | SnippetParamModeReplace = 1 8 | ) 9 | 10 | type SnippetFormatOptions struct { 11 | RemoveComments bool 12 | ParamMode SnippetParamMode 13 | } 14 | 15 | type Snippet interface { 16 | GetID() string 17 | GetTitle() string 18 | GetContent() string 19 | GetTags() []string 20 | GetLanguage() Language 21 | GetParameters() []Parameter 22 | Format([]string, SnippetFormatOptions) string 23 | } 24 | -------------------------------------------------------------------------------- /internal/model/sync.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type SyncEventChannel chan SyncEvent 4 | 5 | type ( 6 | SyncStatus int 7 | SyncLineType int 8 | SyncLoginType int 9 | ) 10 | 11 | const ( 12 | SyncStatusStarted = SyncStatus(1) 13 | SyncStatusFinished = SyncStatus(2) 14 | SyncStatusAborted = SyncStatus(3) 15 | 16 | SyncLineTypeInfo = SyncLineType(0) 17 | SyncLineTypeSuccess = SyncLineType(1) 18 | SyncLineTypeError = SyncLineType(2) 19 | 20 | SyncLoginTypeContinue = SyncLoginType(1) 21 | SyncLoginTypeText = SyncLoginType(2) 22 | ) 23 | 24 | type SyncEvent struct { 25 | Status SyncStatus 26 | Lines []SyncLine 27 | Login *SyncInput 28 | Error error 29 | } 30 | 31 | type SyncInput struct { 32 | Content interface{} 33 | Placeholder string 34 | Type SyncLoginType 35 | Input chan SyncInputResult 36 | } 37 | 38 | type SyncLine struct { 39 | Type SyncLineType 40 | Value string 41 | } 42 | 43 | type SyncInputResult struct { 44 | Continue bool 45 | Abort bool 46 | Text string 47 | } 48 | -------------------------------------------------------------------------------- /internal/ui/assistant/prompt/prompt_test.go: -------------------------------------------------------------------------------- 1 | package prompt 2 | 3 | import ( 4 | "testing" 5 | 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/stretchr/testify/assert" 8 | 9 | "github.com/lemoony/snipkit/internal/ui/style" 10 | "github.com/lemoony/snipkit/internal/utils/termtest" 11 | "github.com/lemoony/snipkit/internal/utils/termutil" 12 | ) 13 | 14 | func TestShowPrompt_WithHistory(t *testing.T) { 15 | history := []string{"previous prompt 1", "previous prompt 2"} 16 | config := Config{History: history} 17 | 18 | termtest.RunTerminalTest(t, func(c *termtest.Console) { 19 | c.ExpectString("SnipKit Assistant") 20 | c.ExpectString("[1] previous prompt 1") 21 | c.ExpectString("[2] previous prompt 2") 22 | c.Send("new prompt text") 23 | c.SendKey(termtest.KeyEnter) 24 | }, func(stdio termutil.Stdio) { 25 | ok, prompt := ShowPrompt(config, style.Style{}, tea.WithInput(stdio.In), tea.WithOutput(stdio.Out)) 26 | assert.True(t, ok) 27 | assert.Equal(t, "new prompt text", prompt) 28 | }) 29 | } 30 | 31 | func TestShowPrompt_EmptyInput(t *testing.T) { 32 | config := Config{History: []string{}} 33 | 34 | termtest.RunTerminalTest(t, func(c *termtest.Console) { 35 | c.ExpectString("SnipKit Assistant") 36 | c.ExpectString("What do you want the script to do?") 37 | c.SendKey(termtest.KeyEnter) 38 | }, func(stdio termutil.Stdio) { 39 | ok, prompt := ShowPrompt(config, style.Style{}, tea.WithInput(stdio.In), tea.WithOutput(stdio.Out)) 40 | assert.True(t, ok) 41 | assert.Equal(t, "", prompt) 42 | }) 43 | } 44 | 45 | func TestShowPrompt_Cancel(t *testing.T) { 46 | config := Config{History: []string{"previous prompt"}} 47 | 48 | termtest.RunTerminalTest(t, func(c *termtest.Console) { 49 | c.ExpectString("SnipKit Assistant") 50 | c.ExpectString("Do you want to provide additional context or change anything?") 51 | c.SendKey(termtest.KeyStrC) 52 | }, func(stdio termutil.Stdio) { 53 | ok, _ := ShowPrompt(config, style.Style{}, tea.WithInput(stdio.In), tea.WithOutput(stdio.Out)) 54 | assert.False(t, ok) 55 | }) 56 | } 57 | -------------------------------------------------------------------------------- /internal/ui/config.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "emperror.dev/errors" 5 | 6 | "github.com/lemoony/snipkit/internal/ui/style" 7 | "github.com/lemoony/snipkit/internal/utils/system" 8 | ) 9 | 10 | type Config struct { 11 | Theme string `yaml:"theme" head_comment:"The theme defines the terminal colors used by Snipkit.\nAvailable themes:default(.light|.dark),simple."` 12 | HideKeyMap bool `yaml:"hideKeyMap,omitempty" head_comment:"If set to true, the key map won't be displayed. Default value: false"` 13 | } 14 | 15 | type NamedTheme struct { 16 | Name string `yaml:"name"` 17 | Values style.ThemeValues `yaml:"values" head_comment:"A color can be created from a color name (W3C name) or by a hex value in the format #ffffff."` 18 | } 19 | 20 | func (c *Config) GetSelectedTheme(system *system.System) style.ThemeValues { 21 | themeName := defaultThemeName 22 | if c.Theme != "" { 23 | themeName = c.Theme 24 | } 25 | 26 | if theme, ok := embeddedTheme(themeName); ok { 27 | return *theme 28 | } 29 | 30 | if theme, ok := customTheme(themeName, system); ok { 31 | return *theme 32 | } 33 | 34 | panic(errors.Wrapf(ErrInvalidTheme, "theme not found: %s", themeName)) 35 | } 36 | 37 | func DefaultConfig() Config { 38 | return Config{Theme: "default"} 39 | } 40 | 41 | func ApplyConfig(cfg Config, system *system.System) { 42 | applyTheme(cfg, system) 43 | } 44 | -------------------------------------------------------------------------------- /internal/ui/config_test.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "path/filepath" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | 9 | "github.com/lemoony/snipkit/internal/utils/assertutil" 10 | "github.com/lemoony/snipkit/internal/utils/testutil" 11 | ) 12 | 13 | func Test_Config_apply(t *testing.T) { 14 | configWithTheme := func(theme string) Config { 15 | cfg := DefaultConfig() 16 | cfg.Theme = theme 17 | return cfg 18 | } 19 | 20 | system := testutil.NewTestSystem() 21 | 22 | bytes := system.ReadFile("testdata/test-custom.yaml") 23 | path := filepath.Join(system.ThemesDir(), "test-custom.yaml") 24 | system.CreatePath(path) 25 | system.WriteFile(path, bytes) 26 | 27 | testdata := []struct { 28 | name string 29 | config Config 30 | previewSchema string 31 | }{ 32 | {name: "default", config: DefaultConfig(), previewSchema: "friendly"}, 33 | {name: "default light", config: configWithTheme("default.light"), previewSchema: "friendly"}, 34 | {name: "default dark", config: configWithTheme("default.dark"), previewSchema: "friendly"}, 35 | {name: "simple", config: configWithTheme("simple"), previewSchema: "pastie"}, 36 | {name: "test-custom", config: configWithTheme("test-custom"), previewSchema: "rainbow_dash"}, 37 | } 38 | 39 | for _, tt := range testdata { 40 | t.Run(tt.name, func(t *testing.T) { 41 | theme := tt.config.GetSelectedTheme(system) 42 | ApplyConfig(tt.config, system) 43 | assert.Equal(t, tt.previewSchema, theme.PreviewColorSchemeName) 44 | }) 45 | } 46 | } 47 | 48 | func Test_GetUnknownTheme(t *testing.T) { 49 | cfg := DefaultConfig() 50 | cfg.Theme = "foo-theme" 51 | 52 | system := testutil.NewTestSystem() 53 | 54 | err := assertutil.AssertPanicsWithError(t, ErrInvalidTheme, func() { 55 | cfg.GetSelectedTheme(system) 56 | }) 57 | 58 | assert.Contains(t, err.Error(), "theme not found: "+cfg.Theme) 59 | } 60 | -------------------------------------------------------------------------------- /internal/ui/form/options.go: -------------------------------------------------------------------------------- 1 | package form 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/spf13/afero" 7 | 8 | "github.com/lemoony/snipkit/internal/ui/style" 9 | ) 10 | 11 | type Option interface { 12 | apply(c *model) 13 | } 14 | 15 | type optionFunc func(o *model) 16 | 17 | func (f optionFunc) apply(o *model) { 18 | f(o) 19 | } 20 | 21 | func WithIn(input io.Reader) Option { 22 | return optionFunc(func(c *model) { 23 | c.input = &input 24 | }) 25 | } 26 | 27 | func WithOut(out io.Writer) Option { 28 | return optionFunc(func(c *model) { 29 | c.output = &out 30 | }) 31 | } 32 | 33 | func WithStyler(styler style.Style) Option { 34 | return optionFunc(func(c *model) { 35 | c.styler = styler 36 | }) 37 | } 38 | 39 | func WithFS(fs afero.Fs) Option { 40 | return optionFunc(func(c *model) { 41 | c.fs = fs 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /internal/ui/form/util.go: -------------------------------------------------------------------------------- 1 | package form 2 | 3 | import ( 4 | "path/filepath" 5 | "strings" 6 | 7 | "github.com/phuslu/log" 8 | "github.com/spf13/afero" 9 | 10 | "github.com/lemoony/snipkit/internal/utils/stringutil" 11 | ) 12 | 13 | func suggestionsForPath(fs afero.Fs, path string) []string { 14 | var result []string 15 | 16 | log.Trace().Msgf("Path input: %s", path) 17 | 18 | var isFile bool 19 | var isPointingToDir bool 20 | var dirPath string 21 | var filePath string 22 | 23 | stat, err := fs.Stat(path) 24 | switch { 25 | case err == nil && stat.IsDir(): 26 | dirPath = path 27 | filePath = "" 28 | isFile = false 29 | isPointingToDir = true 30 | case stat != nil: 31 | dirPath, filePath = filepath.Split(path) 32 | isFile = true 33 | default: 34 | dirPath, filePath = filepath.Split(path) 35 | } 36 | 37 | log.Trace().Msgf("DirPath: %s, FilePath: %s, isFile: %v, isPointingToDir: %v", 38 | dirPath, filePath, isFile, isPointingToDir, 39 | ) 40 | 41 | if !isFile { 42 | if isPointingToDir { 43 | result = append(result, dirPath) 44 | } 45 | 46 | if filesInDir, readDirErr := afero.ReadDir(fs, stringutil.StringOrDefault(dirPath, "./")); readDirErr == nil { 47 | for _, fileInDir := range filesInDir { 48 | altFilePath := dirPath 49 | if dirPath != "" && !strings.HasSuffix(altFilePath, "/") { 50 | altFilePath += "/" 51 | } 52 | altFilePath += fileInDir.Name() 53 | 54 | log.Trace().Msgf("alt file path: %s", altFilePath) 55 | 56 | if strings.HasPrefix(altFilePath, path) { 57 | result = append(result, altFilePath) 58 | } 59 | } 60 | } 61 | } 62 | return result 63 | } 64 | -------------------------------------------------------------------------------- /internal/ui/form/util_test.go: -------------------------------------------------------------------------------- 1 | package form 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/spf13/afero" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func Test_suggestionsForPath(t *testing.T) { 11 | fs := afero.NewMemMapFs() 12 | 13 | createTestFile(t, fs, "testfile-a.txt") 14 | createTestFile(t, fs, "testfile-b.txt") 15 | 16 | createTestDirectory(t, fs, ".config/logs") 17 | createTestFile(t, fs, ".config/some.txt") 18 | 19 | createTestFile(t, fs, ".config/logs/file.log") 20 | createTestFile(t, fs, ".config/logs/HEAD") 21 | 22 | tests := []struct { 23 | path string 24 | expected []string 25 | }{ 26 | {path: "test", expected: []string{"testfile-a.txt", "testfile-b.txt"}}, 27 | {path: "testfile-a.txt", expected: []string{}}, 28 | {path: "./", expected: []string{"./", "./.config", "./testfile-a.txt", "./testfile-b.txt"}}, 29 | {path: ".", expected: []string{".", "./.config", "./testfile-a.txt", "./testfile-b.txt"}}, 30 | {path: "./test", expected: []string{"./testfile-a.txt", "./testfile-b.txt"}}, 31 | {path: "./.config", expected: []string{"./.config", "./.config/logs", "./.config/some.txt"}}, 32 | {path: ".config", expected: []string{".config", ".config/logs", ".config/some.txt"}}, 33 | {path: ".config/logs", expected: []string{".config/logs", ".config/logs/HEAD", ".config/logs/file.log"}}, 34 | } 35 | 36 | for _, tt := range tests { 37 | t.Run(tt.path, func(t *testing.T) { 38 | if len(tt.expected) == 0 { 39 | assert.Empty(t, suggestionsForPath(fs, tt.path)) 40 | } else { 41 | assert.Equal(t, tt.expected, suggestionsForPath(fs, tt.path)) 42 | } 43 | }) 44 | } 45 | } 46 | 47 | func createTestFile(t *testing.T, fs afero.Fs, path string) { 48 | t.Helper() 49 | const fileMode = 0o600 50 | assert.NoError(t, afero.WriteFile(fs, path, []byte("foo"), fileMode)) 51 | } 52 | 53 | func createTestDirectory(t *testing.T, fs afero.Fs, path string) { 54 | t.Helper() 55 | const dirMode = 0o700 56 | assert.NoError(t, fs.MkdirAll(path, dirMode)) 57 | } 58 | -------------------------------------------------------------------------------- /internal/ui/helper_test.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/gdamore/tcell/v2" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func runScreenTest(t *testing.T, procedure func(s tcell.Screen), test func(s tcell.SimulationScreen)) { 12 | t.Helper() 13 | screen := tcell.NewSimulationScreen("") 14 | assert.NoError(t, screen.Init()) 15 | 16 | donec := make(chan struct{}) 17 | go func() { 18 | defer close(donec) 19 | time.Sleep(time.Millisecond * 50) 20 | test(screen) 21 | }() 22 | 23 | procedure(screen) 24 | <-donec 25 | } 26 | -------------------------------------------------------------------------------- /internal/ui/lookup_test.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func Test_fuzzyMatcher(t *testing.T) { 10 | tests := []struct { 11 | str string 12 | substr string 13 | match bool 14 | ranges [][2]int 15 | score int 16 | }{ 17 | { 18 | "Find process listening to port", 19 | "find port", 20 | true, 21 | [][2]int{{0, 4}, {26, 30}}, 22 | 8, 23 | }, 24 | { 25 | "Find process listening to port", 26 | "proc", 27 | true, 28 | [][2]int{{5, 9}}, 29 | 4, 30 | }, 31 | { 32 | "Find process listening to port", 33 | "app", 34 | false, 35 | [][2]int{}, 36 | 0, 37 | }, 38 | } 39 | 40 | for _, tt := range tests { 41 | t.Run(tt.str+" - "+tt.substr, func(t *testing.T) { 42 | ranges, score, match := fuzzyMatcher(tt.str, tt.substr) 43 | 44 | assert.Equal(t, tt.match, match) 45 | if tt.match { 46 | assert.Equal(t, tt.ranges, ranges) 47 | } else { 48 | assert.Len(t, tt.ranges, 0) 49 | } 50 | assert.Equal(t, tt.score, score) 51 | }) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /internal/ui/message_printer.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import "github.com/lemoony/snipkit/internal/ui/uimsg" 4 | 5 | type MessagePrinter interface { 6 | Print(uimsg.Printable) 7 | } 8 | -------------------------------------------------------------------------------- /internal/ui/picker/picker_test.go: -------------------------------------------------------------------------------- 1 | package picker 2 | 3 | import ( 4 | "testing" 5 | 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/stretchr/testify/assert" 8 | 9 | "github.com/lemoony/snipkit/internal/ui/style" 10 | "github.com/lemoony/snipkit/internal/utils/termtest" 11 | "github.com/lemoony/snipkit/internal/utils/termutil" 12 | ) 13 | 14 | func Test_ShowPicker(t *testing.T) { 15 | termtest.RunTerminalTest(t, func(c *termtest.Console) { 16 | c.ExpectString("Which snippet manager should be added to your configuration") 17 | c.SendKey(termtest.KeyDown) 18 | c.SendKey(termtest.KeyDown) 19 | c.SendKey(termtest.KeyUp) 20 | c.SendKey(termtest.KeyEnter) 21 | }, func(stdio termutil.Stdio) { 22 | index, ok := ShowPicker("Which snippet manager should be added to your configuration", []Item{ 23 | NewItem("title1", "desc1"), 24 | NewItem("title2", "desc2"), 25 | NewItem("title3", "desc3"), 26 | }, nil, style.NoopStyle, tea.WithInput(stdio.In), tea.WithOutput(stdio.Out)) 27 | assert.Equal(t, 1, index) 28 | assert.True(t, ok) 29 | }) 30 | } 31 | 32 | func Test_ShowPicker_Cancel(t *testing.T) { 33 | tests := []struct { 34 | name string 35 | key termtest.Key 36 | }{ 37 | {name: "esc", key: termtest.KeyEsc}, 38 | {name: "str+c", key: termtest.KeyStrC}, 39 | } 40 | 41 | for i := range tests { 42 | tt := tests[i] 43 | t.Run(tt.name, func(t *testing.T) { 44 | termtest.RunTerminalTest(t, func(c *termtest.Console) { 45 | c.ExpectString("Which snippet manager should be added to your configuration") 46 | c.SendKey(tt.key) 47 | }, func(stdio termutil.Stdio) { 48 | index, ok := ShowPicker("Which snippet manager should be added to your configuration", 49 | []Item{NewItem("title1", "desc1")}, nil, style.NoopStyle, tea.WithInput(stdio.In), tea.WithOutput(stdio.Out)) 50 | assert.Equal(t, -1, index) 51 | assert.False(t, ok) 52 | }) 53 | }) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /internal/ui/spinner/spinner_test.go: -------------------------------------------------------------------------------- 1 | package spinner 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | tea "github.com/charmbracelet/bubbletea" 8 | 9 | "github.com/lemoony/snipkit/internal/ui/style" 10 | "github.com/lemoony/snipkit/internal/utils/termtest" 11 | "github.com/lemoony/snipkit/internal/utils/termutil" 12 | ) 13 | 14 | func Test_ShowSpinner(t *testing.T) { 15 | tests := []struct { 16 | name string 17 | exitByChan bool 18 | stopKey termtest.Key 19 | }{ 20 | {name: "stop by chan", stopKey: termtest.KeyTab, exitByChan: true}, 21 | {name: "stop by esc", stopKey: termtest.KeyEsc, exitByChan: false}, 22 | {name: "stop by str+c", stopKey: termtest.KeyStrC, exitByChan: false}, 23 | } 24 | for _, tt := range tests { 25 | t.Run(tt.name, func(t *testing.T) { 26 | stopChan := make(chan bool) 27 | termtest.RunTerminalTest(t, func(c *termtest.Console) { 28 | c.ExpectString("Example Title") 29 | time.Sleep(100 * time.Millisecond) 30 | c.ExpectString("Test text...") 31 | time.Sleep(50 * time.Millisecond) 32 | c.SendKey(tt.stopKey) 33 | if tt.exitByChan { 34 | stopChan <- true 35 | } 36 | }, func(stdio termutil.Stdio) { 37 | ShowSpinner("Test text...", "Example Title", stopChan, style.Style{}, tea.WithInput(stdio.In), tea.WithOutput(stdio.Out)) 38 | }) 39 | }) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /internal/ui/style/theme.go: -------------------------------------------------------------------------------- 1 | package style 2 | 3 | type ThemeValues struct { 4 | BorderColor string `yaml:"borderColor"` 5 | BorderTitleColor string `yaml:"borderTitleColor"` 6 | 7 | PreviewColorSchemeName string `yaml:"previewColorSchemeName"` 8 | 9 | TextColor string `yaml:"textColor"` 10 | 11 | SubduedColor string `yaml:"subduedColor"` 12 | SubduedContrastColor string `yaml:"subduedContrastColor"` 13 | 14 | VerySubduedColor string `yaml:"verySubduedColor"` 15 | VerySubduedContrastColor string `yaml:"verySubduedContrastColor"` 16 | 17 | ActiveColor string `yaml:"activeColor"` 18 | ActiveContrastColor string `yaml:"activeContrastColor"` 19 | 20 | TitleColor string `yaml:"titleColor"` 21 | TitleContrastColor string `yaml:"titleContrastColor"` 22 | 23 | HighlightColor string `yaml:"highlightColor"` 24 | HighlightContrastColor string `yaml:"highlightContrastColor"` 25 | 26 | InfoColor string `yaml:"infoColor"` 27 | InfoContrastColor string `yaml:"infoContrastColor"` 28 | 29 | SnippetColor string `yaml:"snippetColor"` 30 | SnippetContrastColor string `yaml:"snippetContrastColor"` 31 | 32 | SuccessColor string `yaml:"successColor"` 33 | ErrorColor string `yaml:"errorColor"` 34 | } 35 | -------------------------------------------------------------------------------- /internal/ui/style/utils.go: -------------------------------------------------------------------------------- 1 | package style 2 | 3 | func HasDarkBackground() bool { 4 | return hasDarkBackground 5 | } 6 | -------------------------------------------------------------------------------- /internal/ui/sync/options.go: -------------------------------------------------------------------------------- 1 | package sync 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/lemoony/snipkit/internal/ui/style" 7 | ) 8 | 9 | type Option interface { 10 | apply(c *model) 11 | } 12 | 13 | type optionFunc func(o *model) 14 | 15 | func (f optionFunc) apply(o *model) { 16 | f(o) 17 | } 18 | 19 | func WithIn(input io.Reader) Option { 20 | return optionFunc(func(c *model) { 21 | c.input = &input 22 | }) 23 | } 24 | 25 | func WithOut(out io.Writer) Option { 26 | return optionFunc(func(c *model) { 27 | c.output = &out 28 | }) 29 | } 30 | 31 | func WithStyler(styler style.Style) Option { 32 | return optionFunc(func(c *model) { 33 | c.styler = styler 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /internal/ui/testdata/github-device-code.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lemoony/snipkit/bca1be1f6647f6d1db5d39815be5d6c388888ddc/internal/ui/testdata/github-device-code.json -------------------------------------------------------------------------------- /internal/ui/testdata/test-custom.yaml: -------------------------------------------------------------------------------- 1 | version: 1.0.0 2 | variables: 3 | white: "#FFFFFF" 4 | black: "#000000" 5 | red: "#ED6A5A" 6 | green: "#44AF69" 7 | yellow: "#FCAB10" 8 | brown: "#3A3335" 9 | gray: "#8A8A8A" 10 | lightGray: "#DDDADA" 11 | theme: 12 | borderColor: ${gray} 13 | borderTitleColor: ${gray} 14 | previewColorSchemeName: "rainbow_dash" 15 | textColor: "" # if empty the default terminal foreground color will be used 16 | subduedColor: ${gray} 17 | subduedContrastColor: ${black} 18 | verySubduedColor: ${lightGray} 19 | verySubduedContrastColor: ${black} 20 | activeColor: ${red} # 21 | activeContrastColor: ${white} 22 | titleColor: ${brown} 23 | titleContrastColor: ${white} 24 | highlightColor: ${green} 25 | highlightContrastColor: ${black} 26 | infoColor: ${yellow} 27 | infoContrastColor: ${white} 28 | snippetColor: ${lightGray} 29 | snippetContrastColor: ${black} 30 | -------------------------------------------------------------------------------- /internal/ui/themes_test.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func Test_defaultThemeAvailable(t *testing.T) { 13 | themeNames := []string{ 14 | defaultThemeName, 15 | } 16 | for _, themeName := range themeNames { 17 | t.Run(themeName, func(t *testing.T) { 18 | theme, ok := embeddedTheme(themeName) 19 | assert.True(t, ok) 20 | assert.NotNil(t, theme) 21 | }) 22 | } 23 | } 24 | 25 | func Test_themeNotFound(t *testing.T) { 26 | theme, ok := embeddedTheme("foo-theme") 27 | assert.Nil(t, theme) 28 | assert.False(t, ok) 29 | } 30 | 31 | func Test_embeddedThemes(t *testing.T) { 32 | fileInfos, err := os.ReadDir("../../themes") 33 | assert.NoError(t, err) 34 | 35 | for _, fileInfo := range fileInfos { 36 | if filepath.Ext(fileInfo.Name()) == ".go" { 37 | continue 38 | } 39 | 40 | themeName := strings.TrimSuffix(fileInfo.Name(), ".yaml") 41 | theme, ok := embeddedTheme(themeName) 42 | assert.True(t, ok) 43 | assert.NotNil(t, theme) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /internal/ui/tui_form_test.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/lemoony/snipkit/internal/model" 9 | ) 10 | 11 | func Test_Form_NoParameters(t *testing.T) { 12 | var parameters []model.Parameter 13 | var parameterValues []model.ParameterValue 14 | 15 | term := NewTUI() 16 | values, ok := term.ShowParameterForm(parameters, parameterValues, OkButtonPrint) 17 | assert.Len(t, values, 0) 18 | assert.True(t, ok) 19 | } 20 | -------------------------------------------------------------------------------- /internal/ui/uimsg/templates/assistant_none_enabled.gotmpl: -------------------------------------------------------------------------------- 1 | No assistant is enabled. To enable an assistant, type in {{- print " " (Highlighted "snipkit assistant choose") -}}. 2 | -------------------------------------------------------------------------------- /internal/ui/uimsg/templates/assistant_snippet_saved.gotmpl: -------------------------------------------------------------------------------- 1 | The snippet was saved successfully! 2 | Title: {{ print (Highlighted .snippetTitle) }} 3 | Saved at: {{ print (Highlighted .snippetPath) }} -------------------------------------------------------------------------------- /internal/ui/uimsg/templates/assistant_update_config_result.gotmpl: -------------------------------------------------------------------------------- 1 | {{- if .confirmed -}} 2 | Updated the configuration file: 3 | {{ print (Highlighted .cfgPath) }} 4 | 5 | Type in {{ print (Highlighted "snipkit config edit") }} to adjust the config as necessary. 6 | 7 | Don't forget to provide the API key as an environment variable. 8 | {{- else -}} 9 | The configuration file was not changed. 10 | {{- end -}} 11 | -------------------------------------------------------------------------------- /internal/ui/uimsg/templates/config_file_create_confirm.gotmpl: -------------------------------------------------------------------------------- 1 | {{ print (Title "Initialize the configuration") }} 2 | {{- if .homeEnvSet }} 3 | SNIPKIT_HOME is set to: {{ .homeEnv }} 4 | {{- else }} 5 | SNIPKIT_HOME is not set. 6 | {{ end }} 7 | 8 | Thus, the config file location is specified to be: 9 | {{ print (Highlighted .cfgPath) }} 10 | {{if .recreate }} 11 | The config file already exists at the specified path. This operation will overwrite the current configuration. 12 | You may loose any modifications already done to it. 13 | {{ end }} 14 | -------------------------------------------------------------------------------- /internal/ui/uimsg/templates/config_file_create_result.gotmpl: -------------------------------------------------------------------------------- 1 | {{- if .created -}} 2 | Config file created at: 3 | {{ print (Highlighted .cfgPath) }} 4 | 5 | To add snippet managers, type in {{- print " " (Highlighted "snipkit manager add") -}}. 6 | 7 | If you want to edit the config, type in {{- print " " (Highlighted "snipkit config edit") -}}. 8 | If you want to delete the config, type in {{- print " " (Highlighted "snipkit config clean") -}}. 9 | {{- else -}} 10 | Config was not {{- if not .recreate }} created {{- else }} reinitialized {{- end -}} 11 | {{- end -}} 12 | -------------------------------------------------------------------------------- /internal/ui/uimsg/templates/config_file_delete_confirm.gotmpl: -------------------------------------------------------------------------------- 1 | {{ print (Title "Delete the configuration") }} 2 | The config file is located at: 3 | {{ print "" (Highlighted .cfgPath) }} 4 | 5 | -------------------------------------------------------------------------------- /internal/ui/uimsg/templates/config_file_delete_result.gotmpl: -------------------------------------------------------------------------------- 1 | {{- if .deleted -}} 2 | Configuration file deleted: {{ .cfgPath }} 3 | {{- else -}} 4 | The configuration file was not deleted. 5 | {{- end -}} 6 | -------------------------------------------------------------------------------- /internal/ui/uimsg/templates/config_file_migration_confirm.gotmpl: -------------------------------------------------------------------------------- 1 | {{ print "" (Title "The config file will be updated as follows:") -}} 2 | 3 | {{ print (Snippet .configYaml .screenWidth) }} 4 | 5 | You might be required to alter the configuration manually after the change is applied. 6 | 7 | -------------------------------------------------------------------------------- /internal/ui/uimsg/templates/config_file_migration_result.gotmpl: -------------------------------------------------------------------------------- 1 | {{- if .migrated -}} 2 | Configuration file updated: {{ .cfgPath }} 3 | {{- else -}} 4 | The configuration file was not updated. 5 | {{- end -}} 6 | -------------------------------------------------------------------------------- /internal/ui/uimsg/templates/config_needs_migration.gotmpl: -------------------------------------------------------------------------------- 1 | Type in 'snipkit config migrate' to migrate the config file from version {{ .current }} to {{ .latest }}. 2 | -------------------------------------------------------------------------------- /internal/ui/uimsg/templates/config_not_found.gotmpl: -------------------------------------------------------------------------------- 1 | No config found at: {{ .cfgPath }} 2 | You can set environment variable 'SNIPKIT_HOME' to change the directory path where snipkit will look for the config file. 3 | Type in 'snipkit config init' to create a configuration file. 4 | -------------------------------------------------------------------------------- /internal/ui/uimsg/templates/exec_confirm.gotmpl: -------------------------------------------------------------------------------- 1 | Snippet: {{ print (Highlighted .title) }} 2 | Command: 3 | {{ print (Snippet .command 0) }} 4 | -------------------------------------------------------------------------------- /internal/ui/uimsg/templates/exec_print.gotmpl: -------------------------------------------------------------------------------- 1 | Snippet: {{ print (Highlighted .title) }} 2 | Command: 3 | {{ print (Snippet .command 0) }} 4 | -------------------------------------------------------------------------------- /internal/ui/uimsg/templates/home_dir_still_exists.gotmpl: -------------------------------------------------------------------------------- 1 | The snipkit home directory still exists exists since it holds non-deleted data: 2 | 3 | {{ .cfgPath }} 4 | 5 | Please check for yourself if it can be deleted safely. 6 | -------------------------------------------------------------------------------- /internal/ui/uimsg/templates/manager_add_config_confirm.gotmpl: -------------------------------------------------------------------------------- 1 | {{ print "" (Title "The following will be added to your config file") -}} 2 | 3 | {{ print (Snippet .configYaml .screenWidth) }} 4 | 5 | You might be required to alter the configuration after the change is applied. 6 | 7 | -------------------------------------------------------------------------------- /internal/ui/uimsg/templates/manager_add_config_result.gotmpl: -------------------------------------------------------------------------------- 1 | {{- if .confirmed -}} 2 | Updated the configuration file: 3 | {{ print (Highlighted .cfgPath) }} 4 | 5 | Type in {{ print (Highlighted "snipkit config edit") }} to adjust the config as necessary. 6 | {{- else -}} 7 | The configuration file was not changed. 8 | {{- end -}} 9 | -------------------------------------------------------------------------------- /internal/ui/uimsg/templates/manager_oauth_device_flow.gotmpl: -------------------------------------------------------------------------------- 1 | OAuth authorization against {{ print (Italic .host) }} is required. 2 | First, copy your one-time code: {{ print (Highlighted .code) }} 3 | Then press [Enter] to continue in the web browser... 4 | -------------------------------------------------------------------------------- /internal/ui/uimsg/templates/themes_delete_confirm.gotmpl: -------------------------------------------------------------------------------- 1 | {{ print (Title "Delete the themes directory") }} 2 | 3 | The directory for custom themes is not empty: 4 | {{ print "" (Highlighted .themesPath) }} 5 | 6 | By deleting this directory, you will loose your custom theme definitions if you have no backups. 7 | -------------------------------------------------------------------------------- /internal/ui/uimsg/templates/themes_delete_result.gotmpl: -------------------------------------------------------------------------------- 1 | {{- if .deleted -}} 2 | The themes directory was deleted: {{ .themesPath }} 3 | {{- else -}} 4 | The themes directory was not deleted. 5 | {{- end -}} 6 | -------------------------------------------------------------------------------- /internal/utils/errorutil/app_error.go: -------------------------------------------------------------------------------- 1 | package errorutil 2 | 3 | import ( 4 | "emperror.dev/errors" 5 | ) 6 | 7 | func NewError(cause error, root error) error { 8 | return errors.Append(cause, errors.WithStackIf(root)) 9 | } 10 | -------------------------------------------------------------------------------- /internal/utils/httputil/http_util.go: -------------------------------------------------------------------------------- 1 | package httputil 2 | 3 | import "net/http" 4 | 5 | type HTTPClient interface { 6 | Do(*http.Request) (*http.Response, error) 7 | } 8 | -------------------------------------------------------------------------------- /internal/utils/idutil/id_util.go: -------------------------------------------------------------------------------- 1 | package idutil 2 | 3 | import ( 4 | "encoding/base64" 5 | "fmt" 6 | ) 7 | 8 | type IDPrefix string 9 | 10 | func FormatSnippetID(id string, prefix IDPrefix) string { 11 | return base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s#%s", prefix, id))) 12 | } 13 | -------------------------------------------------------------------------------- /internal/utils/json/json_util.go: -------------------------------------------------------------------------------- 1 | package jsonutil 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | ) 7 | 8 | func CompactJSON(input []byte) string { 9 | var compactJSON bytes.Buffer 10 | err := json.Compact(&compactJSON, input) 11 | if err != nil { 12 | panic(err) 13 | } 14 | return compactJSON.String() 15 | } 16 | -------------------------------------------------------------------------------- /internal/utils/json/json_util_test.go: -------------------------------------------------------------------------------- 1 | package jsonutil 2 | 3 | import "testing" 4 | 5 | func TestCompactJSON(t *testing.T) { 6 | tests := []struct { 7 | name string 8 | input []byte 9 | expected string 10 | }{ 11 | { 12 | name: "simple json with whitespace", 13 | input: []byte(`{ "test":"foo" }`), 14 | expected: `{"test":"foo"}`, 15 | }, 16 | } 17 | for _, tt := range tests { 18 | t.Run(tt.name, func(t *testing.T) { 19 | if actual := CompactJSON(tt.input); actual != tt.expected { 20 | t.Errorf("CompactJSON() = %v, want %v", actual, tt.expected) 21 | } 22 | }) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /internal/utils/logutil/log_util.go: -------------------------------------------------------------------------------- 1 | package logutil 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | 8 | "github.com/phuslu/log" 9 | 10 | "github.com/lemoony/snipkit/internal/utils/system" 11 | ) 12 | 13 | const maxBackups = 2 14 | 15 | var allLevels = []string{ 16 | log.TraceLevel.String(), 17 | log.DebugLevel.String(), 18 | log.InfoLevel.String(), 19 | log.WarnLevel.String(), 20 | log.ErrorLevel.String(), 21 | log.FatalLevel.String(), 22 | log.PanicLevel.String(), 23 | } 24 | 25 | func ConfigureDefaultLogger(s *system.System) { 26 | if !log.IsTerminal(os.Stderr.Fd()) { 27 | return 28 | } 29 | 30 | fileWriter := log.FileWriter{ 31 | Filename: filepath.Join(s.HomeDir(), ".log", "log"), 32 | EnsureFolder: true, 33 | MaxBackups: maxBackups, 34 | } 35 | 36 | log.DefaultLogger = log.Logger{ 37 | TimeFormat: "15:04:05", 38 | Caller: 1, 39 | Writer: &log.ConsoleWriter{ 40 | QuoteString: true, 41 | EndWithMessage: true, 42 | Writer: &fileWriter, 43 | }, 44 | } 45 | 46 | _ = fileWriter.Rotate() 47 | } 48 | 49 | func SetDefaultLogLevel(logLevel string) { 50 | for _, level := range allLevels { 51 | if logLevel == level { 52 | log.DefaultLogger.Level = log.ParseLevel(level) 53 | } 54 | } 55 | } 56 | 57 | func AllLevelsAsString() string { 58 | return strings.Join(allLevels, ",") 59 | } 60 | -------------------------------------------------------------------------------- /internal/utils/sliceutil/slice_util.go: -------------------------------------------------------------------------------- 1 | package sliceutil 2 | 3 | import "golang.org/x/exp/slices" 4 | 5 | func FindElement[T any](slice []T, predicate func(T) bool) (T, bool) { 6 | // Find index using slices.IndexFunc (can also use slices.ContainsFunc but doesn't return element) 7 | index := slices.IndexFunc(slice, predicate) 8 | if index != -1 { 9 | return slice[index], true 10 | } 11 | var zeroValue T 12 | return zeroValue, false 13 | } 14 | -------------------------------------------------------------------------------- /internal/utils/stringutil/string_set.go: -------------------------------------------------------------------------------- 1 | package stringutil 2 | 3 | type StringSet map[string]struct{} 4 | 5 | func NewStringSet(values []string) StringSet { 6 | result := StringSet{} 7 | for _, value := range values { 8 | result.Add(value) 9 | } 10 | return result 11 | } 12 | 13 | func (s *StringSet) Add(v string) { 14 | (*s)[v] = struct{}{} 15 | } 16 | 17 | func (s StringSet) Keys() []string { 18 | keys := make([]string, len(s)) 19 | i := 0 20 | for k := range s { 21 | keys[i] = k 22 | i += 1 23 | } 24 | return keys 25 | } 26 | 27 | func (s StringSet) Contains(v string) bool { 28 | if _, ok := s[v]; ok { 29 | return true 30 | } 31 | return false 32 | } 33 | -------------------------------------------------------------------------------- /internal/utils/stringutil/string_set_test.go: -------------------------------------------------------------------------------- 1 | package stringutil 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/corbym/gocrest/is" 7 | "github.com/corbym/gocrest/then" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func Test_StringSet(t *testing.T) { 12 | set := StringSet{} 13 | set.Add("Foo") 14 | set.Add("Moo") 15 | 16 | assert.Len(t, set, 2) 17 | assert.True(t, set.Contains("Foo")) 18 | assert.True(t, set.Contains("Moo")) 19 | assert.False(t, set.Contains("Another")) 20 | 21 | then.AssertThat(t, 22 | set.Keys(), 23 | is.AllOf(is.ArrayContaining("Foo"), is.ArrayContaining("Moo")), 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /internal/utils/stringutil/string_utils.go: -------------------------------------------------------------------------------- 1 | package stringutil 2 | 3 | import "strings" 4 | 5 | func StringOrDefault(val string, defaultStr string) string { 6 | if val == "" { 7 | return defaultStr 8 | } 9 | return val 10 | } 11 | 12 | // SplitWithEscape behaves like strings.Split but supports defining an escape character. E.g, when using ',' as split 13 | // separator and '\' es escape code, the string "1\,2,3," will not be split into ["1, 2", "3"]. 14 | func SplitWithEscape(s string, split uint8, escape uint8, trim bool) []string { 15 | var splitIndices []int 16 | for i := range s { 17 | if s[i] == split && (i == 0 || s[i-1] != escape) { 18 | splitIndices = append(splitIndices, i) 19 | } 20 | } 21 | 22 | var result []string 23 | 24 | for i, lowerSplitIndex := 0, 0; i <= len(splitIndices); i++ { 25 | if i > 0 { 26 | lowerSplitIndex = splitIndices[i-1] + 1 27 | } 28 | 29 | upperIndex := len(s) 30 | if i < len(splitIndices) { 31 | upperIndex = splitIndices[i] 32 | } 33 | 34 | sp := s[lowerSplitIndex:upperIndex] 35 | sp = strings.ReplaceAll(sp, "\\,", ",") 36 | if trim { 37 | sp = strings.TrimSpace(sp) 38 | } 39 | 40 | if sp != "" { 41 | result = append(result, sp) 42 | } 43 | } 44 | 45 | return result 46 | } 47 | 48 | func FirstNotEmpty(values ...string) string { 49 | for _, value := range values { 50 | if value != "" { 51 | return value 52 | } 53 | } 54 | return "" 55 | } 56 | -------------------------------------------------------------------------------- /internal/utils/stringutil/string_utils_test.go: -------------------------------------------------------------------------------- 1 | package stringutil 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func Test_StringOrDefault(t *testing.T) { 10 | defaultValue := "default-value" 11 | noDefault := "no-default" 12 | assert.Equal(t, defaultValue, StringOrDefault("", defaultValue)) 13 | assert.Equal(t, noDefault, StringOrDefault("no-default", noDefault)) 14 | } 15 | 16 | func Test_SplitWithEscape(t *testing.T) { 17 | s := "One, Two\\, plus more, Three,, five" 18 | 19 | splits := SplitWithEscape(s, ',', '\\', true) 20 | assert.Len(t, splits, 4) 21 | assert.Equal(t, "One", splits[0]) 22 | assert.Equal(t, "Two, plus more", splits[1]) 23 | assert.Equal(t, "Three", splits[2]) 24 | assert.Equal(t, "five", splits[3]) 25 | } 26 | 27 | func Test_SplitWithEscapeNoSplit(t *testing.T) { 28 | s := "String without split character" 29 | splits := SplitWithEscape(s, ',', '\\', true) 30 | assert.Len(t, splits, 1) 31 | assert.Equal(t, s, splits[0]) 32 | } 33 | 34 | func Test_SplitWithSingleEscapedCharacter(t *testing.T) { 35 | s := "String without split\\, character" 36 | splits := SplitWithEscape(s, ',', '\\', true) 37 | assert.Len(t, splits, 1) 38 | assert.Equal(t, "String without split, character", splits[0]) 39 | } 40 | 41 | func Test_SplitWithoutTrimming(t *testing.T) { 42 | s := " One, Two" 43 | splits := SplitWithEscape(s, ',', '\\', false) 44 | assert.Len(t, splits, 2) 45 | assert.Equal(t, " One", splits[0]) 46 | assert.Equal(t, " Two", splits[1]) 47 | } 48 | 49 | func Test_FirstNotEmpty(t *testing.T) { 50 | tests := []struct { 51 | name string 52 | values []string 53 | expected string 54 | }{ 55 | {name: "no values", values: []string{}, expected: ""}, 56 | {name: "nil", values: nil, expected: ""}, 57 | {name: "first value", values: []string{"first", ""}, expected: "first"}, 58 | {name: "last value", values: []string{"", "last"}, expected: "last"}, 59 | } 60 | 61 | for _, tt := range tests { 62 | t.Run(tt.name, func(t *testing.T) { 63 | assert.Equal(t, tt.expected, FirstNotEmpty(tt.values...)) 64 | }) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /internal/utils/system/errors.go: -------------------------------------------------------------------------------- 1 | package system 2 | 3 | import ( 4 | "fmt" 5 | 6 | "emperror.dev/errors" 7 | ) 8 | 9 | type ErrFileSystem struct { 10 | path string 11 | msg string 12 | cause error 13 | } 14 | 15 | func NewErrFileSystem(err error, path, msg string) error { 16 | return errors.WithStack(ErrFileSystem{path: path, msg: msg, cause: err}) 17 | } 18 | 19 | func (e ErrFileSystem) Error() string { 20 | return fmt.Sprintf("%s - %s: %s", e.msg, e.path, e.cause) 21 | } 22 | 23 | func (e ErrFileSystem) Is(target error) bool { 24 | _, ok := target.(ErrFileSystem) 25 | return ok 26 | } 27 | -------------------------------------------------------------------------------- /internal/utils/system/errors_test.go: -------------------------------------------------------------------------------- 1 | package system 2 | 3 | import ( 4 | "testing" 5 | 6 | "emperror.dev/errors" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func Test_IsErrFileSystem(t *testing.T) { 11 | assert.True(t, errors.Is( 12 | NewErrFileSystem(errors.New("root error"), "/path/to", "failed to do something"), 13 | ErrFileSystem{}, 14 | )) 15 | 16 | assert.False(t, errors.Is(errors.New("root error"), ErrFileSystem{})) 17 | } 18 | 19 | func Test_ErrFileSystem_error(t *testing.T) { 20 | err := NewErrFileSystem(errors.New("root error"), "/path/to", "failed to do something") 21 | assert.Equal(t, "failed to do something - /path/to: root error", err.Error()) 22 | } 23 | -------------------------------------------------------------------------------- /internal/utils/tagutil/tag_util.go: -------------------------------------------------------------------------------- 1 | package tagutil 2 | 3 | import "github.com/lemoony/snipkit/internal/utils/stringutil" 4 | 5 | func HasValidTag(validTagUUIDs stringutil.StringSet, snippetTagUUIDS []string) bool { 6 | if len(validTagUUIDs) == 0 { 7 | return true 8 | } 9 | 10 | for _, tagUUID := range snippetTagUUIDS { 11 | if validTagUUIDs.Contains(tagUUID) { 12 | return true 13 | } 14 | } 15 | return false 16 | } 17 | -------------------------------------------------------------------------------- /internal/utils/tagutil/tag_util_test.go: -------------------------------------------------------------------------------- 1 | package tagutil 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/lemoony/snipkit/internal/utils/stringutil" 9 | ) 10 | 11 | func Test_HasValidTags(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | validTags []string 15 | actualTags []string 16 | expected bool 17 | }{ 18 | {name: "no tags at all", validTags: []string{}, actualTags: []string{}, expected: true}, 19 | {name: "no valid tags but actual tags", validTags: []string{}, actualTags: []string{"foo"}, expected: true}, 20 | {name: "valid tags not found", validTags: []string{"foo"}, actualTags: []string{}, expected: false}, 21 | {name: "multiple valid tags", validTags: []string{"foo", "zoo"}, actualTags: []string{"zoo"}, expected: true}, 22 | } 23 | 24 | for _, tt := range tests { 25 | t.Run(tt.name, func(t *testing.T) { 26 | validTagSet := stringutil.NewStringSet(tt.validTags) 27 | assert.Equal(t, tt.expected, HasValidTag(validTagSet, tt.actualTags)) 28 | }) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /internal/utils/termutil/stdio.go: -------------------------------------------------------------------------------- 1 | package termutil 2 | 3 | import "io" 4 | 5 | // Stdio is the standard input/output the terminal reads/writes with. 6 | type Stdio struct { 7 | In FileReader 8 | Out FileWriter 9 | Err io.Writer 10 | } 11 | 12 | // FileWriter provides a minimal interface for Stdin. 13 | type FileWriter interface { 14 | io.Writer 15 | Fd() uintptr 16 | } 17 | 18 | // FileReader provides a minimal interface for Stdout. 19 | type FileReader interface { 20 | io.Reader 21 | Fd() uintptr 22 | } 23 | -------------------------------------------------------------------------------- /internal/utils/testutil/mockutil/env.go: -------------------------------------------------------------------------------- 1 | package mockutil 2 | 3 | import "os" 4 | 5 | func MockAPIKeyEnv(key, value string) func() { 6 | originalValue := os.Getenv(key) 7 | _ = os.Setenv(key, value) 8 | return func() { 9 | _ = os.Setenv(key, originalValue) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /internal/utils/testutil/mockutil/method_names.go: -------------------------------------------------------------------------------- 1 | package mockutil 2 | 3 | import ( 4 | "github.com/stretchr/testify/mock" 5 | 6 | "github.com/lemoony/snipkit/internal/utils/sliceutil" 7 | ) 8 | 9 | const ( 10 | Print = "Print" 11 | PrintMessage = "PrintMessage" 12 | PrintError = "PrintError" 13 | 14 | ApplyConfig = "ApplyConfig" 15 | 16 | Confirmation = "Confirmation" 17 | 18 | ShowPicker = "ShowPicker" 19 | ShowAssistantPrompt = "ShowAssistantPrompt" 20 | ShowAssistantWizard = "ShowAssistantWizard" 21 | ShowSpinner = "ShowSpinner" 22 | ShowParameterForm = "ShowParameterForm" 23 | OpenEditor = "OpenEditor" 24 | 25 | Query = "Query" 26 | ValidateConfig = "Initialize" 27 | SaveAssistantSnippet = "SaveAssistantSnippet" 28 | ) 29 | 30 | func FindMethodCall(method string, calls []mock.Call) *mock.Call { 31 | if call, ok := sliceutil.FindElement(calls, func(call mock.Call) bool { 32 | return call.Method == method 33 | }); ok { 34 | return &call 35 | } 36 | panic("Failed to find method call for " + method) 37 | } 38 | -------------------------------------------------------------------------------- /internal/utils/testutil/model.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/lemoony/snipkit/internal/model" 7 | "github.com/lemoony/snipkit/internal/parser" 8 | ) 9 | 10 | type TestSnippet struct { 11 | ID string 12 | Tags []string 13 | 14 | Title string 15 | Content string 16 | Language model.Language 17 | } 18 | 19 | var DummySnippet = TestSnippet{ID: "uuid-x", Title: "title-2", Language: model.LanguageBash, Tags: []string{}, Content: "testSnippetContent"} 20 | 21 | func (t TestSnippet) GetID() string { 22 | return t.ID 23 | } 24 | 25 | func (t TestSnippet) GetTitle() string { 26 | return t.Title 27 | } 28 | 29 | func (t TestSnippet) GetContent() string { 30 | return t.Content 31 | } 32 | 33 | func (t TestSnippet) GetTags() []string { 34 | return t.Tags 35 | } 36 | 37 | func (t TestSnippet) GetLanguage() model.Language { 38 | return t.Language 39 | } 40 | 41 | func (t TestSnippet) GetParameters() []model.Parameter { 42 | return parser.ParseParameters(t.Content) 43 | } 44 | 45 | func (t TestSnippet) Format(values []string, options model.SnippetFormatOptions) string { 46 | return parser.CreateSnippet(t.Content, t.GetParameters(), values, options) 47 | } 48 | 49 | func (t TestSnippet) String() string { 50 | return fmt.Sprintf("Testsnippet: %s", t.Title) 51 | } 52 | -------------------------------------------------------------------------------- /internal/utils/testutil/strings.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "regexp" 5 | ) 6 | 7 | const ansi = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))" 8 | 9 | var re = regexp.MustCompile(ansi) 10 | 11 | func StripANSI(str string) string { 12 | return re.ReplaceAllString(str, "") 13 | } 14 | -------------------------------------------------------------------------------- /internal/utils/testutil/system.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "github.com/spf13/afero" 5 | 6 | "github.com/lemoony/snipkit/internal/utils/system" 7 | ) 8 | 9 | func NewTestSystem(options ...system.Option) *system.System { 10 | base := afero.NewOsFs() 11 | roBase := afero.NewReadOnlyFs(base) 12 | ufs := afero.NewCopyOnWriteFs(roBase, afero.NewMemMapFs()) 13 | options = append(options, system.WithFS(ufs)) 14 | return system.NewSystem(options...) 15 | } 16 | -------------------------------------------------------------------------------- /internal/utils/titleheader/title_header_test.go: -------------------------------------------------------------------------------- 1 | package titleheader 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func Test_PruneTitleHeader(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | snippet string 13 | expected string 14 | }{ 15 | {name: "example1", snippet: "#\r\n# Hello\r\n#\n\r\nfoo", expected: "foo"}, 16 | {name: "example2", snippet: "#\n# Get PIDs which listens to port\n#\n\n\nfoo content", expected: "foo content"}, 17 | {name: "example3", snippet: "#\n# title\n#", expected: ""}, 18 | {name: "example4", snippet: "#\n# title", expected: "#\n# title"}, 19 | {name: "example5", snippet: "#/bin/bash\n#\n#title\n#", expected: "#/bin/bash"}, 20 | { 21 | name: "example5", 22 | snippet: "#/bin/bash\n#\n#title\n#\n\n# ${VAR} Name: Variable\necho${VAR}", 23 | expected: "#/bin/bash\n\n# ${VAR} Name: Variable\necho${VAR}", 24 | }, 25 | } 26 | 27 | for _, tt := range tests { 28 | t.Run(tt.name, func(t *testing.T) { 29 | assert.Equal(t, tt.expected, PruneTitleHeader(tt.snippet)) 30 | }) 31 | } 32 | } 33 | 34 | func Test_ParseTitleFromHeader(t *testing.T) { 35 | tests := []struct { 36 | title string 37 | content string 38 | ok bool 39 | }{ 40 | {title: "title 1", content: "#\n# title 1\n#", ok: true}, 41 | {title: "title 2", content: "#\n#title 2\n#", ok: true}, 42 | {title: "title 3", content: "#/bin/bash\n#\n#title 3\n#", ok: true}, 43 | {title: "title 4", content: "#/bin/bash\n\n#\n#title 4\n#", ok: true}, 44 | {title: "title 5", content: "#\n#title 2", ok: false}, 45 | {title: "title 6", content: "#title 2\n#", ok: false}, 46 | {title: "title 7", content: "#\n# \n#", ok: false}, 47 | {title: "title 8", content: "\n\n\n#\n# title 8\n#", ok: false}, 48 | {title: "title 9", content: "\n\n#\n# title 9\n#", ok: true}, 49 | } 50 | 51 | for _, tt := range tests { 52 | t.Run(tt.title, func(t *testing.T) { 53 | name, ok := ParseTitleFromHeader(tt.content) 54 | assert.Equal(t, tt.ok, ok) 55 | if tt.ok { 56 | assert.Equal(t, tt.title, name) 57 | } 58 | }) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /internal/utils/tmpdir/tmpdir.go: -------------------------------------------------------------------------------- 1 | package tmpdir 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | 7 | "github.com/spf13/afero" 8 | 9 | "github.com/lemoony/snipkit/internal/utils/system" 10 | ) 11 | 12 | type TmpDir interface { 13 | CreateTempFile(contents []byte) (bool, string) 14 | ClearFiles() 15 | } 16 | 17 | type tmpDirImpl struct { 18 | system *system.System 19 | dirPath string 20 | mutex sync.Mutex 21 | } 22 | 23 | // CreateTempFile creates a temporary file with the provided contents. 24 | // It returns a boolean indicating success, and the path to the created file. 25 | func (t *tmpDirImpl) CreateTempFile(contents []byte) (bool, string) { 26 | t.mutex.Lock() 27 | defer t.mutex.Unlock() 28 | 29 | // Ensure temporary directory exists 30 | if err := t.ensureTempDir(); err != nil { 31 | return false, "" 32 | } 33 | 34 | // Create a temporary file inside the directory 35 | tempFile, err := afero.TempFile(t.system.Fs, t.dirPath, "tmpfile_*.txt") 36 | if err != nil { 37 | return false, "" 38 | } 39 | 40 | defer func(tempFile afero.File) { 41 | _ = tempFile.Close() 42 | }(tempFile) 43 | 44 | // Write contents to the temporary file 45 | if _, err = tempFile.Write(contents); err != nil { 46 | return false, "" 47 | } 48 | 49 | return true, tempFile.Name() 50 | } 51 | 52 | // ClearFiles removes all files created in the temporary directory. 53 | func (t *tmpDirImpl) ClearFiles() { 54 | t.mutex.Lock() 55 | defer t.mutex.Unlock() 56 | t.system.RemoveAll(t.dirPath) 57 | } 58 | 59 | // ensureTempDir creates the temporary directory if it does not already exist. 60 | func (t *tmpDirImpl) ensureTempDir() error { 61 | if t.dirPath == "" { 62 | dir, err := afero.TempDir(t.system.Fs, "", "snipkit_tmpdir") 63 | if err != nil { 64 | return fmt.Errorf("failed to create temporary directory: %v", err) 65 | } 66 | t.dirPath = dir 67 | } 68 | return nil 69 | } 70 | 71 | func New(s *system.System) TmpDir { 72 | return &tmpDirImpl{system: s} 73 | } 74 | -------------------------------------------------------------------------------- /internal/utils/tmpdir/tmpdir_test.go: -------------------------------------------------------------------------------- 1 | package tmpdir 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/spf13/afero" 7 | "github.com/stretchr/testify/assert" 8 | 9 | "github.com/lemoony/snipkit/internal/utils/testutil" 10 | ) 11 | 12 | func TestCreateTempFile(t *testing.T) { 13 | // Initialize a new tmpDir instance 14 | sys := testutil.NewTestSystem() 15 | tmpDir := New(sys) 16 | 17 | // Test content to write into the temporary file 18 | content := []byte("test content") 19 | success, path := tmpDir.CreateTempFile(content) 20 | 21 | // Assert file creation was successful 22 | assert.True(t, success) 23 | assert.NotEmpty(t, path) 24 | 25 | // Read file contents to confirm it matches 26 | data, err := afero.ReadFile(sys.Fs, path) 27 | assert.NoError(t, err) 28 | assert.Equal(t, content, data) 29 | 30 | // Cleanup 31 | tmpDir.ClearFiles() 32 | } 33 | 34 | func TestClearFiles(t *testing.T) { 35 | sys := testutil.NewTestSystem() 36 | tmpDir := New(sys) 37 | 38 | // Create several temporary files 39 | for i := 0; i < 3; i++ { 40 | content := []byte("test content") 41 | success, _ := tmpDir.CreateTempFile(content) 42 | assert.True(t, success) 43 | } 44 | 45 | // Clear the created files 46 | tmpDir.ClearFiles() 47 | 48 | x := tmpDir.(*tmpDirImpl) 49 | 50 | assert.False(t, x.system.DirExists(x.dirPath)) 51 | } 52 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 NAME HERE 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | package main 17 | 18 | import ( 19 | "github.com/lemoony/snipkit/cmd" 20 | ) 21 | 22 | var version = "dev" 23 | 24 | func main() { 25 | cmd.SetVersion(version) 26 | cmd.Execute() 27 | } 28 | -------------------------------------------------------------------------------- /misc/example-snippets/echo-something.sh: -------------------------------------------------------------------------------- 1 | # some comment 2 | # ${VAR1} Name: First Output 3 | # ${VAR1} Description: What to print on the terminal first 4 | echo "${VAR1}" 5 | 6 | # ${VAR2} Name: Second Output 7 | # ${VAR2} Description: What to print on the terminal second 8 | # ${VAR2} Default: friend 9 | echo "${VAR2}" 10 | 11 | # ${VAR3} Name: Third Output 12 | # ${VAR3} Description: hat to print on the terminal third 13 | # ${VAR3} Values: One + some more, "Two",Three, , 14 | # ${VAR3} Values: Four\, and some more, Five 15 | # ${VAR3} Default: Three 16 | echo "${VAR3}" 17 | -------------------------------------------------------------------------------- /misc/fzf.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Function to check if a command is installed 4 | check_command() { 5 | if ! command -v "$1" &> /dev/null; then 6 | echo "Error: $1 is not installed." 7 | exit 1 8 | fi 9 | } 10 | 11 | # Check if either batcat or bat is installed (required for syntax highlighting) 12 | if command -v batcat &> /dev/null; then 13 | BAT_CMD="batcat" 14 | elif command -v bat &> /dev/null; then 15 | BAT_CMD="bat" 16 | else 17 | echo "Error: bat or batcat is not installed." 18 | exit 1 19 | fi 20 | 21 | # Check if other required commands are installed 22 | check_command "awk" 23 | check_command "jq" 24 | check_command "fzf" 25 | check_command "snipkit" 26 | 27 | # Directly call snipkit export to get JSON data 28 | json_data=$(snipkit export -f=id,title,content) 29 | 30 | # Extract titles, base64 encoded scripts, and IDs using jq 31 | combined=$(echo "$json_data" | jq -r '.snippets[] | [.id, .title, (.content | @base64)] | @tsv') 32 | 33 | # Use fzf to select a title and show the script in a preview window with syntax highlighting 34 | selected=$(echo "$combined" | fzf --prompt="Select a script: " \ 35 | --delimiter=$'\t' \ 36 | --with-nth=2 \ 37 | --preview="echo {} | awk -F'\t' '{print \$3}' | base64 --decode | $BAT_CMD --language=sh --style=numbers --color=always" \ 38 | --preview-window=right:60%:wrap) 39 | 40 | # Extract the ID and script part from the selected line 41 | selected_id=$(echo "$selected" | awk -F'\t' '{print $1}') 42 | selected_script=$(echo "$selected" | awk -F'\t' '{print $3}' | base64 --decode) 43 | 44 | # If a script was selected, execute it using snipkit 45 | if [ -n "$selected_id" ]; then 46 | snipkit exec --id "$selected_id" 47 | else 48 | echo "No script selected." 49 | fi 50 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: SnipKit Documentation 2 | site_url: https://lyrahgames.github.io/snipkit 3 | 4 | site_author: lemoony 5 | site_description: >- 6 | SnipKit helps you to execute scripts saved in your favorite snippets manager without even leaving the terminal. 7 | 8 | repo_name: lemoony/snipkit 9 | repo_url: https://github.com/lemoony/snipkit 10 | 11 | copyright: Copyright © 2024 lemoony 12 | 13 | theme: 14 | name: material 15 | language: en 16 | features: 17 | - navigation.tabs 18 | - navigation.tabs.sticky 19 | - navigation.sections 20 | 21 | extra: 22 | version: 23 | provider: mike 24 | 25 | markdown_extensions: 26 | - pymdownx.highlight: 27 | use_pygments: true 28 | - admonition 29 | - pymdownx.details 30 | - pymdownx.superfences 31 | - attr_list 32 | - md_in_html 33 | nav: 34 | - Home: 'index.md' 35 | - Getting started: 36 | - Overview: 'getting-started/overview.md' 37 | - Parameters: 'getting-started/parameters.md' 38 | - Power Setup: 'getting-started/power-setup.md' 39 | - Fzf: 'getting-started/fzf.md' 40 | - Configuration: 41 | - Overview: 'configuration/overview.md' 42 | - Themes: 'configuration/themes.md' 43 | - Managers: 44 | - Overview: 'managers/overview.md' 45 | - File System Library: 'managers/fslibrary.md' 46 | - GitHub Gist: 'managers/githubgist.md' 47 | - SnippetsLab: 'managers/snippetslab.md' 48 | - Snip: 'managers/pictarinesnip.md' 49 | - Pet: 'managers/pet.md' 50 | - Assistant: 51 | - Overview: 'assistant/index.md' 52 | - OpenAI: 'assistant/openai.md' 53 | - Gemini: 'assistant/gemini.md' 54 | 55 | -------------------------------------------------------------------------------- /themes/data.go: -------------------------------------------------------------------------------- 1 | package themedata 2 | 3 | import "embed" 4 | 5 | //go:embed *.yaml 6 | var Files embed.FS 7 | -------------------------------------------------------------------------------- /themes/default.dark.yaml: -------------------------------------------------------------------------------- 1 | version: 1.0.0 2 | variables: 3 | white: "#FFFFFF" 4 | black: "#000000" 5 | gray: "#8A8A8A" 6 | lightGray: "#DDDADA" 7 | green: "#43BF6D" 8 | red: 9 | theme: 10 | borderColor: ${gray} 11 | borderTitleColor: ${gray} 12 | previewColorSchemeName: "friendly" 13 | textColor: "" # if empty the default terminal foreground color will be used 14 | subduedColor: ${gray} 15 | subduedContrastColor: ${black} 16 | verySubduedColor: ${lightGray} 17 | verySubduedContrastColor: ${black} 18 | activeColor: "#EE6FF8" 19 | activeContrastColor: "#FFFFFF" 20 | titleColor: "62" 21 | titleContrastColor: "230" 22 | highlightColor: ${green} 23 | highlightContrastColor: ${black} 24 | infoColor: "#F2DB6A" 25 | infoContrastColor: "#F2DB6A" 26 | snippetColor: "#EE6FF8" 27 | snippetContrastColor: ${white} 28 | successColor: ${green} 29 | errorColor: "#FF0000" 30 | -------------------------------------------------------------------------------- /themes/default.light.yaml: -------------------------------------------------------------------------------- 1 | version: 1.0.0 2 | variables: 3 | white: "#FFFFFF" 4 | black: "#000000" 5 | gray: "#8A8A8A" 6 | lightGray: "#DDDADA" 7 | green: "#40916c" 8 | theme: 9 | borderColor: ${gray} 10 | borderTitleColor: ${gray} 11 | previewColorSchemeName: "friendly" 12 | textColor: "" # if empty the default terminal foreground color will be used 13 | subduedColor: ${gray} 14 | subduedContrastColor: ${black} 15 | verySubduedColor: ${lightGray} 16 | verySubduedContrastColor: ${black} 17 | activeColor: "#EE6FF8" 18 | activeContrastColor: "#FFFFFF" 19 | titleColor: "62" 20 | titleContrastColor: "230" 21 | highlightColor: ${green} 22 | highlightContrastColor: ${black} 23 | infoColor: "#F6A935" 24 | infoContrastColor: ${black} 25 | snippetColor: "#EE6FF8" 26 | snippetContrastColor: ${white} 27 | successColor: ${green} 28 | errorColor: "#FF0000" 29 | -------------------------------------------------------------------------------- /themes/simple.yaml: -------------------------------------------------------------------------------- 1 | version: 1.0.0 2 | variables: 3 | white: "#FFFFFF" 4 | black: "#000000" 5 | red: "#ED6A5A" 6 | green: "#44AF69" 7 | yellow: "#FCAB10" 8 | brown: "#3A3335" 9 | gray: "#8A8A8A" 10 | lightGray: "#DDDADA" 11 | theme: 12 | borderColor: ${gray} 13 | borderTitleColor: ${gray} 14 | previewColorSchemeName: "pastie" 15 | textColor: "" # if empty the default terminal foreground color will be used 16 | subduedColor: ${gray} 17 | subduedContrastColor: ${black} 18 | verySubduedColor: ${lightGray} 19 | verySubduedContrastColor: ${black} 20 | activeColor: ${red} # 21 | activeContrastColor: ${white} 22 | titleColor: ${brown} 23 | titleContrastColor: ${white} 24 | highlightColor: ${green} 25 | highlightContrastColor: ${black} 26 | infoColor: ${yellow} 27 | infoContrastColor: ${white} 28 | snippetColor: ${lightGray} 29 | snippetContrastColor: ${black} 30 | successColor: ${green} 31 | errorColor: ${red} 32 | -------------------------------------------------------------------------------- /tools/tools.go: -------------------------------------------------------------------------------- 1 | //go:build tools 2 | // +build tools 3 | 4 | package tools 5 | 6 | // Manage tool dependencies via go.mod. 7 | // 8 | // https://github.com/golang/go/wiki/Modules#how-can-i-track-tool-dependencies-for-a-module 9 | // https://github.com/golang/go/issues/25922 10 | import ( 11 | _ "github.com/daixiang0/gci" 12 | _ "github.com/golangci/golangci-lint/cmd/golangci-lint" 13 | _ "github.com/goreleaser/goreleaser" 14 | _ "github.com/vektra/mockery/v2" 15 | _ "mvdan.cc/gofumpt" 16 | ) 17 | --------------------------------------------------------------------------------