Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion rust/crates/sift_cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
Do not add per-client copies of the skill. The pair of near-identical files this
replaced drifted apart, which is the failure this layout exists to prevent.
Claude installs the canonical file to `~/.claude/skills/sift/SKILL.md`; Codex,
Cursor, and OpenCode share `~/.agents/skills/sift/SKILL.md`.
Cursor, Gemini CLI, and OpenCode share `~/.agents/skills/sift/SKILL.md`.
Antigravity CLI installs it to `~/.gemini/config/skills/sift/SKILL.md`.

The skill is embedded at compile time, so rebuild `sift-cli` after changing it.

Expand Down
3 changes: 3 additions & 0 deletions rust/crates/sift_cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ This project adheres to [Semantic Versioning](http://semver.org/).

### What's New

- `sift-cli agent install`, `update`, `doctor`, and `uninstall` now support
Gemini CLI and Antigravity CLI.

## [v0.5.0] - September 3, 2026

### What's New
Expand Down
56 changes: 45 additions & 11 deletions rust/crates/sift_cli/src/cmd/agent/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ pub(super) fn snapshot(harness: Harness, environment: &Environment) -> Result<Sn
}
}
}
Harness::Cursor | Harness::OpenCode => {
Harness::Cursor | Harness::Gemini | Harness::Antigravity | Harness::OpenCode => {
let path = json_path(harness, environment);
let contents = match fs::symlink_metadata(&path) {
Ok(metadata) if metadata.file_type().is_symlink() => {
Expand Down Expand Up @@ -155,8 +155,9 @@ pub(super) fn restore(snapshot: &Snapshot, environment: &Environment) -> Result<
fn inspect_with(harness: Harness, environment: &Environment, runner: &dyn Runner) -> Result<State> {
match harness {
Harness::Claude | Harness::Codex => Ok(inspect_native(harness, environment, runner)?.state),
Harness::Cursor => inspect_json(harness, environment),
Harness::OpenCode => inspect_json(harness, environment),
Harness::Cursor | Harness::Gemini | Harness::Antigravity | Harness::OpenCode => {
inspect_json(harness, environment)
}
}
}

Expand All @@ -178,7 +179,9 @@ fn install_with(
Harness::Claude | Harness::Codex => {
install_native(harness, environment, registration, runner)
}
Harness::Cursor | Harness::OpenCode => install_json(harness, environment, registration),
Harness::Cursor | Harness::Gemini | Harness::Antigravity | Harness::OpenCode => {
install_json(harness, environment, registration)
}
}
}

Expand All @@ -199,7 +202,7 @@ fn uninstall_with(
remove_native(harness, runner)?;
Ok(true)
}
Harness::Cursor | Harness::OpenCode => {
Harness::Cursor | Harness::Gemini | Harness::Antigravity | Harness::OpenCode => {
remove_json(harness, environment)?;
Ok(true)
}
Expand All @@ -215,7 +218,7 @@ fn inspect_native(
match harness {
Harness::Claude => inspect_claude(environment, runner),
Harness::Codex => inspect_codex(environment, runner),
Harness::Cursor | Harness::OpenCode => {
Harness::Cursor | Harness::Gemini | Harness::Antigravity | Harness::OpenCode => {
unreachable!("JSON-backed harnesses do not have native registrations")
}
}
Expand Down Expand Up @@ -478,7 +481,7 @@ fn install_json(
servers.insert(
"sift".to_string(),
match harness {
Harness::Cursor => json!({
Harness::Cursor | Harness::Gemini | Harness::Antigravity => json!({
"command": environment.current_exe,
"args": args
}),
Expand Down Expand Up @@ -515,7 +518,7 @@ fn classify_json_entry(harness: Harness, entry: &Value, environment: &Environmen
};

let (command, args, metadata_is_managed) = match harness {
Harness::Cursor => {
Harness::Cursor | Harness::Gemini => {
let allowed = ["command", "args"];
let managed = entry.keys().all(|key| allowed.contains(&key.as_str()));
let Some(args) = string_array(entry.get("args")) else {
Expand All @@ -532,6 +535,24 @@ fn classify_json_entry(harness: Harness, entry: &Value, environment: &Environmen
managed,
)
}
Harness::Antigravity => {
let allowed = ["command", "args", "disabled"];
let managed = entry.keys().all(|key| allowed.contains(&key.as_str()));
let Some(args) = string_array(entry.get("args")) else {
return State::Conflict(
"the existing `sift` MCP entry has invalid arguments".to_string(),
);
};
let enabled = false_or_missing(entry.get("disabled"));
(
entry
.get("command")
.and_then(Value::as_str)
.map(str::to_string),
args,
managed && enabled,
)
}
Harness::OpenCode => {
let allowed = ["type", "command", "enabled"];
let managed = entry.keys().all(|key| allowed.contains(&key.as_str()))
Expand Down Expand Up @@ -642,6 +663,12 @@ fn is_sift_cli(command: &str) -> bool {
fn json_path(harness: Harness, environment: &Environment) -> PathBuf {
match harness {
Harness::Cursor => environment.home.join(".cursor").join("mcp.json"),
Harness::Gemini => environment.home.join(".gemini").join("settings.json"),
Harness::Antigravity => environment
.home
.join(".gemini")
.join("config")
.join("mcp_config.json"),
Harness::OpenCode => environment
.home
.join(".config")
Expand All @@ -653,7 +680,7 @@ fn json_path(harness: Harness, environment: &Environment) -> PathBuf {

fn container_key(harness: Harness) -> &'static str {
match harness {
Harness::Cursor => "mcpServers",
Harness::Cursor | Harness::Gemini | Harness::Antigravity => "mcpServers",
Harness::OpenCode => "mcp",
_ => unreachable!("only JSON-backed harnesses have container keys"),
}
Expand All @@ -680,6 +707,9 @@ fn load_json(path: &Path) -> Result<Option<Map<String, Value>>> {
return Err(error).with_context(|| format!("failed to read {}", path.display()));
}
};
if contents.is_empty() || contents.iter().all(u8::is_ascii_whitespace) {
return Ok(None);
}
let value: Value = serde_json::from_slice(&contents)
.with_context(|| format!("{} is not valid JSON", path.display()))?;
value
Expand Down Expand Up @@ -788,6 +818,10 @@ fn true_or_missing(value: Option<&Value>) -> bool {
value.is_none_or(|value| value.as_bool() == Some(true))
}

fn false_or_missing(value: Option<&Value>) -> bool {
value.is_none_or(|value| value.as_bool() == Some(false))
}

fn field(contents: &str, prefix: &str) -> Option<String> {
contents
.lines()
Expand Down Expand Up @@ -842,7 +876,7 @@ fn add_native(harness: Harness, entry: &NativeEntry, runner: &dyn Runner) -> Res
os_args(["mcp", "add", "sift", "--"]),
"register the Sift MCP server with Codex",
),
Harness::Cursor | Harness::OpenCode => {
Harness::Cursor | Harness::Gemini | Harness::Antigravity | Harness::OpenCode => {
unreachable!("JSON-backed harnesses do not use native registration commands")
}
};
Expand All @@ -863,7 +897,7 @@ fn remove_native(harness: Harness, runner: &dyn Runner) -> Result<()> {
os_args(["mcp", "remove", "sift"]),
"remove the Codex MCP registration",
),
Harness::Cursor | Harness::OpenCode => {
Harness::Cursor | Harness::Gemini | Harness::Antigravity | Harness::OpenCode => {
unreachable!("JSON-backed harnesses do not use native registration commands")
}
};
Expand Down
22 changes: 18 additions & 4 deletions rust/crates/sift_cli/src/cmd/agent/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ pub(super) enum Harness {
Claude,
Codex,
Cursor,
Gemini,
Antigravity,
OpenCode,
}

Expand All @@ -131,6 +133,8 @@ impl Harness {
Self::Claude => "Claude Code",
Self::Codex => "Codex",
Self::Cursor => "Cursor",
Self::Gemini => "Gemini CLI",
Self::Antigravity => "Antigravity CLI",
Self::OpenCode => "OpenCode",
}
}
Expand Down Expand Up @@ -168,6 +172,16 @@ impl Environment {
&["cursor", "cursor-agent"][..],
self.home.join(".cursor"),
),
(
Harness::Gemini,
&["gemini"][..],
self.home.join(".gemini").join("settings.json"),
),
(
Harness::Antigravity,
&["agy"][..],
self.home.join(".gemini").join("antigravity-cli"),
),
(
Harness::OpenCode,
&["opencode"][..],
Expand All @@ -177,13 +191,13 @@ impl Environment {

candidates
.into_iter()
.filter_map(|(harness, commands, config_dir)| {
.filter_map(|(harness, commands, config_path)| {
let command_exists = commands
.iter()
.any(|command| self.command_available(command));
// A config directory alone counts, so headless environments
// A config path alone counts, so headless environments
// without client binaries still install the skill.
let detected = command_exists || config_dir.exists();
let detected = command_exists || config_path.exists();
detected.then_some(harness)
})
.collect()
Expand Down Expand Up @@ -736,7 +750,7 @@ fn install_environment(
if environment.harnesses.is_empty() && extra_skill_path.is_none() {
println!(
"No supported AI coding clients were detected. Supported clients: \
Claude Code, Codex, Cursor, and OpenCode."
Claude Code, Codex, Cursor, Gemini CLI, Antigravity CLI, and OpenCode."
);
return Ok(ExitCode::FAILURE);
}
Expand Down
8 changes: 7 additions & 1 deletion rust/crates/sift_cli/src/cmd/agent/skill.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,15 @@ pub(super) fn targets(environment: &Environment) -> Vec<Target> {
for harness in &environment.harnesses {
let path = match harness {
Harness::Claude => environment.home.join(".claude").join("skills").join("sift"),
Harness::Codex | Harness::Cursor | Harness::OpenCode => {
Harness::Codex | Harness::Cursor | Harness::Gemini | Harness::OpenCode => {
environment.home.join(".agents").join("skills").join("sift")
}
Harness::Antigravity => environment
.home
.join(".gemini")
.join("config")
.join("skills")
.join("sift"),
};
by_path.entry(path).or_default().push(*harness);
}
Expand Down
Loading
Loading