diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 742930e..a5472a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: run: bash -n gitai - name: Run ShellCheck - run: shellcheck -e SC1091 gitai + run: shellcheck -S warning -e SC1091 gitai - name: Validate Homebrew formula Ruby syntax run: ruby -c Formula/gitai.rb @@ -28,4 +28,5 @@ jobs: test -x gitai tmp="$(mktemp -d)" git -C "$tmp" init - (cd "$tmp" && "$GITHUB_WORKSPACE/gitai" --version) + version="$(cd "$tmp" && "$GITHUB_WORKSPACE/gitai" --version)" + test "$version" = "gitai 0.3.0" diff --git a/Formula/gitai.rb b/Formula/gitai.rb index 9bb4b65..fcc8aa5 100644 --- a/Formula/gitai.rb +++ b/Formula/gitai.rb @@ -1,6 +1,8 @@ class Gitai < Formula desc "AI-assisted Conventional Commits with a terminal-native workflow" homepage "https://github.com/jaavid/gitai" + url "https://github.com/jaavid/gitai.git", revision: "f9bd69c4958b3ae10ae83d19cc8d2b490ca4fd0b" + version "0.3.0" head "https://github.com/jaavid/gitai.git", branch: "main" license "MIT" @@ -13,6 +15,6 @@ def install test do system "git", "init", testpath - assert_match "gitai 0.2.0", shell_output("#{bin}/gitai --version") + assert_match "gitai 0.3.0", shell_output("#{bin}/gitai --version") end end diff --git a/README.md b/README.md index 5d01eb1..10f4ed0 100644 --- a/README.md +++ b/README.md @@ -1,182 +1,147 @@ # GitAI -GitAI is a small terminal-first assistant for generating Conventional Commit messages from staged Git changes, reviewing the result, committing it, and optionally pushing it to one or more remotes. - -It stays close to normal Git instead of replacing your workflow. The UI uses [Gum](https://github.com/charmbracelet/gum) when available and falls back to plain terminal prompts when it is not. +GitAI is a terminal-first assistant for generating Conventional Commit messages from staged Git changes, reviewing the result, committing it, and optionally pushing it to one or more remotes. ## Features -- Generates Conventional Commit titles and descriptions from staged changes. -- Supports AvalAI, OpenRouter, Cloudflare Workers AI, and Google AI Studio (Gemini API). -- Supports provider fallback chains. -- Terminal UI for staging, reviewing, committing, and choosing a push target. -- Multi-remote aware: push to one remote or all configured remotes. -- Works well in Warp, iTerm2, Terminal.app, and other modern terminals without requiring terminal-specific APIs. -- Falls back to simple text prompts when Gum is unavailable or `GITAI_TUI=0`. -- Warns about changes outside the index and lets you keep the current staged set or stage everything. -- Trims very large diffs before sending them to an AI provider. -- User-wide and repository-specific configuration. - -## Requirements - -- `bash` -- `git` -- `curl` -- [`jq`](https://jqlang.github.io/jq/) -- [`gum`](https://github.com/charmbracelet/gum) for the richer terminal UI (optional outside Homebrew installs) +- Conventional Commit title + description generated from staged changes. +- Providers: AvalAI, OpenRouter, Cloudflare Workers AI, and Google AI Studio (Gemini). +- First-run credential setup when no usable API key is found. +- `gum`-based terminal UI with plain-shell fallback. +- Multi-remote push selection, including **All remotes**. +- Works cleanly in Warp without using a full-screen alternate buffer. +- Config discovery for old and new installations. +- Versioned Homebrew formula, while keeping `--HEAD` available for development builds. -## Install with Homebrew +## Homebrew -This repository contains a Homebrew formula. Until a dedicated `homebrew-tap` repository exists, add this repository as a custom tap and install the HEAD formula: +Add the tap once: ```bash brew tap jaavid/gitai https://github.com/jaavid/gitai -brew install --HEAD jaavid/gitai/gitai ``` -Upgrade later with: +Install the stable version: ```bash -brew update -brew upgrade --fetch-HEAD gitai +brew install jaavid/gitai/gitai ``` -Homebrew installs `jq` and `gum` automatically. - -### Manual install +Normal upgrades then work with: ```bash -git clone https://github.com/jaavid/gitai.git -cd gitai -install -m 755 gitai /usr/local/bin/gitai +brew update +brew upgrade gitai ``` -## Configuration - -GitAI reads configuration in this order, with later files overriding earlier values: - -1. Legacy `.gitai` next to the executable, if present. -2. `~/.config/gitai/config` (or `$XDG_CONFIG_HOME/gitai/config`). -3. `.gitai` in the current repository root. +### If you previously installed with `--HEAD` -For API keys, prefer the user-wide config instead of committing credentials into a repository. - -Create it with: +Older GitAI instructions used: ```bash -mkdir -p ~/.config/gitai -$EDITOR ~/.config/gitai/config +brew install --HEAD jaavid/gitai/gitai ``` -Example: +That produces versions such as `HEAD-e7f82ea`, so Homebrew tracks Git commits rather than semantic releases. Switch once to the stable channel after this release is merged: ```bash -export GITAI_PROVIDER="google,openrouter,cloudflare,avalapis" -export GITAI_GOOGLE_API_KEY="your-google-key" -export GITAI_OPENROUTER_API_KEY="your-openrouter-key" -export GITAI_CLOUDFLARE_API_KEY="your-cloudflare-key" -export GITAI_AVALAPIS_API_KEY="your-avalapis-key" -export GITAI_CLOUDFLARE_ACCOUNT_ID="your-cloudflare-account-id" +brew reinstall jaavid/gitai/gitai ``` -### Provider options - -Set `GITAI_PROVIDER` to one provider or a comma-separated fallback chain: +Then verify: -- `avalapis` (default) -- `openrouter` -- `cloudflare` -- `google` +```bash +gitai --version +``` -Examples: +Expected for this release: -```bash -export GITAI_PROVIDER="google,openrouter,cloudflare,avalapis" +```text +gitai 0.3.0 ``` +If you intentionally want the newest `main` commit instead of a release, use: + ```bash -export GITAI_PROVIDER="openrouter" +brew reinstall --HEAD jaavid/gitai/gitai +brew upgrade --fetch-HEAD gitai ``` -Global optional variables: - -- `GITAI_API_BASE`: override endpoint URL for advanced/custom setups. -- `GITAI_MAX_DIFF_LENGTH`: maximum staged diff characters sent to a provider (default: `12000`). -- `GITAI_MODEL`: shared model override. -- `GITAI_TUI=0`: disable Gum and use plain terminal prompts. +Homebrew installs `jq` and `gum` automatically. -Per-provider API keys: +## Configuration and API keys -- `GITAI_AVALAPIS_API_KEY` -- `GITAI_OPENROUTER_API_KEY` -- `GITAI_CLOUDFLARE_API_KEY` -- `GITAI_GOOGLE_API_KEY` +GitAI reads config in this order; later files override earlier values: -Per-provider model overrides: +1. `.gitai` next to the executable. +2. `~/.bin/gitai/.gitai` — historical GitAI location. +3. `~/.config/gitai/config` or `$XDG_CONFIG_HOME/gitai/config`. +4. `/.gitai`. -- `GITAI_AVALAPIS_MODEL` -- `GITAI_OPENROUTER_MODEL` -- `GITAI_CLOUDFLARE_MODEL` -- `GITAI_GOOGLE_MODEL` +So an existing file such as: -Cloudflare additionally needs `GITAI_CLOUDFLARE_ACCOUNT_ID` unless `GITAI_API_BASE` is explicitly set. +```text +/Users/javid/.bin/gitai/.gitai +``` -## Usage +continues to work without migration. -From any Git repository: +If no usable key exists for the configured provider(s), GitAI starts an interactive setup wizard. It asks for the provider and API key, masks the key while typing, and stores it in: -```bash -gitai +```text +~/.config/gitai/config ``` -A typical flow is: +with mode `600`. -1. GitAI shows the repository, branch, and short status. -2. If needed, choose whether to stage all changes or keep only the current staged set. -3. GitAI sends only the staged diff to the configured AI provider chain. -4. Review the generated Conventional Commit message. -5. Confirm the commit. -6. Choose whether and where to push. +Run setup manually at any time: -If the repository has multiple remotes, GitAI presents all of them together with an **All remotes** option. The current branch is pushed explicitly to each selected remote. +```bash +gitai --setup +``` -### Push options +Example config: ```bash -# Commit, then ask where to push -gitai --push +export GITAI_PROVIDER="google,openrouter,cloudflare,avalapis" +export GITAI_GOOGLE_API_KEY="your-google-key" +export GITAI_OPENROUTER_API_KEY="your-openrouter-key" +export GITAI_CLOUDFLARE_API_KEY="your-cloudflare-key" +export GITAI_AVALAPIS_API_KEY="your-avalapis-key" +export GITAI_CLOUDFLARE_ACCOUNT_ID="your-cloudflare-account-id" +``` -# Commit and push to every configured remote -gitai --push-all +Supported providers: -# Commit without offering to push -gitai --no-push -``` +- `google` +- `openrouter` +- `cloudflare` +- `avalapis` -### Disable the richer TUI +A comma-separated value in `GITAI_PROVIDER` acts as a fallback chain. -```bash -gitai --no-tui -``` +## Usage -or: +From any Git repository: ```bash -export GITAI_TUI=0 +gitai ``` -## Warp compatibility - -GitAI deliberately uses normal shell input/output rather than a full-screen alternate-buffer UI. In Warp this means each run remains a normal command block, output stays searchable/copyable, and Git/GitAI subprocess output remains visible in the same block. +Typical flow: -No Warp-specific configuration is required. +1. Load credentials or run setup. +2. Show branch and Git status. +3. Stage changes if needed. +4. Generate a commit message from the staged diff. +5. Review and confirm the commit. +6. Select a remote, all remotes, or skip pushing. -## CLI reference +Useful options: ```text -gitai [options] - ---push Push after committing; asks for a remote when necessary +--setup Configure provider and API key +--push Push after committing; choose remote if needed --push-all Push to every configured remote --no-push Never ask to push --no-tui Use plain prompts instead of Gum @@ -184,9 +149,17 @@ gitai [options] -h, --help ``` -## Contributing +## Warp + +GitAI uses normal terminal input/output rather than a curses-style full-screen interface. In Warp, each run remains a regular command block, and Git/GitAI output stays searchable and copyable. -Issues and pull requests are welcome. +## Manual install + +```bash +git clone https://github.com/jaavid/gitai.git +cd gitai +install -m 755 gitai /usr/local/bin/gitai +``` ## License diff --git a/gitai b/gitai index de6c80c..9a06147 100755 --- a/gitai +++ b/gitai @@ -1,20 +1,15 @@ #!/usr/bin/env bash -# GitAI - AI-assisted Conventional Commits with a terminal-native workflow. - set -euo pipefail -VERSION="0.2.0" +VERSION="0.3.0" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +USER_CONFIG="${XDG_CONFIG_HOME:-$HOME/.config}/gitai/config" +LEGACY_HOME_CONFIG="$HOME/.bin/gitai/.gitai" -fail() { - printf 'gitai: %s\n' "$*" >&2 - exit 1 -} - -command_exists() { - command -v "$1" >/dev/null 2>&1 -} +fail() { printf 'gitai: %s\n' "$*" >&2; exit 1; } +command_exists() { command -v "$1" >/dev/null 2>&1; } +is_interactive() { [ -t 0 ] && [ -t 1 ]; } for dependency in git curl jq; do command_exists "$dependency" || fail "Missing dependency: $dependency" @@ -23,121 +18,164 @@ done git rev-parse --is-inside-work-tree >/dev/null 2>&1 || fail "Run gitai inside a Git repository." GIT_ROOT="$(git rev-parse --show-toplevel)" -# Configuration precedence (later files win): legacy install-local, user, repository. -# Keeping the legacy location preserves compatibility with older installations. -if [ -f "$SCRIPT_DIR/.gitai" ]; then - # shellcheck disable=SC1091 - source "$SCRIPT_DIR/.gitai" -fi -if [ -f "${XDG_CONFIG_HOME:-$HOME/.config}/gitai/config" ]; then - # shellcheck disable=SC1090 - source "${XDG_CONFIG_HOME:-$HOME/.config}/gitai/config" -fi -if [ -f "$GIT_ROOT/.gitai" ]; then +source_config() { + local file="$1" + [ -f "$file" ] || return 0 # shellcheck disable=SC1090 - source "$GIT_ROOT/.gitai" + source "$file" +} + +# Later files override earlier ones. +source_config "$SCRIPT_DIR/.gitai" +if [ "$LEGACY_HOME_CONFIG" != "$SCRIPT_DIR/.gitai" ]; then + source_config "$LEGACY_HOME_CONFIG" fi +source_config "$USER_CONFIG" +source_config "$GIT_ROOT/.gitai" PROVIDER_CHAIN_RAW="${GITAI_PROVIDER:-avalapis}" MAX_DIFF_LENGTH="${GITAI_MAX_DIFF_LENGTH:-12000}" DEFAULT_API_KEY="${GITAI_API_KEY:-}" TUI_ENABLED="${GITAI_TUI:-1}" AUTO_PUSH_MODE="" +FORCE_SETUP=0 -if ! [[ "$MAX_DIFF_LENGTH" =~ ^[0-9]+$ ]] || [ "$MAX_DIFF_LENGTH" -eq 0 ]; then - fail "GITAI_MAX_DIFF_LENGTH must be a positive integer." -fi - -is_interactive() { - [ -t 0 ] && [ -t 1 ] -} +[[ "$MAX_DIFF_LENGTH" =~ ^[0-9]+$ ]] && [ "$MAX_DIFF_LENGTH" -gt 0 ] || fail "GITAI_MAX_DIFF_LENGTH must be a positive integer." use_gum() { [ "$TUI_ENABLED" != "0" ] && is_interactive && command_exists gum && [ "${TERM:-}" != "dumb" ] } ui_header() { - local text="$1" - if use_gum; then - gum style --bold --border rounded --padding "0 2" "$text" - else - printf '\n== %s ==\n' "$text" - fi + if use_gum; then gum style --bold --border rounded --padding "0 2" "$1"; else printf '\n== %s ==\n' "$1"; fi } - ui_note() { - local text="$1" - if use_gum; then - gum style --faint "$text" - else - printf '%s\n' "$text" - fi + if use_gum; then gum style --faint "$1"; else printf '%s\n' "$1"; fi } - ui_success() { - local text="$1" - if use_gum; then - gum style --bold "✓ $text" - else - printf '✓ %s\n' "$text" - fi + if use_gum; then gum style --bold "✓ $1"; else printf '✓ %s\n' "$1"; fi } - ui_warn() { - local text="$1" - if use_gum; then - gum style --bold "! $text" - else - printf '! %s\n' "$text" - fi + if use_gum; then gum style --bold "! $1"; else printf '! %s\n' "$1"; fi } - ui_confirm() { - local prompt="$1" - if use_gum; then - gum confirm "$prompt" - else - local answer - read -r -p "$prompt [y/N] " answer - [[ "$answer" =~ ^[Yy]$ ]] - fi + local answer + if use_gum; then gum confirm "$1"; else read -r -p "$1 [y/N] " answer; [[ "$answer" =~ ^[Yy]$ ]]; fi } - ui_choose() { - local prompt="$1" - shift - - if use_gum; then - gum choose --header "$prompt" "$@" - return - fi - - local options=("$@") - local i choice + local prompt="$1"; shift + if use_gum; then gum choose --header "$prompt" "$@"; return; fi + local options=("$@") i choice printf '%s\n' "$prompt" >&2 - for ((i = 0; i < ${#options[@]}; i++)); do - printf ' %d) %s\n' "$((i + 1))" "${options[$i]}" >&2 - done + for ((i=0; i<${#options[@]}; i++)); do printf ' %d) %s\n' "$((i+1))" "${options[$i]}" >&2; done while true; do read -r -p "> " choice if [[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && [ "$choice" -le "${#options[@]}" ]; then - printf '%s\n' "${options[$((choice - 1))]}" - return + printf '%s\n' "${options[$((choice-1))]}"; return fi - printf 'Choose a number between 1 and %d.\n' "${#options[@]}" >&2 done } +ui_secret() { + local value + if use_gum; then gum input --password --prompt "$1: "; else read -r -s -p "$1: " value; printf '\n' >&2; printf '%s' "$value"; fi +} +ui_input() { + local value + if use_gum; then gum input --prompt "$1: "; else read -r -p "$1: " value; printf '%s' "$value"; fi +} +trim_value() { printf '%s' "$1" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//'; } -trim_value() { - local value="$1" - printf '%s' "$value" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' +resolve_provider_api_key() { + case "$1" in + avalapis) printf '%s' "${GITAI_AVALAPIS_API_KEY:-$DEFAULT_API_KEY}" ;; + openrouter) printf '%s' "${GITAI_OPENROUTER_API_KEY:-$DEFAULT_API_KEY}" ;; + cloudflare) printf '%s' "${GITAI_CLOUDFLARE_API_KEY:-$DEFAULT_API_KEY}" ;; + google) printf '%s' "${GITAI_GOOGLE_API_KEY:-$DEFAULT_API_KEY}" ;; + *) printf '' ;; + esac } -build_chat_prompt() { - local diff_content="$1" +configured_provider_has_key() { + local raw provider providers=() + IFS=',' read -r -a providers <<< "$PROVIDER_CHAIN_RAW" + for raw in "${providers[@]}"; do + provider="$(trim_value "$raw")" + [ -n "$provider" ] || continue + [ -n "$(resolve_provider_api_key "$provider")" ] && return 0 + done + return 1 +} + +write_user_config() { + local provider="$1" key="$2" account_id="${3:-}" key_var tmp + case "$provider" in + avalapis) key_var="GITAI_AVALAPIS_API_KEY" ;; + openrouter) key_var="GITAI_OPENROUTER_API_KEY" ;; + cloudflare) key_var="GITAI_CLOUDFLARE_API_KEY" ;; + google) key_var="GITAI_GOOGLE_API_KEY" ;; + *) fail "Unsupported provider: $provider" ;; + esac + + mkdir -p "$(dirname "$USER_CONFIG")" + umask 077 + tmp="$(mktemp)" + if [ -f "$USER_CONFIG" ]; then + grep -Ev '^export GITAI_PROVIDER=|^export GITAI_(AVALAPIS|OPENROUTER|CLOUDFLARE|GOOGLE)_API_KEY=|^export GITAI_CLOUDFLARE_ACCOUNT_ID=' "$USER_CONFIG" > "$tmp" || true + fi + { + cat "$tmp" + printf 'export GITAI_PROVIDER=%q\n' "$provider" + printf 'export %s=%q\n' "$key_var" "$key" + if [ "$provider" = "cloudflare" ] && [ -n "$account_id" ]; then + printf 'export GITAI_CLOUDFLARE_ACCOUNT_ID=%q\n' "$account_id" + fi + } > "$USER_CONFIG" + rm -f "$tmp" + chmod 600 "$USER_CONFIG" +} + +setup_credentials() { + is_interactive || fail "No API key found. Configure $USER_CONFIG or $LEGACY_HOME_CONFIG, or run gitai --setup interactively." + ui_header "GitAI setup" + ui_note "Credentials will be stored in $USER_CONFIG with mode 600." + + local selection provider key account_id="" + selection="$(ui_choose "Choose your AI provider" \ + "Google AI Studio (Gemini)" \ + "OpenRouter" \ + "AvalAI / AvalAPIs" \ + "Cloudflare Workers AI")" + case "$selection" in + "Google AI Studio (Gemini)") provider="google" ;; + "OpenRouter") provider="openrouter" ;; + "AvalAI / AvalAPIs") provider="avalapis" ;; + "Cloudflare Workers AI") provider="cloudflare" ;; + *) fail "Setup canceled." ;; + esac + + key="$(ui_secret "API key")" + [ -n "$key" ] || fail "API key cannot be empty." + if [ "$provider" = "cloudflare" ]; then + account_id="$(ui_input "Cloudflare account ID")" + [ -n "$account_id" ] || fail "Cloudflare account ID cannot be empty." + fi + write_user_config "$provider" "$key" "$account_id" + export GITAI_PROVIDER="$provider" + PROVIDER_CHAIN_RAW="$provider" + case "$provider" in + avalapis) export GITAI_AVALAPIS_API_KEY="$key" ;; + openrouter) export GITAI_OPENROUTER_API_KEY="$key" ;; + cloudflare) export GITAI_CLOUDFLARE_API_KEY="$key"; export GITAI_CLOUDFLARE_ACCOUNT_ID="$account_id" ;; + google) export GITAI_GOOGLE_API_KEY="$key" ;; + esac + DEFAULT_API_KEY="${GITAI_API_KEY:-}" + ui_success "Configuration saved to $USER_CONFIG" +} + +build_chat_prompt() { cat <(): @@ -145,161 +183,87 @@ Description: Rules: - Allowed types: feat, fix, docs, chore, refactor, test, build, ci, style -- Scope is optional; use a concise feature/folder scope when clear +- Scope is optional - Keep the title under 72 characters - Do not use Markdown fences - Keep the description concise but informative Git diff: -$diff_content +$1 PROMPT } -resolve_provider_api_key() { - local provider="$1" - - case "$provider" in - avalapis) printf '%s' "${GITAI_AVALAPIS_API_KEY:-$DEFAULT_API_KEY}" ;; - openrouter) printf '%s' "${GITAI_OPENROUTER_API_KEY:-$DEFAULT_API_KEY}" ;; - cloudflare) printf '%s' "${GITAI_CLOUDFLARE_API_KEY:-$DEFAULT_API_KEY}" ;; - google) printf '%s' "${GITAI_GOOGLE_API_KEY:-$DEFAULT_API_KEY}" ;; - *) printf '' ;; - esac -} - set_provider_defaults() { - local provider="$1" - - case "$provider" in + case "$1" in avalapis) ATTEMPT_API_BASE="${GITAI_API_BASE:-https://api.avalapis.ir/v1/chat/completions}" - ATTEMPT_MODEL="${GITAI_AVALAPIS_MODEL:-${GITAI_MODEL:-gpt-4.i-nano}}" - ;; + ATTEMPT_MODEL="${GITAI_AVALAPIS_MODEL:-${GITAI_MODEL:-gpt-4.i-nano}}" ;; openrouter) ATTEMPT_API_BASE="${GITAI_API_BASE:-https://openrouter.ai/api/v1/chat/completions}" - ATTEMPT_MODEL="${GITAI_OPENROUTER_MODEL:-${GITAI_MODEL:-openai/gpt-4o-mini}}" - ;; + ATTEMPT_MODEL="${GITAI_OPENROUTER_MODEL:-${GITAI_MODEL:-openai/gpt-4o-mini}}" ;; cloudflare) ATTEMPT_API_BASE="${GITAI_API_BASE:-https://api.cloudflare.com/client/v4/accounts/${GITAI_CLOUDFLARE_ACCOUNT_ID:-}/ai/v1/chat/completions}" - ATTEMPT_MODEL="${GITAI_CLOUDFLARE_MODEL:-${GITAI_MODEL:-@cf/meta/llama-3.3-70b-instruct-fp8-fast}}" - ;; + ATTEMPT_MODEL="${GITAI_CLOUDFLARE_MODEL:-${GITAI_MODEL:-@cf/meta/llama-3.3-70b-instruct-fp8-fast}}" ;; google) ATTEMPT_MODEL="${GITAI_GOOGLE_MODEL:-${GITAI_MODEL:-gemini-2.0-flash}}" - ATTEMPT_API_BASE="${GITAI_API_BASE:-https://generativelanguage.googleapis.com/v1beta/models/${ATTEMPT_MODEL}:generateContent}" - ;; - *) - LAST_ERROR="Unsupported provider '$provider'. Supported: avalapis, openrouter, cloudflare, google" - return 1 - ;; + ATTEMPT_API_BASE="${GITAI_API_BASE:-https://generativelanguage.googleapis.com/v1beta/models/${ATTEMPT_MODEL}:generateContent}" ;; + *) LAST_ERROR="Unsupported provider '$1'."; return 1 ;; esac } call_chat_completions_api() { - local provider="$1" - local prompt="$2" - local api_key="$3" - local response - - JSON_PAYLOAD=$(jq -n \ - --arg model "$ATTEMPT_MODEL" \ - --arg user "$prompt" \ - '{model: $model, messages: [{role: "user", content: $user}], temperature: 0.4}') - - local -a headers - headers=(-H "Content-Type: application/json" -H "Authorization: Bearer $api_key") - - if [ "$provider" = "openrouter" ]; then - headers+=(-H "HTTP-Referer: https://github.com/jaavid/gitai" -H "X-Title: GitAI") - fi - - if ! response=$(curl -sfL "$ATTEMPT_API_BASE" "${headers[@]}" -d "$JSON_PAYLOAD" 2>&1); then - LAST_ERROR="API request failed: $response" - return 1 - fi - - COMMIT_MESSAGE=$(printf '%s' "$response" | jq -r '.choices[0].message.content // empty') - if [ -z "$COMMIT_MESSAGE" ]; then - LAST_ERROR="Empty/invalid response from provider '$provider'" - return 1 - fi + local provider="$1" prompt="$2" api_key="$3" response + JSON_PAYLOAD="$(jq -n --arg model "$ATTEMPT_MODEL" --arg user "$prompt" '{model:$model,messages:[{role:"user",content:$user}],temperature:0.4}')" + local headers=(-H "Content-Type: application/json" -H "Authorization: Bearer $api_key") + [ "$provider" = "openrouter" ] && headers+=(-H "HTTP-Referer: https://github.com/jaavid/gitai" -H "X-Title: GitAI") + if ! response="$(curl -sfL "$ATTEMPT_API_BASE" "${headers[@]}" -d "$JSON_PAYLOAD" 2>&1)"; then LAST_ERROR="API request failed: $response"; return 1; fi + COMMIT_MESSAGE="$(printf '%s' "$response" | jq -r '.choices[0].message.content // empty')" + [ -n "$COMMIT_MESSAGE" ] || { LAST_ERROR="Empty/invalid response from '$provider'"; return 1; } } call_google_api() { - local prompt="$1" - local api_key="$2" - local response - - JSON_PAYLOAD=$(jq -n \ - --arg user "$prompt" \ - '{contents: [{role: "user", parts: [{text: $user}]}], generationConfig: {temperature: 0.4}}') - - if ! response=$(curl -sfL "$ATTEMPT_API_BASE?key=$api_key" \ - -H "Content-Type: application/json" \ - -d "$JSON_PAYLOAD" 2>&1); then - LAST_ERROR="API request failed: $response" - return 1 - fi - - COMMIT_MESSAGE=$(printf '%s' "$response" | jq -r '.candidates[0].content.parts[0].text // empty') - if [ -z "$COMMIT_MESSAGE" ]; then - LAST_ERROR="Empty/invalid response from provider 'google'" - return 1 - fi + local prompt="$1" api_key="$2" response + JSON_PAYLOAD="$(jq -n --arg user "$prompt" '{contents:[{role:"user",parts:[{text:$user}]}],generationConfig:{temperature:0.4}}')" + if ! response="$(curl -sfL "$ATTEMPT_API_BASE?key=$api_key" -H "Content-Type: application/json" -d "$JSON_PAYLOAD" 2>&1)"; then LAST_ERROR="API request failed: $response"; return 1; fi + COMMIT_MESSAGE="$(printf '%s' "$response" | jq -r '.candidates[0].content.parts[0].text // empty')" + [ -n "$COMMIT_MESSAGE" ] || { LAST_ERROR="Empty/invalid response from 'google'"; return 1; } } try_provider() { - local provider="$1" - local prompt="$2" - local provider_key - + local provider="$1" prompt="$2" provider_key set_provider_defaults "$provider" || return 1 provider_key="$(resolve_provider_api_key "$provider")" - - if [ -z "$provider_key" ]; then - LAST_ERROR="Missing API key for provider '$provider'." - return 1 - fi - - if [ "$provider" = "cloudflare" ] && [ -z "${GITAI_CLOUDFLARE_ACCOUNT_ID:-}" ] && [ -z "${GITAI_API_BASE:-}" ]; then - LAST_ERROR="Missing GITAI_CLOUDFLARE_ACCOUNT_ID for provider 'cloudflare'." - return 1 - fi - - if [ "$provider" = "google" ]; then - call_google_api "$prompt" "$provider_key" - else - call_chat_completions_api "$provider" "$prompt" "$provider_key" - fi + [ -n "$provider_key" ] || { LAST_ERROR="Missing API key for '$provider'."; return 1; } + if [ "$provider" = "cloudflare" ] && [ -z "${GITAI_CLOUDFLARE_ACCOUNT_ID:-}" ] && [ -z "${GITAI_API_BASE:-}" ]; then LAST_ERROR="Missing GITAI_CLOUDFLARE_ACCOUNT_ID."; return 1; fi + if [ "$provider" = "google" ]; then call_google_api "$prompt" "$provider_key"; else call_chat_completions_api "$provider" "$prompt" "$provider_key"; fi } show_help() { cat <<'HELP' GitAI — AI-assisted Conventional Commits -Usage: - gitai [options] +Usage: gitai [options] Options: - --push Push after committing (asks for remote if needed) - --push-all Push the new commit to every configured remote - --no-push Never ask to push - --no-tui Disable Gum UI and use plain terminal prompts + --setup Configure provider and API key + --push Push after committing; choose remote if needed + --push-all Push to every configured remote + --no-push Do not offer to push + --no-tui Use plain terminal prompts -v, --version Show version - -h, --help Show this help + -h, --help Show help -Configuration: - ~/.config/gitai/config User-wide config - /.gitai Repository-specific config - -Environment: - GITAI_PROVIDER Provider or fallback chain - GITAI_TUI=0 Disable terminal UI - GITAI_MAX_DIFF_LENGTH Maximum staged diff characters sent to AI +Config precedence: + .gitai next to executable + ~/.bin/gitai/.gitai + ~/.config/gitai/config (or $XDG_CONFIG_HOME/gitai/config) + /.gitai HELP } while [ "$#" -gt 0 ]; do case "$1" in + --setup) FORCE_SETUP=1 ;; --push) AUTO_PUSH_MODE="ask-remote" ;; --push-all) AUTO_PUSH_MODE="all" ;; --no-push) AUTO_PUSH_MODE="none" ;; @@ -311,14 +275,12 @@ while [ "$#" -gt 0 ]; do shift done +if [ "$FORCE_SETUP" -eq 1 ]; then setup_credentials; exit 0; fi +configured_provider_has_key || setup_credentials + ui_header "GitAI · $(basename "$GIT_ROOT")" CURRENT_BRANCH="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)" -if [ -n "$CURRENT_BRANCH" ]; then - ui_note "Branch: $CURRENT_BRANCH" -else - ui_warn "Detached HEAD: committing is allowed, pushing is disabled." -fi - +[ -n "$CURRENT_BRANCH" ] && ui_note "Branch: $CURRENT_BRANCH" || ui_warn "Detached HEAD: pushing is disabled." git status --short if [ -n "$(git status --porcelain)" ]; then @@ -329,18 +291,11 @@ if [ -n "$(git status --porcelain)" ]; then fi if [ -n "$(git status --porcelain)" ] && [ -z "$(git diff --cached --name-only)" ]; then - STAGE_ACTION=$(ui_choose "No staged changes. What should GitAI do?" \ - "Stage all changes" \ - "Cancel") - case "$STAGE_ACTION" in - "Stage all changes") git add -A; ui_success "All changes staged" ;; - *) exit 0 ;; - esac + STAGE_ACTION="$(ui_choose "No staged changes." "Stage all changes" "Cancel")" + [ "$STAGE_ACTION" = "Stage all changes" ] || exit 0 + git add -A; ui_success "All changes staged" elif [ -n "$(git diff --name-only)$(git ls-files --others --exclude-standard)" ]; then - STAGE_ACTION=$(ui_choose "There are changes outside the current index." \ - "Keep current staged changes" \ - "Stage all changes" \ - "Cancel") + STAGE_ACTION="$(ui_choose "There are changes outside the current index." "Keep current staged changes" "Stage all changes" "Cancel")" case "$STAGE_ACTION" in "Stage all changes") git add -A; ui_success "All changes staged" ;; "Cancel") exit 0 ;; @@ -349,108 +304,56 @@ fi GIT_DIFF="$(git diff --cached --no-ext-diff)" [ -n "$GIT_DIFF" ] || fail "No staged changes found." - -DIFF_LENGTH=${#GIT_DIFF} TRIMMED_DIFF="$GIT_DIFF" -if [ "$DIFF_LENGTH" -gt "$MAX_DIFF_LENGTH" ]; then - ui_warn "Diff is $DIFF_LENGTH characters; sending the first $MAX_DIFF_LENGTH characters." +if [ "${#GIT_DIFF}" -gt "$MAX_DIFF_LENGTH" ]; then + ui_warn "Diff is ${#GIT_DIFF} characters; sending the first $MAX_DIFF_LENGTH." TRIMMED_DIFF="${GIT_DIFF:0:$MAX_DIFF_LENGTH}" fi PROMPT="$(build_chat_prompt "$TRIMMED_DIFF")" IFS=',' read -r -a PROVIDER_CHAIN <<< "$PROVIDER_CHAIN_RAW" -COMMIT_MESSAGE="" -LAST_ERROR="" -SUCCESS_PROVIDER="" - +COMMIT_MESSAGE=""; LAST_ERROR=""; SUCCESS_PROVIDER="" for raw_provider in "${PROVIDER_CHAIN[@]}"; do - provider="$(trim_value "$raw_provider")" - [ -n "$provider" ] || continue - + provider="$(trim_value "$raw_provider")"; [ -n "$provider" ] || continue ui_note "Trying provider: $provider" - if try_provider "$provider" "$PROMPT"; then - SUCCESS_PROVIDER="$provider" - break - fi + if try_provider "$provider" "$PROMPT"; then SUCCESS_PROVIDER="$provider"; break; fi ui_warn "Provider '$provider' failed: $LAST_ERROR" done - [ -n "$SUCCESS_PROVIDER" ] || fail "All configured providers failed. Last error: $LAST_ERROR" ui_success "Generated with $SUCCESS_PROVIDER" TITLE="$(printf '%s\n' "$COMMIT_MESSAGE" | sed -n 's/^[Tt]itle:[[:space:]]*//p' | head -n 1)" DESCRIPTION="$(printf '%s\n' "$COMMIT_MESSAGE" | sed -n 's/^[Dd]escription:[[:space:]]*//p' | head -n 1)" - [ -n "$TITLE" ] || fail "Provider response did not contain a Title line." [ -n "$DESCRIPTION" ] || DESCRIPTION="AI-generated commit from staged changes." - ui_header "Suggested commit" printf '%s\n\n%s\n' "$TITLE" "$DESCRIPTION" - -if ! ui_confirm "Create this commit?"; then - ui_note "Commit canceled." - exit 0 -fi - +ui_confirm "Create this commit?" || { ui_note "Commit canceled."; exit 0; } git commit -m "$TITLE" -m "$DESCRIPTION" ui_success "Commit created" -if [ -z "$CURRENT_BRANCH" ]; then - exit 0 -fi - +[ -n "$CURRENT_BRANCH" ] || exit 0 REMOTE_NAMES=() -while IFS= read -r remote; do - [ -n "$remote" ] && REMOTE_NAMES+=("$remote") -done < <(git remote) - -if [ "${#REMOTE_NAMES[@]}" -eq 0 ]; then - ui_note "No Git remotes configured; push skipped." - exit 0 -fi +while IFS= read -r remote; do [ -n "$remote" ] && REMOTE_NAMES+=("$remote"); done < <(git remote) +[ "${#REMOTE_NAMES[@]}" -gt 0 ] || { ui_note "No Git remotes configured; push skipped."; exit 0; } -push_remote() { - local remote="$1" - ui_note "Pushing $CURRENT_BRANCH → $remote" - git push "$remote" "$CURRENT_BRANCH" - ui_success "Pushed to $remote" -} - -push_all_remotes() { - local remote - for remote in "${REMOTE_NAMES[@]}"; do - push_remote "$remote" - done -} - -case "$AUTO_PUSH_MODE" in - none) exit 0 ;; - all) push_all_remotes; exit 0 ;; -esac +push_remote() { ui_note "Pushing $CURRENT_BRANCH → $1"; git push "$1" "$CURRENT_BRANCH"; ui_success "Pushed to $1"; } +push_all_remotes() { local remote; for remote in "${REMOTE_NAMES[@]}"; do push_remote "$remote"; done; } +case "$AUTO_PUSH_MODE" in none) exit 0 ;; all) push_all_remotes; exit 0 ;; esac if [ "${#REMOTE_NAMES[@]}" -eq 1 ]; then - if [ "$AUTO_PUSH_MODE" = "ask-remote" ] || ui_confirm "Push to ${REMOTE_NAMES[0]}?"; then - push_remote "${REMOTE_NAMES[0]}" - fi + if [ "$AUTO_PUSH_MODE" = "ask-remote" ] || ui_confirm "Push to ${REMOTE_NAMES[0]}?"; then push_remote "${REMOTE_NAMES[0]}"; fi exit 0 fi PUSH_OPTIONS=("Skip push" "All remotes") for remote in "${REMOTE_NAMES[@]}"; do url="$(git remote get-url --push "$remote" 2>/dev/null || git remote get-url "$remote" 2>/dev/null || true)" - if [ -n "$url" ]; then - PUSH_OPTIONS+=("$remote · $url") - else - PUSH_OPTIONS+=("$remote") - fi + [ -n "$url" ] && PUSH_OPTIONS+=("$remote · $url") || PUSH_OPTIONS+=("$remote") done - PUSH_SELECTION="$(ui_choose "Where should this commit be pushed?" "${PUSH_OPTIONS[@]}")" case "$PUSH_SELECTION" in "Skip push") exit 0 ;; "All remotes") push_all_remotes ;; - *) - SELECTED_REMOTE="${PUSH_SELECTION%% · *}" - push_remote "$SELECTED_REMOTE" - ;; + *) push_remote "${PUSH_SELECTION%% · *}" ;; esac