diff --git a/crates/forge_main/src/cli.rs b/crates/forge_main/src/cli.rs index 45af4cd6c4..a2f7b37160 100644 --- a/crates/forge_main/src/cli.rs +++ b/crates/forge_main/src/cli.rs @@ -1,7 +1,6 @@ use std::path::PathBuf; use clap::{Parser, Subcommand, ValueEnum}; -use forge_domain::ConversationId; #[derive(Parser)] #[command(version = env!("CARGO_PKG_VERSION"))] @@ -60,19 +59,12 @@ pub struct Cli { /// Generate a new conversation ID and exit. /// /// When enabled, generates a new unique conversation ID and prints it to - /// stdout. This ID can be used with --resume --conversation-id to - /// manage multiple terminal sessions with separate conversation + /// stdout. This ID can be used with the FORGE_CONVERSATION_ID environment + /// variable to manage multiple terminal sessions with separate conversation /// contexts. #[arg(long, default_value_t = false)] pub generate_conversation_id: bool, - /// Resume an existing conversation by providing its conversation ID. - /// - /// The conversation ID must be a valid UUID format. You can generate a new - /// conversation ID using --generate-conversation-id. - #[arg(long, value_parser = parse_conversation_id)] - pub resume: Option, - /// Top-level subcommands #[command(subcommand)] pub subcommands: Option, @@ -89,18 +81,6 @@ pub struct Cli { /// The worktree name will be used as the branch name. #[arg(long)] pub sandbox: Option, - /// Specify an agent to execute tasks with. - /// - /// Allows selecting a specific agent to handle the conversation and task - /// execution. Agents provide specialized capabilities and knowledge - /// domains. Example: --agent sage, --agent planner, --agent - /// "code-reviewer" - #[arg(long, short = 'a')] - pub agent: Option, -} - -fn parse_conversation_id(s: &str) -> std::result::Result { - ConversationId::parse(s).map_err(|e| e.to_string()) } impl Cli { diff --git a/crates/forge_main/src/env.rs b/crates/forge_main/src/env.rs new file mode 100644 index 0000000000..bbda7b55c4 --- /dev/null +++ b/crates/forge_main/src/env.rs @@ -0,0 +1,96 @@ +use forge_api::ConversationId; + +// Environment variable names +pub const FORGE_CONVERSATION_ID: &str = "FORGE_CONVERSATION_ID"; +pub const FORGE_ACTIVE_AGENT: &str = "FORGE_ACTIVE_AGENT"; + +/// Get conversation ID from FORGE_CONVERSATION_ID environment variable +pub fn get_conversation_id_from_env() -> Option { + std::env::var(FORGE_CONVERSATION_ID) + .ok() + .and_then(|env_id| forge_domain::ConversationId::parse(&env_id).ok()) +} + +/// Get agent ID from FORGE_ACTIVE_AGENT environment variable +pub fn get_agent_from_env() -> Option { + std::env::var(FORGE_ACTIVE_AGENT).ok() +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn test_get_conversation_id_from_env_with_valid_id() { + let fixture_env_value = "01234567-89ab-cdef-0123-456789abcdef"; + unsafe { + std::env::set_var(FORGE_CONVERSATION_ID, fixture_env_value); + } + + let actual = get_conversation_id_from_env(); + let expected = forge_domain::ConversationId::parse(fixture_env_value).ok(); + + assert_eq!(actual, expected); + unsafe { + std::env::remove_var(FORGE_CONVERSATION_ID); + } + } + + #[test] + fn test_get_conversation_id_from_env_with_invalid_id() { + let fixture_env_value = "invalid-uuid"; + unsafe { + std::env::set_var(FORGE_CONVERSATION_ID, fixture_env_value); + } + + let actual = get_conversation_id_from_env(); + let expected = None; + + assert_eq!(actual, expected); + unsafe { + std::env::remove_var(FORGE_CONVERSATION_ID); + } + } + + #[test] + fn test_get_conversation_id_from_env_not_set() { + unsafe { + std::env::remove_var(FORGE_CONVERSATION_ID); + } + + let actual = get_conversation_id_from_env(); + let expected = None; + + assert_eq!(actual, expected); + } + + #[test] + fn test_get_agent_from_env_with_value() { + let fixture_env_value = "sage"; + unsafe { + std::env::set_var(FORGE_ACTIVE_AGENT, fixture_env_value); + } + + let actual = get_agent_from_env(); + let expected = Some("sage".to_string()); + + assert_eq!(actual, expected); + unsafe { + std::env::remove_var(FORGE_ACTIVE_AGENT); + } + } + + #[test] + fn test_get_agent_from_env_not_set() { + unsafe { + std::env::remove_var(FORGE_ACTIVE_AGENT); + } + + let actual = get_agent_from_env(); + let expected = None; + + assert_eq!(actual, expected); + } +} diff --git a/crates/forge_main/src/lib.rs b/crates/forge_main/src/lib.rs index 802f56405c..c907685481 100644 --- a/crates/forge_main/src/lib.rs +++ b/crates/forge_main/src/lib.rs @@ -3,6 +3,7 @@ mod cli; mod completer; mod conversation_selector; mod editor; +mod env; mod info; mod input; mod model; diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index f64d45734f..cbb59af60a 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -22,6 +22,7 @@ use tokio_stream::StreamExt; use crate::cli::{Cli, McpCommand, TopLevelCommand, Transport}; use crate::conversation_selector::ConversationSelector; +use crate::env::{get_agent_from_env, get_conversation_id_from_env}; use crate::info::{Info, get_usage}; use crate::input::Console; use crate::model::{Command, ForgeCommandManager}; @@ -100,7 +101,6 @@ impl A> UI { // Reset previously set CLI parameters by the user self.cli.conversation = None; - self.cli.resume = None; self.display_banner()?; self.trace_user(); @@ -741,8 +741,7 @@ impl A> UI { serde_json::from_str(ForgeFS::read_utf8(path.as_os_str()).await?.as_str()) .context("Failed to parse Conversation")?; conversation - } else if let Some(conversation_id) = self.cli.resume { - // Use the explicitly provided conversation ID + } else if let Some(conversation_id) = get_conversation_id_from_env() { // Check if conversation with this ID already exists if let Some(conversation) = self.api.conversation(&conversation_id).await? { conversation @@ -765,7 +764,7 @@ impl A> UI { self.spinner.stop(None)?; let mut sub_title = conversation.id.into_string(); - if let Some(ref agent) = self.cli.agent { + if let Some(ref agent) = get_agent_from_env() { sub_title.push_str(format!(" [via {agent}]").as_str()); } @@ -782,7 +781,7 @@ impl A> UI { } }; - if let Some(ref agent) = self.cli.agent { + if let Some(ref agent) = get_agent_from_env() { self.state.operating_agent = AgentId::new(agent) } diff --git a/shell-plugin/README.md b/shell-plugin/README.md new file mode 100644 index 0000000000..bc3f483aab --- /dev/null +++ b/shell-plugin/README.md @@ -0,0 +1,174 @@ +# Forge ZSH Plugin + +A powerful ZSH plugin that provides intelligent command transformation, file tagging, and conversation management for the Forge AI assistant. + +## Features + +- **Smart Command Transformation**: Convert `:command` syntax into forge executions +- **Agent Selection**: Tab completion for available agents using `:agent_name` +- **File Tagging**: Interactive file selection with `@[filename]` syntax +- **Syntax Highlighting**: Visual feedback for commands and tagged files +- **Conversation Continuity**: Automatic session management across commands +- **Interactive Completion**: Fuzzy finding for files and agents + +## Prerequisites + +Before using this plugin, ensure you have the following tools installed: + +- **fzf** - Command-line fuzzy finder +- **fd** - Fast file finder (alternative to find) +- **forge** - The Forge CLI tool + +### Installation of Prerequisites + +```bash +# macOS (using Homebrew) +brew install fzf fd + +# Ubuntu/Debian +sudo apt install fzf fd-find + +# Arch Linux +sudo pacman -S fzf fd +``` + +## Usage + +### Starting a Conversation + +Begin any command with `:` followed by your prompt: + +```bash +: Get the current time +``` + +This automatically starts a new conversation with the default Forge agent. + +### Using Specific Agents + +Specify an agent by name after the colon: + +```bash +:sage How does caching work in this system? +:muse Create a deployment strategy for my app +``` + +**Tab Completion**: Type `:` followed by partial agent name and press `TAB` for interactive selection. + +### File Tagging + +Tag files in your commands using the `@[filename]` syntax: + +```bash +: Review this code @[src/main.rs] +: Explain the configuration in @[config.yaml] +``` + +**Interactive Selection**: Type `@` and press `TAB` to search and select files interactively using fuzzy finder. + +### Conversation Continuity + +Commands within the same session maintain context: + +```bash +# First command +: My project uses React and TypeScript + +# Second command (remembers previous context) +: How can I optimize the build process? +``` + +The plugin automatically manages conversation IDs to maintain context across related commands. + +### Session Management + +#### Resetting Sessions + +Clear the current conversation context: + +```bash +:reset +``` + +This will: + +- Clear the current conversation ID +- Reset the session state +- Display a confirmation message with timestamp + +#### Session Status + +The plugin automatically displays session information including: + +- Conversation ID when starting new sessions +- Active agent information +- Reset confirmations with timestamps + +## Syntax Highlighting + +The plugin provides visual feedback through syntax highlighting: + +- **Tagged Files** (`@[filename]`): Displayed in **green bold** +- **Agent Commands** (`:agent`): Agent names in **yellow bold** +- **Command Text**: Remaining text in **white bold** + +## Configuration + +Customize the plugin behavior by setting these variables before loading the plugin: + +```bash +# Custom forge binary location +export FORGE_BIN="/path/to/custom/forge" + +# Custom reset command (default: "reset") +export FORGE_RESET_COMMAND="clear" +``` + +### Available Configuration Variables + +- `FORGE_BIN`: Path to the forge executable (default: `forge`) +- Internal pattern matching for conversation syntax (`:`) +- Reset command keyword (default: `reset`) + +## Advanced Features + +### Command History + +All transformed commands are properly saved to ZSH history, allowing you to: + +- Navigate command history with arrow keys +- Search previous forge commands with `Ctrl+R` +- Reuse complex commands with file tags + +### Keyboard Shortcuts + +- **Tab**: Interactive completion for files (`@`) and agents (`:`) +- **Enter**: Transform and execute `:commands` +- **Ctrl+C**: Interrupt running forge commands + +## Examples + +### Basic Usage + +```bash +: What's the weather like? +:sage Explain the MVC pattern +:planner Help me structure this project +``` + +### With File Tagging + +```bash +: Review this implementation @[src/auth.rs] +: Debug the issue in @[logs/error.log] @[config/app.yml] +``` + +### Session Flow + +```bash +: I'm working on a Rust web API +: What are the best practices for error handling? +: Show me an example with @[src/errors.rs] +:reset +: New conversation starts here +``` diff --git a/shell-plugin/forge.plugin.zsh b/shell-plugin/forge.plugin.zsh index 9a0c59037b..c03cf4cd3b 100644 --- a/shell-plugin/forge.plugin.zsh +++ b/shell-plugin/forge.plugin.zsh @@ -1,18 +1,15 @@ #!/usr/bin/env zsh -# Forge ZSH Plugin - ZLE Widget Version -# Converts command-tagged commands to resume conversations using ZLE widgets -# Supports :plan/:p (muse), :ask/:a (sage), :new (start new conversation), :command_name (custom command), : (forge default) -# Features: Auto-resume existing conversations or start new ones, @ tab completion support, banner display for new conversations +# Documentation in [README.md](./README.md) + # Configuration: Change these variables to customize the forge command and special characters # Using typeset to keep variables local to plugin scope and prevent public exposure typeset -h _FORGE_BIN="${FORGE_BIN:-forge}" typeset -h _FORGE_CONVERSATION_PATTERN=":" -typeset -h _FORGE_RESET_COMMAND="reset" -# Style tagged files to be in green -ZSH_HIGHLIGHT_PATTERNS+=('@\[[^]]#\]' 'fg=green,bold') +# Style tagged files +ZSH_HIGHLIGHT_PATTERNS+=('@\[[^]]#\]' 'fg=cyan,bold') ZSH_HIGHLIGHT_HIGHLIGHTERS+=(pattern) # Style the conversation pattern with appropriate highlighting @@ -24,52 +21,20 @@ ZSH_HIGHLIGHT_PATTERNS+=('(#s):[a-zA-Z]#' 'fg=yellow,bold') # Highlight everything after that word + space in white ZSH_HIGHLIGHT_PATTERNS+=('(#s):[a-zA-Z]# *(*|[[:graph:]]*)' 'fg=white,bold') +# Private fzf function with common options for consistent UX +function _forge_fzf() { + fzf --cycle --select-1 --height 40% --reverse "$@" +} + # Store conversation ID in a temporary variable (local to plugin) -typeset -h _FORGE_CONVERSATION_ID="" +export FORGE_CONVERSATION_ID="" +export FORGE_ACTIVE_AGENT="" # Store the last command for reuse typeset -h _FORGE_USER_ACTION="" - -# Helper function for buffer parsing (kept for potential future use) -# Note: This function is currently not used but kept for backward compatibility -function _forge_transform_buffer() { - local user_action="" - local input_text="" - - # Check if the line starts with any of the supported patterns - if [[ "$BUFFER" =~ "^:([a-zA-Z][a-zA-Z0-9_-]*)( (.*))?$" ]]; then - # Action with or without parameters: :foo or :foo bar baz - user_action="${match[1]}" - input_text="${match[3]:-}" # Use empty string if no parameters - elif [[ "$BUFFER" =~ "^: (.*)$" ]]; then - # Default action with parameters: : something - user_action="" - input_text="${match[1]}" - else - return 1 # No transformation needed - fi - - # Handle reset as a special case - if [[ "$user_action" == "$_FORGE_RESET_COMMAND" ]]; then - return 1 # No transformation needed - fi - - # Note: Cannot generate conversation ID here as this runs in subshell - # This would need to be passed as parameter or handled in parent - local conversation_id="${1:-$_FORGE_CONVERSATION_ID}" - - # Build the forge command with the appropriate command - local forge_cmd="$_FORGE_BIN --resume $conversation_id --agent ${user_action:-forge}" - - # Return the transformed command - echo "$forge_cmd -p $(printf %q "$input_text")" - - return 0 # Successfully transformed -} - # Custom completion widget that handles both :commands and @ completion function forge-completion() { local current_word="${LBUFFER##* }" @@ -79,9 +44,9 @@ function forge-completion() { local filter_text="${current_word#@}" local selected if [[ -n "$filter_text" ]]; then - selected=$(fd --type f --hidden --exclude .git | fzf --select-1 --query "$filter_text" --height 40% --reverse) + selected=$(fd --type f --hidden --exclude .git | _forge_fzf --query "$filter_text") else - selected=$(fd --type f --hidden --exclude .git | fzf --select-1 --height 40% --reverse) + selected=$(fd --type f --hidden --exclude .git | _forge_fzf) fi if [[ -n "$selected" ]]; then @@ -108,9 +73,9 @@ function forge-completion() { # Use fzf for interactive selection with prefilled filter local selected if [[ -n "$filter_text" ]]; then - selected=$(echo "$command_output" | fzf --select-1 --nth=1 --query "$filter_text" --height 40% --reverse --prompt="Agent ❯ ") + selected=$(echo "$command_output" | _forge_fzf --nth=1 --query "$filter_text" --prompt="Agent ❯ ") else - selected=$(echo "$command_output" | fzf --select-1 --nth=1 --height 40% --reverse --prompt="Agent ❯ ") + selected=$(echo "$command_output" | _forge_fzf --nth=1 --prompt="Agent ❯ ") fi if [[ -n "$selected" ]]; then @@ -143,7 +108,7 @@ function forge-accept-line() { # Action with or without parameters: :foo or :foo bar baz user_action="${match[1]}" input_text="${match[3]:-}" # Use empty string if no parameters - elif [[ "$BUFFER" =~ "^: (.*)$" ]]; then + elif [[ "$BUFFER" =~ "^: (.*)$" ]]; then # Default action with parameters: : something user_action="" input_text="${match[1]}" @@ -157,13 +122,13 @@ function forge-accept-line() { print -s -- "$original_buffer" # Handle reset command specially - if [[ "$user_action" == "$_FORGE_RESET_COMMAND" ]]; then + if [[ "$user_action" == "reset" || "$user_action" == "r" ]]; then echo - if [[ -n "$_FORGE_CONVERSATION_ID" ]]; then - echo "\033[36m⏺\033[0m \033[90m[$(date '+%H:%M:%S')] Reset ${_FORGE_CONVERSATION_ID}\033[0m" + if [[ -n "$FORGE_CONVERSATION_ID" ]]; then + echo "\033[36m⏺\033[0m \033[90m[$(date '+%H:%M:%S')] Reset ${FORGE_CONVERSATION_ID}\033[0m" fi - _FORGE_CONVERSATION_ID="" + FORGE_CONVERSATION_ID="" _FORGE_USER_ACTION="" BUFFER="" CURSOR=${#BUFFER} @@ -175,21 +140,22 @@ function forge-accept-line() { _FORGE_USER_ACTION="$user_action" # Generate conversation ID if needed (in parent shell context) - if [[ -z "$_FORGE_CONVERSATION_ID" ]]; then - _FORGE_CONVERSATION_ID=$($_FORGE_BIN --generate-conversation-id) + if [[ -z "$FORGE_CONVERSATION_ID" ]]; then + FORGE_CONVERSATION_ID=$($_FORGE_BIN --generate-conversation-id) fi - # Build and execute the forge command - local forge_cmd="$_FORGE_BIN --resume $_FORGE_CONVERSATION_ID --agent ${user_action:-forge}" - local full_command="$forge_cmd -p $(printf %q "$input_text")" - - echo # Add a newline before execution for better UX - eval "$full_command" + # Set the active agent for this execution + FORGE_ACTIVE_AGENT="${user_action:-${FORGE_ACTIVE_AGENT:-forge}}" - # Clear the buffer for next command - BUFFER="" - CURSOR=${#BUFFER} - zle reset-prompt + # Build and execute the forge command + local forge_cmd="$_FORGE_BIN" + local quoted_input=${input_text//\'/\'\\\'\'} + local full_command="$forge_cmd -p '$quoted_input'" + + # Set buffer to the transformed command and execute + BUFFER="$full_command" + zle accept-line + return } # Register ZLE widgets