diff --git a/AGENTS.md b/AGENTS.md index 6ccbb4a6c..4240df7ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -119,3 +119,8 @@ INSTA_UPDATE=auto cargo test --features test-utils # 自动写新 .snap - ESLint:next/core-web-vitals + typescript + prettier - TypeScript:strict 模式,启用 `noUnusedLocals` 和 `noUnusedParameters` - Rust:2021 edition,使用 `thiserror` 定义错误类型 + +## Local Service Runtime + +- Run exactly one `codeg-server` instance at a time. +- Use `127.0.0.1:3080`. If an older Codeg service occupies the port, stop it before starting the current service; do not switch to another port. diff --git a/CLAUDE.md b/CLAUDE.md index af3556115..d1815a603 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -119,3 +119,8 @@ INSTA_UPDATE=auto cargo test --features test-utils # 自动写新 .snap - ESLint:next/core-web-vitals + typescript + prettier - TypeScript:strict 模式,启用 `noUnusedLocals` 和 `noUnusedParameters` - Rust:2021 edition,使用 `thiserror` 定义错误类型 + +## Local Service Runtime + +- Run exactly one `codeg-server` instance at a time. +- Use `127.0.0.1:3080`. If an older Codeg service occupies the port, stop it before starting the current service; do not switch to another port. diff --git a/README.md b/README.md index b9b9eab8c..68459d090 100644 --- a/README.md +++ b/README.md @@ -23,34 +23,6 @@ Codeg (Code Generation) is a multi-agent coding workspace. It brings multiple ag ![gallery](./docs/images/gallery.svg) -## Sponsors - - - - - - - - - - - - - - -
- Compshare
- Compshare (UCloud) -
Thanks to Compshare for sponsoring this project! Compshare is UCloud's AI cloud platform, offering cost-effective monthly and pay-as-you-go agent Plan subscriptions for Chinese models, starting at just ¥49/month. It also provides stable officially-proxied access to overseas models. Supports Claude Code, Codex, and API integrations. Enterprise-ready with high concurrency, 24/7 technical support, and self-service invoicing. Users who sign up via this link receive ¥5 in free platform credits!
- 随想AI中转站
- 随想AI中转站 -
Thanks to 随想AI中转站 for sponsoring this project! 随想AI中转站 is a reliable and efficient API relay provider, offering relay services for Claude, Codex, Gemini, and more. New accounts earn ¥0.5 in test credit with each daily check-in after signing up; top-ups are credited 1:1 — no subscription, pay as you go. Multi-route redundancy, cross-region disaster recovery, and automatic failover keep long-lived SSE connections uninterrupted.
- 合租巴士
- 合租巴士 -
Thanks to 合租巴士 for sponsoring this project! 合租巴士 is a reliable and efficient AI relay platform, offering highly stable relay for mainstream models such as Codex and Claude Code. Top-ups are credited at a transparent 1:1 ratio, with Codex rate subsidies as low as 0.08. Join the group via the official website to get $5 in trial credit.
- -> Want to become a Codeg sponsor? [Reach out to us by email.](mailto:itpkcn@gmail.com) - ## Main Interface ![Codeg Light](./docs/images/main-light.png#gh-light-mode-only) @@ -426,15 +398,6 @@ Next.js 16 (Static Export) + React 19 - System proxy support for enterprise environments - Web service mode uses token-based authentication -## Community - -- Scan the QR code below to join our WeChat group for discussions, feedback, and updates - -WeChat -WeChat - -- Thanks to the [LinuxDO](https://linux.do) community for their support - ## Acknowledgments - [ACP](https://agentclientprotocol.com) — the Agent Client Protocol (ACP) is the foundation that enables Codeg to connect with multiple agents diff --git a/docs/readme/README.ar.md b/docs/readme/README.ar.md index 1ef746602..e5cd5b61f 100644 --- a/docs/readme/README.ar.md +++ b/docs/readme/README.ar.md @@ -23,34 +23,6 @@ Codeg (Code Generation) هو مساحة عمل للبرمجة متعددة ال ![gallery](../images/gallery.svg) -## الرعاة - - - - - - - - - - - - - - -
- Compshare
- Compshare (UCloud) -
شكراً لـ Compshare على رعاية هذا المشروع! Compshare هي منصة الذكاء الاصطناعي السحابية التابعة لشركة UCloud، وتقدّم باقات Plan للوكلاء بنماذج محلية بأسعار اقتصادية شهرياً أو حسب الاستخدام، بدءاً من 49 يوان/شهر. كما توفّر وصولاً مستقراً إلى النماذج الأجنبية عبر وكيل رسمي. تدعم التكامل مع Claude Code وCodex واستدعاءات API. جاهزة للمؤسسات: تزامن عالٍ، ودعم فني على مدار الساعة طوال أيام الأسبوع، وإصدار الفواتير ذاتياً. المستخدمون الذين يسجّلون عبر هذا الرابط يحصلون على رصيد تجريبي مجاني بقيمة 5 يوان على المنصة!
- 随想AI中转站
- 随想AI中转站 -
شكراً لـ 随想AI中转站 على رعاية هذا المشروع! 随想AI中转站 هي مزوّد موثوق وفعّال لخدمات ترحيل واجهات API، وتوفّر خدمات الترحيل لنماذج Claude وCodex وGemini وغيرها. تحصل الحسابات الجديدة بعد التسجيل على رصيد تجريبي بقيمة 0.5 يوان مقابل تسجيل الحضور اليومي؛ وتُضاف عمليات الشحن بنسبة 1:1، دون اشتراك وبالدفع حسب الاستخدام. خطوط متعددة متكرّرة، وتعافٍ من الكوارث عبر المناطق، وتبديل تلقائي عند الأعطال — لتبقى اتصالات SSE طويلة الأمد دون انقطاع.
- 合租巴士
- 合租巴士 -
شكراً لـ 合租巴士 على رعاية هذا المشروع! 合租巴士 هي منصة موثوقة وفعّالة لخدمات ترحيل الذكاء الاصطناعي، توفّر ترحيلاً عالي الاستقرار للنماذج الرئيسية مثل Codex وClaude Code. نسبة الشحن شفافة (1:1)، مع دعم لمعدّل Codex يبدأ من 0.08 فقط. انضم إلى المجموعة عبر الموقع الرسمي للحصول على رصيد تجريبي بقيمة 5 دولارات.
- -> هل ترغب في أن تصبح راعياً لـ Codeg؟ [راسلنا عبر البريد الإلكتروني.](mailto:itpkcn@gmail.com) - ## الواجهة الرئيسية ![Codeg Light](../images/main-light.png#gh-light-mode-only) @@ -426,15 +398,6 @@ Next.js 16 (Static Export) + React 19 - دعم بروكسي النظام لبيئات المؤسسات - وضع خدمة الويب يستخدم مصادقة قائمة على الرموز -## المجتمع - -- امسح رمز QR أدناه للانضمام إلى مجموعة WeChat الخاصة بنا للنقاشات والملاحظات والتحديثات - -WeChat -WeChat - -- شكراً لمجتمع [LinuxDO](https://linux.do) على دعمه - ## شكر وتقدير - [ACP](https://agentclientprotocol.com) — بروتوكول Agent Client (ACP) هو الأساس الذي يمكّن Codeg من الاتصال بعدة وكلاء diff --git a/docs/readme/README.de.md b/docs/readme/README.de.md index 80651eb87..b95355340 100644 --- a/docs/readme/README.de.md +++ b/docs/readme/README.de.md @@ -23,34 +23,6 @@ Codeg (Code Generation) ist ein Multi-Agent-Coding-Workspace. Es vereint mehrere ![gallery](../images/gallery.svg) -## Sponsoren - - - - - - - - - - - - - - -
- Compshare
- Compshare (UCloud) -
Vielen Dank an Compshare für die Unterstützung dieses Projekts! Compshare ist die KI-Cloud-Plattform von UCloud und bietet preiswerte monatliche und nutzungsbasierte Plan-Tarife für inländische Modell-Agents ab 49 ¥/Monat. Zusätzlich bietet sie stabilen, offiziell weitergeleiteten Zugriff auf Modelle aus Übersee. Unterstützt Claude Code, Codex und API-Aufrufe. Enterprise-tauglich: hohe Parallelität, 24/7-Support, Self-Service-Rechnungsstellung. Wer sich über diesen Link registriert, erhält 5 ¥ Plattformguthaben gratis!
- 随想AI中转站
- 随想AI中转站 -
Vielen Dank an 随想AI中转站 für die Unterstützung dieses Projekts! 随想AI中转站 ist ein zuverlässiger und effizienter API-Relay-Anbieter mit Relay-Diensten für Claude, Codex, Gemini und mehr. Neue Konten erhalten nach der Registrierung für jedes tägliche Einchecken 0,5 ¥ Testguthaben; Aufladungen werden 1:1 gutgeschrieben – ohne Abo, Bezahlung nach Verbrauch. Mehrfach redundante Leitungen, regionsübergreifende Notfallwiederherstellung und automatisches Failover halten langlebige SSE-Verbindungen unterbrechungsfrei.
- 合租巴士
- 合租巴士 -
Vielen Dank an 合租巴士 für die Unterstützung dieses Projekts! 合租巴士 ist eine zuverlässige und effiziente KI-Relay-Plattform, die hochstabiles Relay für gängige Modelle wie Codex und Claude Code bietet. Das Aufladeverhältnis ist transparent (1:1), mit Codex-Ratenzuschüssen ab nur 0,08. Treten Sie der Gruppe über die offizielle Website bei und erhalten Sie 5 USD Testguthaben.
- -> Möchten Sie Codeg-Sponsor werden? [Schreiben Sie uns gerne eine E-Mail.](mailto:itpkcn@gmail.com) - ## Hauptoberfläche ![Codeg Light](../images/main-light.png#gh-light-mode-only) @@ -426,15 +398,6 @@ Next.js 16 (Static Export) + React 19 - Systemproxy-Unterstützung für Unternehmensumgebungen - Der Webdienst-Modus verwendet tokenbasierte Authentifizierung -## Community - -- Scannen Sie den unten stehenden QR-Code, um unserer WeChat-Gruppe für Diskussionen, Feedback und Updates beizutreten - -WeChat -WeChat - -- Danke an die [LinuxDO](https://linux.do)-Community für ihre Unterstützung - ## Danksagungen - [ACP](https://agentclientprotocol.com) — das Agent Client Protocol (ACP) ist die Grundlage, die es Codeg ermöglicht, sich mit mehreren Agenten zu verbinden diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md index cb5f5df51..491e5344e 100644 --- a/docs/readme/README.es.md +++ b/docs/readme/README.es.md @@ -23,34 +23,6 @@ Codeg (Code Generation) es un espacio de trabajo de codificación multiagente. U ![gallery](../images/gallery.svg) -## Patrocinadores - - - - - - - - - - - - - - -
- Compshare
- Compshare (UCloud) -
¡Gracias a Compshare por patrocinar este proyecto! Compshare es la plataforma de IA en la nube de UCloud, que ofrece planes Plan de agentes con modelos nacionales en suscripción mensual o por uso, desde 49 ¥/mes. También proporciona acceso estable a modelos extranjeros mediante proxy oficial. Compatible con Claude Code, Codex y llamadas a la API. Apto para empresas: alta concurrencia, soporte técnico 24/7 y facturación en autoservicio. ¡Los usuarios que se registren a través de este enlace recibirán 5 ¥ de saldo de prueba gratis!
- 随想AI中转站
- 随想AI中转站 -
¡Gracias a 随想AI中转站 por patrocinar este proyecto! 随想AI中转站 es un proveedor de retransmisión de API fiable y eficiente, que ofrece servicios de retransmisión para Claude, Codex, Gemini y más. Las cuentas nuevas reciben 0,5 ¥ de crédito de prueba con cada registro de asistencia diario tras registrarse; las recargas se acreditan 1:1, sin suscripción y con pago por uso. La redundancia multilínea, la recuperación ante desastres entre regiones y la conmutación por error automática mantienen sin interrupciones las conexiones SSE de larga duración.
- 合租巴士
- 合租巴士 -
¡Gracias a 合租巴士 por patrocinar este proyecto! 合租巴士 es una plataforma de retransmisión de IA fiable y eficiente que ofrece una retransmisión de alta estabilidad para los principales modelos como Codex y Claude Code. La proporción de recarga es transparente (1:1), con subvenciones de tarifa de Codex desde tan solo 0,08. Únete al grupo desde el sitio web oficial para obtener 5 USD de crédito de prueba.
- -> ¿Quieres convertirte en patrocinador de Codeg? [Contáctanos por correo electrónico.](mailto:itpkcn@gmail.com) - ## Interfaz principal ![Codeg Light](../images/main-light.png#gh-light-mode-only) @@ -426,15 +398,6 @@ Next.js 16 (Static Export) + React 19 - Soporte de proxy del sistema para entornos empresariales - El modo de servicio web utiliza autenticación basada en tokens -## Comunidad - -- Escanea el código QR de abajo para unirte a nuestro grupo de WeChat para discusiones, comentarios y actualizaciones - -WeChat -WeChat - -- Gracias a la comunidad de [LinuxDO](https://linux.do) por su apoyo - ## Agradecimientos - [ACP](https://agentclientprotocol.com) — el Agent Client Protocol (ACP) es la base que permite a Codeg conectarse con múltiples agentes diff --git a/docs/readme/README.fr.md b/docs/readme/README.fr.md index b1f54c72d..53b51c81d 100644 --- a/docs/readme/README.fr.md +++ b/docs/readme/README.fr.md @@ -23,34 +23,6 @@ Codeg (Code Generation) est un espace de travail de codage multi-agent. Il réun ![gallery](../images/gallery.svg) -## Sponsors - - - - - - - - - - - - - - -
- Compshare
- Compshare (UCloud) -
Merci à Compshare pour son parrainage de ce projet ! Compshare est la plateforme cloud IA d'UCloud, proposant des forfaits Plan d'agents avec modèles nationaux en abonnement mensuel ou à l'usage, à partir de 49 ¥/mois. Elle offre également un accès stable aux modèles étrangers via relais officiel. Compatible avec Claude Code, Codex et les appels d'API. Prête pour l'entreprise : forte concurrence, assistance technique 24h/24 et 7j/7, facturation en libre-service. Les utilisateurs qui s'inscrivent via ce lien reçoivent 5 ¥ de crédits gratuits sur la plateforme !
- 随想AI中转站
- 随想AI中转站 -
Merci à 随想AI中转站 pour son parrainage de ce projet ! 随想AI中转站 est un fournisseur de relais d'API fiable et efficace, proposant des services de relais pour Claude, Codex, Gemini et plus encore. Les nouveaux comptes reçoivent 0,5 ¥ de crédit de test à chaque pointage quotidien après inscription ; les recharges sont créditées à 1:1, sans abonnement, avec paiement à l'usage. La redondance multi-lignes, la reprise après sinistre inter-régions et le basculement automatique garantissent des connexions SSE de longue durée sans interruption.
- 合租巴士
- 合租巴士 -
Merci à 合租巴士 pour son parrainage de ce projet ! 合租巴士 est une plateforme de relais d'IA fiable et efficace, offrant un relais très stable pour les principaux modèles tels que Codex et Claude Code. Le ratio de recharge est transparent (1:1), avec des subventions de taux Codex dès 0,08. Rejoignez le groupe depuis le site officiel pour obtenir 5 USD de crédit d'essai.
- -> Vous souhaitez devenir sponsor de Codeg ? [Contactez-nous par e-mail.](mailto:itpkcn@gmail.com) - ## Interface principale ![Codeg Light](../images/main-light.png#gh-light-mode-only) @@ -426,15 +398,6 @@ Next.js 16 (Static Export) + React 19 - Prise en charge du proxy système pour les environnements d'entreprise - Le mode service web utilise l'authentification par jeton -## Communauté - -- Scannez le QR code ci-dessous pour rejoindre notre groupe WeChat pour des discussions, des retours et des mises à jour - -WeChat -WeChat - -- Merci à la communauté [LinuxDO](https://linux.do) pour son soutien - ## Remerciements - [ACP](https://agentclientprotocol.com) — l'Agent Client Protocol (ACP) est la base qui permet à Codeg de se connecter à plusieurs agents diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index 06ef23cbd..3edcc6b75 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -23,34 +23,6 @@ Codeg(Code Generation)は、マルチエージェント・コーディング ![gallery](../images/gallery.svg) -## スポンサー - - - - - - - - - - - - - - -
- Compshare
- Compshare(UCloud) -
本プロジェクトをスポンサードしてくださった Compshare に感謝します!Compshare は UCloud 傘下の AI クラウドプラットフォームで、月額制・従量制のコストパフォーマンスに優れた国内モデル agent Plan プランを提供しており、月額 49 元から利用可能です。安定した公式リダイレクトによる海外モデルへのアクセスも提供しています。Claude Code、Codex、API 連携に対応。企業向けの高並列対応、7×24 テクニカルサポート、セルフ請求書発行をサポートしています。こちらのリンクから登録された方には、5 元分の無料プラットフォームクレジットが進呈されます!
- 随想AI中转站
- 随想AI中转站 -
本プロジェクトをスポンサードしてくださった随想AI中转站に感謝します!随想AI中转站は、Claude、Codex、Gemini などの中継サービスを提供する、信頼性が高く効率的な API 中継サービスプロバイダーです。新規アカウントは登録後、毎日のチェックインで 0.5 元のテストクレジットがもらえます。チャージは 1:1 で反映され、サブスクリプション不要の従量課金制です。複数回線の冗長化、リージョン間ディザスタリカバリ、自動フェイルオーバーにより、長時間の SSE 接続も途切れません。
- 合租巴士
- 合租巴士 -
本プロジェクトをスポンサードしてくださった合租巴士に感謝します!合租巴士は、Codex や Claude Code などの主流モデルに高い安定性の中継機能を提供する、信頼性が高く効率的な AI 中継サービスプラットフォームです。チャージ比率は透明(1:1)で、Codex のレート補助は 0.08 から利用可能です。公式サイトからグループに参加すると $5 分の体験クレジットがもらえます
- -> Codeg のスポンサーになりませんか?[メールでお問い合わせください。](mailto:itpkcn@gmail.com) - ## メインインターフェース ![Codeg Light](../images/main-light.png#gh-light-mode-only) @@ -426,15 +398,6 @@ Next.js 16 (Static Export) + React 19 - エンタープライズ環境向けのシステムプロキシサポート - Web サービスモードではトークンベースの認証を使用 -## コミュニティ - -- QRコードをスキャンして、ディスカッション、フィードバック、アップデートのための WeChat グループに参加してください - -WeChat -WeChat - -- [LinuxDO](https://linux.do) コミュニティのサポートに感謝します - ## 謝辞 - [ACP](https://agentclientprotocol.com) — Agent Client Protocol (ACP) は、Codeg が複数のエージェントに接続できる基盤です diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index 48a91b287..fcbf8e8ae 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -23,34 +23,6 @@ Codeg(Code Generation)는 멀티 에이전트 코딩 워크스페이스입니다 ![gallery](../images/gallery.svg) -## 스폰서 - - - - - - - - - - - - - - -
- Compshare
- Compshare(UCloud) -
본 프로젝트를 후원해 주신 Compshare에 감사드립니다! Compshare는 UCloud 산하의 AI 클라우드 플랫폼으로, 월정액·종량제 방식의 가성비 높은 국내 모델 agent Plan 요금제를 월 49위안부터 제공합니다. 또한 안정적인 공식 프록시 방식의 해외 모델 접근도 지원합니다. Claude Code, Codex 및 API 연동을 지원하며, 기업 환경의 높은 동시성, 7×24 기술 지원, 셀프 인보이스 발급도 지원합니다. 이 링크를 통해 가입하시면 무료 5위안 플랫폼 체험 크레딧을 받으실 수 있습니다!
- 随想AI中转站
- 随想AI中转站 -
본 프로젝트를 후원해 주신 随想AI中转站에 감사드립니다! 随想AI中转站는 Claude, Codex, Gemini 등의 중계 서비스를 제공하는 신뢰할 수 있고 효율적인 API 중계 서비스 제공업체입니다. 신규 계정은 가입 후 매일 출석 체크만 해도 0.5위안의 테스트 크레딧을 받을 수 있으며, 충전 금액은 1:1로 적립되고 구독 없이 사용한 만큼만 결제합니다. 다중 회선 이중화, 리전 간 재해 복구, 자동 장애 조치로 장시간 SSE 연결이 끊기지 않습니다.
- 合租巴士
- 合租巴士 -
본 프로젝트를 후원해 주신 合租巴士에 감사드립니다! 合租巴士는 Codex, Claude Code 등 주요 모델에 대한 높은 안정성의 중계 기능을 제공하는 신뢰할 수 있고 효율적인 AI 중계 서비스 플랫폼입니다. 충전 비율이 투명하며(1:1), Codex 요율 보조는 최저 0.08까지 제공됩니다. 공식 웹사이트에서 그룹에 참여하면 $5 체험 크레딧을 받을 수 있습니다.
- -> Codeg의 스폰서가 되고 싶으신가요? [이메일로 문의해 주세요.](mailto:itpkcn@gmail.com) - ## 메인 인터페이스 ![Codeg Light](../images/main-light.png#gh-light-mode-only) @@ -426,15 +398,6 @@ Next.js 16 (Static Export) + React 19 - 엔터프라이즈 환경을 위한 시스템 프록시 지원 - 웹 서비스 모드에서는 토큰 기반 인증 사용 -## 커뮤니티 - -- 아래 QR 코드를 스캔하여 토론, 피드백, 업데이트를 위한 WeChat 그룹에 참여하세요 - -WeChat -WeChat - -- [LinuxDO](https://linux.do) 커뮤니티의 지원에 감사드립니다 - ## 감사의 말 - [ACP](https://agentclientprotocol.com) — Agent Client Protocol(ACP)은 Codeg가 여러 에이전트에 연결할 수 있게 해주는 기반입니다 diff --git a/docs/readme/README.pt.md b/docs/readme/README.pt.md index d950c1ff7..3868c1186 100644 --- a/docs/readme/README.pt.md +++ b/docs/readme/README.pt.md @@ -23,34 +23,6 @@ Codeg (Code Generation) é um workspace de codificação multiagente. Ele reúne ![gallery](../images/gallery.svg) -## Patrocinadores - - - - - - - - - - - - - - -
- Compshare
- Compshare (UCloud) -
Agradecemos à Compshare por patrocinar este projeto! A Compshare é a plataforma de nuvem de IA da UCloud, oferecendo planos Plan de agentes com modelos nacionais em assinatura mensal ou pagamento por uso, a partir de ¥49/mês. Também oferece acesso estável a modelos estrangeiros via proxy oficial. Compatível com Claude Code, Codex e chamadas de API. Pronto para empresas: alta concorrência, suporte técnico 24/7 e emissão de notas fiscais em autoatendimento. Quem se cadastrar através deste link ganha ¥5 de crédito de avaliação grátis na plataforma!
- 随想AI中转站
- 随想AI中转站 -
Agradecemos ao 随想AI中转站 por patrocinar este projeto! O 随想AI中转站 é um provedor de retransmissão de API confiável e eficiente, oferecendo serviços de retransmissão para Claude, Codex, Gemini e mais. Contas novas ganham ¥0,5 de crédito de teste a cada check-in diário após o cadastro; as recargas são creditadas 1:1, sem assinatura e com pagamento por uso. Redundância de múltiplas linhas, recuperação de desastres entre regiões e failover automático mantêm as conexões SSE de longa duração sem interrupções.
- 合租巴士
- 合租巴士 -
Agradecemos ao 合租巴士 por patrocinar este projeto! O 合租巴士 é uma plataforma de retransmissão de IA confiável e eficiente, oferecendo retransmissão de alta estabilidade para modelos populares como Codex e Claude Code. A proporção de recarga é transparente (1:1), com subsídios de taxa do Codex a partir de apenas 0,08. Entre no grupo pelo site oficial para ganhar US$ 5 de crédito de teste.
- -> Quer se tornar patrocinador do Codeg? [Entre em contato por e-mail.](mailto:itpkcn@gmail.com) - ## Interface principal ![Codeg Light](../images/main-light.png#gh-light-mode-only) @@ -426,15 +398,6 @@ Next.js 16 (Static Export) + React 19 - Suporte a proxy do sistema para ambientes corporativos - O modo de serviço web usa autenticação baseada em token -## Comunidade - -- Escaneie o QR code abaixo para entrar em nosso grupo do WeChat para discussões, feedback e atualizações - -WeChat -WeChat - -- Obrigado à comunidade [LinuxDO](https://linux.do) pelo apoio - ## Agradecimentos - [ACP](https://agentclientprotocol.com) — o Agent Client Protocol (ACP) é a base que permite ao Codeg conectar-se a múltiplos agentes diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index d5367e4ea..9de17120b 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -23,34 +23,6 @@ Codeg(Code Generation)是一个多智能体编码工作台,它将多个智 ![gallery](../images/gallery.svg) -## 赞助 - - - - - - - - - - - - - - -
- 优云智算
- 优云智算 -
感谢优云智算赞助了本项目!优云智算是 UCloud 旗下 AI 云平台,主打包月、按次的高性价比国模 agent Plan 套餐,低至 49 元/月起。同时提供官转稳定海外模型。支持接入 Claude Code、Codex 及 API 调用。支持企业高并发、7*24 技术支持、自助开票。通过此链接注册的用户,可得免费 5 元平台体验金!
- 随想AI中转站
- 随想AI中转站 -
感谢随想AI中转站对本项目的赞助!随想AI中转站是一家可靠高效的 API 中继服务提供商,提供 Claude、Codex、Gemini 等的中继服务。新账户注册后每日签到就送 0.5 元测试额度,充值额度 1:1,无需订阅,按量付费。多线路冗余、跨区域容灾、自动故障切换,长链路 SSE 不中断。
- 合租巴士
- 合租巴士 -
感谢合租巴士对本项目的赞助!合租巴士是一家可靠高效的 AI 中转服务平台,主要提供 Codex、Claude Code 等主流模型的高稳定中转能力,充值比例透明(1:1),Codex 倍率补贴低至 0.08。官网进群送 5 刀体验金
- -> 想成为 Codeg 赞助商?[欢迎通过邮件与我们联系。](mailto:itpkcn@gmail.com) - ## 主界面 ![Codeg Light](../images/main-light.png#gh-light-mode-only) @@ -426,15 +398,6 @@ Next.js 16 (Static Export) + React 19 - 支持系统代理,适配企业网络环境 - Web 服务模式使用基于令牌的身份认证 -## 交流 - -- 扫描下方二维码加入我们的微信群,参与讨论、反馈与更新 - -WeChat -WeChat - -- 感谢 [LinuxDO](https://linux.do) 社区的支持 - ## 鸣谢 - [ACP](https://agentclientprotocol.com):智能体客户端协议 (ACP) 是 codeg 实现多智能体连接的基础 diff --git a/docs/readme/README.zh-TW.md b/docs/readme/README.zh-TW.md index b70e2e467..a140dd7d1 100644 --- a/docs/readme/README.zh-TW.md +++ b/docs/readme/README.zh-TW.md @@ -23,34 +23,6 @@ Codeg(Code Generation)是一個多智慧體編碼工作台,它將多個智 ![gallery](../images/gallery.svg) -## 贊助 - - - - - - - - - - - - - - -
- 優雲智算
- 優雲智算 -
感謝優雲智算贊助了本專案!優雲智算是 UCloud 旗下 AI 雲平台,主打包月、按次的高性價比國模 agent Plan 套餐,低至 49 元/月起。同時提供官轉穩定海外模型。支援接入 Claude Code、Codex 及 API 呼叫。支援企業高併發、7*24 技術支援、自助開票。透過此連結註冊的使用者,可得免費 5 元平台體驗金!
- 隨想AI中轉站
- 隨想AI中轉站 -
感謝隨想AI中轉站對本專案的贊助!隨想AI中轉站是一家可靠高效的 API 中繼服務提供商,提供 Claude、Codex、Gemini 等的中繼服務。新帳戶註冊後每日簽到就送 0.5 元測試額度,儲值額度 1:1,無需訂閱,按量付費。多線路冗餘、跨區域容災、自動故障切換,長連線 SSE 不中斷。
- 合租巴士
- 合租巴士 -
感謝合租巴士對本專案的贊助!合租巴士是一家可靠高效的 AI 中轉服務平台,主要提供 Codex、Claude Code 等主流模型的高穩定中轉能力,儲值比例透明(1:1),Codex 倍率補貼低至 0.08。官網進群送 5 美元體驗金
- -> 想成為 Codeg 贊助商?[歡迎透過郵件與我們聯絡。](mailto:itpkcn@gmail.com) - ## 主介面 ![Codeg Light](../images/main-light.png#gh-light-mode-only) @@ -426,15 +398,6 @@ Next.js 16 (Static Export) + React 19 - 支援系統代理,適配企業網路環境 - Web 服務模式使用基於令牌的身份認證 -## 交流 - -- 掃描下方 QR Code 加入我們的微信群,參與討論、回饋與更新 - -WeChat -WeChat - -- 感謝 [LinuxDO](https://linux.do) 社群的支持 - ## 致謝 - [ACP](https://agentclientprotocol.com):智能體客戶端協定 (ACP) 是 codeg 實現多智能體連接的基礎 diff --git a/src-tauri/src/acp/antigravity_bridge.rs b/src-tauri/src/acp/antigravity_bridge.rs new file mode 100644 index 000000000..b2f18b576 --- /dev/null +++ b/src-tauri/src/acp/antigravity_bridge.rs @@ -0,0 +1,270 @@ +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::process::Stdio; +use std::time::Duration; + +use crate::acp::error::AcpError; +use crate::acp::types::PromptInputBlock; + +const DEFAULT_PRINT_TIMEOUT: &str = "5m0s"; +const DEFAULT_CHILD_TIMEOUT_SECS: u64 = 330; +const MODEL_DISCOVERY_TIMEOUT_SECS: u64 = 15; +const FALLBACK_MODELS: &[&str] = &[ + "Gemini 3.5 Flash (Medium)", + "Gemini 3.5 Flash (High)", + "Gemini 3.5 Flash (Low)", + "Gemini 3.1 Pro (Low)", + "Gemini 3.1 Pro (High)", + "Claude Sonnet 4.6 (Thinking)", + "Claude Opus 4.6 (Thinking)", + "GPT-OSS 120B (Medium)", +]; + +static AVAILABLE_MODELS: tokio::sync::OnceCell> = tokio::sync::OnceCell::const_new(); + +#[derive(Debug, Clone)] +pub struct AntigravityPrintOptions { + pub command: PathBuf, + pub workspace: PathBuf, + pub model: Option, + pub project: Option, + pub conversation: Option, + pub print_timeout: String, + pub child_timeout: Duration, + pub env: BTreeMap, +} + +impl AntigravityPrintOptions { + pub fn from_runtime_env( + command: PathBuf, + workspace: PathBuf, + runtime_env: &BTreeMap, + ) -> Self { + let print_timeout = runtime_env + .get("ANTIGRAVITY_PRINT_TIMEOUT") + .map(|v| v.trim()) + .filter(|v| !v.is_empty()) + .unwrap_or(DEFAULT_PRINT_TIMEOUT) + .to_string(); + let child_timeout_secs = runtime_env + .get("ANTIGRAVITY_CHILD_TIMEOUT_SECS") + .and_then(|v| v.trim().parse::().ok()) + .filter(|v| *v > 0) + .unwrap_or(DEFAULT_CHILD_TIMEOUT_SECS); + + Self { + command, + workspace, + model: runtime_env + .get("ANTIGRAVITY_MODEL") + .map(|v| v.trim()) + .filter(|v| !v.is_empty()) + .map(ToString::to_string), + project: runtime_env + .get("ANTIGRAVITY_PROJECT") + .map(|v| v.trim()) + .filter(|v| !v.is_empty()) + .map(ToString::to_string), + conversation: runtime_env + .get("ANTIGRAVITY_CONVERSATION") + .map(|v| v.trim()) + .filter(|v| !v.is_empty()) + .map(ToString::to_string), + print_timeout, + child_timeout: Duration::from_secs(child_timeout_secs), + env: runtime_env.clone(), + } + } +} + +pub fn prompt_text_from_blocks(blocks: &[PromptInputBlock]) -> String { + let mut parts = Vec::new(); + for block in blocks { + match block { + PromptInputBlock::Text { text } => parts.push(text.clone()), + PromptInputBlock::Image { uri, .. } => { + let label = uri.as_deref().unwrap_or("inline image"); + parts.push(format!("[image attachment omitted: {label}]")); + } + PromptInputBlock::Resource { uri, text, .. } => { + if let Some(text) = text.as_ref().filter(|t| !t.trim().is_empty()) { + parts.push(format!("{uri}\n{text}")); + } else { + parts.push(format!("[resource]({uri})")); + } + } + PromptInputBlock::ResourceLink { + uri, + name, + description, + .. + } => { + if let Some(description) = description.as_ref().filter(|t| !t.trim().is_empty()) { + parts.push(format!("[{name}]({uri})\n{description}")); + } else { + parts.push(format!("[{name}]({uri})")); + } + } + } + } + parts.join("\n\n") +} + +pub fn parse_antigravity_models(stdout: &str) -> Vec { + let mut models = Vec::new(); + for line in stdout + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + { + if !models.iter().any(|model| model == line) { + models.push(line.to_string()); + } + } + models +} + +pub fn fallback_antigravity_models() -> Vec { + FALLBACK_MODELS + .iter() + .map(|model| (*model).to_string()) + .collect() +} + +pub async fn query_antigravity_models( + options: &AntigravityPrintOptions, +) -> Result, AcpError> { + let mut cmd = crate::process::tokio_command(&options.command); + cmd.arg("models") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + + if options.workspace.is_dir() { + cmd.current_dir(&options.workspace); + } + for (key, value) in &options.env { + cmd.env(key, value); + } + + let output = match tokio::time::timeout( + Duration::from_secs(MODEL_DISCOVERY_TIMEOUT_SECS), + cmd.output(), + ) + .await + { + Ok(result) => result + .map_err(|err| AcpError::SpawnFailed(format!("failed to list agy models: {err}")))?, + Err(_) => { + return Err(AcpError::protocol(format!( + "Antigravity CLI model discovery timeout after {MODEL_DISCOVERY_TIMEOUT_SECS} seconds" + ))); + } + }; + + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if !output.status.success() { + let detail = if stderr.is_empty() { + format!("exit status {}", output.status) + } else { + format!("exit status {}: {stderr}", output.status) + }; + return Err(AcpError::protocol(format!( + "Antigravity CLI model discovery failed with {detail}" + ))); + } + + let models = parse_antigravity_models(&String::from_utf8_lossy(&output.stdout)); + if models.is_empty() { + return Err(AcpError::protocol( + "Antigravity CLI returned an empty model list", + )); + } + Ok(models) +} + +pub async fn available_antigravity_models(options: &AntigravityPrintOptions) -> Vec { + AVAILABLE_MODELS + .get_or_init(|| async { + match query_antigravity_models(options).await { + Ok(models) => models, + Err(err) => { + tracing::warn!( + "[ACP][Antigravity] failed to discover models, using fallback list: {err}" + ); + fallback_antigravity_models() + } + } + }) + .await + .clone() +} + +pub async fn run_antigravity_print( + options: &AntigravityPrintOptions, + prompt: &str, +) -> Result { + let mut cmd = crate::process::tokio_command(&options.command); + cmd.arg(format!("--print-timeout={}", options.print_timeout)) + .arg(format!("--add-dir={}", options.workspace.display())); + + if let Some(model) = &options.model { + cmd.arg(format!("--model={model}")); + } + if let Some(project) = &options.project { + cmd.arg(format!("--project={project}")); + } + if let Some(conversation) = &options.conversation { + cmd.arg(format!("--conversation={conversation}")); + } + if options + .env + .get("ANTIGRAVITY_DANGEROUSLY_SKIP_PERMISSIONS") + .is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true")) + { + cmd.arg("--dangerously-skip-permissions"); + } + + cmd.arg("--print") + .arg(prompt) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + + if options.workspace.is_dir() { + cmd.current_dir(&options.workspace); + } + + for (key, value) in &options.env { + cmd.env(key, value); + } + + let output = match tokio::time::timeout(options.child_timeout, cmd.output()).await { + Ok(result) => { + result.map_err(|err| AcpError::SpawnFailed(format!("failed to run agy: {err}")))? + } + Err(_) => { + return Err(AcpError::protocol(format!( + "Antigravity CLI print timeout after {} seconds", + options.child_timeout.as_secs() + ))); + } + }; + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if !output.status.success() { + let detail = if stderr.is_empty() { + format!("exit status {}", output.status) + } else { + format!("exit status {}: {stderr}", output.status) + }; + return Err(AcpError::protocol(format!( + "Antigravity CLI print failed with {detail}" + ))); + } + if stdout.is_empty() { + return Err(AcpError::protocol("Antigravity CLI returned empty output")); + } + Ok(stdout) +} diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index f16d4550d..c52a524da 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -604,6 +604,9 @@ async fn build_agent( }) .map_err(|e| AcpError::SpawnFailed(e.to_string())) } + AgentDistribution::SystemCommand { .. } => Err(AcpError::protocol( + "system command agents must use their dedicated bridge", + )), }?; // Run the agent subprocess in the session's working directory rather than @@ -686,6 +689,22 @@ pub async fn spawn_agent_connection( // agree. Computed here because `working_dir` is moved into run_connection // below. let launch_cwd = resolve_working_dir(working_dir.as_deref()); + if agent_type == AgentType::Gemini { + return spawn_antigravity_bridge_connection( + connection_id, + working_dir, + session_id, + runtime_env, + preferred_config_values, + owner_window_label, + emitter, + connections, + session_state, + session_started_rx, + launch_cwd, + ) + .await; + } let agent = build_agent(agent_type, &runtime_env, &launch_cwd).await?; // Forward only the codeg git credential helper keys into the terminal @@ -829,6 +848,485 @@ pub async fn spawn_agent_connection( Ok(session_started_rx) } +#[allow(clippy::too_many_arguments)] +async fn spawn_antigravity_bridge_connection( + connection_id: String, + working_dir: Option, + session_id: Option, + runtime_env: BTreeMap, + preferred_config_values: BTreeMap, + owner_window_label: String, + emitter: EventEmitter, + connections: Arc>>, + session_state: Arc>, + session_started_rx: tokio::sync::oneshot::Receiver<()>, + launch_cwd: PathBuf, +) -> Result, AcpError> { + let meta = registry::get_agent_meta(AgentType::Gemini); + let agy_path = match meta.distribution { + AgentDistribution::SystemCommand { cmd, .. } => { + crate::commands::acp::resolve_command_on_path(cmd).ok_or_else(|| { + AcpError::SdkNotInstalled(format!( + "{} is not installed. Please install `{cmd}` and make sure it is on PATH.", + meta.name + )) + })? + } + _ => { + return Err(AcpError::protocol( + "Antigravity bridge requires a system command distribution", + )); + } + }; + + let options = crate::acp::antigravity_bridge::AntigravityPrintOptions::from_runtime_env( + agy_path, + launch_cwd, + &runtime_env, + ); + let (cmd_tx, cmd_rx) = mpsc::channel::(32); + let config_fingerprint = + crate::commands::acp::fingerprint_config(AgentType::Gemini, &runtime_env); + + connections.lock().await.insert( + connection_id.clone(), + AgentConnection { + id: connection_id.clone(), + agent_type: AgentType::Gemini, + status: ConnectionStatus::Connecting, + owner_window_label, + cmd_tx, + state: Arc::clone(&session_state), + emitter: emitter.clone(), + prompt_lock: Arc::new(tokio::sync::Mutex::new(())), + last_observed_fingerprint: config_fingerprint.clone(), + config_fingerprint, + }, + ); + + let cleanup_connections = connections.clone(); + let cleanup_connection_id = connection_id.clone(); + let state_clone = Arc::clone(&session_state); + let emitter_clone = emitter.clone(); + tokio::spawn(async move { + let _cleanup = ConnectionCleanupGuard { + connections: cleanup_connections, + connection_id: cleanup_connection_id, + }; + run_antigravity_bridge_loop( + connection_id, + working_dir, + session_id, + cmd_rx, + emitter_clone.clone(), + Arc::clone(&state_clone), + options, + preferred_config_values, + ) + .await; + emit_with_state( + &state_clone, + &emitter_clone, + AcpEvent::StatusChanged { + status: ConnectionStatus::Disconnected, + }, + ) + .await; + }); + + Ok(session_started_rx) +} + +async fn emit_antigravity_unsupported( + state: &Arc>, + emitter: &EventEmitter, + message: impl Into, +) { + emit_with_state( + state, + emitter, + AcpEvent::Error { + message: message.into(), + agent_type: AgentType::Gemini.to_string(), + code: None, + terminal: false, + }, + ) + .await; +} + +async fn emit_antigravity_turn_complete( + state: &Arc>, + emitter: &EventEmitter, + session_id: &str, + stop_reason: &str, +) { + emit_with_state( + state, + emitter, + AcpEvent::TurnComplete { + session_id: session_id.to_string(), + stop_reason: stop_reason.to_string(), + agent_type: registry::registry_id_for(AgentType::Gemini).to_string(), + }, + ) + .await; +} + +const ANTIGRAVITY_MODEL_CONFIG_ID: &str = "model"; +const ANTIGRAVITY_AUTO_MODEL_ID: &str = "__auto__"; + +fn antigravity_model_config_option( + options: &crate::acp::antigravity_bridge::AntigravityPrintOptions, + available_models: &[String], +) -> SessionConfigOptionInfo { + let mut models = vec![SessionConfigSelectOptionInfo { + value: ANTIGRAVITY_AUTO_MODEL_ID.to_string(), + name: "Auto".to_string(), + description: Some("Use the Antigravity CLI default model".to_string()), + }]; + models.extend( + available_models + .iter() + .map(|model| SessionConfigSelectOptionInfo { + value: model.clone(), + name: model.clone(), + description: None, + }), + ); + + if let Some(current) = options.model.as_ref() { + if !models.iter().any(|model| model.value == *current) { + models.push(SessionConfigSelectOptionInfo { + value: current.clone(), + name: current.clone(), + description: Some("Custom model from Antigravity settings".to_string()), + }); + } + } + + SessionConfigOptionInfo { + id: ANTIGRAVITY_MODEL_CONFIG_ID.to_string(), + name: "Model".to_string(), + description: Some("Choose the model for future Antigravity turns".to_string()), + category: Some("model".to_string()), + kind: SessionConfigKindInfo::Select(SessionConfigSelectInfo { + current_value: options + .model + .clone() + .unwrap_or_else(|| ANTIGRAVITY_AUTO_MODEL_ID.to_string()), + options: models, + groups: Vec::new(), + }), + } +} + +fn set_antigravity_model( + options: &mut crate::acp::antigravity_bridge::AntigravityPrintOptions, + available_models: &[String], + config_id: &str, + value_id: &str, +) -> Result<(), String> { + if config_id != ANTIGRAVITY_MODEL_CONFIG_ID { + return Err(format!( + "Antigravity bridge does not support config option '{config_id}'" + )); + } + + if value_id == ANTIGRAVITY_AUTO_MODEL_ID { + options.model = None; + return Ok(()); + } + + let value_id = value_id.trim(); + let is_current_custom = options.model.as_deref() == Some(value_id); + if value_id.is_empty() + || (!is_current_custom && !available_models.iter().any(|model| model == value_id)) + { + return Err(format!("Unknown Antigravity model '{value_id}'")); + } + + options.model = Some(value_id.to_string()); + Ok(()) +} + +async fn emit_antigravity_model_options( + state: &Arc>, + emitter: &EventEmitter, + options: &crate::acp::antigravity_bridge::AntigravityPrintOptions, + available_models: &[String], +) { + emit_with_state( + state, + emitter, + AcpEvent::SessionConfigOptions { + config_options: vec![antigravity_model_config_option(options, available_models)], + }, + ) + .await; +} + +async fn run_antigravity_bridge_loop( + _connection_id: String, + _working_dir: Option, + session_id: Option, + mut cmd_rx: mpsc::Receiver, + emitter: EventEmitter, + state: Arc>, + mut options: crate::acp::antigravity_bridge::AntigravityPrintOptions, + preferred_config_values: BTreeMap, +) { + let external_session_id = + session_id.unwrap_or_else(|| format!("antigravity-{}", uuid::Uuid::new_v4())); + let fallback_models = crate::acp::antigravity_bridge::fallback_antigravity_models(); + let mut initial_options = options.clone(); + if let Some(preferred_model) = preferred_config_values.get(ANTIGRAVITY_MODEL_CONFIG_ID) { + // Best-effort preview for the connection snapshot. The authoritative + // validation runs again against `agy models` below, using the untouched + // launch options so a fallback-only value cannot become a custom model. + let _ = set_antigravity_model( + &mut initial_options, + &fallback_models, + ANTIGRAVITY_MODEL_CONFIG_ID, + preferred_model, + ); + } + emit_antigravity_model_options(&state, &emitter, &initial_options, &fallback_models).await; + emit_with_state( + &state, + &emitter, + AcpEvent::SessionStarted { + session_id: external_session_id.clone(), + }, + ) + .await; + + let available_models = + crate::acp::antigravity_bridge::available_antigravity_models(&options).await; + if let Some(preferred_model) = preferred_config_values.get(ANTIGRAVITY_MODEL_CONFIG_ID) { + if let Err(err) = set_antigravity_model( + &mut options, + &available_models, + ANTIGRAVITY_MODEL_CONFIG_ID, + preferred_model, + ) { + tracing::warn!( + "[ACP][Antigravity] ignoring saved model preference '{preferred_model}': {err}" + ); + } + } + + emit_with_state( + &state, + &emitter, + AcpEvent::PromptCapabilities { + prompt_capabilities: PromptCapabilitiesInfo { + image: false, + audio: false, + embedded_context: false, + }, + }, + ) + .await; + emit_with_state( + &state, + &emitter, + AcpEvent::ForkSupported { supported: false }, + ) + .await; + emit_antigravity_model_options(&state, &emitter, &options, &available_models).await; + emit_with_state(&state, &emitter, AcpEvent::SelectorsReady).await; + emit_with_state( + &state, + &emitter, + AcpEvent::StatusChanged { + status: ConnectionStatus::Connected, + }, + ) + .await; + + while let Some(cmd) = cmd_rx.recv().await { + match cmd { + ConnectionCommand::Prompt { + blocks, + user_message, + } => { + let prompt = crate::acp::antigravity_bridge::prompt_text_from_blocks(&blocks); + emit_with_state( + &state, + &emitter, + AcpEvent::StatusChanged { + status: ConnectionStatus::Prompting, + }, + ) + .await; + if let Some((message_id, blocks)) = user_message { + emit_with_state( + &state, + &emitter, + AcpEvent::UserMessage { message_id, blocks }, + ) + .await; + } + + if prompt.trim().is_empty() { + emit_antigravity_unsupported(&state, &emitter, "Prompt must not be empty") + .await; + emit_antigravity_turn_complete(&state, &emitter, &external_session_id, "empty") + .await; + continue; + } + + // A model change received while this turn is running applies to + // the next prompt. Clone the launch options so the in-flight + // subprocess keeps a stable model while `options` stays mutable. + let turn_options = options.clone(); + let mut bridge = Box::pin(crate::acp::antigravity_bridge::run_antigravity_print( + &turn_options, + &prompt, + )); + let mut disconnect_after_turn = false; + loop { + tokio::select! { + result = &mut bridge => { + match result { + Ok(text) => { + emit_with_state(&state, &emitter, AcpEvent::ContentDelta { text }).await; + emit_antigravity_turn_complete( + &state, + &emitter, + &external_session_id, + "end_turn", + ).await; + } + Err(err) => { + emit_with_state( + &state, + &emitter, + AcpEvent::Error { + message: err.to_string(), + agent_type: AgentType::Gemini.to_string(), + code: err.code().map(str::to_string), + terminal: false, + }, + ).await; + emit_antigravity_turn_complete( + &state, + &emitter, + &external_session_id, + "refusal", + ).await; + } + } + break; + } + queued = cmd_rx.recv() => { + match queued { + Some(ConnectionCommand::Cancel) => { + emit_antigravity_turn_complete( + &state, + &emitter, + &external_session_id, + "cancelled", + ).await; + break; + } + Some(ConnectionCommand::Disconnect) | None => { + emit_antigravity_turn_complete( + &state, + &emitter, + &external_session_id, + "cancelled", + ).await; + disconnect_after_turn = true; + break; + } + Some(ConnectionCommand::RespondPermission { .. }) => { + emit_antigravity_unsupported( + &state, + &emitter, + "Antigravity bridge does not support Codeg permission requests", + ).await; + } + Some(ConnectionCommand::SetConfigOption { + config_id, + value_id, + }) => { + match set_antigravity_model( + &mut options, + &available_models, + &config_id, + &value_id, + ) { + Ok(()) => { + emit_antigravity_model_options( + &state, + &emitter, + &options, + &available_models, + ).await; + } + Err(err) => { + emit_antigravity_unsupported(&state, &emitter, err).await; + } + } + } + Some(ConnectionCommand::SetMode { .. }) + | Some(ConnectionCommand::Fork { .. }) + | Some(ConnectionCommand::Prompt { .. }) => {} + } + } + } + } + if disconnect_after_turn { + break; + } + } + ConnectionCommand::SetMode { .. } => { + emit_antigravity_unsupported( + &state, + &emitter, + "Antigravity bridge does not expose ACP session modes", + ) + .await; + } + ConnectionCommand::SetConfigOption { + config_id, + value_id, + } => { + match set_antigravity_model(&mut options, &available_models, &config_id, &value_id) + { + Ok(()) => { + emit_antigravity_model_options( + &state, + &emitter, + &options, + &available_models, + ) + .await; + } + Err(err) => emit_antigravity_unsupported(&state, &emitter, err).await, + } + } + ConnectionCommand::Cancel => {} + ConnectionCommand::RespondPermission { .. } => { + emit_antigravity_unsupported( + &state, + &emitter, + "Antigravity bridge does not support Codeg permission requests", + ) + .await; + } + ConnectionCommand::Fork { reply } => { + let _ = reply.send(Err(AcpError::protocol( + "Antigravity bridge does not support session fork", + ))); + } + ConnectionCommand::Disconnect => break, + } + } +} + /// Shared state for pending permission responders. type PendingPermissions = Arc>>>; @@ -5572,6 +6070,200 @@ mod tests { use super::*; use sacp::schema::Diff; + fn antigravity_options( + model: Option<&str>, + ) -> crate::acp::antigravity_bridge::AntigravityPrintOptions { + crate::acp::antigravity_bridge::AntigravityPrintOptions { + command: PathBuf::from("agy"), + workspace: PathBuf::from("."), + model: model.map(str::to_string), + project: None, + conversation: None, + print_timeout: "5m0s".to_string(), + child_timeout: std::time::Duration::from_secs(330), + env: BTreeMap::new(), + } + } + + #[test] + fn antigravity_model_option_projects_catalog_and_auto_default() { + let options = antigravity_options(None); + let catalog = vec![ + "Gemini 3.5 Flash (High)".to_string(), + "Gemini 3.1 Pro (High)".to_string(), + ]; + + let option = antigravity_model_config_option(&options, &catalog); + + assert_eq!(option.id, ANTIGRAVITY_MODEL_CONFIG_ID); + assert_eq!(option.category.as_deref(), Some("model")); + let SessionConfigKindInfo::Select(select) = option.kind; + assert_eq!(select.current_value, ANTIGRAVITY_AUTO_MODEL_ID); + assert_eq!(select.options[0].value, ANTIGRAVITY_AUTO_MODEL_ID); + assert_eq!(select.options[0].name, "Auto"); + assert_eq!(select.options[1].value, catalog[0]); + assert_eq!(select.options[2].value, catalog[1]); + } + + #[test] + fn antigravity_model_option_keeps_custom_configured_model() { + let options = antigravity_options(Some("Custom Preview Model")); + let option = antigravity_model_config_option(&options, &[]); + let SessionConfigKindInfo::Select(select) = option.kind; + + assert_eq!(select.current_value, "Custom Preview Model"); + assert!(select + .options + .iter() + .any(|item| item.value == "Custom Preview Model")); + } + + #[test] + fn antigravity_model_selection_updates_and_clears_launch_model() { + let mut options = antigravity_options(None); + let catalog = vec!["Gemini 3.1 Pro (High)".to_string()]; + + set_antigravity_model( + &mut options, + &catalog, + ANTIGRAVITY_MODEL_CONFIG_ID, + "Gemini 3.1 Pro (High)", + ) + .expect("catalog model should be accepted"); + assert_eq!(options.model.as_deref(), Some("Gemini 3.1 Pro (High)")); + + set_antigravity_model( + &mut options, + &catalog, + ANTIGRAVITY_MODEL_CONFIG_ID, + ANTIGRAVITY_AUTO_MODEL_ID, + ) + .expect("auto should clear the explicit model"); + assert_eq!(options.model, None); + } + + #[test] + fn antigravity_model_selection_rejects_stale_gemini_preference() { + let mut options = antigravity_options(None); + let catalog = vec!["Gemini 3.1 Pro (High)".to_string()]; + + let error = set_antigravity_model( + &mut options, + &catalog, + ANTIGRAVITY_MODEL_CONFIG_ID, + "gemini-2.5-pro", + ) + .expect_err("official Gemini CLI ids must not be forwarded to agy"); + + assert!(error.contains("Unknown Antigravity model")); + assert_eq!(options.model, None); + } + + #[test] + fn antigravity_saved_model_revalidates_against_discovered_catalog() { + let base_options = antigravity_options(None); + let fallback = vec!["Retired Preview Model".to_string()]; + let discovered = vec!["Newly Discovered Model".to_string()]; + + let mut preview = base_options.clone(); + assert!(set_antigravity_model( + &mut preview, + &fallback, + ANTIGRAVITY_MODEL_CONFIG_ID, + "Newly Discovered Model", + ) + .is_err()); + + let mut launch = base_options.clone(); + set_antigravity_model( + &mut launch, + &discovered, + ANTIGRAVITY_MODEL_CONFIG_ID, + "Newly Discovered Model", + ) + .expect("a model added by the live catalog should become valid"); + assert_eq!(launch.model.as_deref(), Some("Newly Discovered Model")); + + let mut retired_preview = base_options.clone(); + set_antigravity_model( + &mut retired_preview, + &fallback, + ANTIGRAVITY_MODEL_CONFIG_ID, + "Retired Preview Model", + ) + .expect("the fallback can render a saved preview"); + let mut retired_launch = base_options; + assert!(set_antigravity_model( + &mut retired_launch, + &discovered, + ANTIGRAVITY_MODEL_CONFIG_ID, + "Retired Preview Model", + ) + .is_err()); + assert_eq!(retired_launch.model, None); + } + + #[tokio::test] + async fn antigravity_session_starts_with_model_config_in_snapshot() { + let mut initial_state = SessionState::new( + "antigravity-test".to_string(), + AgentType::Gemini, + Some(PathBuf::from(".")), + "test-window".to_string(), + None, + ); + let session_started = initial_state.install_session_started_signal(); + let state = Arc::new(RwLock::new(initial_state)); + let (cmd_tx, cmd_rx) = mpsc::channel(4); + let preferred = "Gemini 3.1 Pro (High)"; + let preferred_config_values = BTreeMap::from([( + ANTIGRAVITY_MODEL_CONFIG_ID.to_string(), + preferred.to_string(), + )]); + let mut options = antigravity_options(None); + options.command = PathBuf::from("missing-agy-for-model-snapshot-test"); + + let task = tokio::spawn(run_antigravity_bridge_loop( + "antigravity-test".to_string(), + Some(".".to_string()), + None, + cmd_rx, + EventEmitter::Noop, + Arc::clone(&state), + options, + preferred_config_values, + )); + + tokio::time::timeout(std::time::Duration::from_secs(1), session_started) + .await + .expect("session should start without waiting for model discovery") + .expect("session-start signal should remain connected"); + + let snapshot = state.read().await.to_snapshot(); + let model = snapshot + .config_options + .expect("model config must exist when SessionStarted fires") + .into_iter() + .find(|option| option.id == ANTIGRAVITY_MODEL_CONFIG_ID) + .expect("model config option"); + let SessionConfigKindInfo::Select(select) = model.kind; + assert_eq!(select.current_value, preferred); + assert_eq!(select.options[0].value, ANTIGRAVITY_AUTO_MODEL_ID); + assert!(select + .options + .iter() + .any(|option| option.value == preferred)); + + cmd_tx + .send(ConnectionCommand::Disconnect) + .await + .expect("disconnect command"); + tokio::time::timeout(std::time::Duration::from_secs(1), task) + .await + .expect("bridge loop should stop") + .expect("bridge loop task should not panic"); + } + fn diff_content(path: &str, old: Option<&str>, new: &str) -> ToolCallContent { let mut d = Diff::new(path, new); if let Some(o) = old { diff --git a/src-tauri/src/acp/mod.rs b/src-tauri/src/acp/mod.rs index 682f0c574..717bb6d07 100644 --- a/src-tauri/src/acp/mod.rs +++ b/src-tauri/src/acp/mod.rs @@ -1,4 +1,5 @@ pub mod background_watch; +pub mod antigravity_bridge; pub mod binary_cache; pub mod codex_goal; pub mod connection; diff --git a/src-tauri/src/acp/preflight.rs b/src-tauri/src/acp/preflight.rs index 5d4f30262..6f8dc8c7e 100644 --- a/src-tauri/src/acp/preflight.rs +++ b/src-tauri/src/acp/preflight.rs @@ -70,6 +70,9 @@ pub async fn run_preflight(agent_type: AgentType) -> PreflightResult { system_cmd, .. } => check_uv_environment(*uv_required, *system_cmd).await, + AgentDistribution::SystemCommand { cmd, .. } => { + check_system_command_environment(cmd).await + } }; let passed = checks @@ -294,6 +297,62 @@ fn build_node_version_check(current_version: Option<&str>, required: &str) -> Ch /// Preflight for `Uvx` agents (Python ACP agents launched via `uvx`, e.g. /// Hermes). Passes when either the `uv` tool runner is resolvable, or — as a /// fallback — the agent's own CLI is already installed on PATH. +async fn check_system_command_environment(cmd: &str) -> Vec { + let Some(path) = crate::commands::acp::resolve_command_on_path(cmd) else { + return vec![CheckItem { + check_id: format!("{cmd}_available"), + label: cmd.to_string(), + status: CheckStatus::Fail, + message: format!("{cmd} is not installed or not in PATH"), + fixes: vec![], + }]; + }; + + match crate::process::tokio_command(&path) + .arg("--version") + .output() + .await + { + Ok(output) if output.status.success() => { + let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let message = if version.is_empty() { + format!("{cmd} available") + } else { + format!("{cmd} {version} available") + }; + vec![CheckItem { + check_id: format!("{cmd}_available"), + label: cmd.to_string(), + status: CheckStatus::Pass, + message, + fixes: vec![], + }] + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let detail = if stderr.is_empty() { + "version check failed".to_string() + } else { + stderr + }; + vec![CheckItem { + check_id: format!("{cmd}_available"), + label: cmd.to_string(), + status: CheckStatus::Fail, + message: format!("{cmd} is installed but unusable: {detail}"), + fixes: vec![], + }] + } + Err(err) => vec![CheckItem { + check_id: format!("{cmd}_available"), + label: cmd.to_string(), + status: CheckStatus::Fail, + message: format!("{cmd} version check failed: {err}"), + fixes: vec![], + }], + } +} + async fn check_uv_environment( uv_required: Option<&str>, system_cmd: Option<(&str, &[&str])>, diff --git a/src-tauri/src/acp/registry.rs b/src-tauri/src/acp/registry.rs index 80566a136..d40cbad20 100644 --- a/src-tauri/src/acp/registry.rs +++ b/src-tauri/src/acp/registry.rs @@ -42,6 +42,13 @@ pub enum AgentDistribution { /// its official installer launch it without `uv`. system_cmd: Option<(&'static str, &'static [&'static str])>, }, + /// Agents provided by a system command already installed on PATH. Codeg + /// does not install or upgrade these; it only verifies and launches them. + SystemCommand { + cmd: &'static str, + args: &'static [&'static str], + env: &'static [(&'static str, &'static str)], + }, } #[derive(Debug, Clone)] @@ -70,6 +77,7 @@ impl AcpAgentMeta { AgentDistribution::Npx { version, .. } | AgentDistribution::Binary { version, .. } | AgentDistribution::Uvx { version, .. } => Some(*version), + AgentDistribution::SystemCommand { .. } => None, } } } @@ -195,16 +203,13 @@ pub fn get_agent_meta(agent_type: AgentType) -> AcpAgentMeta { }, AgentType::Gemini => AcpAgentMeta { agent_type, - supports_mcp: true, - name: "Gemini CLI", - description: "Google's official CLI for Gemini", - distribution: AgentDistribution::Npx { - version: "0.50.0", - package: "@google/gemini-cli@0.50.0", - cmd: "gemini", - args: &["--acp", "--skip-trust"], + supports_mcp: false, + name: "Antigravity CLI", + description: "Google Antigravity CLI via the local agy command", + distribution: AgentDistribution::SystemCommand { + cmd: "agy", + args: &[], env: &[], - node_required: Some("20.0.0"), }, }, AgentType::OpenClaw => AcpAgentMeta { @@ -478,6 +483,24 @@ mod tests { } } + fn assert_system_command( + agent_type: AgentType, + expected_cmd: &str, + expected_args: &[&str], + ) { + let meta = get_agent_meta(agent_type); + match meta.distribution { + AgentDistribution::SystemCommand { cmd, args, .. } => { + assert_eq!(cmd, expected_cmd); + assert_eq!(args, expected_args); + assert_eq!(meta.registry_version(), None); + } + other => { + panic!("expected system command distribution for {agent_type:?}, got {other:?}"); + } + } + } + #[test] fn registry_pins_current_acp_agent_versions() { assert_npx_version( @@ -486,11 +509,10 @@ mod tests { "@agentclientprotocol/claude-agent-acp@0.58.1", Some("22.0.0"), ); - assert_npx_version( + assert_system_command( AgentType::Gemini, - "0.50.0", - "@google/gemini-cli@0.50.0", - Some("20.0.0"), + "agy", + &[], ); assert_npx_version( AgentType::OpenClaw, @@ -548,14 +570,22 @@ mod tests { // registry means a newly-added agent that wrongly opts out — or a // regression flipping OpenClaw back on — trips this assert. #[test] - fn only_openclaw_opts_out_of_mcp() { + fn openclaw_and_antigravity_opt_out_of_mcp() { for agent_type in all_acp_agents() { let meta = get_agent_meta(agent_type); assert_eq!( meta.supports_mcp, - agent_type != AgentType::OpenClaw, + !matches!(agent_type, AgentType::OpenClaw | AgentType::Gemini), "unexpected supports_mcp for {agent_type:?}" ); } } + + #[test] + fn gemini_slot_displays_as_antigravity() { + let meta = get_agent_meta(AgentType::Gemini); + assert_eq!(meta.name, "Antigravity CLI"); + assert!(meta.description.contains("Antigravity")); + assert_eq!(AgentType::Gemini.to_string(), "Antigravity CLI"); + } } diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index 3a9b35e81..42f84fc3d 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -496,6 +496,16 @@ pub(crate) async fn verify_agent_installed(agent_type: AgentType) -> Result<(), ))) } } + registry::AgentDistribution::SystemCommand { cmd, .. } => { + if resolve_command_on_path(cmd).is_some() { + Ok(()) + } else { + Err(AcpError::SdkNotInstalled(format!( + "{} is not installed. Please install `{cmd}` and make sure it is on PATH.", + meta.name + ))) + } + } } } @@ -567,6 +577,27 @@ async fn detect_local_version(agent_type: AgentType) -> Option { .flatten() } registry::AgentDistribution::Uvx { .. } => binary_cache::uvx_prepared_version(agent_type), + registry::AgentDistribution::SystemCommand { cmd, .. } => { + detect_system_command_version(cmd).await + } + } +} + +async fn detect_system_command_version(cmd: &str) -> Option { + let path = resolve_command_on_path(cmd)?; + let output = crate::process::tokio_command(&path) + .arg("--version") + .output() + .await + .ok()?; + if !output.status.success() { + return None; + } + let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if version.is_empty() { + None + } else { + Some(version) } } @@ -4540,7 +4571,12 @@ fn reconcile_hermes_runtime_env_in(home: &Path) -> Result<(), AcpError> { fn agent_local_config_path(agent_type: AgentType) -> Option { match agent_type { AgentType::ClaudeCode => Some(home_dir_or_default().join(".claude").join("settings.json")), - AgentType::Gemini => Some(home_dir_or_default().join(".gemini").join("settings.json")), + AgentType::Gemini => Some( + home_dir_or_default() + .join(".gemini") + .join("antigravity-cli") + .join("settings.json"), + ), AgentType::OpenCode => Some(resolve_opencode_config_path()), AgentType::Cline => Some(cline_global_state_path()), // Kimi Code's native config is `~/.kimi-code/config.toml`. Exposing the @@ -5162,7 +5198,11 @@ fn agent_env_keys(agent_type: AgentType) -> (&'static str, &'static str, &'stati "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_MODEL", ), - AgentType::Gemini => ("GOOGLE_GEMINI_BASE_URL", "GEMINI_API_KEY", "GEMINI_MODEL"), + AgentType::Gemini => ( + "ANTIGRAVITY_BASE_URL", + "ANTIGRAVITY_API_KEY", + "ANTIGRAVITY_MODEL", + ), // Kimi Code does NOT read shell KIMI_API_KEY/OPENAI_API_KEY; the only // non-interactive credential path is the `KIMI_MODEL_*` family, which // also takes priority over `~/.kimi-code/config.toml`. @@ -5284,7 +5324,7 @@ const CLAUDE_MODEL_KEY_MAP: &[(&str, &str)] = &[ /// ANTHROPIC_*_MODEL fields plus the ANTHROPIC_CUSTOM_MODEL_OPTION trio. Each /// entry is `None` when the provider's JSON omits that key or has an empty /// value. -/// - Gemini: returns `GEMINI_MODEL`. +/// - Antigravity (wire value `gemini`): returns `ANTIGRAVITY_MODEL`. /// - Codex: returns `OPENAI_MODEL` so the provider can override env_json (the /// root `model` in `config.toml` is handled separately by /// `provider_codex_model_action`). @@ -5312,7 +5352,10 @@ pub(crate) fn parse_provider_model( } } AgentType::Gemini => { - out.insert("GEMINI_MODEL".to_string(), trimmed_raw.map(str::to_string)); + out.insert( + "ANTIGRAVITY_MODEL".to_string(), + trimmed_raw.map(str::to_string), + ); } // Kimi reads its model name from KIMI_MODEL_NAME (the `KIMI_MODEL_*` // family), not OPENAI_MODEL — see `agent_env_keys`. @@ -6213,6 +6256,10 @@ pub(crate) async fn acp_get_agent_status_core( uvx_agent_launchable(*system_cmd), binary_cache::uvx_prepared_version(agent_type), ), + registry::AgentDistribution::SystemCommand { cmd, .. } => ( + resolve_command_on_path(cmd).is_some(), + detect_system_command_version(cmd).await, + ), }; Ok(crate::acp::types::AcpAgentStatus { @@ -6286,6 +6333,11 @@ pub(crate) async fn acp_list_agents_core(db: &AppDatabase) -> Result ( + resolve_command_on_path(cmd).is_some(), + "system", + detect_system_command_version(cmd).await, + ), }; let mut env = setting @@ -6318,7 +6370,7 @@ pub(crate) async fn acp_list_agents_core(db: &AppDatabase) -> Result Err(AcpError::protocol( "download is only supported for binary agents", )), + registry::AgentDistribution::SystemCommand { .. } => Err(AcpError::protocol( + "download is not supported for system command agents", + )), }; match &result { @@ -7360,17 +7415,15 @@ pub(crate) async fn acp_detect_agent_local_version_core( return Ok(Some(version)); } - // Binary agents detect their version purely from the on-disk cache, so a - // `None` here means the binary is genuinely absent (cleared cache, or a - // failed custom/upgrade install). Return `None` authoritatively rather than - // falling back to the DB, which would resurrect a removed version as a - // phantom that can no longer be launched. The returned value does NOT depend - // on the mirror write below, so a swallowed write cannot reintroduce the - // phantom. (NPX detection runs `npm list`, which can fail transiently, so - // for npx we keep the DB value as a best-effort fallback.) + // Binary and system-command agents detect their version from launchable + // local state, so a `None` here is authoritative. Return it rather than + // falling back to the DB, which would resurrect a removed binary or stale + // system-command version as a phantom. (NPX detection runs `npm list`, + // which can fail transiently, so for npx we keep the DB value.) if matches!( registry::get_agent_meta(agent_type).distribution, registry::AgentDistribution::Binary { .. } + | registry::AgentDistribution::SystemCommand { .. } ) { let _ = agent_setting_service::set_installed_version(conn, agent_type, None).await; return Ok(None); @@ -7492,6 +7545,9 @@ pub(crate) async fn acp_prepare_npx_agent_core( registry::AgentDistribution::Binary { .. } => Err(AcpError::protocol( "prepare is only supported for npx agents", )), + registry::AgentDistribution::SystemCommand { .. } => Err(AcpError::protocol( + "prepare is not supported for system command agents", + )), registry::AgentDistribution::Uvx { package, cmd, @@ -7616,6 +7672,11 @@ pub(crate) async fn acp_uninstall_agent_core( registry::AgentDistribution::Uvx { .. } => { binary_cache::clear_uvx_agent_prepared(agent_type)?; } + registry::AgentDistribution::SystemCommand { .. } => { + return Err(AcpError::protocol( + "uninstall is not supported for system command agents", + )); + } } agent_setting_service::set_installed_version(&db.conn, agent_type, None) @@ -8944,6 +9005,16 @@ wire_api = "chat" ); } + #[test] + fn parse_provider_model_uses_antigravity_model_for_gemini_slot() { + let out = parse_provider_model(AgentType::Gemini, Some("Gemini 3.1 Pro (High)")); + assert_eq!( + out.get("ANTIGRAVITY_MODEL"), + Some(&Some("Gemini 3.1 Pro (High)".to_string())) + ); + assert!(!out.contains_key("GEMINI_MODEL")); + } + #[test] fn merge_json_values_clears_stale_custom_model_option_via_null() { // The local-config cascade (cascade_update_agent_config) encodes a @@ -9222,20 +9293,20 @@ wire_api = "chat" #[test] fn build_npm_install_spec_uses_registry_when_no_override() { assert_eq!( - build_npm_install_spec("@google/gemini-cli@0.44.1", None).unwrap(), - "@google/gemini-cli@0.44.1" + build_npm_install_spec("cline@3.0.9", None).unwrap(), + "cline@3.0.9" ); assert_eq!( - build_npm_install_spec("@google/gemini-cli@0.44.1", Some(" ")).unwrap(), - "@google/gemini-cli@0.44.1" + build_npm_install_spec("cline@3.0.9", Some(" ")).unwrap(), + "cline@3.0.9" ); } #[test] fn build_npm_install_spec_applies_custom_version() { assert_eq!( - build_npm_install_spec("@google/gemini-cli@0.44.1", Some("0.43.0")).unwrap(), - "@google/gemini-cli@0.43.0" + build_npm_install_spec("openclaw@2026.6.11", Some("2026.6.10")).unwrap(), + "openclaw@2026.6.10" ); // Scoped/plain package name is preserved; a leading `v` is stripped. assert_eq!( @@ -9281,9 +9352,9 @@ wire_api = "chat" std::fs::create_dir_all(&bin_dir).expect("create npm prefix bin directory"); #[cfg(windows)] - let command_path = bin_dir.join("gemini.cmd"); + let command_path = bin_dir.join("cline.cmd"); #[cfg(not(windows))] - let command_path = bin_dir.join("gemini"); + let command_path = bin_dir.join("cline"); std::fs::write(&command_path, "").expect("write command shim"); #[cfg(not(windows))] @@ -9298,7 +9369,7 @@ wire_api = "chat" .expect("mark command shim executable"); } - let resolved = resolve_npx_command_from_npm_prefix("gemini", &prefix); + let resolved = resolve_npx_command_from_npm_prefix("cline", &prefix); assert_eq!(resolved.as_deref(), Some(command_path.as_path())); let _ = std::fs::remove_dir_all(prefix); @@ -9323,10 +9394,10 @@ wire_api = "chat" let prefix = unique_test_dir("npm-prefix-non-executable"); let bin_dir = npm_prefix_bin_dir(&prefix); std::fs::create_dir_all(&bin_dir).expect("create npm prefix bin directory"); - let command_path = bin_dir.join("gemini"); + let command_path = bin_dir.join("cline"); std::fs::write(&command_path, "").expect("write command shim"); - let resolved = resolve_npx_command_from_npm_prefix("gemini", &prefix); + let resolved = resolve_npx_command_from_npm_prefix("cline", &prefix); assert_eq!(resolved, None); let _ = std::fs::remove_dir_all(prefix); diff --git a/src-tauri/src/commands/project_boot.rs b/src-tauri/src/commands/project_boot.rs index a72bb9963..5e83a4738 100644 --- a/src-tauri/src/commands/project_boot.rs +++ b/src-tauri/src/commands/project_boot.rs @@ -296,6 +296,8 @@ const HYPERFRAMES_SKILL_PREFIX: &str = "hyperframes"; /// Map a `skills` CLI `--agent` id (what the install command and the frontend /// speak) to codeg's internal `AgentType`, which drives skill-dir resolution. +/// `gemini-cli` is retained here as the external skills CLI compatibility id; +/// codeg displays and launches this slot as Antigravity. fn agent_type_for_skill_id(skill_agent: &str) -> Option { Some(match skill_agent { "claude-code" => AgentType::ClaudeCode, diff --git a/src-tauri/src/models/agent.rs b/src-tauri/src/models/agent.rs index f214b8555..d96b95006 100644 --- a/src-tauri/src/models/agent.rs +++ b/src-tauri/src/models/agent.rs @@ -23,7 +23,7 @@ impl fmt::Display for AgentType { AgentType::ClaudeCode => write!(f, "Claude Code"), AgentType::Codex => write!(f, "Codex CLI"), AgentType::OpenCode => write!(f, "OpenCode"), - AgentType::Gemini => write!(f, "Gemini CLI"), + AgentType::Gemini => write!(f, "Antigravity CLI"), AgentType::OpenClaw => write!(f, "OpenClaw"), AgentType::Cline => write!(f, "Cline"), AgentType::Hermes => write!(f, "Hermes Agent"), diff --git a/src-tauri/src/parsers/gemini.rs b/src-tauri/src/parsers/gemini.rs index 0c12ff8b9..5696b1621 100644 --- a/src-tauri/src/parsers/gemini.rs +++ b/src-tauri/src/parsers/gemini.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; -use chrono::{DateTime, Utc}; +use chrono::{DateTime, TimeZone, Utc}; use serde_json::{Map, Value}; use walkdir::WalkDir; @@ -15,6 +15,20 @@ pub struct GeminiParser { base_dir: PathBuf, } +#[derive(Debug, Clone)] +struct AntigravityHistoryEntry { + id: String, + display: Option, + workspace: Option, + timestamp: Option>, +} + +#[derive(Debug, Clone)] +struct AntigravityParsedConversation { + summary: ConversationSummary, + messages: Vec, +} + impl Default for GeminiParser { fn default() -> Self { Self::new() @@ -42,6 +56,18 @@ impl GeminiParser { self.base_dir.join("history") } + fn antigravity_dir(&self) -> PathBuf { + self.base_dir.join("antigravity-cli") + } + + fn antigravity_history_path(&self) -> PathBuf { + self.antigravity_dir().join("history.jsonl") + } + + fn antigravity_brain_dir(&self) -> PathBuf { + self.antigravity_dir().join("brain") + } + fn projects_json_path(&self) -> PathBuf { self.base_dir.join("projects.json") } @@ -224,6 +250,11 @@ impl GeminiParser { value.and_then(|v| v.as_str()?.parse::>().ok()) } + fn parse_millis_timestamp(value: Option<&serde_json::Value>) -> Option> { + let millis = value.and_then(|v| v.as_i64())?; + Utc.timestamp_millis_opt(millis).single() + } + fn extract_text(value: &Value) -> Option { match value { Value::String(text) => { @@ -457,6 +488,268 @@ impl GeminiParser { }) } + fn antigravity_history_entries(&self) -> HashMap { + let raw = match fs::read_to_string(self.antigravity_history_path()) { + Ok(raw) => raw, + Err(_) => return HashMap::new(), + }; + + let mut entries = HashMap::new(); + for line in raw.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let Ok(value) = serde_json::from_str::(trimmed) else { + continue; + }; + let timestamp = Self::parse_millis_timestamp(value.get("timestamp")); + let id = value + .get("conversationId") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .or_else(|| timestamp.map(|ts| format!("history-{}", ts.timestamp_millis()))); + let Some(id) = id else { + continue; + }; + let entry = AntigravityHistoryEntry { + id: id.clone(), + display: value + .get("display") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToString::to_string), + workspace: value + .get("workspace") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToString::to_string), + timestamp, + }; + entries.insert(id, entry); + } + entries + } + + fn antigravity_transcript_files_for(&self, conversation_id: &str) -> Vec { + let brain = self.antigravity_brain_dir(); + let mut files = Vec::new(); + let Ok(entries) = fs::read_dir(brain) else { + return files; + }; + for entry in entries.flatten() { + if entry.file_name().to_string_lossy() != conversation_id { + continue; + } + let logs_dir = entry.path().join(".system_generated").join("logs"); + if !logs_dir.exists() { + continue; + } + files.extend( + WalkDir::new(logs_dir) + .into_iter() + .filter_map(|e| e.ok()) + .map(|e| e.path().to_path_buf()) + .filter(|p| { + p.is_file() + && p.extension().and_then(|e| e.to_str()) == Some("jsonl") + && p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|name| name.starts_with("transcript")) + }), + ); + } + files.sort(); + files + } + + fn antigravity_conversation_ids(&self) -> Vec { + let mut ids: Vec = self.antigravity_history_entries().into_keys().collect(); + if let Ok(entries) = fs::read_dir(self.antigravity_brain_dir()) { + ids.extend(entries.flatten().filter_map(|entry| { + entry + .file_type() + .ok() + .filter(|ft| ft.is_dir()) + .and_then(|_| entry.file_name().to_str().map(ToString::to_string)) + })); + } + ids.sort(); + ids.dedup(); + ids + } + + fn parse_antigravity_transcript_messages( + &self, + conversation_id: &str, + ) -> Vec { + let mut messages = Vec::new(); + for path in self.antigravity_transcript_files_for(conversation_id) { + let Ok(raw) = fs::read_to_string(path) else { + continue; + }; + for line in raw.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let Ok(value) = serde_json::from_str::(trimmed) else { + continue; + }; + if value + .get("status") + .and_then(|v| v.as_str()) + .is_some_and(|status| !status.eq_ignore_ascii_case("done")) + { + continue; + } + let timestamp = Self::parse_timestamp(value.get("created_at")) + .unwrap_or_else(Utc::now); + let id = value + .get("step_index") + .and_then(|v| v.as_i64()) + .map(|idx| format!("{conversation_id}-{idx}")) + .unwrap_or_else(|| format!("{conversation_id}-{}", messages.len())); + let source = value + .get("source") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_ascii_lowercase(); + let kind = value + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_ascii_lowercase(); + let content = value + .get("content") + .and_then(Self::extract_text) + .unwrap_or_default(); + + if source.contains("user") || kind == "user_input" { + let text = content.trim(); + if text.is_empty() { + continue; + } + messages.push(UnifiedMessage { + id, + role: MessageRole::User, + content: vec![ContentBlock::Text { + text: text.to_string(), + }], + timestamp, + usage: None, + duration_ms: None, + model: None, + completed_at: Some(timestamp), + }); + continue; + } + + if source == "model" || kind.ends_with("_response") { + let mut blocks = Vec::new(); + if let Some(thinking) = value + .get("thinking") + .and_then(Self::extract_text) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + { + blocks.push(ContentBlock::Thinking { text: thinking }); + } + if !content.trim().is_empty() { + blocks.push(ContentBlock::Text { + text: content.trim().to_string(), + }); + } + if blocks.is_empty() { + continue; + } + messages.push(UnifiedMessage { + id, + role: MessageRole::Assistant, + content: blocks, + timestamp, + usage: None, + duration_ms: None, + model: None, + completed_at: Some(timestamp), + }); + } + } + } + messages.sort_by_key(|m| m.timestamp); + messages + } + + fn parse_antigravity_conversation( + &self, + conversation_id: &str, + history: Option<&AntigravityHistoryEntry>, + ) -> Option { + let mut messages = self.parse_antigravity_transcript_messages(conversation_id); + if messages.is_empty() { + let entry = history?; + let text = entry.display.as_ref()?.trim(); + if text.is_empty() { + return None; + } + let timestamp = entry.timestamp.unwrap_or_else(Utc::now); + messages.push(UnifiedMessage { + id: format!("{}-history", entry.id), + role: MessageRole::User, + content: vec![ContentBlock::Text { + text: text.to_string(), + }], + timestamp, + usage: None, + duration_ms: None, + model: None, + completed_at: Some(timestamp), + }); + } + + let started_at = messages + .first() + .map(|m| m.timestamp) + .or_else(|| history.and_then(|h| h.timestamp)) + .unwrap_or_else(Utc::now); + let ended_at = messages + .last() + .map(|m| m.completed_at.unwrap_or(m.timestamp)) + .or_else(|| history.and_then(|h| h.timestamp)); + let title = messages + .iter() + .filter(|m| matches!(m.role, MessageRole::User)) + .find_map(|m| { + m.content.iter().find_map(|block| match block { + ContentBlock::Text { text } => Some(title_from_user_text(text)), + _ => None, + }) + }) + .or_else(|| history.and_then(|h| h.display.as_ref().map(|s| title_from_user_text(s)))); + let folder_path = history.and_then(|h| h.workspace.clone()); + let folder_name = folder_path.as_ref().map(|p| folder_name_from_path(p)); + + let summary = ConversationSummary { + id: conversation_id.to_string(), + agent_type: AgentType::Gemini, + folder_path, + folder_name, + title, + started_at, + ended_at, + message_count: messages.len() as u32, + model: None, + git_branch: None, + parent_id: None, + parent_tool_use_id: None, + delegation_call_id: None, + }; + + Some(AntigravityParsedConversation { summary, messages }) + } + fn result_preview(result: Option<&Value>) -> Option { let v = result?; if let Some(s) = v.as_str() { @@ -766,6 +1059,16 @@ impl AgentParser for GeminiParser { } } + let antigravity_history = self.antigravity_history_entries(); + for conversation_id in self.antigravity_conversation_ids() { + if let Some(parsed) = self.parse_antigravity_conversation( + &conversation_id, + antigravity_history.get(&conversation_id), + ) { + conversations.push(parsed.summary); + } + } + conversations.sort_by_key(|b| std::cmp::Reverse(b.started_at)); Ok(conversations) } @@ -791,6 +1094,25 @@ impl AgentParser for GeminiParser { return self.parse_conversation_detail(&chat_file, &value, conversation_id); } + let antigravity_history = self.antigravity_history_entries(); + if let Some(parsed) = + self.parse_antigravity_conversation(conversation_id, antigravity_history.get(conversation_id)) + { + let mut turns = group_into_turns(parsed.messages); + super::relocate_orphaned_tool_results(&mut turns); + super::structurize_read_tool_output(&mut turns); + super::resolve_patch_line_numbers(&mut turns, parsed.summary.folder_path.as_deref()); + let session_stats = super::compute_session_stats(&turns); + let mut summary = parsed.summary; + summary.message_count = turns.len() as u32; + return Ok(ConversationDetail { + summary, + turns, + session_stats, + transcript_watermark: None, + }); + } + Err(ParseError::ConversationNotFound( conversation_id.to_string(), )) @@ -1114,6 +1436,87 @@ mod tests { let _ = fs::remove_dir_all(base); } + #[test] + fn parses_antigravity_history_and_brain_transcript() { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time ok") + .as_nanos(); + let base: PathBuf = env::temp_dir().join(format!("codeg-antigravity-test-{nanos}")); + let ag_dir = base.join("antigravity-cli"); + let conversation_id = "ag-conv-1"; + fs::create_dir_all(&ag_dir).expect("create antigravity dir"); + fs::write( + ag_dir.join("history.jsonl"), + format!( + "{}\n", + serde_json::json!({ + "display": "Plan migration", + "timestamp": 1_780_132_452_000_i64, + "workspace": "/tmp/ag-work", + "conversationId": conversation_id + }) + ), + ) + .expect("write antigravity history"); + + let logs_dir = ag_dir + .join("brain") + .join(conversation_id) + .join(".system_generated") + .join("logs"); + fs::create_dir_all(&logs_dir).expect("create transcript logs"); + let transcript = [ + serde_json::json!({ + "step_index": 0, + "source": "USER_EXPLICIT", + "type": "USER_INPUT", + "status": "DONE", + "created_at": "2026-06-30T08:44:12Z", + "content": "Plan migration" + }), + serde_json::json!({ + "step_index": 1, + "source": "MODEL", + "type": "PLANNER_RESPONSE", + "status": "DONE", + "created_at": "2026-06-30T08:44:15Z", + "thinking": "Check registry first", + "content": "Use agy bridge" + }), + ] + .into_iter() + .map(|v| v.to_string()) + .collect::>() + .join("\n"); + fs::write(logs_dir.join("transcript-1.jsonl"), format!("{transcript}\n")) + .expect("write transcript"); + + let parser = GeminiParser::with_base_dir(base.clone()); + let summaries = parser.list_conversations().expect("list conversations"); + assert_eq!(summaries.len(), 1); + assert_eq!(summaries[0].id, conversation_id); + assert_eq!(summaries[0].title.as_deref(), Some("Plan migration")); + assert_eq!(summaries[0].folder_path.as_deref(), Some("/tmp/ag-work")); + + let detail = parser + .get_conversation(conversation_id) + .expect("get conversation"); + assert_eq!(detail.turns.len(), 2); + assert!(matches!(detail.turns[0].role, TurnRole::User)); + assert!(matches!(detail.turns[1].role, TurnRole::Assistant)); + assert!(detail.turns[1] + .blocks + .iter() + .any(|block| matches!(block, ContentBlock::Thinking { text } if text == "Check registry first"))); + assert!(detail.turns[1] + .blocks + .iter() + .any(|block| matches!(block, ContentBlock::Text { text } if text == "Use agy bridge"))); + + let _ = fs::remove_dir_all(base); + } + #[test] fn parse_detail_completion_time_uses_assistant_timestamp_not_next_message_gap() { // Regression: Gemini's `duration_ms` heuristic is `next_msg.ts - diff --git a/src-tauri/src/parsers/mod.rs b/src-tauri/src/parsers/mod.rs index 239805f9a..8fa604368 100644 --- a/src-tauri/src/parsers/mod.rs +++ b/src-tauri/src/parsers/mod.rs @@ -71,7 +71,7 @@ pub fn external_transcript_sources() -> Vec { agent: "gemini", root: gemini::resolve_gemini_base_dir(), is_file: false, - include_top: Some(&["tmp", "history", "projects.json"]), + include_top: Some(&["tmp", "history", "projects.json", "antigravity-cli"]), }, ExternalSource { agent: "cline", diff --git a/src-tauri/tests/antigravity_bridge.rs b/src-tauri/tests/antigravity_bridge.rs new file mode 100644 index 000000000..04ffd8864 --- /dev/null +++ b/src-tauri/tests/antigravity_bridge.rs @@ -0,0 +1,222 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use codeg_lib::acp::antigravity_bridge::{ + fallback_antigravity_models, parse_antigravity_models, query_antigravity_models, + run_antigravity_print, AntigravityPrintOptions, +}; + +fn fake_agy_script(dir: &Path) -> (PathBuf, PathBuf) { + let log_path = dir.join("agy-args.log"); + + #[cfg(windows)] + { + let script_path = dir.join("agy.cmd"); + let log_arg = log_path.to_string_lossy().replace('%', "%%"); + let script = format!( + r#"@echo off +echo %* > "{log_arg}" +if "%AGY_TEST_MODE%"=="fail" ( + echo bridge failed 1>&2 + exit /b 7 +) +if "%AGY_TEST_MODE%"=="empty" ( + exit /b 0 +) +if "%AGY_TEST_MODE%"=="sleep" ( + powershell -NoProfile -Command "Start-Sleep -Milliseconds 500" + echo too late + exit /b 0 +) +if "%AGY_TEST_MODE%"=="models" ( + echo Gemini 3.5 Flash ^(High^) + echo Gemini 3.1 Pro ^(High^) + echo Claude Sonnet 4.6 ^(Thinking^) + exit /b 0 +) +echo bridge ok +"# + ); + fs::write(&script_path, script).expect("write fake agy"); + (script_path, log_path) + } + + #[cfg(not(windows))] + { + use std::os::unix::fs::PermissionsExt; + + let script_path = dir.join("agy"); + let log_arg = log_path.to_string_lossy(); + let script = format!( + r#"#!/bin/sh +printf '%s\n' "$*" > "{log_arg}" +case "$AGY_TEST_MODE" in + fail) + echo "bridge failed" >&2 + exit 7 + ;; + empty) + exit 0 + ;; + sleep) + sleep 1 + echo "too late" + exit 0 + ;; + models) + echo "Gemini 3.5 Flash (High)" + echo "Gemini 3.1 Pro (High)" + echo "Claude Sonnet 4.6 (Thinking)" + exit 0 + ;; +esac +echo "bridge ok" +"# + ); + fs::write(&script_path, script).expect("write fake agy"); + let mut perms = fs::metadata(&script_path).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms).unwrap(); + (script_path, log_path) + } +} + +fn options(command: PathBuf, workspace: PathBuf) -> AntigravityPrintOptions { + AntigravityPrintOptions { + command, + workspace, + model: Some("Gemini 3.1 Pro (High)".to_string()), + project: Some("proj-1".to_string()), + conversation: Some("conv-1".to_string()), + print_timeout: "45s".to_string(), + child_timeout: Duration::from_secs(2), + env: BTreeMap::new(), + } +} + +#[test] +fn antigravity_model_parser_trims_blanks_and_deduplicates() { + let models = parse_antigravity_models( + "\n Gemini 3.5 Flash (High) \nGemini 3.1 Pro (High)\n\ + Gemini 3.5 Flash (High)\n", + ); + assert_eq!( + models, + vec!["Gemini 3.5 Flash (High)", "Gemini 3.1 Pro (High)"] + ); +} + +#[test] +fn antigravity_fallback_models_cover_current_agy_catalog() { + let models = fallback_antigravity_models(); + assert!(models.contains(&"Gemini 3.5 Flash (High)".to_string())); + assert!(models.contains(&"Gemini 3.1 Pro (High)".to_string())); + assert!(models.contains(&"Claude Sonnet 4.6 (Thinking)".to_string())); + assert!(models.contains(&"GPT-OSS 120B (Medium)".to_string())); +} + +#[tokio::test] +async fn antigravity_models_queries_agy_catalog() { + let temp = tempfile::tempdir().expect("tempdir"); + let (command, log_path) = fake_agy_script(temp.path()); + let mut opts = options(command, temp.path().to_path_buf()); + opts.env.insert("AGY_TEST_MODE".into(), "models".into()); + + let models = query_antigravity_models(&opts) + .await + .expect("model catalog"); + + assert_eq!( + models, + vec![ + "Gemini 3.5 Flash (High)", + "Gemini 3.1 Pro (High)", + "Claude Sonnet 4.6 (Thinking)", + ] + ); + assert_eq!(fs::read_to_string(log_path).unwrap().trim(), "models"); +} + +#[tokio::test] +async fn antigravity_print_passes_expected_args_and_returns_stdout() { + let temp = tempfile::tempdir().expect("tempdir"); + let (command, log_path) = fake_agy_script(temp.path()); + let workspace = temp.path().join("workspace"); + fs::create_dir_all(&workspace).expect("workspace"); + + let output = run_antigravity_print(&options(command, workspace.clone()), "hello") + .await + .expect("bridge output"); + + assert_eq!(output, "bridge ok"); + let args = fs::read_to_string(log_path).expect("args log"); + assert!(args.contains("--print")); + assert!(args.contains("--print-timeout")); + assert!(args.contains("45s")); + assert!(args.contains("--add-dir")); + assert!(args.contains(&workspace.to_string_lossy().to_string())); + assert!(args.contains("--model")); + assert!(args.contains("Gemini 3.1 Pro (High)")); + assert!(args.contains("--project")); + assert!(args.contains("proj-1")); + assert!(args.contains("--conversation")); + assert!(args.contains("conv-1")); + assert!(args.contains("hello")); + let timeout_idx = args + .find("--print-timeout") + .expect("print timeout option is present"); + let print_idx = args.rfind("--print").expect("print flag is present"); + assert!( + timeout_idx < print_idx, + "Antigravity parses args after --print as prompt text: {args}" + ); +} + +#[tokio::test] +async fn antigravity_print_reports_nonzero_exit_with_stderr() { + let temp = tempfile::tempdir().expect("tempdir"); + let (command, _) = fake_agy_script(temp.path()); + let mut opts = options(command, temp.path().to_path_buf()); + opts.env.insert("AGY_TEST_MODE".into(), "fail".into()); + + let err = run_antigravity_print(&opts, "hello") + .await + .expect_err("nonzero exit should fail") + .to_string(); + + assert!(err.contains("bridge failed"), "{err}"); + assert!(err.contains("exit"), "{err}"); +} + +#[tokio::test(start_paused = true)] +async fn antigravity_print_times_out() { + let temp = tempfile::tempdir().expect("tempdir"); + let (command, _) = fake_agy_script(temp.path()); + let mut opts = options(command, temp.path().to_path_buf()); + opts.child_timeout = Duration::from_millis(10); + opts.env.insert("AGY_TEST_MODE".into(), "sleep".into()); + + let err = run_antigravity_print(&opts, "hello") + .await + .expect_err("timeout should fail") + .to_string(); + + assert!(err.to_ascii_lowercase().contains("timeout"), "{err}"); +} + +#[tokio::test] +async fn antigravity_print_rejects_empty_stdout() { + let temp = tempfile::tempdir().expect("tempdir"); + let (command, _) = fake_agy_script(temp.path()); + let mut opts = options(command, temp.path().to_path_buf()); + opts.env.insert("AGY_TEST_MODE".into(), "empty".into()); + + let err = run_antigravity_print(&opts, "hello") + .await + .expect_err("empty stdout should fail") + .to_string(); + + assert!(err.to_ascii_lowercase().contains("empty"), "{err}"); +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index a02ef36fe..d1e003467 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from "react" import { useRouter } from "next/navigation" import { useTranslations } from "next-intl" import { isDesktop } from "@/lib/platform" +import { setCodegToken } from "@/lib/transport/web-auth" export default function LoginPage() { const router = useRouter() @@ -39,7 +40,7 @@ export default function LoginPage() { }) if (res.ok) { - localStorage.setItem("codeg_token", token) + setCodegToken(token) router.replace("/workspace") } else if (res.status === 401) { setError(t("invalidToken")) diff --git a/src/app/page.test.tsx b/src/app/page.test.tsx new file mode 100644 index 000000000..a9edddaab --- /dev/null +++ b/src/app/page.test.tsx @@ -0,0 +1,120 @@ +import { render, waitFor } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +const mocks = vi.hoisted(() => ({ + replace: vi.fn(), + isDesktop: vi.fn(() => false), +})) + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ replace: mocks.replace }), +})) + +vi.mock("@/lib/platform", () => ({ + isDesktop: mocks.isDesktop, +})) + +import Page from "./page" + +const response = (status: number) => + ({ ok: status >= 200 && status < 300, status }) as Response + +beforeEach(() => { + mocks.replace.mockClear() + mocks.isDesktop.mockReturnValue(false) + localStorage.clear() + window.history.replaceState(null, "", "/") + vi.stubGlobal("fetch", vi.fn()) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe("web root authentication", () => { + it("validates a launcher token, stores it, and scrubs it from the URL", async () => { + window.history.replaceState( + null, + "", + "/#codeg_token=secret%2Ftoken%2Bvalue%3D&view=diff" + ) + vi.mocked(fetch).mockResolvedValue(response(200)) + + render() + + await waitFor(() => + expect(mocks.replace).toHaveBeenCalledWith("/workspace") + ) + expect(fetch).toHaveBeenCalledWith( + "/api/health", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer secret/token+value=", + }), + }) + ) + expect(localStorage.getItem("codeg_token")).toBe("secret/token+value=") + expect(window.location.hash).toBe("#view=diff") + }) + + it("uses a launcher token in preference to a previously stored token", async () => { + localStorage.setItem("codeg_token", "old-token") + window.history.replaceState(null, "", "/#codeg_token=new-token") + vi.mocked(fetch).mockResolvedValue(response(200)) + + render() + + await waitFor(() => + expect(mocks.replace).toHaveBeenCalledWith("/workspace") + ) + expect(fetch).toHaveBeenCalledWith( + "/api/health", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer new-token", + }), + }) + ) + expect(localStorage.getItem("codeg_token")).toBe("new-token") + }) + + it("clears a rejected launcher token and returns to login", async () => { + localStorage.setItem("codeg_token", "old-token") + window.history.replaceState(null, "", "/#codeg_token=invalid-token") + vi.mocked(fetch).mockResolvedValue(response(401)) + + render() + + await waitFor(() => expect(mocks.replace).toHaveBeenCalledWith("/login")) + expect(localStorage.getItem("codeg_token")).toBeNull() + expect(window.location.hash).toBe("") + }) + + it("returns to login without calling the server when no token exists", () => { + render() + + expect(mocks.replace).toHaveBeenCalledWith("/login") + expect(fetch).not.toHaveBeenCalled() + }) + + it("keeps a stored token during a transient network failure", async () => { + localStorage.setItem("codeg_token", "stored-token") + vi.mocked(fetch).mockRejectedValue(new TypeError("Failed to fetch")) + + render() + + await waitFor(() => + expect(mocks.replace).toHaveBeenCalledWith("/workspace") + ) + expect(localStorage.getItem("codeg_token")).toBe("stored-token") + }) + + it("skips web authentication in the desktop runtime", () => { + mocks.isDesktop.mockReturnValue(true) + + render() + + expect(mocks.replace).toHaveBeenCalledWith("/workspace") + expect(fetch).not.toHaveBeenCalled() + }) +}) diff --git a/src/app/page.tsx b/src/app/page.tsx index e5f8ed643..2651db682 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,6 +3,12 @@ import { useEffect } from "react" import { useRouter } from "next/navigation" import { isDesktop } from "@/lib/platform" +import { + clearCodegToken, + consumeCodegTokenFromFragment, + getCodegToken, + setCodegToken, +} from "@/lib/transport/web-auth" export default function Page() { const router = useRouter() @@ -11,9 +17,18 @@ export default function Page() { router.replace("/workspace") return } + // The local launcher passes its token in the URL fragment. Fragments never + // reach the server; consume it once, scrub the address bar, then validate + // it through the same health check as a manually entered token. + const bootstrapToken = consumeCodegTokenFromFragment() + if (bootstrapToken !== null) { + setCodegToken(bootstrapToken) + } + // Web mode: validate token before entering app - const token = localStorage.getItem("codeg_token") + const token = bootstrapToken ?? getCodegToken() if (!token) { + clearCodegToken() router.replace("/login") return } @@ -33,7 +48,7 @@ export default function Page() { } if (res.status === 401) { // Token genuinely rejected → clear it and re-authenticate. - localStorage.removeItem("codeg_token") + clearCodegToken() router.replace("/login") return } diff --git a/src/components/agent-icon.tsx b/src/components/agent-icon.tsx index d5cae0ab6..fa7ac919e 100644 --- a/src/components/agent-icon.tsx +++ b/src/components/agent-icon.tsx @@ -64,7 +64,7 @@ const GeminiCliColorIcon = memo(function GeminiCliColorIcon({ width={size} xmlns="http://www.w3.org/2000/svg" > - Gemini CLI + Antigravity CLI { + afterEach(() => cleanup()) + + it("renders the model selector in the composer's lower-left toolbar", async () => { + const antigravityModel: SessionConfigOptionInfo = { + ...MODEL_OPTION, + kind: { + type: "select", + current_value: "Gemini 3.1 Pro (High)", + options: [ + { + value: "Gemini 3.1 Pro (High)", + name: "Gemini 3.1 Pro (High)", + description: null, + }, + { + value: "Gemini 3.5 Flash (High)", + name: "Gemini 3.5 Flash (High)", + description: null, + }, + ], + groups: [], + }, + } + const { container } = renderInput({ + agentType: "gemini", + configOptions: [antigravityModel], + }) + await waitFor(() => + expect(container.querySelector('[role="textbox"]')).not.toBeNull() + ) + + const addButton = screen.getByRole("button", { + name: enMessages.Folder.chat.messageInput.addActions, + }) + const modelButton = screen.getByRole("button", { + name: "Model: Gemini 3.1 Pro (High)", + }) + const sendButton = screen.getByRole("button", { + name: enMessages.Folder.chat.messageInput.send, + }) + + expect( + addButton.compareDocumentPosition(modelButton) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy() + expect( + modelButton.compareDocumentPosition(sendButton) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy() + }) +}) + describe("MessageInput collapsed selectors popover", () => { afterEach(() => cleanup()) diff --git a/src/components/project-boot/hyperframes/constants.ts b/src/components/project-boot/hyperframes/constants.ts index 94a960bca..976d6967c 100644 --- a/src/components/project-boot/hyperframes/constants.ts +++ b/src/components/project-boot/hyperframes/constants.ts @@ -12,11 +12,13 @@ export const HYPERFRAMES_RESOLUTION_OPTIONS = [ // codeg's six agents, mapped to the `skills` CLI `--agent` ids used by the // global HyperFrames skills install. Order matches the backend allowlist. +// `gemini-cli` is retained as the external skills CLI compatibility id; codeg +// displays and launches that slot as Antigravity. export const HYPERFRAMES_SKILL_AGENTS = [ { id: "claude-code", label: "Claude Code" }, { id: "codex", label: "Codex" }, { id: "opencode", label: "OpenCode" }, - { id: "gemini-cli", label: "Gemini" }, + { id: "gemini-cli", label: "Antigravity" }, { id: "openclaw", label: "OpenClaw" }, { id: "cline", label: "Cline" }, ] diff --git a/src/components/settings/acp-agent-settings.test.tsx b/src/components/settings/acp-agent-settings.test.tsx index 4c97a2040..b525c110f 100644 --- a/src/components/settings/acp-agent-settings.test.tsx +++ b/src/components/settings/acp-agent-settings.test.tsx @@ -221,6 +221,22 @@ describe("buildGrokSaveOptions — one save persists both surfaces", () => { }) describe("buildVersionCheck", () => { + it("treats system command agents as unmanaged runtime checks", () => { + const check = buildVersionCheck( + makeAgent({ + agent_type: "gemini" as AgentType, + name: "Antigravity CLI", + distribution_type: "system", + registry_version: null, + installed_version: "1.0.15", + }) + ) + + expect(check?.status).toBe("pass") + expect(check?.message).toContain("1.0.15") + expect(check?.fixes).toHaveLength(0) + }) + // uv runtime not ready: a uvx agent (Hermes) must surface a blocked // version-status with the agent-install action DISABLED — the actual install // happens via the separate "Install uv" preflight action, not here. diff --git a/src/components/settings/acp-agent-settings.tsx b/src/components/settings/acp-agent-settings.tsx index 729c3d25c..98066f01b 100644 --- a/src/components/settings/acp-agent-settings.tsx +++ b/src/components/settings/acp-agent-settings.tsx @@ -152,12 +152,9 @@ interface AgentDraft { model: string claudeAuthMode: ClaudeAuthMode modelProviderId: number | null - geminiAuthMode: GeminiAuthMode - geminiApiKey: string - googleApiKey: string - googleCloudProject: string - googleCloudLocation: string - googleApplicationCredentials: string + antigravityProject: string + antigravityConversation: string + antigravityPrintTimeout: string codexAuthMode: CodexAuthMode codexModelProvider: string codexProviderOptions: string[] @@ -257,7 +254,14 @@ function acpText( fallback: string, values?: Record ): string { - if (!acpTranslator) return fallback + if (!acpTranslator) { + if (!values) return fallback + return fallback.replace(/\{(\w+)\}/g, (match, name: string) => + Object.prototype.hasOwnProperty.call(values, name) + ? String(values[name]) + : match + ) + } return acpTranslator(key, values) } @@ -355,29 +359,11 @@ function normalizeClaudeEffortLevel(value: unknown): ClaudeEffortLevel { return "" } -const GEMINI_AUTH_MODES = [ - "custom", - "login_google", - "gemini_api_key", - "vertex_adc", - "vertex_service_account", - "vertex_api_key", - "model_provider", -] as const - -type GeminiAuthMode = (typeof GEMINI_AUTH_MODES)[number] - -const GEMINI_ENV_KEYS = { - baseUrl: "GOOGLE_GEMINI_BASE_URL", - legacyBaseUrl: "GEMINI_BASE_URL", - geminiApiKey: "GEMINI_API_KEY", - legacyGeminiApiKey: "GOOGLE_GEMINI_API_KEY", - googleApiKey: "GOOGLE_API_KEY", - cloudProject: "GOOGLE_CLOUD_PROJECT", - cloudProjectLegacy: "GOOGLE_CLOUD_PROJECT_ID", - cloudLocation: "GOOGLE_CLOUD_LOCATION", - applicationCredentials: "GOOGLE_APPLICATION_CREDENTIALS", - model: "GEMINI_MODEL", +const ANTIGRAVITY_ENV_KEYS = { + model: "ANTIGRAVITY_MODEL", + project: "ANTIGRAVITY_PROJECT", + conversation: "ANTIGRAVITY_CONVERSATION", + printTimeout: "ANTIGRAVITY_PRINT_TIMEOUT", } as const const OPENCLAW_ENV_KEYS = { @@ -428,14 +414,9 @@ function importantEnvKeysByAgent(agentType: AgentType): ImportantEnvKeys { } if (agentType === "gemini") { return { - apiBaseUrl: ["GOOGLE_GEMINI_BASE_URL", "GEMINI_BASE_URL", "API_BASE_URL"], - apiKey: [ - GEMINI_ENV_KEYS.geminiApiKey, - GEMINI_ENV_KEYS.googleApiKey, - GEMINI_ENV_KEYS.legacyGeminiApiKey, - "API_KEY", - ], - model: ["GEMINI_MODEL", "MODEL"], + apiBaseUrl: [], + apiKey: [], + model: [ANTIGRAVITY_ENV_KEYS.model, "MODEL"], } } if (agentType === "grok") { @@ -676,83 +657,21 @@ function extractImportantConfigValues( } } -interface GeminiImportantValues { - authMode: GeminiAuthMode - apiBaseUrl: string - geminiApiKey: string - googleApiKey: string - googleCloudProject: string - googleCloudLocation: string - googleApplicationCredentials: string +interface AntigravityImportantValues { model: string + project: string + conversation: string + printTimeout: string } -function inferGeminiAuthMode(values: { - apiBaseUrl: string - geminiApiKey: string - googleApiKey: string - googleCloudProject: string - googleCloudLocation: string - googleApplicationCredentials: string -}): GeminiAuthMode { - if (values.apiBaseUrl.trim()) return "custom" - if (values.geminiApiKey.trim()) return "gemini_api_key" - if (values.googleApiKey.trim()) return "vertex_api_key" - if (values.googleApplicationCredentials.trim()) - return "vertex_service_account" - if (values.googleCloudProject.trim() || values.googleCloudLocation.trim()) { - return "vertex_adc" - } - return "login_google" -} - -function extractGeminiImportantValues( - env: Record, - configText: string -): GeminiImportantValues { - const parseResult = parseConfigJsonText(configText) - const config = parseResult.config - const configEnv = envFromConfig(config) - const mergedEnv = { ...env, ...configEnv } - - const apiBaseUrl = findEnvValue(mergedEnv, [ - GEMINI_ENV_KEYS.baseUrl, - GEMINI_ENV_KEYS.legacyBaseUrl, - "API_BASE_URL", - ]) - const geminiApiKey = findEnvValue(mergedEnv, [ - GEMINI_ENV_KEYS.geminiApiKey, - GEMINI_ENV_KEYS.legacyGeminiApiKey, - ]) - const googleApiKey = findEnvValue(mergedEnv, [GEMINI_ENV_KEYS.googleApiKey]) - const googleCloudProject = findEnvValue(mergedEnv, [ - GEMINI_ENV_KEYS.cloudProject, - GEMINI_ENV_KEYS.cloudProjectLegacy, - ]) - const googleCloudLocation = findEnvValue(mergedEnv, [ - GEMINI_ENV_KEYS.cloudLocation, - ]) - const googleApplicationCredentials = findEnvValue(mergedEnv, [ - GEMINI_ENV_KEYS.applicationCredentials, - ]) - const model = findEnvValue(mergedEnv, [GEMINI_ENV_KEYS.model, "MODEL"]) - +function extractAntigravityImportantValues( + env: Record +): AntigravityImportantValues { return { - authMode: inferGeminiAuthMode({ - apiBaseUrl, - geminiApiKey, - googleApiKey, - googleCloudProject, - googleCloudLocation, - googleApplicationCredentials, - }), - apiBaseUrl, - geminiApiKey, - googleApiKey, - googleCloudProject, - googleCloudLocation, - googleApplicationCredentials, - model: model ?? "", + model: env[ANTIGRAVITY_ENV_KEYS.model]?.trim() ?? "", + project: env[ANTIGRAVITY_ENV_KEYS.project]?.trim() ?? "", + conversation: env[ANTIGRAVITY_ENV_KEYS.conversation]?.trim() ?? "", + printTimeout: env[ANTIGRAVITY_ENV_KEYS.printTimeout]?.trim() ?? "", } } @@ -798,239 +717,6 @@ function extractOpenClawImportantValues( } } -function patchGeminiConfigText( - configText: string, - patch: { - apiBaseUrl?: string - model?: string - geminiApiKey?: string - googleApiKey?: string - googleCloudProject?: string - googleCloudLocation?: string - googleApplicationCredentials?: string - } -): { - configText: string - recoveredFromInvalid: boolean -} { - const parseResult = parseConfigJsonText(configText) - const config = parseResult.error ? {} : { ...parseResult.config } - const env = - typeof config.env === "object" && config.env && !Array.isArray(config.env) - ? { ...(config.env as Record) } - : {} - - const assignOrRemoveEnv = (key: string, value: string | undefined) => { - if (typeof value !== "string") return - const trimmed = value.trim() - if (!trimmed) { - delete env[key] - return - } - env[key] = trimmed - } - - if (typeof patch.model === "string") { - delete config.model - delete config.model_name - assignOrRemoveEnv(GEMINI_ENV_KEYS.model, patch.model) - } - assignOrRemoveEnv(GEMINI_ENV_KEYS.baseUrl, patch.apiBaseUrl) - if (typeof patch.apiBaseUrl === "string") { - assignOrRemoveEnv(GEMINI_ENV_KEYS.legacyBaseUrl, "") - } - assignOrRemoveEnv(GEMINI_ENV_KEYS.geminiApiKey, patch.geminiApiKey) - assignOrRemoveEnv(GEMINI_ENV_KEYS.googleApiKey, patch.googleApiKey) - if (typeof patch.geminiApiKey === "string") { - assignOrRemoveEnv(GEMINI_ENV_KEYS.legacyGeminiApiKey, "") - } - if (typeof patch.googleCloudProject === "string") { - const project = patch.googleCloudProject.trim() - if (!project) { - delete env[GEMINI_ENV_KEYS.cloudProject] - delete env[GEMINI_ENV_KEYS.cloudProjectLegacy] - } else { - env[GEMINI_ENV_KEYS.cloudProject] = project - delete env[GEMINI_ENV_KEYS.cloudProjectLegacy] - } - } - assignOrRemoveEnv(GEMINI_ENV_KEYS.cloudLocation, patch.googleCloudLocation) - assignOrRemoveEnv( - GEMINI_ENV_KEYS.applicationCredentials, - patch.googleApplicationCredentials - ) - - if (Object.keys(env).length === 0) { - delete config.env - } else { - config.env = env - } - - return { - configText: - Object.keys(config).length === 0 ? "" : JSON.stringify(config, null, 2), - recoveredFromInvalid: Boolean(parseResult.error), - } -} - -function patchGeminiEnvText( - envText: string, - patch: { - apiBaseUrl?: string - geminiApiKey?: string - googleApiKey?: string - googleCloudProject?: string - googleCloudLocation?: string - googleApplicationCredentials?: string - model?: string - } -): string { - const envPatch: Record = {} - if (typeof patch.apiBaseUrl === "string") { - envPatch[GEMINI_ENV_KEYS.baseUrl] = patch.apiBaseUrl - envPatch[GEMINI_ENV_KEYS.legacyBaseUrl] = "" - } - if (typeof patch.geminiApiKey === "string") { - envPatch[GEMINI_ENV_KEYS.geminiApiKey] = patch.geminiApiKey - envPatch[GEMINI_ENV_KEYS.legacyGeminiApiKey] = "" - } - if (typeof patch.googleApiKey === "string") { - envPatch[GEMINI_ENV_KEYS.googleApiKey] = patch.googleApiKey - } - if (typeof patch.googleCloudProject === "string") { - envPatch[GEMINI_ENV_KEYS.cloudProject] = patch.googleCloudProject - envPatch[GEMINI_ENV_KEYS.cloudProjectLegacy] = "" - } - if (typeof patch.googleCloudLocation === "string") { - envPatch[GEMINI_ENV_KEYS.cloudLocation] = patch.googleCloudLocation - } - if (typeof patch.googleApplicationCredentials === "string") { - envPatch[GEMINI_ENV_KEYS.applicationCredentials] = - patch.googleApplicationCredentials - } - if (typeof patch.model === "string") { - envPatch[GEMINI_ENV_KEYS.model] = patch.model - } - return patchEnvText(envText, envPatch) -} - -function patchGeminiAuthMode( - current: GeminiImportantValues, - mode: GeminiAuthMode -) { - const next = { - ...current, - authMode: mode, - } - if (mode === "login_google") { - next.apiBaseUrl = "" - next.geminiApiKey = "" - next.googleApiKey = "" - next.googleCloudProject = "" - next.googleCloudLocation = "" - next.googleApplicationCredentials = "" - return next - } - if (mode === "custom") { - next.googleApiKey = "" - next.googleCloudProject = "" - next.googleCloudLocation = "" - next.googleApplicationCredentials = "" - return next - } - if (mode === "gemini_api_key") { - next.apiBaseUrl = "" - next.googleApiKey = "" - next.googleCloudProject = "" - next.googleCloudLocation = "" - next.googleApplicationCredentials = "" - return next - } - if (mode === "vertex_api_key") { - next.apiBaseUrl = "" - next.geminiApiKey = "" - next.googleApplicationCredentials = "" - return next - } - if (mode === "vertex_service_account") { - next.apiBaseUrl = "" - next.geminiApiKey = "" - next.googleApiKey = "" - return next - } - if (mode === "model_provider") { - next.googleCloudProject = "" - next.googleCloudLocation = "" - next.googleApplicationCredentials = "" - return next - } - next.apiBaseUrl = "" - next.geminiApiKey = "" - next.googleApiKey = "" - next.googleApplicationCredentials = "" - return next -} - -function geminiAuthModeLabel(mode: GeminiAuthMode): string { - if (mode === "custom") - return acpText("authModeCustomEndpoint", "Custom Endpoint") - if (mode === "login_google") - return acpText("gemini.mode.loginGoogle", "Google Login (OAuth)") - if (mode === "gemini_api_key") return "Gemini API Key" - if (mode === "vertex_adc") return "Vertex AI (ADC)" - if (mode === "vertex_service_account") - return acpText( - "gemini.mode.vertexServiceAccount", - "Vertex AI (Service Account)" - ) - if (mode === "model_provider") - return acpText("authModeModelProvider", "Model Provider") - return "Vertex AI API Key" -} - -function geminiAuthModeHint(mode: GeminiAuthMode): string { - if (mode === "custom") { - return acpText( - "gemini.hint.custom", - "Fill API URL, API Key and Model, mapped to GOOGLE_GEMINI_BASE_URL / GEMINI_API_KEY / GEMINI_MODEL." - ) - } - if (mode === "login_google") { - return acpText( - "gemini.hint.loginGoogle", - "Run gemini in terminal and complete Google login first; API key is not required." - ) - } - if (mode === "gemini_api_key") { - return acpText( - "gemini.hint.geminiApiKey", - "Fill GEMINI_API_KEY when using Gemini API." - ) - } - if (mode === "vertex_adc") { - return acpText( - "gemini.hint.vertexAdc", - "Use gcloud ADC; GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION are recommended." - ) - } - if (mode === "vertex_service_account") { - return acpText( - "gemini.hint.vertexServiceAccount", - "Set service account JSON path to GOOGLE_APPLICATION_CREDENTIALS." - ) - } - if (mode === "model_provider") { - return acpText( - "modelProviderHint", - "Use API URL and API Key from a configured model provider." - ) - } - return acpText( - "gemini.hint.vertexApiKey", - "Fill GOOGLE_API_KEY when using Vertex AI API key." - ) -} - /** * Compare original and current config objects. For any key present in * original but missing in current, set it to `null` in the result so @@ -2673,7 +2359,7 @@ function buildAgentDraft(agent: AcpAgentInfo): AgentDraft { agent.env, configText ) - const geminiImportant = extractGeminiImportantValues(agent.env, configText) + const antigravityImportant = extractAntigravityImportantValues(agent.env) const openClawImportant = extractOpenClawImportantValues( agent.env, configText @@ -2711,24 +2397,20 @@ function buildAgentDraft(agent: AcpAgentInfo): AgentDraft { ? (hermesValues?.baseUrl ?? "") : agent.agent_type === "codex" ? codexImportant.apiBaseUrl - : agent.agent_type === "gemini" - ? geminiImportant.apiBaseUrl - : important.apiBaseUrl, + : important.apiBaseUrl, apiKey: agent.agent_type === "hermes" ? (hermesValues?.apiKey ?? "") : agent.agent_type === "codex" ? (codexImportant.apiKey ?? "") - : agent.agent_type === "gemini" - ? geminiImportant.geminiApiKey || geminiImportant.googleApiKey - : important.apiKey, + : important.apiKey, model: agent.agent_type === "hermes" ? (hermesValues?.model ?? "") : agent.agent_type === "codex" ? codexImportant.model : agent.agent_type === "gemini" - ? geminiImportant.model + ? antigravityImportant.model : agent.agent_type === "open_code" ? openCodeImportant.model : important.model, @@ -2740,15 +2422,9 @@ function buildAgentDraft(agent: AcpAgentInfo): AgentDraft { ? "custom" : "official_subscription", modelProviderId: agent.model_provider_id ?? null, - geminiAuthMode: - agent.agent_type === "gemini" && agent.model_provider_id != null - ? "model_provider" - : geminiImportant.authMode, - geminiApiKey: geminiImportant.geminiApiKey, - googleApiKey: geminiImportant.googleApiKey, - googleCloudProject: geminiImportant.googleCloudProject, - googleCloudLocation: geminiImportant.googleCloudLocation, - googleApplicationCredentials: geminiImportant.googleApplicationCredentials, + antigravityProject: antigravityImportant.project, + antigravityConversation: antigravityImportant.conversation, + antigravityPrintTimeout: antigravityImportant.printTimeout, codexAuthMode, codexModelProvider: codexImportant.modelProvider, codexProviderOptions: codexImportant.providerOptions, @@ -2841,6 +2517,26 @@ export function buildVersionCheck( agent: AcpAgentInfo, uvReady: boolean = true ): UiCheckItem | null { + if (agent.distribution_type === "system") { + const localVersion = + agent.installed_version ?? acpText("version.notInstalled", "Not installed") + return { + check_id: "version_status", + label: acpText("version.statusLabel", "Version Status"), + status: agent.available ? "pass" : "fail", + message: agent.available + ? acpText("version.systemCommandReady", "Local: {localVersion}", { + localVersion, + }) + : acpText( + "version.systemCommandMissing", + "{agentName} is not installed or not in PATH.", + { agentName: agent.name } + ), + fixes: [], + } + } + if ( agent.distribution_type !== "binary" && agent.distribution_type !== "npx" && @@ -4918,8 +4614,6 @@ export function AcpAgentSettings() { if (at === "claude_code") return selectedDraft.claudeAuthMode === "model_provider" if (at === "codex") return selectedDraft.codexAuthMode === "model_provider" - if (at === "gemini") - return selectedDraft.geminiAuthMode === "model_provider" return false }, [selectedAgent, selectedDraft]) @@ -5128,39 +4822,12 @@ export function AcpAgentSettings() { parseEnvText(selectedDraft.envText), nextText ) - const geminiImportant = - selectedAgent.agent_type === "gemini" - ? extractGeminiImportantValues( - parseEnvText(selectedDraft.envText), - nextText - ) - : null updateSelectedDraft((current) => ({ ...current, configText: nextText, - apiBaseUrl: geminiImportant - ? geminiImportant.apiBaseUrl - : important.apiBaseUrl, + apiBaseUrl: important.apiBaseUrl, apiKey: important.apiKey, - model: geminiImportant ? geminiImportant.model : important.model, - geminiAuthMode: geminiImportant - ? geminiImportant.authMode - : current.geminiAuthMode, - geminiApiKey: geminiImportant - ? geminiImportant.geminiApiKey - : current.geminiApiKey, - googleApiKey: geminiImportant - ? geminiImportant.googleApiKey - : current.googleApiKey, - googleCloudProject: geminiImportant - ? geminiImportant.googleCloudProject - : current.googleCloudProject, - googleCloudLocation: geminiImportant - ? geminiImportant.googleCloudLocation - : current.googleCloudLocation, - googleApplicationCredentials: geminiImportant - ? geminiImportant.googleApplicationCredentials - : current.googleApplicationCredentials, + model: important.model, claudeMainModel: important.claudeMainModel, claudeReasoningModel: important.claudeReasoningModel, claudeDefaultHaikuModel: important.claudeDefaultHaikuModel, @@ -5467,37 +5134,6 @@ export function AcpAgentSettings() { OPENAI_MODEL: codexModel, }), })) - } else if (agentType === "gemini") { - const geminiModel = provider?.model?.trim() ?? "" - const nextConfigJson = patchGeminiConfigText(selectedDraft.configText, { - apiBaseUrl: apiUrl, - geminiApiKey: apiKey, - }) - setConfigErrors((prev) => ({ - ...prev, - [agentType]: null, - })) - updateSelectedDraft((current) => { - let nextEnvText = patchGeminiEnvText(current.envText, { - apiBaseUrl: apiUrl, - geminiApiKey: apiKey, - }) - // Always overwrite GEMINI_MODEL with the provider's value (empty - // string clears it). - nextEnvText = patchEnvText(nextEnvText, { - GEMINI_MODEL: geminiModel, - }) - return { - ...current, - modelProviderId: providerId, - apiBaseUrl: apiUrl, - apiKey, - geminiApiKey: apiKey, - model: geminiModel, - envText: nextEnvText, - configText: nextConfigJson.configText, - } - }) } else { updateSelectedDraft((current) => ({ ...current, @@ -5523,16 +5159,9 @@ export function AcpAgentSettings() { handleModelProviderSelect, ]) - const handleGeminiFieldChange = useCallback( + const handleAntigravityFieldChange = useCallback( ( - key: - | "apiBaseUrl" - | "model" - | "geminiApiKey" - | "googleApiKey" - | "googleCloudProject" - | "googleCloudLocation" - | "googleApplicationCredentials", + key: "model" | "antigravityProject" | "antigravityConversation" | "antigravityPrintTimeout", value: string ) => { if ( @@ -5542,148 +5171,31 @@ export function AcpAgentSettings() { ) return - const nextValues = { - authMode: selectedDraft.geminiAuthMode, - apiBaseUrl: selectedDraft.apiBaseUrl, - geminiApiKey: selectedDraft.geminiApiKey, - googleApiKey: selectedDraft.googleApiKey, - googleCloudProject: selectedDraft.googleCloudProject, - googleCloudLocation: selectedDraft.googleCloudLocation, - googleApplicationCredentials: - selectedDraft.googleApplicationCredentials, - model: selectedDraft.model, - } - nextValues[key] = value - const normalizedValues = patchGeminiAuthMode( - nextValues, - nextValues.authMode - ) - - const nextConfig = patchGeminiConfigText(selectedDraft.configText, { - apiBaseUrl: normalizedValues.apiBaseUrl, - model: normalizedValues.model, - geminiApiKey: normalizedValues.geminiApiKey, - googleApiKey: normalizedValues.googleApiKey, - googleCloudProject: normalizedValues.googleCloudProject, - googleCloudLocation: normalizedValues.googleCloudLocation, - googleApplicationCredentials: - normalizedValues.googleApplicationCredentials, - }) - if (nextConfig.recoveredFromInvalid) { - toast.warning(t("warnings.nativeJsonRecoveredStructured")) - } - setConfigErrors((prev) => ({ - ...prev, - [selectedAgent.agent_type]: null, - })) - updateSelectedDraft((current) => { - const nextEnvText = patchGeminiEnvText(current.envText, { - apiBaseUrl: normalizedValues.apiBaseUrl, - model: normalizedValues.model, - geminiApiKey: normalizedValues.geminiApiKey, - googleApiKey: normalizedValues.googleApiKey, - googleCloudProject: normalizedValues.googleCloudProject, - googleCloudLocation: normalizedValues.googleCloudLocation, - googleApplicationCredentials: - normalizedValues.googleApplicationCredentials, + const next = { + model: current.model, + antigravityProject: current.antigravityProject, + antigravityConversation: current.antigravityConversation, + antigravityPrintTimeout: current.antigravityPrintTimeout, + [key]: value, + } + const envText = patchEnvText(current.envText, { + [ANTIGRAVITY_ENV_KEYS.model]: next.model, + [ANTIGRAVITY_ENV_KEYS.project]: next.antigravityProject, + [ANTIGRAVITY_ENV_KEYS.conversation]: next.antigravityConversation, + [ANTIGRAVITY_ENV_KEYS.printTimeout]: next.antigravityPrintTimeout, }) return { ...current, - apiBaseUrl: normalizedValues.apiBaseUrl, - model: normalizedValues.model, - apiKey: - normalizedValues.geminiApiKey || normalizedValues.googleApiKey, - geminiAuthMode: normalizedValues.authMode, - geminiApiKey: normalizedValues.geminiApiKey, - googleApiKey: normalizedValues.googleApiKey, - googleCloudProject: normalizedValues.googleCloudProject, - googleCloudLocation: normalizedValues.googleCloudLocation, - googleApplicationCredentials: - normalizedValues.googleApplicationCredentials, - envText: nextEnvText, - configText: nextConfig.configText, + model: next.model, + antigravityProject: next.antigravityProject, + antigravityConversation: next.antigravityConversation, + antigravityPrintTimeout: next.antigravityPrintTimeout, + envText, } }) }, - [selectedAgent, selectedDraft, t, updateSelectedDraft] - ) - - const handleGeminiAuthModeChange = useCallback( - (nextMode: GeminiAuthMode) => { - if ( - !selectedAgent || - !selectedDraft || - selectedAgent.agent_type !== "gemini" - ) - return - - if (nextMode === "model_provider") { - // Keep existing values; provider selection will fill API URL/Key - updateSelectedDraft((current) => ({ - ...current, - geminiAuthMode: nextMode, - modelProviderId: current.modelProviderId, - })) - return - } - - const patched = patchGeminiAuthMode( - { - authMode: selectedDraft.geminiAuthMode, - apiBaseUrl: selectedDraft.apiBaseUrl, - geminiApiKey: selectedDraft.geminiApiKey, - googleApiKey: selectedDraft.googleApiKey, - googleCloudProject: selectedDraft.googleCloudProject, - googleCloudLocation: selectedDraft.googleCloudLocation, - googleApplicationCredentials: - selectedDraft.googleApplicationCredentials, - model: selectedDraft.model, - }, - nextMode - ) - - const nextConfig = patchGeminiConfigText(selectedDraft.configText, { - apiBaseUrl: patched.apiBaseUrl, - model: patched.model, - geminiApiKey: patched.geminiApiKey, - googleApiKey: patched.googleApiKey, - googleCloudProject: patched.googleCloudProject, - googleCloudLocation: patched.googleCloudLocation, - googleApplicationCredentials: patched.googleApplicationCredentials, - }) - if (nextConfig.recoveredFromInvalid) { - toast.warning(t("warnings.nativeJsonRecoveredStructured")) - } - setConfigErrors((prev) => ({ - ...prev, - [selectedAgent.agent_type]: null, - })) - - updateSelectedDraft((current) => ({ - ...current, - geminiAuthMode: patched.authMode, - modelProviderId: null, - apiBaseUrl: patched.apiBaseUrl, - apiKey: patched.geminiApiKey || patched.googleApiKey, - geminiApiKey: patched.geminiApiKey, - googleApiKey: patched.googleApiKey, - googleCloudProject: patched.googleCloudProject, - googleCloudLocation: patched.googleCloudLocation, - googleApplicationCredentials: patched.googleApplicationCredentials, - envText: patchGeminiEnvText(current.envText, { - apiBaseUrl: patched.apiBaseUrl, - model: patched.model, - geminiApiKey: patched.geminiApiKey, - googleApiKey: patched.googleApiKey, - googleCloudProject: patched.googleCloudProject, - googleCloudLocation: patched.googleCloudLocation, - googleApplicationCredentials: patched.googleApplicationCredentials, - }), - configText: nextConfig.configText, - })) - }, - [selectedAgent, selectedDraft, t, updateSelectedDraft] + [selectedAgent, selectedDraft, updateSelectedDraft] ) const handleOpenClawFieldChange = useCallback( @@ -7755,307 +7267,108 @@ supports_websockets = true`}

