use std::time::Duration;
use serde_json::{Value, json};
use crate::{
Result,
common::{
parts::{self, GroupHooks},
protocol, settings, toml_out,
},
harness::{Context, Harness, MergeOp, Part, PartResult, Reads, Scope},
hook::{Answer, Event, HookInput, Output, ToolCall, ToolKind, claude_names},
integration::{Integration, Item},
};
#[derive(Debug, Clone, Copy, Default)]
pub struct Gemini;
const ID: &str = "gemini";
const SETTINGS: &str = ".gemini/settings.json";
fn event_name(event: Event) -> Option<&'static str> {
Some(match event {
Event::SessionStart => "SessionStart",
Event::SessionEnd => "SessionEnd",
Event::PromptSubmit => "BeforeAgent",
Event::PreTool => "BeforeTool",
Event::PostTool => "AfterTool",
Event::Stop => "AfterAgent",
Event::PreCompact => "PreCompress",
})
}
fn matcher(kind: ToolKind) -> Option<&'static str> {
match kind {
ToolKind::Shell => Some("run_shell_command"),
ToolKind::Read => Some("read_file|read_many_files|glob|search_file_content|list_directory"),
ToolKind::Write => Some("write_file|replace"),
ToolKind::Mcp => Some("mcp_.*"),
_ => None,
}
}
fn tool_kind(name: &str) -> ToolKind {
match name {
"run_shell_command" => ToolKind::Shell,
"read_file" | "read_many_files" | "glob" | "search_file_content" | "list_directory" => {
ToolKind::Read
}
"write_file" | "replace" => ToolKind::Write,
n if n.starts_with("mcp_") => ToolKind::Mcp,
_ => ToolKind::Other,
}
}
fn millis(t: Duration) -> Value {
json!(u64::try_from(t.as_millis()).unwrap_or(u64::MAX))
}
const LAYOUT: GroupHooks = GroupHooks {
path: |e| vec!["hooks".into(), e.into()],
event: event_name,
matcher,
timeout: millis,
harness: ID,
};
const CONTEXT: settings::ContextLayout = settings::ContextLayout {
settings: SETTINGS,
user_display: "~/.gemini/settings.json",
user_dir: ".gemini/",
default: &["GEMINI.md"],
};
fn context_files(cx: &Context) -> Result<(String, Vec<String>)> {
settings::context_files(cx, &CONTEXT)
}
fn command_toml(c: &crate::integration::Command) -> String {
toml_out::strings(&[
("description", c.description()),
("prompt", &c.prompt().replace("$ARGUMENTS", "{{args}}")),
])
}
fn location(item: Item, cx: &Context) -> Result<Option<String>> {
Ok(Some(match item {
Item::Instructions => context_files(cx)?.0,
Item::Skills => ".agents/skills".into(),
Item::Hooks | Item::Mcp | Item::Permissions => SETTINGS.into(),
Item::Agents => ".gemini/agents".into(),
Item::Commands => ".gemini/commands".into(),
}))
}
fn claude_name(name: &str) -> Option<&'static str> {
match name {
"run_shell_command" => Some("Bash"),
"read_file" | "read_many_files" => Some("Read"),
"search_file_content" => Some("Grep"),
"glob" => Some("Glob"),
"list_directory" => Some("LS"),
"replace" => Some("Edit"),
"write_file" => Some("Write"),
_ => None,
}
}
impl Harness for Gemini {
fn id(&self) -> &str {
ID
}
fn program(&self) -> Option<&str> {
Some("gemini")
}
fn claude_tool(&self, call: &ToolCall) -> ToolCall {
claude_names::named(call, claude_name(&call.name))
}
fn scopes(&self) -> &[Scope] {
&[Scope::Project, Scope::User]
}
fn render(&self, integration: &Integration, cx: &Context) -> Result<Vec<Part>> {
let mut out = Vec::new();
for item in integration.items() {
let Some(at) = location(item, cx)? else {
continue;
};
out.push(match item {
Item::Instructions => settings::context_reference(integration, cx, &CONTEXT)
.unwrap_or_else(|| {
parts::instructions(
&at,
integration.instructions_block().unwrap_or_default(),
)
}),
Item::Skills => parts::skills(&at, integration),
Item::Hooks => {
Part::merge(item.as_str(), at, parts::group_hooks(&LAYOUT, integration)?)
}
Item::Mcp => parts::mcp(&at, "mcpServers", integration, settings::mcp_entry),
Item::Permissions if integration.allowed_commands().is_empty() => continue,
Item::Permissions => Part::merge(
item.as_str(),
at,
integration
.allowed_commands()
.iter()
.map(|p| {
MergeOp::array_entry(
["tools", "allowed"],
format!("run_shell_command({p})"),
)
})
.collect(),
),
Item::Agents => parts::agents(&at, ".md", integration, |a| a.to_markdown(&[])),
Item::Commands => parts::commands(&at, ".toml", integration, command_toml),
});
}
Ok(out)
}
fn reads(&self, item: Item, cx: &Context) -> Result<Reads> {
Ok(match item {
Item::Instructions => Reads::always(context_files(cx)?.1),
_ => Reads::always(location(item, cx)?),
})
}
fn parse_hook(&self, event: Event, text: &str) -> serde_json::Result<HookInput> {
protocol::parse(ID, event, text, tool_kind, "prompt_response")
}
fn answer(&self, event: Event, answer: &Answer) -> Result<Output> {
Ok(match (answer, event) {
(Answer::Allow { stderr }, _) => Output::json(json!({})).stderr(stderr.clone()),
(Answer::Deny { reason }, Event::PreTool | Event::PromptSubmit)
| (Answer::Continue { reason }, Event::Stop) => {
Output::json(json!({ "decision": "deny", "reason": reason }))
}
(
Answer::Context { text },
Event::SessionStart | Event::PromptSubmit | Event::PostTool,
) => Output::json(json!({ "hookSpecificOutput": { "additionalContext": text } })),
_ => return Err(protocol::unsupported(ID, event, answer)),
})
}
fn notes(&self, cx: &Context, parts: &[PartResult]) -> Vec<String> {
if cx.scope == Scope::Project && parts.iter().any(PartResult::wrote) {
vec!["with folder trust on, Gemini CLI reads the project's settings only in a trusted folder".into()]
} else {
Vec::new()
}
}
}
#[cfg(test)]
mod tests {
use std::fs;
use super::*;
use crate::{
Error,
harness::{Action, Profile, State},
integration::McpServer,
integration::{Agent, Command, Hook, Skill},
test_support::temp_dir,
};
fn full() -> Integration {
Integration::new()
.instructions("Use t.\n")
.skill(Skill::new("t", "Use t.", "# T\n"))
.hook(
Hook::new(Event::PreTool, "t hook {harness} {event}")
.tools(ToolKind::Shell)
.timeout(Duration::from_secs(2)),
)
.mcp_server(McpServer::http("w", "https://w").header("A", "b"))
.allow_command("t")
.agent(Agent::new("r", "Reviews.", "Review.\n"))
.command(Command::new("c", "Checks.", "Check $ARGUMENTS.\n"))
}
#[test]
fn renders_every_item() {
let dir = temp_dir("gemini");
let cx = Context::new("t", Scope::Project, &dir, None);
let p = Profile::new(Gemini.render(&full(), &cx).unwrap());
let names: Vec<_> = p
.parts()
.iter()
.map(|p| format!("{} {}", p.name(), p.location()))
.collect();
assert_eq!(
names,
[
"instructions GEMINI.md",
"skills .agents/skills",
"hooks .gemini/settings.json",
"mcp .gemini/settings.json",
"permissions .gemini/settings.json",
"agents .gemini/agents",
"commands .gemini/commands"
]
);
assert_eq!(
p.part("hooks").unwrap().ops()[3].value(),
&json!([{ "matcher": "run_shell_command", "hooks": [{ "type": "command", "command": "t hook gemini pre-tool", "timeout": 2000 }] }])
);
assert_eq!(
p.part("hooks").unwrap().ops()[5].path(),
["hooks", "AfterAgent"]
);
assert_eq!(
p.part("mcp").unwrap().ops()[0].value(),
&json!({ "httpUrl": "https://w", "headers": { "A": "b" } })
);
assert_eq!(
p.part("permissions").unwrap().ops(),
&[MergeOp::array_entry(
["tools", "allowed"],
"run_shell_command(t)"
)]
);
let cmd = command_toml(&Command::new("c", "Checks.", "Check $ARGUMENTS.\n"));
assert!(cmd.contains("Check {{args}}."), "{cmd}");
assert_eq!(Gemini.scopes(), [Scope::Project, Scope::User]);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_context_file_follows_the_settings() {
let dir = temp_dir("gemini-ctx");
let home = temp_dir("gemini-home");
let cx = Context::new("t", Scope::Project, &dir, Some(home.clone()));
assert_eq!(
context_files(&cx).unwrap(),
("GEMINI.md".into(), vec!["GEMINI.md".into()])
);
fs::create_dir_all(home.join(".gemini")).unwrap();
fs::write(
home.join(SETTINGS),
r#"{"context":{"fileName":"AGENTS.md"}}"#,
)
.unwrap();
assert_eq!(context_files(&cx).unwrap().0, "AGENTS.md");
fs::create_dir_all(dir.join(".gemini")).unwrap();
fs::write(
dir.join(SETTINGS),
r#"{"context":{"fileName":["GEMINI.md","AGENTS.md"]}}"#,
)
.unwrap();
assert_eq!(
context_files(&cx).unwrap(),
(
"AGENTS.md".into(),
vec!["GEMINI.md".into(), "AGENTS.md".into()]
)
);
assert_eq!(
Gemini.reads(Item::Instructions, &cx).unwrap(),
Reads::always(["GEMINI.md", "AGENTS.md"])
);
fs::write(dir.join(SETTINGS), r#"{"context":{"fileName":["CTX.md"]}}"#).unwrap();
assert_eq!(context_files(&cx).unwrap().0, "CTX.md");
fs::write(dir.join(SETTINGS), r#"{"context":{"fileName":[]}}"#).unwrap();
assert_eq!(context_files(&cx).unwrap().0, "AGENTS.md");
let user = Context::new("t", Scope::User, &home, Some(home.clone()));
assert_eq!(context_files(&user).unwrap().0, ".gemini/AGENTS.md");
fs::write(dir.join(SETTINGS), "{ nope").unwrap();
assert!(matches!(context_files(&cx), Err(Error::File { .. })));
fs::remove_dir_all(&dir).unwrap();
fs::remove_dir_all(&home).unwrap();
}
#[test]
fn input_and_answers() {
let i = Gemini
.parse_hook(
Event::Stop,
r#"{"stop_hook_active":true,"prompt_response":"done","tool_name":"replace"}"#,
)
.unwrap();
assert!(i.continuing);
assert_eq!(i.last_message.as_deref(), Some("done"));
assert_eq!(i.tool.unwrap().kind, ToolKind::Write);
for (n, k) in [
("run_shell_command", ToolKind::Shell),
("glob", ToolKind::Read),
("mcp_s_t", ToolKind::Mcp),
("web_fetch", ToolKind::Other),
] {
assert_eq!(tool_kind(n), k);
}
let out = |e, a: Answer| Gemini.answer(e, &a).map(|o| o.stdout);
assert_eq!(
out(Event::Stop, Answer::Continue { reason: "r".into() }).unwrap(),
r#"{"decision":"deny","reason":"r"}"#
);
assert_eq!(
out(Event::PreTool, Answer::Deny { reason: "r".into() }).unwrap(),
r#"{"decision":"deny","reason":"r"}"#
);
assert_eq!(
out(Event::SessionStart, Answer::Context { text: "c".into() }).unwrap(),
r#"{"hookSpecificOutput":{"additionalContext":"c"}}"#
);
assert_eq!(
out(Event::Stop, Answer::Allow { stderr: None }).unwrap(),
"{}"
);
assert!(out(Event::Stop, Answer::Deny { reason: "r".into() }).is_err());
}
#[test]
fn a_note_on_folder_trust() {
let cx = Context::new("t", Scope::Project, "/nowhere", None);
let written = PartResult {
part: "hooks".into(),
state: State::Absent,
action: Some(Action::Created),
path: String::new(),
by: None,
in_place: false,
};
assert_eq!(Gemini.notes(&cx, std::slice::from_ref(&written)).len(), 1);
let user = Context::new("t", Scope::User, "/nowhere", None);
assert!(Gemini.notes(&user, &[written]).is_empty());
}
}