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
-
+
> [+ 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