use super::ToolAdapter;
use crate::core::state::ForgeState;
use crate::core::task::{AgentType, Task};
use std::path::Path;
use std::process::Command;
pub struct GeminiAdapter;
impl ToolAdapter for GeminiAdapter {
fn name(&self) -> &str {
"antigravity"
}
fn render_config(
&self,
state: &ForgeState,
tasks: &[Task],
project_root: &Path,
) -> anyhow::Result<()> {
let assigned: Vec<_> = tasks
.iter()
.filter(|t| {
t.assigned_to
.as_ref()
.is_some_and(|a| *a == AgentType::Gemini)
})
.collect();
let mut content = String::new();
content.push_str("# ANTIGRAVITY.md (Auto-generated by Forge Orchestrator)\n\n");
content.push_str(&format!("**Project:** {}\n", state.project_name));
content.push_str(&format!(
"**Last updated:** {}\n\n",
state.updated_at.format("%Y-%m-%d %H:%M UTC")
));
content.push_str("**Agent:** Antigravity CLI (`agy`) — Gemini 3.1 Pro (High)\n\n");
content.push_str("---\n\n");
if assigned.is_empty() {
content.push_str("## Status\n\nNo tasks currently assigned to Antigravity.\n\n");
} else {
content.push_str("## Assigned Tasks\n\n");
for task in &assigned {
content.push_str(&format!(
"### {}: {}\n\n{}\n\n",
task.id, task.title, task.description
));
if !task.acceptance_criteria.is_empty() {
content.push_str("**Acceptance Criteria:**\n");
for c in &task.acceptance_criteria {
content.push_str(&format!("- [ ] {c}\n"));
}
content.push('\n');
}
if !task.locked_files.is_empty() {
content.push_str("**Files you may modify:**\n");
for file in &task.locked_files {
content.push_str(&format!("- `{file}`\n"));
}
content.push('\n');
}
}
}
content.push_str("## Guidelines\n\n");
content.push_str("- Complete tasks one at a time\n");
content.push_str("- Do NOT modify files outside your assigned task scope\n");
content.push_str("- Run tests before marking a task complete\n");
content.push_str("- Check `.forge/state.json` for current orchestration state\n");
std::fs::write(project_root.join("ANTIGRAVITY.md"), content)?;
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
let settings_path = Path::new(&home)
.join(".gemini")
.join("antigravity-cli")
.join("settings.json");
add_trusted_workspace(&settings_path, project_root)?;
Ok(())
}
fn build_command(
&self,
task: &Task,
project_root: &Path,
auth_mode: &str,
permissions: &str,
) -> Command {
let task_type = task.task_type.as_deref().unwrap_or("");
let prompt = build_gemini_prompt(task, task_type);
let mut cmd = Command::new("agy");
cmd.args(["--print", &prompt]);
if permissions == "yolo" {
cmd.arg("--dangerously-skip-permissions");
}
cmd.current_dir(project_root);
if auth_mode == "subscription" {
cmd.env_remove("GOOGLE_API_KEY")
.env_remove("GEMINI_API_KEY");
}
cmd
}
fn build_command_interactive(
&self,
_task: &Task,
project_root: &Path,
auth_mode: &str,
_permissions: &str,
) -> Command {
let mut cmd = Command::new("agy");
cmd.arg("--dangerously-skip-permissions");
cmd.current_dir(project_root);
if auth_mode == "subscription" {
cmd.env_remove("GOOGLE_API_KEY")
.env_remove("GEMINI_API_KEY");
}
cmd
}
fn initial_input(&self, task: &Task) -> Option<String> {
let task_type = task.task_type.as_deref().unwrap_or("");
let prompt = build_gemini_prompt(task, task_type).replace('\n', " ");
Some(format!("{}\r", prompt))
}
fn ready_pattern(&self) -> Option<&str> {
Some("? for shortcuts")
}
}
pub(crate) fn add_trusted_workspace(settings_path: &Path, workspace: &Path) -> anyhow::Result<()> {
let content = if settings_path.exists() {
std::fs::read_to_string(settings_path)?
} else {
"{}".to_string()
};
let mut settings: serde_json::Value =
serde_json::from_str(&content).unwrap_or(serde_json::Value::Object(Default::default()));
let workspace_str = workspace.to_string_lossy().into_owned();
let already_trusted = settings["trustedWorkspaces"]
.as_array()
.is_some_and(|arr| arr.iter().any(|v| v.as_str() == Some(&workspace_str)));
if !already_trusted {
match settings["trustedWorkspaces"].as_array_mut() {
Some(arr) => arr.push(serde_json::Value::String(workspace_str)),
None => {
settings["trustedWorkspaces"] = serde_json::json!([workspace_str]);
}
}
if let Some(parent) = settings_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(settings_path, serde_json::to_string_pretty(&settings)?)?;
}
Ok(())
}
pub(crate) fn build_gemini_prompt(task: &Task, task_type: &str) -> String {
let criteria = if task.acceptance_criteria.is_empty() {
String::new()
} else {
format!(
"Acceptance criteria:\n{}\n\n",
task.acceptance_criteria
.iter()
.map(|c| format!("- {c}"))
.collect::<Vec<_>>()
.join("\n")
)
};
match task_type {
"verify" => format!(
"You are working on task {id}: {title}\n\n\
This is a VERIFICATION task. You must ACTIVELY TEST the code, not just read it.\n\n\
Description: {desc}\n\n\
{criteria}\
Required verification steps:\n\
1. Run the project's test suite (e.g. `npm test`, `pytest`, `cargo test`)\n\
2. Run the type checker (e.g. `tsc --noEmit`, `mypy`, `cargo clippy`)\n\
3. Check that all new/modified files have corresponding test files\n\
4. Verify test mocks match production code shapes (interceptors, constructors, etc.)\n\
5. Run the linter (e.g. `eslint`, `ruff`, `cargo fmt --check`)\n\
6. If this task touches auth/routing/middleware, also run E2E tests\n\n\
Report your findings: what passed, what failed, what's missing.\n\
If tests fail or coverage is missing, report the specific failures.",
id = task.id,
title = task.title,
desc = task.description,
),
"review" => format!(
"You are working on task {id}: {title}\n\n\
This is a REVIEW task. Analyze the code without making changes.\n\n\
Description: {desc}\n\n\
{criteria}\
Do NOT modify files — this is a read-only review.\n\
When done, summarize your findings.",
id = task.id,
title = task.title,
desc = task.description,
),
_ => format!(
"Complete task {id}: {title}\n\nDescription: {desc}\n\n\
{criteria}\
IMPORTANT: Before marking this task complete:\n\
- Write unit tests for any new files you create\n\
- Run the existing test suite to verify nothing breaks\n\
- Run the type checker if the project has one\n\
- If you modify a file that has a corresponding .test file, update the test mocks\n\n\
When done, summarize what you did.",
id = task.id,
title = task.title,
desc = task.description,
),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_task(task_type: &str) -> Task {
let mut task = Task::new("T-001", "Add login form", "Implement the login UI");
task.task_type = Some(task_type.to_string());
task.acceptance_criteria = vec!["Form validates email".to_string()];
task
}
#[test]
fn test_gemini_verify_prompt() {
let task = make_task("verify");
let prompt = build_gemini_prompt(&task, "verify");
assert!(prompt.contains("VERIFICATION task"));
assert!(prompt.contains("ACTIVELY TEST"));
assert!(prompt.contains("Run the project's test suite"));
}
#[test]
fn test_gemini_implement_prompt_has_test_requirement() {
let task = make_task("implement");
let prompt = build_gemini_prompt(&task, "implement");
assert!(prompt.contains("Write unit tests"));
assert!(prompt.contains("Run the existing test suite"));
}
#[test]
fn test_gemini_review_prompt_is_readonly() {
let task = make_task("review");
let prompt = build_gemini_prompt(&task, "review");
assert!(prompt.contains("REVIEW task"));
assert!(prompt.contains("Do NOT modify files"));
}
#[test]
fn test_gemini_default_prompt() {
let task = make_task("something");
let prompt = build_gemini_prompt(&task, "something");
assert!(prompt.contains("T-001"));
assert!(prompt.contains("Write unit tests"));
}
#[test]
fn test_antigravity_name() {
assert_eq!(GeminiAdapter.name(), "antigravity");
}
#[test]
fn test_antigravity_headless_command_uses_agy() {
let task = make_task("implement");
let project = std::path::PathBuf::from("/tmp/test-project");
let cmd = GeminiAdapter.build_command(&task, &project, "api", "normal");
let prog = format!("{:?}", cmd.get_program());
assert!(
prog.contains("agy"),
"headless command must use agy binary, got: {prog}"
);
}
#[test]
fn test_antigravity_headless_command_uses_print_flag() {
let task = make_task("implement");
let project = std::path::PathBuf::from("/tmp/test-project");
let cmd = GeminiAdapter.build_command(&task, &project, "api", "normal");
let args: Vec<_> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert!(
args.iter().any(|a| a == "--print"),
"headless command must use --print flag, got: {:?}",
args
);
}
#[test]
fn test_antigravity_headless_normal_has_no_skip_permissions() {
let task = make_task("implement");
let project = std::path::PathBuf::from("/tmp/test-project");
let cmd = GeminiAdapter.build_command(&task, &project, "api", "normal");
let args: Vec<_> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert!(
!args.iter().any(|a| a == "--dangerously-skip-permissions"),
"normal permissions must NOT add --dangerously-skip-permissions, got: {:?}",
args
);
}
#[test]
fn test_antigravity_headless_yolo_adds_skip_permissions() {
let task = make_task("implement");
let project = std::path::PathBuf::from("/tmp/test-project");
let cmd = GeminiAdapter.build_command(&task, &project, "api", "yolo");
let args: Vec<_> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert!(
args.iter().any(|a| a == "--dangerously-skip-permissions"),
"yolo mode must add --dangerously-skip-permissions, got: {:?}",
args
);
}
#[test]
fn test_antigravity_headless_not_legacy_gemini_binary() {
let task = make_task("implement");
let project = std::path::PathBuf::from("/tmp/test-project");
let cmd = GeminiAdapter.build_command(&task, &project, "api", "normal");
let prog = format!("{:?}", cmd.get_program());
assert!(
!prog.contains("gemini"),
"must NOT use retired gemini binary — use agy, got: {prog}"
);
}
#[test]
fn test_antigravity_pty_command_uses_agy() {
let task = make_task("implement");
let project = std::path::PathBuf::from("/tmp/test-project");
let cmd = GeminiAdapter.build_command_interactive(&task, &project, "api", "normal");
let prog = format!("{:?}", cmd.get_program());
assert!(
prog.contains("agy"),
"PTY command must use agy binary, got: {prog}"
);
}
#[test]
fn test_antigravity_pty_always_skip_permissions() {
let task = make_task("implement");
let project = std::path::PathBuf::from("/tmp/test-project");
let cmd = GeminiAdapter.build_command_interactive(&task, &project, "api", "normal");
let args: Vec<_> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert!(
args.iter().any(|a| a == "--dangerously-skip-permissions"),
"PTY must always include --dangerously-skip-permissions (Stargate contract), got: {:?}",
args
);
}
#[test]
fn test_antigravity_ready_pattern_is_shortcuts_hint() {
assert_eq!(GeminiAdapter.ready_pattern(), Some("? for shortcuts"));
}
#[test]
fn test_antigravity_ready_pattern_not_legacy_gemini() {
assert_ne!(
GeminiAdapter.ready_pattern(),
Some("Type your message"),
"must not use retired Gemini CLI ready pattern"
);
}
#[test]
fn test_trusted_workspace_creates_entry() {
let dir = tempfile::tempdir().unwrap();
let settings_path = dir.path().join("settings.json");
let workspace = std::path::PathBuf::from("/tmp/test-workspace");
add_trusted_workspace(&settings_path, &workspace).unwrap();
let content = std::fs::read_to_string(&settings_path).unwrap();
let settings: serde_json::Value = serde_json::from_str(&content).unwrap();
let trusted = settings["trustedWorkspaces"].as_array().unwrap();
assert!(
trusted
.iter()
.any(|v| v.as_str() == Some("/tmp/test-workspace")),
"workspace must appear in trustedWorkspaces"
);
}
#[test]
fn test_trusted_workspace_idempotent() {
let dir = tempfile::tempdir().unwrap();
let settings_path = dir.path().join("settings.json");
let workspace = std::path::PathBuf::from("/tmp/test-workspace");
add_trusted_workspace(&settings_path, &workspace).unwrap();
add_trusted_workspace(&settings_path, &workspace).unwrap();
let content = std::fs::read_to_string(&settings_path).unwrap();
let settings: serde_json::Value = serde_json::from_str(&content).unwrap();
let trusted = settings["trustedWorkspaces"].as_array().unwrap();
let count = trusted
.iter()
.filter(|v| v.as_str() == Some("/tmp/test-workspace"))
.count();
assert_eq!(count, 1, "duplicate entries must not be added");
}
#[test]
fn test_trusted_workspace_preserves_existing_entries() {
let dir = tempfile::tempdir().unwrap();
let settings_path = dir.path().join("settings.json");
let initial = serde_json::json!({ "trustedWorkspaces": ["/existing/path"] });
std::fs::write(&settings_path, serde_json::to_string(&initial).unwrap()).unwrap();
let workspace = std::path::PathBuf::from("/tmp/new-workspace");
add_trusted_workspace(&settings_path, &workspace).unwrap();
let content = std::fs::read_to_string(&settings_path).unwrap();
let settings: serde_json::Value = serde_json::from_str(&content).unwrap();
let trusted = settings["trustedWorkspaces"].as_array().unwrap();
assert!(
trusted.iter().any(|v| v.as_str() == Some("/existing/path")),
"existing entry must be preserved"
);
assert!(
trusted
.iter()
.any(|v| v.as_str() == Some("/tmp/new-workspace")),
"new entry must be added"
);
}
}