Skip to content

Commit 2c8f740

Browse files
committed
Add Antigravity CLI
Add Antigravity CLI and a required back button from chats + fix batch/folder specific import
1 parent c96a939 commit 2c8f740

6 files changed

Lines changed: 215 additions & 12 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Native desktop GUI for managing AI coding-agent configuration across providers.
99
- **Scope Switching** — project-level vs global configuration, with workspace browser.
1010
- **Diff Workbench** — compare project and global configs with stable, secret-safe fingerprints. Detects duplicates, missing targets, and scope conflicts.
1111
- **Hook Cockpit** — static hook inventory showing event, matcher, handler, blocking risk, timeout, duplicates, and project/global overlaps.
12-
- **Chat Manager** — unified chat history browser across Claude Code, Codex CLI, Gemini CLI, and Kiro. Search, export (single JSON or multi-chat ZIP), soft-delete with Trash, and import archived sessions.
12+
- **Chat Manager** — unified chat history browser across Claude Code, Codex CLI, Gemini CLI, Antigravity CLI, and Kiro. Search, export (single JSON or multi-chat ZIP), soft-delete with Trash, and import archived sessions.
1313
- **Inline Editor** — edit instruction files, rules, and steering docs without leaving the app.
1414
- **JSON Backups** — automatic `.bak` creation before any config mutation.
1515
- **Cross-platform** — Windows, Linux, and macOS builds.
@@ -21,6 +21,7 @@ Native desktop GUI for managing AI coding-agent configuration across providers.
2121
| Claude Code | `CLAUDE.md` | `.claude/skills/` | `settings.json` | `settings.json` | Rules |
2222
| Codex CLI | `AGENTS.md` | `.codex/skills/`, `.agents/skills/` | `config.toml`, `hooks.json` | `config.toml`, `.mcp.json` ||
2323
| Gemini CLI | `GEMINI.md`, `AGENTS.md` | `.gemini/skills/` | `settings.json` | `settings.json` | Rules |
24+
| Antigravity CLI | `GEMINI.md`, `AGENTS.md` | `.agents/skills/` | `settings.json` | `mcp_config.json` ||
2425
| Kiro ||| Agent JSON | `settings/mcp.json` | Steering, Specs, Agents |
2526
| OpenCode | `AGENTS.md` | `.opencode/skills/` | Plugins | `opencode.json` | Agents |
2627