- {t("gemini.authConfigDescription")} + Uses the local agy executable in print mode.

-
- - -

- {geminiAuthModeHint(selectedDraft.geminiAuthMode)} -

-
- - {selectedDraft.geminiAuthMode === "model_provider" && ( +
- {selectedModelProviders.length > 0 ? ( - - ) : ( -

- {t("noModelProviderAvailable")} -

- )} + { + handleAntigravityFieldChange( + "model", + event.target.value + ) + }} + placeholder="Gemini 3.1 Pro (High)" + />
- )} - -
- - { - handleGeminiFieldChange("model", event.target.value) - }} - placeholder="gemini-3-pro-preview" - /> -

- {t("modelHintDefault")} -

-
- - {(selectedDraft.geminiAuthMode === "custom" || - selectedDraft.geminiAuthMode === "model_provider") && (
{ - handleGeminiFieldChange( - "apiBaseUrl", + handleAntigravityFieldChange( + "antigravityProject", event.target.value ) }} - placeholder="https://your-gemini-endpoint.example.com" + placeholder="Optional project ID" />
- )} - - {(selectedDraft.geminiAuthMode === "custom" || - selectedDraft.geminiAuthMode === "gemini_api_key" || - selectedDraft.geminiAuthMode === "model_provider" || - selectedDraft.geminiAuthMode === "vertex_api_key") && (
-
- { - if ( - selectedDraft.geminiAuthMode === - "vertex_api_key" - ) { - handleGeminiFieldChange( - "googleApiKey", - event.target.value - ) - return - } - handleGeminiFieldChange( - "geminiApiKey", - event.target.value - ) - }} - placeholder="AIza..." - /> - -
-
- )} - - {(selectedDraft.geminiAuthMode === "vertex_adc" || - selectedDraft.geminiAuthMode === - "vertex_service_account" || - selectedDraft.geminiAuthMode === "vertex_api_key") && ( -
-
- - { - handleGeminiFieldChange( - "googleCloudProject", - event.target.value - ) - }} - placeholder="my-gcp-project-id" - /> -
-
- - { - handleGeminiFieldChange( - "googleCloudLocation", - event.target.value - ) - }} - placeholder="global / us-central1" - /> -
+ { + handleAntigravityFieldChange( + "antigravityConversation", + event.target.value + ) + }} + placeholder="Optional conversation ID" + />
- )} - - {selectedDraft.geminiAuthMode === - "vertex_service_account" && (
{ - handleGeminiFieldChange( - "googleApplicationCredentials", + handleAntigravityFieldChange( + "antigravityPrintTimeout", event.target.value ) }} - placeholder="/path/to/service-account.json" + placeholder="5m0s" />
- )} +
- +

