diff --git a/.github/.deploy/bump.sh b/.github/.deploy/bump.sh new file mode 100755 index 0000000..94433f6 --- /dev/null +++ b/.github/.deploy/bump.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +# Bump the version number in the package.json file +version=$(git describe --tags | sed 's/^v//;s/\([^-]*-g\)/r\1/') +version2=$(git describe --tags | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g') + +echo "Bumping Cargo version to $version" +echo "Bumping PKGBUILD version to $version2" + +# Update the version in the PKGBUILD file. +sed -i "s/pkgver=.*/pkgver=$version2/" PKGBUILD + +# Replace the version in the Cargo.toml file with the $version variable +sed -i "0,/version = \".*\"/s//version = \"$version\"/" Cargo.toml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6b9c4e6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,182 @@ +on: + release: + types: + - created + +env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CARGO_TERM_COLOR: always + +name: Create Release / Upload Assets + +jobs: + version_bump: + name: Bump cache version + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Cache Version + run: .github/.deploy/bump.sh + + - uses: actions/upload-artifact@v2 + with: + name: pkg-version + path: Cargo.toml + + windows: + name: Build for Windows + runs-on: windows-latest + needs: [version_bump] + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - uses: actions/download-artifact@v2 + with: + name: pkg-version + + - name: Set up cargo cache + uses: actions/cache@v3 + continue-on-error: false + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-cargo- + + - name: Build + run: cargo build --release + + - name: "Move to outputs/ folder" + run: | + mkdir outputs + cp target/release/*.exe outputs/plz-win-x86_64.exe + + - name: Upload to temporary storage + uses: actions/upload-artifact@master + with: + name: output-artifact + path: outputs + + linux: + name: Build for Linux + runs-on: ubuntu-latest + needs: [version_bump] + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - uses: actions/download-artifact@v2 + with: + name: pkg-version + + - name: Set up cargo cache + uses: actions/cache@v3 + continue-on-error: false + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-cargo- + + - name: Build + run: cargo build --release + + - name: Install cargo-deb + run: cargo install cargo-deb + continue-on-error: true + + - name: Create deb package + run: cargo deb + + - name: "Move to outputs/ folder" + run: | + mkdir outputs + cp target/release/plz outputs/plz-linux-x86_64 + cp target/debian/*.deb outputs/plz-linux-x86_64.deb + + - name: Upload to temporary storage + uses: actions/upload-artifact@master + with: + name: output-artifact + path: outputs + + macos: + name: Build for Mac + runs-on: macos-11 + needs: [version_bump] + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - uses: actions/download-artifact@v2 + with: + name: pkg-version + + - name: Set up cargo cache + uses: actions/cache@v3 + continue-on-error: false + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-cargo- + + - name: Install ARM target + run: rustup update && rustup target add aarch64-apple-darwin + + - name: ARM Build + run: cargo build --release --target=aarch64-apple-darwin + + - name: Build + run: cargo build --release + + - name: "Move to outputs/ folder" + run: | + mkdir outputs + cp target/aarch64-apple-darwin/release/plz outputs/plz-darwin-aarch64 + cp target/release/plz outputs/plz-darwin-x86_64 + + - name: Upload to temporary storage + uses: actions/upload-artifact@master + with: + name: output-artifact + path: outputs + + release: + name: Release assets + runs-on: ubuntu-latest + needs: [windows, linux, macos] + + steps: + - name: Download from temporary storage + uses: actions/download-artifact@master + with: + name: output-artifact + path: outputs + + - name: Upload binaries to release + uses: svenstaro/upload-release-action@v2 + with: + repo_token: ${{ secrets.GITHUB_TOKEN }} + file: outputs/* + tag: ${{ github.ref }} + overwrite: true + file_glob: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5dd6ff5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Generated by Cargo +# will have compiled files and executables +/target/ + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +.DS_Store diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..3294904 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "plz" +license = "MIT" +edition = "2021" +version = "0.1.0" +readme = "README.md" +categories = ["command-line-utilities"] +homepage = "https://github.com/m1guelpf/plz-cli" +repository = "https://github.com/m1guelpf/plz-cli" +authors = ["Miguel Piedrafita "] +description = "Generate bash scripts from the command line, using Codex" + +[dependencies] +question = "0.2.2" +spinners = "4.1.0" +serde_json = { version = "1.0.89", default-features = false } +clap = { version = "4.0.29", features = ["derive"] } +reqwest = { version = "0.11.13", default-features = false, features = ["json", "blocking", "native-tls-crate", "default-tls", "hyper-tls", "__tls"] } +bat = { version = "0.22.1", default-features = false, features = ["regex-onig"] } diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9978859 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Miguel Piedrafita + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8e88b66 --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# Copilot, for your terminal + +A CLI tool that generates shell scripts from a human readable description. + +## Installation + +You can install `plz` by running the following command in your terminal. + +``` +curl -fsSL https://raw.githubusercontent.com/m1guelpf/plz-cli/main/install.sh | sh - +``` + +You may need to close and reopen your terminal after installation. Alternatively, you can download the binary corresponding to your OS from the [latest release](https://github.com/m1guelpf/plz-cli/releases/latest). + +## Usage + +`plz` uses [GPT-3](https://beta.openai.com/). To use it, you'll need to grab an API key from [your dashboard](https://beta.openai.com/), and save it to `OPENAI_API_KEY` as follows (you can also save it in your bash/zsh profile for persistance between sessions). + +```bash +export OPENAI_API_KEY='sk-XXXXXXXX' +``` + +Once you have configured your environment, run `plz` followed by whatever it is that you want to do (`plz "show me all options for the plz cli"`). + +To get a full overview of all available options, run `plz --help` + +```sh +$ plz --help +Generates bash scripts from the command line + +Usage: plz [OPTIONS] + +Arguments: + Description of the command to execute + +Options: + -y, --force Run the generated program without asking for confirmation + -h, --help Print help information + -V, --version Print version information +``` + +## Develop + +Make sure you have the latest version of rust installed (use [rustup](https://rustup.rs/)). Then, you can build the project by running `cargo build`, and run it with `cargo run`. + +## License + +This project is open-sourced under the MIT license. See [the License file](LICENSE) for more information. diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..0f78c13 --- /dev/null +++ b/install.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -e + +main() { + BIN_DIR=${BIN_DIR-"$HOME/.bin"} + mkdir -p $BIN_DIR + + case $SHELL in + */zsh) + PROFILE=$HOME/.zshrc + PREF_SHELL=zsh + ;; + */bash) + PROFILE=$HOME/.bashrc + PREF_SHELL=bash + ;; + */fish) + PROFILE=$HOME/.config/fish/config.fish + PREF_SHELL=fish + ;; + */ash) + PROFILE=$HOME/.profile + PREF_SHELL=ash + ;; + *) + echo "could not detect shell, manually add ${BIN_DIR} to your PATH." + exit 1 + esac + + if [[ ":$PATH:" != *":${BIN_DIR}:"* ]]; then + echo >> $PROFILE && echo "export PATH=\"\$PATH:$BIN_DIR\"" >> $PROFILE + fi + + PLATFORM="$(uname -s)" + case $PLATFORM in + Linux) + PLATFORM="linux" + ;; + Darwin) + PLATFORM="darwin" + ;; + *) + err "unsupported platform: $PLATFORM" + ;; + esac + + ARCHITECTURE="$(uname -m)" + if [ "${ARCHITECTURE}" = "x86_64" ]; then + # Redirect stderr to /dev/null to avoid printing errors if non Rosetta. + if [ "$(sysctl -n sysctl.proc_translated 2>/dev/null)" = "1" ]; then + ARCHITECTURE="aarch64" # Rosetta. + else + ARCHITECTURE="x86_64" # Intel. + fi + elif [ "${ARCHITECTURE}" = "arm64" ] ||[ "${ARCHITECTURE}" = "aarch64" ] ; then + ARCHITECTURE="aarch64" # Arm. + else + ARCHITECTURE="x86_64" # Amd. + fi + + BINARY_URL="https://github.com/m1guelpf/plz-cli/releases/latest/download/plz-${PLATFORM}-${ARCHITECTURE}" + echo $BINARY_URL + + echo "downloading latest binary" + ensure curl -L "$BINARY_URL" -o "$BIN_DIR/plz" + chmod +x "$BIN_DIR/plz" + + echo "installed - $("$BIN_DIR/plz" --version)" +} + +# Run a command that should never fail. If the command fails execution +# will immediately terminate with an error showing the failing +# command. +ensure() { + if ! "$@"; then err "command failed: $*"; fi +} + +main "$@" || exit 1 diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..74bb97c --- /dev/null +++ b/src/main.rs @@ -0,0 +1,99 @@ +use bat::PrettyPrinter; +use clap::Parser; +use question::{Answer, Question}; +use reqwest::blocking::Client; +use serde_json::json; +use spinners::{Spinner, Spinners}; +use std::{env, fs, io::Write, process::Command}; + +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +struct Cli { + /// Description of the command to execute + prompt: String, + + /// Run the generated program without asking for confirmation + #[clap(short = 'y', long)] + force: bool, +} + +fn main() { + let cli = Cli::parse(); + let api_key = env::var("OPENAI_API_KEY").unwrap_or_else(|_| { + println!("This program requires an OpenAI API key to run. Please set the OPENAI_API_KEY environment variable."); + std::process::exit(1); + }); + + let mut spinner = Spinner::new(Spinners::BouncingBar, "Generating your command...".into()); + + let client = Client::new(); + let response = client + .post("https://api.openai.com/v1/completions") + .json(&json!({ + "top_p": 1, + "temperature": 0, + "suffix": "\n```", + "max_tokens": 1000, + "presence_penalty": 0, + "frequency_penalty": 0, + "model": "text-davinci-003", + "prompt": format!("{}:\n```bash\n#!/bin/bash\n", cli.prompt), + })) + .header("Authorization", format!("Bearer {}", api_key)) + .send() + .unwrap() + .error_for_status() + .unwrap_or_else(|_| { + spinner.stop_and_persist( + "✖", + "Failed to get a response. Have you set the OPENAI_API_KEY variable?".into(), + ); + std::process::exit(1); + }); + + let text = response.json::().unwrap()["choices"][0]["text"] + .as_str() + .unwrap() + .to_string(); + + spinner.stop_and_persist("✔", "Got some code!".into()); + + PrettyPrinter::new() + .input_from_bytes(text.trim().as_bytes()) + .language("bash") + .grid(true) + .print() + .unwrap(); + + let mut file = fs::File::create(".tmp.sh").unwrap(); + file.write_all(text.as_bytes()).unwrap(); + + let mut should_run = true; + if !cli.force { + should_run = Question::new("\x1b[90m>> Run the generated program? [Y/n]\x1b[0m") + .yes_no() + .until_acceptable() + .default(Answer::YES) + .ask() + .expect("Couldn't ask question.") + == Answer::YES; + } + + if should_run { + spinner = Spinner::new(Spinners::BouncingBar, "Executing...".into()); + + let output = Command::new("bash") + .arg(".tmp.sh") + .output() + .unwrap_or_else(|_| { + spinner.stop_and_persist("✖", "Failed to execute the generated program.".into()); + std::process::exit(1); + }); + + spinner.stop_and_persist("✔", "Command ran successfully".into()); + + println!("{}", String::from_utf8_lossy(&output.stdout)); + } + + fs::remove_file(".tmp.sh").unwrap(); +}