src/app.rs

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,16 @@ impl eframe::App for App {
390390
impl App {
391391
fn show_chats(&mut self, ui_panel: &mut egui::Ui) {
392392
ui_panel.horizontal(|ui| {
393+
if ui.button("Back").clicked() {
394+
if self.selected_provider.is_none() {
395+
self.selected_provider = self
396+
.providers
397+
.iter()
398+
.find(|(_, exists)| *exists)
399+
.map(|(id, _)| *id);
400+
}
401+
self.view = View::Items;
402+
}
393403
ui.label(
394404
egui::RichText::new(if self.chat_trash_mode {
395405
"Chats Trash"
@@ -428,15 +438,36 @@ impl App {
428438
}
429439
if action.import {
430440
if let Some(path) = rfd::FileDialog::new()
431-
.add_filter("AgentSwitch chat", &["json"])
441+
.add_filter("AgentSwitch chat/zip", &["json", "zip"])
442+
.set_directory(chat::exports_dir())
432443
.pick_file()
433444
{
434-
match chat::import_archive(&path) {
435-
Ok(_) => {
436-
self.rescan_chats();
437-
self.status_msg = Some("Chat imported".into());
445+
let project_dir = rfd::FileDialog::new()
446+
.set_title("Associate with project directory (Cancel to skip)")
447+
.pick_folder();
448+
let is_zip = path
449+
.extension()
450+
.and_then(|e| e.to_str())
451+
.is_some_and(|e| e.eq_ignore_ascii_case("zip"));
452+
if is_zip {
453+
match chat::import_zip(&path, project_dir.as_deref()) {
454+
Ok(report) => {
455+
self.rescan_chats();
456+
self.status_msg = Some(format!(
457+
"{} chats imported, {} failed",
458+
report.ok, report.failed
459+
));
460+
}
461+
Err(e) => self.status_msg = Some(format!("Import error: {e}")),
462+
}
463+
} else {
464+
match chat::import_archive(&path, project_dir.as_deref()) {
465+
Ok(_) => {
466+
self.rescan_chats();
467+
self.status_msg = Some("Chat imported".into());
468+
}
469+
Err(e) => self.status_msg = Some(format!("Import error: {e}")),
438470
}
439-
Err(e) => self.status_msg = Some(format!("Import error: {e}")),
440471
}
441472
}
442473
}
@@ -446,11 +477,13 @@ impl App {
446477
.iter()
447478
.filter_map(|idx| self.chat_sessions.get(*idx).cloned())
448479
.collect();
480+
let _ = std::fs::create_dir_all(chat::exports_dir());
449481
if sessions.len() == 1 {
450482
let session = &sessions[0];
451483
if let Some(path) = rfd::FileDialog::new()
452484
.add_filter("AgentSwitch chat", &["json"])
453485
.set_file_name(chat::suggested_export_name(session))
486+
.set_directory(chat::exports_dir())
454487
.save_file()
455488
{
456489
match chat::export_session(session, &path) {
@@ -462,6 +495,7 @@ impl App {
462495
if let Some(path) = rfd::FileDialog::new()
463496
.add_filter("AgentSwitch chats", &["zip"])
464497
.set_file_name(chat::suggested_zip_export_name())
498+
.set_directory(chat::exports_dir())
465499
.save_file()
466500
{
467501
match chat::export_sessions_zip(&sessions, &path) {
@@ -586,6 +620,10 @@ fn instruction_files(provider: ProviderId, root: &Path, dir: &Path, scope: Scope
586620
vec![root.join("GEMINI.md"), root.join("AGENTS.md")]
587621
}
588622
(ProviderId::Gemini, Scope::Global) => vec![dir.join("GEMINI.md")],
623+
(ProviderId::Antigravity, Scope::Project) => {
624+
vec![root.join("GEMINI.md"), root.join("AGENTS.md")]
625+
}
626+
(ProviderId::Antigravity, Scope::Global) => vec![dir.join("GEMINI.md")],
589627
(ProviderId::Kiro, Scope::Project) => {
590628
vec![root.join(".kiro").join("steering").join("instructions.md")]
591629
}

src/chat.rs

Lines changed: 124 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use std::{
1111
time::{SystemTime, UNIX_EPOCH},
1212
};
1313
use walkdir::WalkDir;
14-
use zip::{write::SimpleFileOptions, CompressionMethod, ZipWriter};
14+
use zip::{read::ZipArchive, write::SimpleFileOptions, CompressionMethod, ZipWriter};
1515

1616
const ARCHIVE_VERSION: u32 = 1;
1717
const ARCHIVE_EXT: &str = "agentswitch-chat.json";
@@ -21,6 +21,7 @@ pub enum ChatProvider {
2121
Claude,
2222
Codex,
2323
Gemini,
24+
Antigravity,
2425
Kiro,
2526
}
2627

@@ -30,6 +31,7 @@ impl ChatProvider {
3031
Self::Claude => "Claude Code",
3132
Self::Codex => "Codex CLI",
3233
Self::Gemini => "Gemini CLI",
34+
Self::Antigravity => "Antigravity CLI",
3335
Self::Kiro => "Kiro",
3436
}
3537
}
@@ -39,6 +41,7 @@ impl ChatProvider {
3941
Self::Claude => "claude",
4042
Self::Codex => "codex",
4143
Self::Gemini => "gemini",
44+
Self::Antigravity => "antigravity",
4245
Self::Kiro => "kiro",
4346
}
4447
}
@@ -48,6 +51,7 @@ impl ChatProvider {
4851
Self::Claude => ProviderId::Claude.color(),
4952
Self::Codex => ProviderId::Codex.color(),
5053
Self::Gemini => ProviderId::Gemini.color(),
54+
Self::Antigravity => ProviderId::Antigravity.color(),
5155
Self::Kiro => ProviderId::Kiro.color(),
5256
}
5357
}
@@ -160,6 +164,7 @@ pub fn scan_all(workspace: &Path) -> Vec<ChatSession> {
160164
sessions.extend(scan_claude());
161165
sessions.extend(scan_codex());
162166
sessions.extend(scan_gemini());
167+
sessions.extend(scan_antigravity());
163168
sessions.extend(scan_kiro(workspace));
164169
sessions.extend(scan_imported());
165170
sessions.sort_by(|a, b| {
@@ -263,9 +268,12 @@ pub fn export_sessions_zip(sessions: &[ChatSession], target: &Path) -> Result<Ba
263268
Ok(report)
264269
}
265270

266-
pub fn import_archive(path: &Path) -> Result<PathBuf> {
267-
let archive: ChatArchive = serde_json::from_str(&fs::read_to_string(path)?)?;
271+
pub fn import_archive(path: &Path, project_dir: Option<&Path>) -> Result<PathBuf> {
272+
let mut archive: ChatArchive = serde_json::from_str(&fs::read_to_string(path)?)?;
268273
validate_archive(&archive)?;
274+
if let Some(dir) = project_dir {
275+
archive.project_path = dir.to_string_lossy().to_string();
276+
}
269277
let dir = imports_dir();
270278
fs::create_dir_all(&dir)?;
271279
let base = safe_file_stem(&format!(
@@ -279,10 +287,59 @@ pub fn import_archive(path: &Path) -> Result<PathBuf> {
279287
target = dir.join(format!("{base}-{n}.{ARCHIVE_EXT}"));
280288
n += 1;
281289
}
282-
fs::copy(path, &target)?;
290+
fs::write(&target, serde_json::to_string_pretty(&archive)?)?;
283291
Ok(target)
284292
}
285293

294+
pub fn import_zip(path: &Path, project_dir: Option<&Path>) -> Result<BatchReport> {
295+
let file = File::open(path)?;
296+
let mut zip = ZipArchive::new(file)?;
297+
let dir = imports_dir();
298+
fs::create_dir_all(&dir)?;
299+
let mut report = BatchReport::default();
300+
for i in 0..zip.len() {
301+
let mut entry = zip.by_index(i)?;
302+
let name = entry.name().to_string();
303+
if !name.ends_with(ARCHIVE_EXT) {
304+
continue;
305+
}
306+
let mut buf = String::new();
307+
std::io::Read::read_to_string(&mut entry, &mut buf)?;
308+
let mut archive: ChatArchive = match serde_json::from_str(&buf) {
309+
Ok(a) => a,
310+
Err(_) => {
311+
report.failed += 1;
312+
continue;
313+
}
314+
};
315+
if validate_archive(&archive).is_err() {
316+
report.failed += 1;
317+
continue;
318+
}
319+
if let Some(d) = project_dir {
320+
archive.project_path = d.to_string_lossy().to_string();
321+
}
322+
let base = safe_file_stem(&format!(
323+
"{}-{}",
324+
archive.source_provider.id(),
325+
archive.title
326+
));
327+
let mut target = dir.join(format!("{base}.{ARCHIVE_EXT}"));
328+
let mut n = 2usize;
329+
while target.exists() {
330+
target = dir.join(format!("{base}-{n}.{ARCHIVE_EXT}"));
331+
n += 1;
332+
}
333+
fs::write(&target, serde_json::to_string_pretty(&archive)?)?;
334+
report.ok += 1;
335+
}
336+
Ok(report)
337+
}
338+
339+
pub fn exports_dir() -> PathBuf {
340+
data_dir().join("chats").join("exports")
341+
}
342+
286343
pub fn soft_delete(session: &ChatSession, workspace: &Path) -> Result<()> {
287344
let _ = workspace;
288345
if session.source_kind == ChatSourceKind::KiroCli {
@@ -593,6 +650,59 @@ fn scan_gemini() -> Vec<ChatSession> {
593650
out
594651
}
595652

653+
fn scan_antigravity() -> Vec<ChatSession> {
654+
let Some(home) = dirs::home_dir() else {
655+
return vec![];
656+
};
657+
let tmp = home.join(".gemini").join("antigravity-cli").join("tmp");
658+
if !tmp.is_dir() {
659+
return vec![];
660+
}
661+
let mut out = Vec::new();
662+
let Ok(projects) = fs::read_dir(&tmp) else {
663+
return out;
664+
};
665+
for project in projects.flatten().filter(|e| e.path().is_dir()) {
666+
let chats = project.path().join("chats");
667+
if let Ok(entries) = fs::read_dir(&chats) {
668+
for entry in entries.flatten() {
669+
let path = entry.path();
670+
if path.is_file() && is_gemini_session_file(&path) {
671+
if let Ok(session) = jsonl_session(ChatProvider::Antigravity, &tmp, &path) {
672+
if session.turn_count > 0 {
673+
out.push(session);
674+
}
675+
}
676+
} else if path.is_dir() {
677+
let files = jsonl_files_in(&path, 1);
678+
if !files.is_empty() {
679+
if let Ok(session) =
680+
jsonl_dir_session(ChatProvider::Antigravity, &tmp, &path, files)
681+
{
682+
if session.turn_count > 0 {
683+
out.push(session);
684+
}
685+
}
686+
}
687+
}
688+
}
689+
}
690+
if let Ok(entries) = fs::read_dir(project.path()) {
691+
for entry in entries.flatten() {
692+
let path = entry.path();
693+
if path.is_file() && is_gemini_checkpoint_file(&path) {
694+
if let Ok(session) = jsonl_session(ChatProvider::Antigravity, &tmp, &path) {
695+
if session.turn_count > 0 {
696+
out.push(session);
697+
}
698+
}
699+
}
700+
}
701+
}
702+
}
703+
out
704+
}
705+
596706
fn scan_kiro(workspace: &Path) -> Vec<ChatSession> {
597707
let _ = workspace;
598708
let Some(home) = dirs::home_dir() else {
@@ -983,6 +1093,10 @@ fn update_meta_from_event(provider: ChatProvider, value: &Value, meta: &mut Sess
9831093
meta.project_path =
9841094
str_field(value, &["projectHash"]).map(|s| format!("Gemini project {s}"));
9851095
}
1096+
if meta.project_path.is_none() && provider == ChatProvider::Antigravity {
1097+
meta.project_path =
1098+
str_field(value, &["projectHash"]).map(|s| format!("Antigravity project {s}"));
1099+
}
9861100
}
9871101

9881102
fn tool_title(value: &Value) -> Option<&str> {
@@ -1184,6 +1298,12 @@ fn project_label_from_path(provider: ChatProvider, root: &Path, path: &Path) ->
11841298
.and_then(|p| p.components().next())
11851299
.map(|c| c.as_os_str().to_string_lossy().to_string())
11861300
.unwrap_or_else(|| "Gemini project".into()),
1301+
ChatProvider::Antigravity => path
1302+
.strip_prefix(root)
1303+
.ok()
1304+
.and_then(|p| p.components().next())
1305+
.map(|c| c.as_os_str().to_string_lossy().to_string())
1306+
.unwrap_or_else(|| "Antigravity project".into()),
11871307
_ => path
11881308
.parent()
11891309
.map(|p| p.to_string_lossy().to_string())

src/scanner.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ pub fn scan_provider(id: ProviderId, root: &Path, scope: Scope) -> Vec<ConfigIte
66
ProviderId::Claude => scan_claude(root, scope),
77
ProviderId::Codex => scan_codex(root, scope),
88
ProviderId::Gemini => scan_gemini(root, scope),
9+
ProviderId::Antigravity => scan_antigravity(root, scope),
910
ProviderId::Kiro => scan_kiro(root, scope),
1011
ProviderId::OpenCode => scan_opencode(root, scope),
1112
}
@@ -24,6 +25,11 @@ pub fn provider_exists(id: ProviderId, root: &Path, scope: Scope) -> bool {
2425
provider_dir(id, root, scope).is_dir() || has_cmd("codex")
2526
}
2627
(ProviderId::Gemini, _) => provider_dir(id, root, scope).is_dir() || has_cmd("gemini"),
28+
(ProviderId::Antigravity, _) => {
29+
provider_dir(id, root, scope).is_dir()
30+
|| root.join(".agents").is_dir()
31+
|| has_cmd("agy")
32+
}
2733
(ProviderId::Kiro, _) => {
2834
provider_dir(id, root, scope).is_dir() || has_cmd("kiro") || has_cmd("kiro-cli")
2935
}
@@ -40,6 +46,8 @@ pub fn provider_dir(id: ProviderId, root: &Path, scope: Scope) -> PathBuf {
4046
(ProviderId::Codex, Scope::Global) => home.join(".codex"),
4147
(ProviderId::Gemini, Scope::Project) => root.join(".gemini"),
4248
(ProviderId::Gemini, Scope::Global) => home.join(".gemini"),
49+
(ProviderId::Antigravity, Scope::Project) => root.join(".agents"),
50+
(ProviderId::Antigravity, Scope::Global) => home.join(".gemini").join("antigravity-cli"),
4351
(ProviderId::Kiro, Scope::Project) => root.join(".kiro"),
4452
(ProviderId::Kiro, Scope::Global) => home.join(".kiro"),
4553
(ProviderId::OpenCode, Scope::Project) => root.join(".opencode"),
@@ -519,6 +527,38 @@ fn scan_gemini(root: &Path, scope: Scope) -> Vec<ConfigItem> {
519527
items
520528
}
521529

530+
fn scan_antigravity(root: &Path, scope: Scope) -> Vec<ConfigItem> {
531+
let d = provider_dir(ProviderId::Antigravity, root, scope);
532+
let mut items = vec![];
533+
if scope == Scope::Project {
534+
items.extend(check_file(
535+
root.join("GEMINI.md"),
536+
ItemKind::InstructionFile,
537+
ProviderId::Antigravity,
538+
));
539+
items.extend(check_file(
540+
root.join("AGENTS.md"),
541+
ItemKind::InstructionFile,
542+
ProviderId::Antigravity,
543+
));
544+
}
545+
items.extend(collect_subdirs_both(
546+
&d.join("skills"),
547+
ItemKind::Skill,
548+
ProviderId::Antigravity,
549+
));
550+
let mcp_cfg = d.join("mcp_config.json");
551+
items.extend(scan_json_keys(
552+
&mcp_cfg,
553+
"mcpServers",
554+
ItemKind::Mcp,
555+
ProviderId::Antigravity,
556+
));
557+
let settings = d.join("settings.json");
558+
items.extend(scan_hook_entries(&settings, ProviderId::Antigravity, &[]));
559+
items
560+
}
561+
522562
fn scan_kiro(root: &Path, scope: Scope) -> Vec<ConfigItem> {
523563
let d = provider_dir(ProviderId::Kiro, root, scope);
524564
let mut items = vec![];

src/toggler.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ pub fn toggle_item(item: &mut ConfigItem) -> Result<()> {
3939
}
4040

4141
fn toggle_hook(item: &mut ConfigItem, loc: &HookLoc) -> Result<()> {
42-
if item.provider == ProviderId::Gemini {
42+
if item.provider == ProviderId::Gemini || item.provider == ProviderId::Antigravity {
4343
return toggle_gemini_hook(item, loc);
4444
}
4545
toggle_hook_stash(item, loc)

0 commit comments

Comments
 (0)