diff --git a/README.md b/README.md index e16cf6d92..e083ff79f 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ fe2c4c8 (claude [session_id] 2025-12-02 19:25:13 -0500 142) let fro ### Supported Agents -Claude Code Codex Cursor OpenCode Windsurf Amp Gemini GitHub Copilot Continue Droid Junie Rovo Dev +Claude Code Codex Cursor OpenCode Windsurf Amp Gemini GitHub Copilot Continue Droid Junie Rovo Dev Firebender > [+ Add support for another agent](https://usegitai.com/docs/cli/add-your-agent) diff --git a/assets/docs/badges/firebender.svg b/assets/docs/badges/firebender.svg new file mode 100644 index 000000000..aba5bee9a --- /dev/null +++ b/assets/docs/badges/firebender.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/commands/checkpoint_agent/agent_presets.rs b/src/commands/checkpoint_agent/agent_presets.rs index 6b256da9f..e01b48323 100644 --- a/src/commands/checkpoint_agent/agent_presets.rs +++ b/src/commands/checkpoint_agent/agent_presets.rs @@ -3590,3 +3590,109 @@ impl AgentCheckpointPreset for AiTabPreset { }) } } + +// Firebender to checkpoint preset +pub struct FirebenderPreset; + +#[derive(Debug, Deserialize)] +struct FirebenderHookInput { + hook_event_name: String, + model: String, + repo_working_dir: Option, + workspace_roots: Option>, + will_edit_filepaths: Option>, + edited_filepaths: Option>, + file_path: Option, + completion_id: Option, + dirty_files: Option>, +} + +impl AgentCheckpointPreset for FirebenderPreset { + fn run(&self, flags: AgentCheckpointFlags) -> Result { + let hook_input_json = flags.hook_input.ok_or_else(|| { + GitAiError::PresetError("hook_input is required for firebender preset".to_string()) + })?; + + let hook_input: FirebenderHookInput = serde_json::from_str(&hook_input_json) + .map_err(|e| GitAiError::PresetError(format!("Invalid JSON in hook_input: {}", e)))?; + + let FirebenderHookInput { + hook_event_name, + model, + repo_working_dir, + workspace_roots, + will_edit_filepaths, + edited_filepaths, + file_path, + completion_id, + dirty_files, + } = hook_input; + + let normalized_event = match hook_event_name.as_str() { + "before_edit" | "beforeSubmitPrompt" => "before_edit", + "after_edit" | "afterFileEdit" => "after_edit", + _ => { + return Err(GitAiError::PresetError(format!( + "Unsupported hook_event_name '{}' for firebender preset (expected 'before_edit' or 'after_edit')", + hook_event_name + ))); + } + }; + + let mut resolved_will_edit = will_edit_filepaths; + let mut resolved_edited = edited_filepaths; + if let Some(path) = file_path.filter(|p| !p.trim().is_empty()) { + if normalized_event == "before_edit" && resolved_will_edit.is_none() { + resolved_will_edit = Some(vec![path.clone()]); + } + if normalized_event == "after_edit" && resolved_edited.is_none() { + resolved_edited = Some(vec![path]); + } + } + + let repo_working_dir = repo_working_dir + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .or_else(|| workspace_roots.and_then(|roots| roots.into_iter().next())); + + let model = model.trim().to_string(); + if model.is_empty() { + return Err(GitAiError::PresetError( + "model must be a non-empty string for firebender preset".to_string(), + )); + } + + let agent_id = AgentId { + tool: "firebender".to_string(), + id: format!( + "firebender-{}", + completion_id.unwrap_or_else(|| Utc::now().timestamp_millis().to_string()) + ), + model, + }; + + if normalized_event == "before_edit" { + return Ok(AgentRunResult { + agent_id, + agent_metadata: None, + checkpoint_kind: CheckpointKind::Human, + transcript: None, + repo_working_dir, + edited_filepaths: None, + will_edit_filepaths: resolved_will_edit, + dirty_files, + }); + } + + Ok(AgentRunResult { + agent_id, + agent_metadata: None, + checkpoint_kind: CheckpointKind::AiAgent, + transcript: None, + repo_working_dir, + edited_filepaths: resolved_edited, + will_edit_filepaths: None, + dirty_files, + }) + } +} diff --git a/src/commands/git_ai_handlers.rs b/src/commands/git_ai_handlers.rs index 9d6a5d695..98462b60d 100644 --- a/src/commands/git_ai_handlers.rs +++ b/src/commands/git_ai_handlers.rs @@ -7,8 +7,8 @@ use crate::authorship::working_log::{AgentId, CheckpointKind}; use crate::commands; use crate::commands::checkpoint_agent::agent_presets::{ AgentCheckpointFlags, AgentCheckpointPreset, AgentRunResult, AiTabPreset, ClaudePreset, - CodexPreset, ContinueCliPreset, CursorPreset, DroidPreset, GeminiPreset, GithubCopilotPreset, - WindsurfPreset, + CodexPreset, ContinueCliPreset, CursorPreset, DroidPreset, FirebenderPreset, GeminiPreset, + GithubCopilotPreset, WindsurfPreset, }; use crate::commands::checkpoint_agent::agent_v1_preset::AgentV1Preset; use crate::commands::checkpoint_agent::amp_preset::AmpPreset; @@ -252,7 +252,7 @@ fn print_help() { eprintln!("Commands:"); eprintln!(" checkpoint Checkpoint working changes and attribute author"); eprintln!( - " Presets: claude, codex, continue-cli, cursor, gemini, github-copilot, amp, windsurf, opencode, ai_tab, mock_ai" + " Presets: claude, codex, continue-cli, cursor, gemini, github-copilot, amp, windsurf, opencode, ai_tab, firebender, mock_ai" ); eprintln!( " --hook-input JSON payload required by presets, or 'stdin' to read from stdin" @@ -546,6 +546,22 @@ fn handle_checkpoint(args: &[String]) { } } } + "firebender" => { + match FirebenderPreset.run(AgentCheckpointFlags { + hook_input: hook_input.clone(), + }) { + Ok(agent_run) => { + if agent_run.repo_working_dir.is_some() { + repository_working_dir = agent_run.repo_working_dir.clone().unwrap(); + } + agent_run_result = Some(agent_run); + } + Err(e) => { + eprintln!("Firebender preset error: {}", e); + std::process::exit(0); + } + } + } "agent-v1" => { match AgentV1Preset.run(AgentCheckpointFlags { hook_input: hook_input.clone(), diff --git a/src/mdm/agents/firebender.rs b/src/mdm/agents/firebender.rs new file mode 100644 index 000000000..e22a24fec --- /dev/null +++ b/src/mdm/agents/firebender.rs @@ -0,0 +1,256 @@ +use crate::error::GitAiError; +use crate::mdm::hook_installer::{HookCheckResult, HookInstaller, HookInstallerParams}; +use crate::mdm::utils::{generate_diff, home_dir, write_atomic}; +use serde_json::{Value, json}; +use std::fs; +use std::path::PathBuf; + +const FIREBENDER_CHECKPOINT_CMD: &str = "checkpoint firebender --hook-input stdin"; + +pub struct FirebenderInstaller; + +impl FirebenderInstaller { + fn hooks_path() -> PathBuf { + home_dir().join(".firebender").join("hooks.json") + } + + fn is_firebender_checkpoint_command(cmd: &str) -> bool { + cmd.contains("git-ai checkpoint firebender") + || (cmd.contains("git-ai") + && cmd.contains("checkpoint") + && cmd.contains("firebender")) + } +} + +impl HookInstaller for FirebenderInstaller { + fn name(&self) -> &str { + "Firebender" + } + + fn id(&self) -> &str { + "firebender" + } + + fn check_hooks(&self, _params: &HookInstallerParams) -> Result { + let has_dotfiles = home_dir().join(".firebender").exists(); + if !has_dotfiles { + return Ok(HookCheckResult { + tool_installed: false, + hooks_installed: false, + hooks_up_to_date: false, + }); + } + + let hooks_path = Self::hooks_path(); + if !hooks_path.exists() { + return Ok(HookCheckResult { + tool_installed: true, + hooks_installed: false, + hooks_up_to_date: false, + }); + } + + let content = fs::read_to_string(&hooks_path)?; + let existing: Value = serde_json::from_str(&content).unwrap_or_else(|_| json!({})); + + let has_before = existing + .get("hooks") + .and_then(|h| h.get("beforeSubmitPrompt")) + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter().any(|item| { + item.get("command") + .and_then(|c| c.as_str()) + .map(Self::is_firebender_checkpoint_command) + .unwrap_or(false) + }) + }) + .unwrap_or(false); + + let has_after = existing + .get("hooks") + .and_then(|h| h.get("afterFileEdit")) + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter().any(|item| { + item.get("command") + .and_then(|c| c.as_str()) + .map(Self::is_firebender_checkpoint_command) + .unwrap_or(false) + }) + }) + .unwrap_or(false); + + Ok(HookCheckResult { + tool_installed: true, + hooks_installed: has_before && has_after, + hooks_up_to_date: has_before && has_after, + }) + } + + fn install_hooks( + &self, + params: &HookInstallerParams, + dry_run: bool, + ) -> Result, GitAiError> { + let hooks_path = Self::hooks_path(); + if let Some(dir) = hooks_path.parent() { + fs::create_dir_all(dir)?; + } + + let existing_content = if hooks_path.exists() { + fs::read_to_string(&hooks_path)? + } else { + String::new() + }; + + let existing: Value = if existing_content.trim().is_empty() { + json!({}) + } else { + serde_json::from_str(&existing_content)? + }; + + let command = format!( + "{} {}", + params.binary_path.display(), + FIREBENDER_CHECKPOINT_CMD + ); + + let desired: Value = json!({ + "version": 1, + "hooks": { + "beforeSubmitPrompt": [ + { + "command": command + } + ], + "afterFileEdit": [ + { + "command": command + } + ] + } + }); + + let mut merged = existing.clone(); + if merged.get("version").is_none() && let Some(obj) = merged.as_object_mut() { + obj.insert("version".to_string(), json!(1)); + } + + let mut hooks_obj = merged.get("hooks").cloned().unwrap_or_else(|| json!({})); + + for hook_name in &["beforeSubmitPrompt", "afterFileEdit"] { + let desired_hooks = desired + .get("hooks") + .and_then(|h| h.get(*hook_name)) + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + + let mut existing_hooks = hooks_obj + .get(*hook_name) + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + + for desired_hook in desired_hooks { + let Some(desired_cmd) = desired_hook.get("command").and_then(|c| c.as_str()) else { + continue; + }; + + let mut found_idx = None; + let mut needs_update = false; + + for (idx, existing_hook) in existing_hooks.iter().enumerate() { + if let Some(existing_cmd) = existing_hook.get("command").and_then(|c| c.as_str()) + && Self::is_firebender_checkpoint_command(existing_cmd) + { + found_idx = Some(idx); + if existing_cmd != desired_cmd { + needs_update = true; + } + break; + } + } + + match found_idx { + Some(idx) if needs_update => existing_hooks[idx] = desired_hook.clone(), + Some(_) => {} + None => existing_hooks.push(desired_hook.clone()), + } + } + + if let Some(obj) = hooks_obj.as_object_mut() { + obj.insert(hook_name.to_string(), Value::Array(existing_hooks)); + } + } + + if let Some(root) = merged.as_object_mut() { + root.insert("hooks".to_string(), hooks_obj); + } + + if existing == merged { + return Ok(None); + } + + let new_content = serde_json::to_string_pretty(&merged)?; + let diff_output = generate_diff(&hooks_path, &existing_content, &new_content); + + if !dry_run { + write_atomic(&hooks_path, new_content.as_bytes())?; + } + + Ok(Some(diff_output)) + } + + fn uninstall_hooks( + &self, + _params: &HookInstallerParams, + dry_run: bool, + ) -> Result, GitAiError> { + let hooks_path = Self::hooks_path(); + if !hooks_path.exists() { + return Ok(None); + } + + let existing_content = fs::read_to_string(&hooks_path)?; + let existing: Value = serde_json::from_str(&existing_content)?; + + let mut merged = existing.clone(); + let mut hooks_obj = merged.get("hooks").cloned().unwrap_or_else(|| json!({})); + let mut changed = false; + + for hook_name in &["beforeSubmitPrompt", "afterFileEdit"] { + if let Some(arr) = hooks_obj.get_mut(*hook_name).and_then(|v| v.as_array_mut()) { + let original_len = arr.len(); + arr.retain(|item| { + if let Some(cmd) = item.get("command").and_then(|c| c.as_str()) { + !Self::is_firebender_checkpoint_command(cmd) + } else { + true + } + }); + if arr.len() != original_len { + changed = true; + } + } + } + + if !changed { + return Ok(None); + } + + if let Some(root) = merged.as_object_mut() { + root.insert("hooks".to_string(), hooks_obj); + } + + let new_content = serde_json::to_string_pretty(&merged)?; + let diff_output = generate_diff(&hooks_path, &existing_content, &new_content); + + if !dry_run { + write_atomic(&hooks_path, new_content.as_bytes())?; + } + + Ok(Some(diff_output)) + } +} diff --git a/src/mdm/agents/mod.rs b/src/mdm/agents/mod.rs index 57f637471..97c3f4e80 100644 --- a/src/mdm/agents/mod.rs +++ b/src/mdm/agents/mod.rs @@ -3,6 +3,7 @@ mod claude_code; mod codex; mod cursor; mod droid; +mod firebender; mod gemini; mod github_copilot; mod jetbrains; @@ -15,6 +16,7 @@ pub use claude_code::ClaudeCodeInstaller; pub use codex::CodexInstaller; pub use cursor::CursorInstaller; pub use droid::DroidInstaller; +pub use firebender::FirebenderInstaller; pub use gemini::GeminiInstaller; pub use github_copilot::GitHubCopilotInstaller; pub use jetbrains::JetBrainsInstaller; @@ -36,6 +38,7 @@ pub fn get_all_installers() -> Vec> { Box::new(OpenCodeInstaller), Box::new(GeminiInstaller), Box::new(DroidInstaller), + Box::new(FirebenderInstaller), Box::new(JetBrainsInstaller), Box::new(WindsurfInstaller), ] diff --git a/tests/integration/repos/test_repo.rs b/tests/integration/repos/test_repo.rs index 4b0dccca9..751927848 100644 --- a/tests/integration/repos/test_repo.rs +++ b/tests/integration/repos/test_repo.rs @@ -641,6 +641,7 @@ fn is_known_checkpoint_preset(arg: &str) -> bool { | "windsurf" | "opencode" | "ai_tab" + | "firebender" | "mock_ai" | "droid" | "agent-v1"