diff --git a/.gitignore b/.gitignore index ea8c4bf7..82466c0e 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +lumen.config.json diff --git a/Cargo.lock b/Cargo.lock index ee4072da..702dbf63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,15 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + [[package]] name = "anstream" version = "0.6.17" @@ -566,6 +575,8 @@ dependencies = [ "clap", "dirs", "indoc", + "lazy_static", + "regex", "reqwest", "serde", "serde_json", @@ -780,6 +791,35 @@ dependencies = [ "thiserror 2.0.12", ] +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + [[package]] name = "reqwest" version = "0.11.27" diff --git a/Cargo.toml b/Cargo.toml index 305d1ca2..11e6f89a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,8 @@ spinoff = { version = "0.8.0", features = ["dots"] } thiserror = "1.0" indoc = "2.0.5" dirs = "6.0.0" +regex = "1.11.1" +lazy_static = "1.5.0" [profile.release] lto = true diff --git a/README.md b/README.md index 09c6bc65..9d515ee3 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,11 @@ A command-line tool that uses AI to streamline your git workflow - from generati ![demo](https://github.com/user-attachments/assets/0d029bdb-3b11-4b5c-bed6-f5a91d8529f2) ## GitAds Sponsored + [![Sponsored by GitAds](https://gitads.dev/v1/ad-serve?source=jnsahaj/lumen@github)](https://gitads.dev/v1/ad-track?source=jnsahaj/lumen@github) ## Table of Contents + - [Features](#features-) - [Getting Started](#getting-started-) - [Prerequisites](#prerequisites) @@ -41,7 +43,9 @@ A command-line tool that uses AI to streamline your git workflow - from generati ## Getting Started 🔅 ### Prerequisites + Before you begin, ensure you have: + 1. `git` installed on your system 2. [fzf](https://github.com/junegunn/fzf) (optional) - Required for `lumen list` command 3. [mdcat](https://github.com/swsnr/mdcat) (optional) - Required for pretty output formatting @@ -49,15 +53,17 @@ Before you begin, ensure you have: ### Installation #### Using Homebrew (MacOS and Linux) + ```bash brew install jnsahaj/lumen/lumen ``` #### Using Cargo -> [!IMPORTANT] -> `cargo` is a package manager for `rust`, + +> [!IMPORTANT] > `cargo` is a package manager for `rust`, > and is installed automatically when you install `rust`. > See [installation guide](https://doc.rust-lang.org/cargo/getting-started/installation.html) + ```bash cargo install lumen ``` @@ -78,7 +84,6 @@ lumen draft --context "match brand guidelines" # Output: "feat(button.tsx): Update button color to align with brand identity guidelines" ``` - ### Generate Git Commands Ask Lumen to generate Git commands based on a natural language query: @@ -110,6 +115,7 @@ lumen explain HEAD --query "What are the potential side effects?" ``` ### Interactive Mode + ```bash # Launch interactive fuzzy finder to search through commits (requires: fzf) lumen list @@ -126,29 +132,30 @@ lumen draft | xclip -selection c # Linux lumen draft | tee >(pbcopy) # Open in your favorite editor -lumen draft | code - +lumen draft | code - # Directly commit using the generated message -lumen draft | git commit -F - +lumen draft | git commit -F - ``` If you are using [lazygit](https://github.com/jesseduffield/lazygit), you can add this to the [user config](https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md) + ```yml customCommands: - - key: '' - context: 'files' - command: 'lumen draft | tee >(pbcopy)' - loadingText: 'Generating message...' + - key: "" + context: "files" + command: "lumen draft | tee >(pbcopy)" + loadingText: "Generating message..." showOutput: true - - key: '' - context: 'files' - command: 'lumen draft -c {{.Form.Context | quote}} | tee >(pbcopy)' - loadingText: 'Generating message...' + - key: "" + context: "files" + command: "lumen draft -c {{.Form.Context | quote}} | tee >(pbcopy)" + loadingText: "Generating message..." showOutput: true prompts: - - type: 'input' - title: 'Context' - key: 'Context' + - type: "input" + title: "Context" + key: "Context" ``` ## AI Providers 🔅 @@ -167,26 +174,27 @@ export LUMEN_AI_MODEL="gpt-4o" ### Supported Providers -| Provider | API Key Required | Models | -|----------|-----------------|---------| -| [Phind](https://www.phind.com/agent) `phind` (Default) | No | `Phind-70B` | -| [Groq](https://groq.com/) `groq` | Yes (free) | `llama2-70b-4096`, `mixtral-8x7b-32768` (default: `mixtral-8x7b-32768`) | -| [OpenAI](https://platform.openai.com/docs/guides/text-generation/chat-completions-api) `openai` | Yes | `gpt-4o`, `gpt-4o-mini`, `gpt-4`, `gpt-3.5-turbo` (default: `gpt-4o-mini`) | -| [Claude](https://claude.ai/new) `claude` | Yes | [see list](https://docs.anthropic.com/en/docs/about-claude/models#model-names) (default: `claude-3-5-sonnet-20241022`) | -| [Ollama](https://github.com/ollama/ollama) `ollama` | No (local) | [see list](https://github.com/ollama/ollama/blob/main/docs/api.md#model-names) (required) | -| [OpenRouter](https://openrouter.ai/) `openrouter` | Yes | [see list](https://openrouter.ai/models) (default: `anthropic/claude-3.5-sonnet`) | -| [DeepSeek](https://www.deepseek.com/) `deepseek` | Yes | `deepseek-chat`, `deepseek-reasoner` (default: `deepseek-reasoner`) | +| Provider | API Key Required | Models | +| ----------------------------------------------------------------------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------- | +| [Phind](https://www.phind.com/agent) `phind` (Default) | No | `Phind-70B` | +| [Groq](https://groq.com/) `groq` | Yes (free) | `llama2-70b-4096`, `mixtral-8x7b-32768` (default: `mixtral-8x7b-32768`) | +| [OpenAI](https://platform.openai.com/docs/guides/text-generation/chat-completions-api) `openai` | Yes | `gpt-4o`, `gpt-4o-mini`, `gpt-4`, `gpt-3.5-turbo` (default: `gpt-4o-mini`) | +| [Claude](https://claude.ai/new) `claude` | Yes | [see list](https://docs.anthropic.com/en/docs/about-claude/models#model-names) (default: `claude-3-5-sonnet-20241022`) | +| [Ollama](https://github.com/ollama/ollama) `ollama` | No (local) | [see list](https://github.com/ollama/ollama/blob/main/docs/api.md#model-names) (required) | +| [OpenRouter](https://openrouter.ai/) `openrouter` | Yes | [see list](https://openrouter.ai/models) (default: `anthropic/claude-3.5-sonnet`) | +| [DeepSeek](https://www.deepseek.com/) `deepseek` | Yes | `deepseek-chat`, `deepseek-reasoner` (default: `deepseek-reasoner`) | ## Advanced Configuration 🔅 ### Configuration File + Lumen supports configuration through a JSON file. You can place the configuration file in one of the following locations: 1. Project Root: Create a lumen.config.json file in your project's root directory. 2. Custom Path: Specify a custom path using the --config CLI option. 3. Global Configuration (Optional): Place a lumen.config.json file in your system's default configuration directory: - - Linux/macOS: `~/.config/lumen/lumen.config.json` - - Windows: `%USERPROFILE%\.config\lumen\lumen.config.json` + - Linux/macOS: `~/.config/lumen/lumen.config.json` + - Windows: `%USERPROFILE%\.config\lumen\lumen.config.json` Lumen will load configurations in the following order of priority: @@ -213,19 +221,23 @@ Lumen will load configurations in the following order of priority: "revert": "Reverts a previous commit", "feat": "A new feature", "fix": "A bug fix" - } + }, + "trim_thinking_tags": false // Set to true to automatically remove ... blocks from AI output. } } ``` ### Configuration Precedence + Options are applied in the following order (highest to lowest priority): + 1. CLI Flags 2. Configuration File 3. Environment Variables 4. Default options Example: Using different providers for different projects: + ```bash # Set global defaults in .zshrc/.bashrc export LUMEN_AI_PROVIDER="openai" @@ -241,4 +253,5 @@ export LUMEN_API_KEY="sk-xxxxxxxxxxxxxxxxxxxxxxxx" # Or override using CLI flags lumen -p "ollama" -m "llama3.2" draft ``` + diff --git a/src/ai_prompt.rs b/src/ai_prompt.rs index f672c108..4a7a73a0 100644 --- a/src/ai_prompt.rs +++ b/src/ai_prompt.rs @@ -4,6 +4,7 @@ use crate::{ }; use indoc::{formatdoc, indoc}; use thiserror::Error; +use serde_json; #[derive(Error, Debug)] #[error("{0}")] @@ -65,7 +66,7 @@ impl AIPrompt { None => match &command.git_entity { GitEntity::Commit(_) => formatdoc! {" {base_content} - + Provide a short explanation covering: 1. Core changes made 2. Direct impact @@ -73,7 +74,7 @@ impl AIPrompt { }, GitEntity::Diff(Diff::WorkingTree { .. }) => formatdoc! {" {base_content} - + Provide: 1. Key changes 2. Notable concerns (if any) @@ -81,7 +82,7 @@ impl AIPrompt { }, GitEntity::Diff(Diff::CommitsRange { .. }) => formatdoc! {" {base_content} - + Provide: 1. Core changes made 2. Direct impact @@ -122,6 +123,9 @@ impl AIPrompt { "".to_string() }; + let commit_types_json = serde_json::to_string(&command.draft_config.commit_types) + .map_err(|e| AIPromptError(format!("Failed to serialize commit types: {}", e)))?; + let user_prompt = String::from(formatdoc! {" Generate a concise git commit message written in present tense for the following code diff with the given specifications below: @@ -139,7 +143,8 @@ impl AIPrompt { {diff} ``` ", - commit_types = command.draft_config.commit_types, + commit_types = commit_types_json, + diff = diff, }); Ok(AIPrompt { @@ -156,7 +161,7 @@ impl AIPrompt { "}); let user_prompt = formatdoc! {" Generate Git command for: {query} - + Git command Brief explanation Required for destructive commands only - omit for safe commands diff --git a/src/command/draft.rs b/src/command/draft.rs index d2046b78..93c2ccbc 100644 --- a/src/command/draft.rs +++ b/src/command/draft.rs @@ -1,6 +1,8 @@ use std::io::Write; use async_trait::async_trait; +use regex::Regex; +use lazy_static::lazy_static; use crate::{ config::configuration::DraftConfig, error::LumenError, git_entity::GitEntity, @@ -9,6 +11,10 @@ use crate::{ use super::Command; +lazy_static! { + static ref THINK_TAG_REGEX: Regex = Regex::new(r"(?s).*?\s*").unwrap(); +} + pub struct DraftCommand { pub git_entity: GitEntity, pub context: Option, @@ -18,8 +24,11 @@ pub struct DraftCommand { #[async_trait] impl Command for DraftCommand { async fn execute(&self, provider: &LumenProvider) -> Result<(), LumenError> { - let result = provider.draft(self).await?; - + let mut result = provider.draft(self).await?; + if self.draft_config.trim_thinking_tags { + result = THINK_TAG_REGEX.replace_all(&result, "").to_string(); + result = result.trim().to_string(); + } print!("{result}"); std::io::stdout().flush()?; Ok(()) diff --git a/src/config/configuration.rs b/src/config/configuration.rs index 39af8d3e..0ecacbdd 100644 --- a/src/config/configuration.rs +++ b/src/config/configuration.rs @@ -32,9 +32,10 @@ pub struct LumenConfig { pub struct DraftConfig { #[serde( default = "default_commit_types", - deserialize_with = "deserialize_commit_types" )] - pub commit_types: String, + pub commit_types: HashMap, + #[serde(default)] + pub trim_thinking_tags: bool, } fn default_ai_provider() -> ProviderType { @@ -52,23 +53,20 @@ where s.parse().map_err(serde::de::Error::custom) } -fn default_commit_types() -> String { - indoc! {r#" - { - "docs": "Documentation only changes", - "style": "Changes that do not affect the meaning of the code", - "refactor": "A code change that neither fixes a bug nor adds a feature", - "perf": "A code change that improves performance", - "test": "Adding missing tests or correcting existing tests", - "build": "Changes that affect the build system or external dependencies", - "ci": "Changes to our CI configuration files and scripts", - "chore": "Other changes that don't modify src or test files", - "revert": "Reverts a previous commit", - "feat": "A new feature", - "fix": "A bug fix" - } - "#} - .to_string() +fn default_commit_types() -> HashMap { + let mut map = HashMap::new(); + map.insert("docs".to_string(), "Documentation only changes".to_string()); + map.insert("style".to_string(), "Changes that do not affect the meaning of the code".to_string()); + map.insert("refactor".to_string(), "A code change that neither fixes a bug nor adds a feature".to_string()); + map.insert("perf".to_string(), "A code change that improves performance".to_string()); + map.insert("test".to_string(), "Adding missing tests or correcting existing tests".to_string()); + map.insert("build".to_string(), "Changes that affect the build system or external dependencies".to_string()); + map.insert("ci".to_string(), "Changes to our CI configuration files and scripts".to_string()); + map.insert("chore".to_string(), "Other changes that don't modify src or test files".to_string()); + map.insert("revert".to_string(), "Reverts a previous commit".to_string()); + map.insert("feat".to_string(), "A new feature".to_string()); + map.insert("fix".to_string(), "A bug fix".to_string()); + map } fn default_model() -> Option { @@ -79,17 +77,18 @@ fn default_api_key() -> Option { std::env::var("LUMEN_API_KEY").ok() } -fn deserialize_commit_types<'de, D>(deserializer: D) -> Result -where - D: Deserializer<'de>, -{ - let commit_types_map: HashMap = HashMap::deserialize(deserializer)?; - serde_json::to_string(&commit_types_map).map_err(serde::de::Error::custom) -} +// fn deserialize_commit_types<'de, D>(deserializer: D) -> Result +// where +// D: Deserializer<'de>, +// { +// let commit_types_map: HashMap = HashMap::deserialize(deserializer)?; +// serde_json::to_string(&commit_types_map).map_err(serde::de::Error::custom) +// } fn default_draft_config() -> DraftConfig { DraftConfig { commit_types: default_commit_types(), + trim_thinking_tags: false, } } diff --git a/src/main.rs b/src/main.rs index 00fb1d61..4ee32a0c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,7 +31,6 @@ async fn run() -> Result<(), LumenError> { Ok(config) => config, Err(e) => return Err(e), }; - let provider = provider::LumenProvider::new(client, config.provider, config.api_key, config.model)?; let command = command::LumenCommand::new(provider);