+ {selectedAgent.installed_version + ? `agy ${selectedAgent.installed_version}` + : "agy not found"} +

diff --git a/src/components/settings/mcp-settings.tsx b/src/components/settings/mcp-settings.tsx index fbc6ec239..6145de356 100644 --- a/src/components/settings/mcp-settings.tsx +++ b/src/components/settings/mcp-settings.tsx @@ -85,7 +85,6 @@ type McpTranslator = ( const APP_OPTIONS: { value: McpAppType; label: string }[] = [ { value: "claude_code", label: "Claude Code" }, { value: "codex", label: "Codex CLI" }, - { value: "gemini", label: "Gemini CLI" }, // OpenClaw 不接受 ACP 线缆上的 MCP 服务器条目(后端 registry.rs supports_mcp=false // 会让其 mcpServers 恒为空 [],否则带条目时 OpenClaw 会在建会话阶段报错),按产品 // 决策不作为可分配目标。McpAppType 仍保留 "open_claw" 以兼容回读存量配置, diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 082c98b75..57b15635c 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -671,23 +671,23 @@ "loginSaveFailed": "تم تسجيل الدخول بنجاح ولكن فشل حفظ الإعدادات" }, "gemini": { - "authConfig": "إعداد مصادقة Gemini", - "authConfigDescription": "متوافق مع وثائق مصادقة Gemini CLI، ويدعم endpoint مخصص وتسجيل دخول Google وGemini API Key وVertex AI ‏(ADC / حساب خدمة / API Key).", - "authMode": "وضع المصادقة", - "selectAuthMode": "اختر وضع المصادقة", - "viewAuthDoc": "عرض وثائق المصادقة", + "authConfig": "Antigravity Settings", + "authConfigDescription": "Runs Antigravity CLI through the local agy command. Configure only model, project, conversation, and print timeout.", + "authMode": "Runtime Mode", + "selectAuthMode": "Select runtime mode", + "viewAuthDoc": "View Antigravity docs", "mode": { - "custom": "Endpoint مخصص", - "loginGoogle": "تسجيل دخول Google (OAuth)", - "vertexServiceAccount": "Vertex AI (حساب خدمة)" + "custom": "Custom runtime options", + "loginGoogle": "Local agy login status", + "vertexServiceAccount": "Local agy configuration" }, "hint": { - "custom": "أدخل API URL وAPI Key وModel؛ وسيتم ربطها بـ GOOGLE_GEMINI_BASE_URL / GEMINI_API_KEY / GEMINI_MODEL.", - "loginGoogle": "شغّل gemini في الطرفية وأكمل تسجيل دخول Google أولًا؛ لا حاجة إلى API key.", - "geminiApiKey": "أدخل GEMINI_API_KEY عند استخدام Gemini API.", - "vertexAdc": "استخدم gcloud ADC؛ ويوصى بتعيين GOOGLE_CLOUD_PROJECT وGOOGLE_CLOUD_LOCATION.", - "vertexServiceAccount": "عيّن مسار JSON لحساب الخدمة في GOOGLE_APPLICATION_CREDENTIALS.", - "vertexApiKey": "أدخل GOOGLE_API_KEY عند استخدام مفتاح Vertex AI API." + "custom": "Set ANTIGRAVITY_MODEL, ANTIGRAVITY_PROJECT, ANTIGRAVITY_CONVERSATION, and ANTIGRAVITY_PRINT_TIMEOUT.", + "loginGoogle": "Confirm agy is authenticated and runnable from the terminal.", + "geminiApiKey": "Legacy API key fields are ignored by the Antigravity CLI slot.", + "vertexAdc": "Legacy cloud auth fields are ignored by the Antigravity CLI slot.", + "vertexServiceAccount": "Legacy service account fields are ignored by the Antigravity CLI slot.", + "vertexApiKey": "Legacy API key fields are ignored by the Antigravity CLI slot." } }, "openCode": { diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 704fe559e..8b03d7e11 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -671,23 +671,23 @@ "loginSaveFailed": "Anmeldung erfolgreich, aber Konfiguration konnte nicht gespeichert werden" }, "gemini": { - "authConfig": "Gemini-Auth-Konfiguration", - "authConfigDescription": "Ausgerichtet an den Gemini CLI-Authentifizierungsdokumenten, mit Unterstützung für benutzerdefinierten Endpoint, Google-Login, Gemini API Key und Vertex AI (ADC / Servicekonto / API Key).", - "authMode": "Auth-Modus", - "selectAuthMode": "Auth-Modus auswählen", - "viewAuthDoc": "Auth-Dokumentation anzeigen", + "authConfig": "Antigravity Settings", + "authConfigDescription": "Runs Antigravity CLI through the local agy command. Configure only model, project, conversation, and print timeout.", + "authMode": "Runtime Mode", + "selectAuthMode": "Select runtime mode", + "viewAuthDoc": "View Antigravity docs", "mode": { - "custom": "Benutzerdefinierter Endpoint", - "loginGoogle": "Google-Login (OAuth)", - "vertexServiceAccount": "Vertex AI (Servicekonto)" + "custom": "Custom runtime options", + "loginGoogle": "Local agy login status", + "vertexServiceAccount": "Local agy configuration" }, "hint": { - "custom": "API URL, API Key und Modell ausfüllen; wird auf GOOGLE_GEMINI_BASE_URL / GEMINI_API_KEY / GEMINI_MODEL abgebildet.", - "loginGoogle": "Gemini zuerst im Terminal ausführen und Google-Login abschließen; API key ist nicht erforderlich.", - "geminiApiKey": "Beim Verwenden der Gemini API GEMINI_API_KEY eintragen.", - "vertexAdc": "gcloud ADC verwenden; GOOGLE_CLOUD_PROJECT und GOOGLE_CLOUD_LOCATION werden empfohlen.", - "vertexServiceAccount": "Pfad zur Servicekonto-JSON in GOOGLE_APPLICATION_CREDENTIALS setzen.", - "vertexApiKey": "Beim Verwenden eines Vertex AI API Keys GOOGLE_API_KEY eintragen." + "custom": "Set ANTIGRAVITY_MODEL, ANTIGRAVITY_PROJECT, ANTIGRAVITY_CONVERSATION, and ANTIGRAVITY_PRINT_TIMEOUT.", + "loginGoogle": "Confirm agy is authenticated and runnable from the terminal.", + "geminiApiKey": "Legacy API key fields are ignored by the Antigravity CLI slot.", + "vertexAdc": "Legacy cloud auth fields are ignored by the Antigravity CLI slot.", + "vertexServiceAccount": "Legacy service account fields are ignored by the Antigravity CLI slot.", + "vertexApiKey": "Legacy API key fields are ignored by the Antigravity CLI slot." } }, "openCode": { diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 25cf7d0a3..73743905d 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -671,23 +671,23 @@ "loginSaveFailed": "Login succeeded but failed to save config" }, "gemini": { - "authConfig": "Gemini Auth Config", - "authConfigDescription": "Aligned with Gemini CLI authentication docs, supporting custom endpoint, Google login, Gemini API Key and Vertex AI (ADC / service account / API Key).", - "authMode": "Auth Mode", - "selectAuthMode": "Select auth mode", - "viewAuthDoc": "View auth docs", + "authConfig": "Antigravity Settings", + "authConfigDescription": "Runs Antigravity CLI through the local agy command. Configure only model, project, conversation, and print timeout.", + "authMode": "Runtime Mode", + "selectAuthMode": "Select runtime mode", + "viewAuthDoc": "View Antigravity docs", "mode": { - "custom": "Custom Endpoint", - "loginGoogle": "Google Login (OAuth)", - "vertexServiceAccount": "Vertex AI (Service Account)" + "custom": "Custom runtime options", + "loginGoogle": "Local agy login status", + "vertexServiceAccount": "Local agy configuration" }, "hint": { - "custom": "Fill API URL, API Key and Model, mapped to GOOGLE_GEMINI_BASE_URL / GEMINI_API_KEY / GEMINI_MODEL.", - "loginGoogle": "Run gemini in terminal and complete Google login first; API key is not required.", - "geminiApiKey": "Fill GEMINI_API_KEY when using Gemini API.", - "vertexAdc": "Use gcloud ADC; GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION are recommended.", - "vertexServiceAccount": "Set service account JSON path to GOOGLE_APPLICATION_CREDENTIALS.", - "vertexApiKey": "Fill GOOGLE_API_KEY when using Vertex AI API key." + "custom": "Set ANTIGRAVITY_MODEL, ANTIGRAVITY_PROJECT, ANTIGRAVITY_CONVERSATION, and ANTIGRAVITY_PRINT_TIMEOUT.", + "loginGoogle": "Confirm agy is authenticated and runnable from the terminal.", + "geminiApiKey": "Legacy API key fields are ignored by the Antigravity CLI slot.", + "vertexAdc": "Legacy cloud auth fields are ignored by the Antigravity CLI slot.", + "vertexServiceAccount": "Legacy service account fields are ignored by the Antigravity CLI slot.", + "vertexApiKey": "Legacy API key fields are ignored by the Antigravity CLI slot." } }, "openCode": { diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 547c32047..ae629dfd0 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -671,23 +671,23 @@ "loginSaveFailed": "Inicio de sesión exitoso pero falló al guardar la configuración" }, "gemini": { - "authConfig": "Configuración de autenticación de Gemini", - "authConfigDescription": "Alineado con la documentación de autenticación de Gemini CLI, con soporte para endpoint personalizado, inicio de sesión de Google, Gemini API Key y Vertex AI (ADC / cuenta de servicio / API Key).", - "authMode": "Modo de autenticación", - "selectAuthMode": "Seleccionar modo de autenticación", - "viewAuthDoc": "Ver documentación de autenticación", + "authConfig": "Antigravity Settings", + "authConfigDescription": "Runs Antigravity CLI through the local agy command. Configure only model, project, conversation, and print timeout.", + "authMode": "Runtime Mode", + "selectAuthMode": "Select runtime mode", + "viewAuthDoc": "View Antigravity docs", "mode": { - "custom": "Endpoint personalizado", - "loginGoogle": "Inicio de sesión de Google (OAuth)", - "vertexServiceAccount": "Vertex AI (Cuenta de servicio)" + "custom": "Custom runtime options", + "loginGoogle": "Local agy login status", + "vertexServiceAccount": "Local agy configuration" }, "hint": { - "custom": "Completa API URL, API Key y Modelo; se mapean a GOOGLE_GEMINI_BASE_URL / GEMINI_API_KEY / GEMINI_MODEL.", - "loginGoogle": "Ejecuta gemini en terminal y completa primero el inicio de sesión de Google; no se requiere API key.", - "geminiApiKey": "Completa GEMINI_API_KEY cuando uses la API de Gemini.", - "vertexAdc": "Usa gcloud ADC; se recomienda configurar GOOGLE_CLOUD_PROJECT y GOOGLE_CLOUD_LOCATION.", - "vertexServiceAccount": "Establece la ruta del JSON de la cuenta de servicio en GOOGLE_APPLICATION_CREDENTIALS.", - "vertexApiKey": "Completa GOOGLE_API_KEY cuando uses API key de Vertex AI." + "custom": "Set ANTIGRAVITY_MODEL, ANTIGRAVITY_PROJECT, ANTIGRAVITY_CONVERSATION, and ANTIGRAVITY_PRINT_TIMEOUT.", + "loginGoogle": "Confirm agy is authenticated and runnable from the terminal.", + "geminiApiKey": "Legacy API key fields are ignored by the Antigravity CLI slot.", + "vertexAdc": "Legacy cloud auth fields are ignored by the Antigravity CLI slot.", + "vertexServiceAccount": "Legacy service account fields are ignored by the Antigravity CLI slot.", + "vertexApiKey": "Legacy API key fields are ignored by the Antigravity CLI slot." } }, "openCode": { diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 43cf72f54..fe8d46ffb 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -671,23 +671,23 @@ "loginSaveFailed": "Connexion réussie mais échec de la sauvegarde de la configuration" }, "gemini": { - "authConfig": "Configuration d’authentification Gemini", - "authConfigDescription": "Alignée sur la documentation d’authentification Gemini CLI, avec prise en charge d’un endpoint personnalisé, connexion Google, Gemini API Key et Vertex AI (ADC / compte de service / API Key).", - "authMode": "Mode d’authentification", - "selectAuthMode": "Sélectionner un mode d’authentification", - "viewAuthDoc": "Voir la documentation d’authentification", + "authConfig": "Antigravity Settings", + "authConfigDescription": "Runs Antigravity CLI through the local agy command. Configure only model, project, conversation, and print timeout.", + "authMode": "Runtime Mode", + "selectAuthMode": "Select runtime mode", + "viewAuthDoc": "View Antigravity docs", "mode": { - "custom": "Endpoint personnalisé", - "loginGoogle": "Connexion Google (OAuth)", - "vertexServiceAccount": "Vertex AI (Compte de service)" + "custom": "Custom runtime options", + "loginGoogle": "Local agy login status", + "vertexServiceAccount": "Local agy configuration" }, "hint": { - "custom": "Renseignez API URL, API Key et Model, mappés sur GOOGLE_GEMINI_BASE_URL / GEMINI_API_KEY / GEMINI_MODEL.", - "loginGoogle": "Exécutez d’abord gemini dans le terminal et terminez la connexion Google ; la clé API n’est pas requise.", - "geminiApiKey": "Renseignez GEMINI_API_KEY lors de l’utilisation de l’API Gemini.", - "vertexAdc": "Utilisez gcloud ADC ; GOOGLE_CLOUD_PROJECT et GOOGLE_CLOUD_LOCATION sont recommandés.", - "vertexServiceAccount": "Définissez le chemin JSON du compte de service dans GOOGLE_APPLICATION_CREDENTIALS.", - "vertexApiKey": "Renseignez GOOGLE_API_KEY lors de l’utilisation d’une clé API Vertex AI." + "custom": "Set ANTIGRAVITY_MODEL, ANTIGRAVITY_PROJECT, ANTIGRAVITY_CONVERSATION, and ANTIGRAVITY_PRINT_TIMEOUT.", + "loginGoogle": "Confirm agy is authenticated and runnable from the terminal.", + "geminiApiKey": "Legacy API key fields are ignored by the Antigravity CLI slot.", + "vertexAdc": "Legacy cloud auth fields are ignored by the Antigravity CLI slot.", + "vertexServiceAccount": "Legacy service account fields are ignored by the Antigravity CLI slot.", + "vertexApiKey": "Legacy API key fields are ignored by the Antigravity CLI slot." } }, "openCode": { diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index a37d286f9..6ec524a0d 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -671,23 +671,23 @@ "loginSaveFailed": "ログインは成功しましたが、設定の保存に失敗しました" }, "gemini": { - "authConfig": "Gemini 認証設定", - "authConfigDescription": "Gemini CLI の認証ドキュメントに準拠し、カスタムエンドポイント、Google ログイン、Gemini API Key、Vertex AI(ADC / サービスアカウント / API Key)をサポートします。", - "authMode": "認証モード", - "selectAuthMode": "認証モードを選択", - "viewAuthDoc": "認証ドキュメントを見る", + "authConfig": "Antigravity Settings", + "authConfigDescription": "Runs Antigravity CLI through the local agy command. Configure only model, project, conversation, and print timeout.", + "authMode": "Runtime Mode", + "selectAuthMode": "Select runtime mode", + "viewAuthDoc": "View Antigravity docs", "mode": { - "custom": "カスタムエンドポイント", - "loginGoogle": "Google ログイン(OAuth)", - "vertexServiceAccount": "Vertex AI(サービスアカウント)" + "custom": "Custom runtime options", + "loginGoogle": "Local agy login status", + "vertexServiceAccount": "Local agy configuration" }, "hint": { - "custom": "API URL、API Key、Model を入力してください。GOOGLE_GEMINI_BASE_URL / GEMINI_API_KEY / GEMINI_MODEL に対応します。", - "loginGoogle": "先にターミナルで gemini を実行して Google ログインを完了してください。API key は不要です。", - "geminiApiKey": "Gemini API を使う場合は GEMINI_API_KEY を入力してください。", - "vertexAdc": "gcloud ADC を使用します。GOOGLE_CLOUD_PROJECT と GOOGLE_CLOUD_LOCATION の設定を推奨します。", - "vertexServiceAccount": "サービスアカウント JSON のパスを GOOGLE_APPLICATION_CREDENTIALS に設定してください。", - "vertexApiKey": "Vertex AI API key を使う場合は GOOGLE_API_KEY を入力してください。" + "custom": "Set ANTIGRAVITY_MODEL, ANTIGRAVITY_PROJECT, ANTIGRAVITY_CONVERSATION, and ANTIGRAVITY_PRINT_TIMEOUT.", + "loginGoogle": "Confirm agy is authenticated and runnable from the terminal.", + "geminiApiKey": "Legacy API key fields are ignored by the Antigravity CLI slot.", + "vertexAdc": "Legacy cloud auth fields are ignored by the Antigravity CLI slot.", + "vertexServiceAccount": "Legacy service account fields are ignored by the Antigravity CLI slot.", + "vertexApiKey": "Legacy API key fields are ignored by the Antigravity CLI slot." } }, "openCode": { diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 5733c13ed..699c34685 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -671,23 +671,23 @@ "loginSaveFailed": "로그인 성공했지만 설정 저장 실패" }, "gemini": { - "authConfig": "Gemini 인증 설정", - "authConfigDescription": "Gemini CLI 인증 문서와 정렬되며, 커스텀 엔드포인트, Google 로그인, Gemini API Key, Vertex AI(ADC / 서비스 계정 / API Key)를 지원합니다.", - "authMode": "인증 모드", - "selectAuthMode": "인증 모드 선택", - "viewAuthDoc": "인증 문서 보기", + "authConfig": "Antigravity Settings", + "authConfigDescription": "Runs Antigravity CLI through the local agy command. Configure only model, project, conversation, and print timeout.", + "authMode": "Runtime Mode", + "selectAuthMode": "Select runtime mode", + "viewAuthDoc": "View Antigravity docs", "mode": { - "custom": "커스텀 엔드포인트", - "loginGoogle": "Google 로그인 (OAuth)", - "vertexServiceAccount": "Vertex AI (서비스 계정)" + "custom": "Custom runtime options", + "loginGoogle": "Local agy login status", + "vertexServiceAccount": "Local agy configuration" }, "hint": { - "custom": "API URL, API Key, Model을 입력하세요. GOOGLE_GEMINI_BASE_URL / GEMINI_API_KEY / GEMINI_MODEL에 매핑됩니다.", - "loginGoogle": "먼저 터미널에서 gemini를 실행해 Google 로그인을 완료하세요. API key는 필요하지 않습니다.", - "geminiApiKey": "Gemini API 사용 시 GEMINI_API_KEY를 입력하세요.", - "vertexAdc": "gcloud ADC를 사용합니다. GOOGLE_CLOUD_PROJECT와 GOOGLE_CLOUD_LOCATION 설정을 권장합니다.", - "vertexServiceAccount": "서비스 계정 JSON 경로를 GOOGLE_APPLICATION_CREDENTIALS에 설정하세요.", - "vertexApiKey": "Vertex AI API key 사용 시 GOOGLE_API_KEY를 입력하세요." + "custom": "Set ANTIGRAVITY_MODEL, ANTIGRAVITY_PROJECT, ANTIGRAVITY_CONVERSATION, and ANTIGRAVITY_PRINT_TIMEOUT.", + "loginGoogle": "Confirm agy is authenticated and runnable from the terminal.", + "geminiApiKey": "Legacy API key fields are ignored by the Antigravity CLI slot.", + "vertexAdc": "Legacy cloud auth fields are ignored by the Antigravity CLI slot.", + "vertexServiceAccount": "Legacy service account fields are ignored by the Antigravity CLI slot.", + "vertexApiKey": "Legacy API key fields are ignored by the Antigravity CLI slot." } }, "openCode": { diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 1f7f5e939..0012bea7b 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -671,23 +671,23 @@ "loginSaveFailed": "Login realizado com sucesso, mas falha ao salvar configuração" }, "gemini": { - "authConfig": "Configuração de autenticação do Gemini", - "authConfigDescription": "Alinhada à documentação de autenticação do Gemini CLI, com suporte a endpoint personalizado, login Google, Gemini API Key e Vertex AI (ADC / conta de serviço / API Key).", - "authMode": "Modo de autenticação", - "selectAuthMode": "Selecionar modo de autenticação", - "viewAuthDoc": "Ver documentação de autenticação", + "authConfig": "Antigravity Settings", + "authConfigDescription": "Runs Antigravity CLI through the local agy command. Configure only model, project, conversation, and print timeout.", + "authMode": "Runtime Mode", + "selectAuthMode": "Select runtime mode", + "viewAuthDoc": "View Antigravity docs", "mode": { - "custom": "Endpoint personalizado", - "loginGoogle": "Login Google (OAuth)", - "vertexServiceAccount": "Vertex AI (Conta de serviço)" + "custom": "Custom runtime options", + "loginGoogle": "Local agy login status", + "vertexServiceAccount": "Local agy configuration" }, "hint": { - "custom": "Preencha API URL, API Key e Modelo; mapeados para GOOGLE_GEMINI_BASE_URL / GEMINI_API_KEY / GEMINI_MODEL.", - "loginGoogle": "Execute gemini no terminal e conclua o login Google primeiro; API key não é necessária.", - "geminiApiKey": "Preencha GEMINI_API_KEY ao usar a API Gemini.", - "vertexAdc": "Use gcloud ADC; GOOGLE_CLOUD_PROJECT e GOOGLE_CLOUD_LOCATION são recomendados.", - "vertexServiceAccount": "Defina o caminho do JSON da conta de serviço em GOOGLE_APPLICATION_CREDENTIALS.", - "vertexApiKey": "Preencha GOOGLE_API_KEY ao usar API key do Vertex AI." + "custom": "Set ANTIGRAVITY_MODEL, ANTIGRAVITY_PROJECT, ANTIGRAVITY_CONVERSATION, and ANTIGRAVITY_PRINT_TIMEOUT.", + "loginGoogle": "Confirm agy is authenticated and runnable from the terminal.", + "geminiApiKey": "Legacy API key fields are ignored by the Antigravity CLI slot.", + "vertexAdc": "Legacy cloud auth fields are ignored by the Antigravity CLI slot.", + "vertexServiceAccount": "Legacy service account fields are ignored by the Antigravity CLI slot.", + "vertexApiKey": "Legacy API key fields are ignored by the Antigravity CLI slot." } }, "openCode": { diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 2453c3667..8d6edf99d 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -671,23 +671,23 @@ "loginSaveFailed": "登录成功但配置保存失败" }, "gemini": { - "authConfig": "Gemini 认证配置", - "authConfigDescription": "对齐 Gemini CLI 认证文档,支持自定义、Google 登录、Gemini API Key、Vertex AI(ADC / 服务账号 / API Key)。", - "authMode": "认证方式", - "selectAuthMode": "选择认证方式", - "viewAuthDoc": "查看认证文档", + "authConfig": "Antigravity ??", + "authConfigDescription": "???? agy ???? Antigravity CLI????????????? print ???", + "authMode": "????", + "selectAuthMode": "??????", + "viewAuthDoc": "?? Antigravity ??", "mode": { - "custom": "自定义接口", - "loginGoogle": "Google 登录(OAuth)", - "vertexServiceAccount": "Vertex AI(服务账号)" + "custom": "???????", + "loginGoogle": "?? agy ????", + "vertexServiceAccount": "?? agy ??" }, "hint": { - "custom": "填写 API URL、API Key 和 Model,分别映射到 GOOGLE_GEMINI_BASE_URL / GEMINI_API_KEY / GEMINI_MODEL。", - "loginGoogle": "首次在终端运行 gemini 并完成 Google 登录;无需填写 API Key。", - "geminiApiKey": "使用 Gemini API 时填写 GEMINI_API_KEY。", - "vertexAdc": "使用 gcloud ADC,建议填写 GOOGLE_CLOUD_PROJECT 与 GOOGLE_CLOUD_LOCATION。", - "vertexServiceAccount": "服务账号 JSON 路径写入 GOOGLE_APPLICATION_CREDENTIALS。", - "vertexApiKey": "使用 Vertex AI API key 时填写 GOOGLE_API_KEY。" + "custom": "?? ANTIGRAVITY_MODEL?ANTIGRAVITY_PROJECT?ANTIGRAVITY_CONVERSATION ? ANTIGRAVITY_PRINT_TIMEOUT?", + "loginGoogle": "??????? agy ????????", + "geminiApiKey": "Antigravity CLI ?????? API Key ???", + "vertexAdc": "Antigravity CLI ????????????", + "vertexServiceAccount": "Antigravity CLI ?????????????", + "vertexApiKey": "Antigravity CLI ?????? API Key ???" } }, "openCode": { diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index b396dd78a..49566cf01 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -671,23 +671,23 @@ "loginSaveFailed": "登入成功但配置儲存失敗" }, "gemini": { - "authConfig": "Gemini 認證配置", - "authConfigDescription": "對齊 Gemini CLI 認證文件,支援自訂、Google 登入、Gemini API Key、Vertex AI(ADC / 服務帳號 / API Key)。", - "authMode": "認證方式", - "selectAuthMode": "選擇認證方式", - "viewAuthDoc": "查看認證文件", + "authConfig": "Antigravity ??", + "authConfigDescription": "???? agy ???? Antigravity CLI????????????? print ???", + "authMode": "????", + "selectAuthMode": "??????", + "viewAuthDoc": "?? Antigravity ??", "mode": { - "custom": "自訂介面", - "loginGoogle": "Google 登入(OAuth)", - "vertexServiceAccount": "Vertex AI(服務帳號)" + "custom": "??????", + "loginGoogle": "?? agy ????", + "vertexServiceAccount": "?? agy ??" }, "hint": { - "custom": "填寫 API URL、API Key 和 Model,分別映射到 GOOGLE_GEMINI_BASE_URL / GEMINI_API_KEY / GEMINI_MODEL。", - "loginGoogle": "首次在終端執行 gemini 並完成 Google 登入;無需填寫 API Key。", - "geminiApiKey": "使用 Gemini API 時填寫 GEMINI_API_KEY。", - "vertexAdc": "使用 gcloud ADC,建議填寫 GOOGLE_CLOUD_PROJECT 與 GOOGLE_CLOUD_LOCATION。", - "vertexServiceAccount": "服務帳號 JSON 路徑寫入 GOOGLE_APPLICATION_CREDENTIALS。", - "vertexApiKey": "使用 Vertex AI API key 時填寫 GOOGLE_API_KEY。" + "custom": "?? ANTIGRAVITY_MODEL?ANTIGRAVITY_PROJECT?ANTIGRAVITY_CONVERSATION ? ANTIGRAVITY_PRINT_TIMEOUT?", + "loginGoogle": "??????? agy ????????", + "geminiApiKey": "Antigravity CLI ?????? API Key ???", + "vertexAdc": "Antigravity CLI ?????????????", + "vertexServiceAccount": "Antigravity CLI ?????????????", + "vertexApiKey": "Antigravity CLI ?????? API Key ???" } }, "openCode": { diff --git a/src/lib/transport/web-auth.test.ts b/src/lib/transport/web-auth.test.ts new file mode 100644 index 000000000..7d16c09ae --- /dev/null +++ b/src/lib/transport/web-auth.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it } from "vitest" +import { + clearCodegToken, + consumeCodegTokenFromFragment, + getCodegToken, + setCodegToken, +} from "./web-auth" + +beforeEach(() => { + localStorage.clear() + window.history.replaceState(null, "", "/") +}) + +describe("web auth token storage", () => { + it("stores and clears the token through the shared helpers", () => { + setCodegToken("secret") + expect(getCodegToken()).toBe("secret") + + clearCodegToken() + expect(getCodegToken()).toBe("") + }) + + it("consumes and decodes a launcher token from the URL fragment", () => { + window.history.replaceState( + null, + "", + "/workspace?tab=chat#codeg_token=secret%2Ftoken%2Bvalue%3D" + ) + + expect(consumeCodegTokenFromFragment()).toBe("secret/token+value=") + expect(window.location.pathname).toBe("/workspace") + expect(window.location.search).toBe("?tab=chat") + expect(window.location.hash).toBe("") + }) + + it("preserves unrelated fragment parameters and consumes only once", () => { + window.history.replaceState( + null, + "", + "/#view=diff&codeg_token=secret&line=12" + ) + + expect(consumeCodegTokenFromFragment()).toBe("secret") + expect(window.location.hash).toBe("#view=diff&line=12") + expect(consumeCodegTokenFromFragment()).toBeNull() + }) + + it("leaves fragments without a launcher token unchanged", () => { + window.history.replaceState(null, "", "/#view=files") + + expect(consumeCodegTokenFromFragment()).toBeNull() + expect(window.location.hash).toBe("#view=files") + }) +}) diff --git a/src/lib/transport/web-auth.ts b/src/lib/transport/web-auth.ts index dde9cc4a2..11b37342f 100644 --- a/src/lib/transport/web-auth.ts +++ b/src/lib/transport/web-auth.ts @@ -6,13 +6,39 @@ // call site. const TOKEN_KEY = "codeg_token" +const TOKEN_FRAGMENT_KEY = "codeg_token" export function getCodegToken(): string { return localStorage.getItem(TOKEN_KEY) ?? "" } +export function setCodegToken(token: string): void { + localStorage.setItem(TOKEN_KEY, token) +} + +export function clearCodegToken(): void { + localStorage.removeItem(TOKEN_KEY) +} + +export function consumeCodegTokenFromFragment(): string | null { + const rawFragment = window.location.hash.slice(1) + if (!rawFragment) return null + + const params = new URLSearchParams(rawFragment) + const token = params.get(TOKEN_FRAGMENT_KEY) + if (token === null) return null + + params.delete(TOKEN_FRAGMENT_KEY) + const remainingFragment = params.toString() + const cleanUrl = `${window.location.pathname}${window.location.search}${ + remainingFragment ? `#${remainingFragment}` : "" + }` + window.history.replaceState(window.history.state, "", cleanUrl) + return token +} + export function redirectToCodegLogin(): void { if (window.location.pathname.startsWith("/login")) return - localStorage.removeItem(TOKEN_KEY) + clearCodegToken() window.location.href = "/login" } diff --git a/src/lib/types.ts b/src/lib/types.ts index a6230623f..a5ad4ec74 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -739,7 +739,7 @@ export const HERMES_PROVIDERS: HermesProviderOption[] = [ }, { id: "google-gemini-cli", - label: "Gemini CLI", + label: "Antigravity CLI", needsBaseUrl: false, kind: "oauth", }, @@ -776,7 +776,7 @@ export const AGENT_LABELS: Record = { claude_code: "Claude Code", codex: "Codex", open_code: "OpenCode", - gemini: "Gemini CLI", + gemini: "Antigravity CLI", open_claw: "OpenClaw", cline: "Cline", hermes: "Hermes Agent",