diff --git a/docs/agy-integration-zh.md b/docs/agy-integration-zh.md new file mode 100644 index 000000000..b4a8fe727 --- /dev/null +++ b/docs/agy-integration-zh.md @@ -0,0 +1,151 @@ +# Antigravity CLI(agy)接入 CC Switch + +## 范围与现状 + +`agy` 是独立应用 ID,复用 CCS 的供应商、Skills、MCP、会话和用量页面。它与 Gemini CLI 的数据库标记、供应商选择和文件路径分别管理。此次在已有未完成实现上直接补齐;不迁移或覆盖 Gemini CLI 的配置。 + +| 功能 | 实现 | 数据来源 / 写入目标 | +| ---------- | -------------------------------------------------------------- | --------------------------------------------------------- | +| 供应商切换 | 官方登录、Gemini API Key、端点、模型;编辑当前供应商后重新应用 | `~/.gemini/antigravity-cli/settings.json`、`env` | +| 模型选择 | 手动填写、第三方 Gemini models API、当前本机 `agy models` | `config.model.name`,兼容 `env.GEMINI_MODEL` | +| Token 信息 | 输入、输出、缓存读写;会话消息元数据和统一用量面板 | `brain//.system_generated/logs/transcript_full.jsonl` | +| MCP | 导入、编辑、独立启用/禁用、删除、重新同步 | `~/.gemini/config/mcp_config.json` | +| Skills | 扫描导入、安装、启用/禁用、更新、迁移存储、卸载 | `~/.gemini/config/skills//` | +| 会话 | 列表、摘要、工作目录、消息预览、Token、恢复命令 | 只读 SQLite 索引和 JSONL 日志 | + +本机 CLI 自带文档明确全局定制目录为 `~/.gemini/config/`。公开迁移文档存在不同历史路径,因此本次以安装版本的内置文档和隔离 HOME 实测为准。参考:[官方 MCP 说明](https://www.agy.dev/docs/cli/mcp)、[官方迁移说明](https://www.antigravity.google/docs/cli/gcli-migration/)。MCP 与 Skills 的全局目录可能被同机其他 Antigravity 产品共享,CCS 页面显示实际写入路径。 + +## 供应商、凭据和模型 + +供应商在 CCS 中继续使用兼容现有 Gemini 表单的数据结构: + +```json +{ + "env": { + "GEMINI_API_KEY": "your-key", + "GOOGLE_GEMINI_BASE_URL": "https://your-gemini-endpoint" + }, + "config": { + "model": { "name": "your-model-id" } + } +} +``` + +这是 CCS 的存储结构,并不原样写入 agy。投影仅修改原生 `settings.json` 的 `modelProvider` 与 `model` 两项,保留主题、受信工作区和其他配置。API Key 模式设置 `modelProvider: "gemini"`;官方登录移除该字段,并清除启动器中的 API Key 和自定义端点,不接管 agy 自身的登录令牌。 + +官方身份由分类或明确的官方预设标记决定;供应商名称含 Google 不会自动删除其 API Key。第三方供应商须兼容 Gemini 协议,不能将任意 OpenAI/Anthropic 端点直接当作 Gemini 使用。 + +agy 从进程环境读 Key 和端点,不读取 Gemini CLI 的 `.env`。CCS 因此写入权限为 `0600` 的专用 `env`,并生成 `~/.gemini/antigravity-cli/bin/agy` 启动器。启动器先清除继承的旧值,再加载当前配置,最后调用真正的 CLI: + +- 凭据和路径进行 shell 引号转义,不拼接执行凭据内容。 +- 手动传入 `--model` 优先于 CCS 默认模型。 +- `mcp`、`models`、`plugin` 等子命令不追加会话模型参数。 +- 同名但没有 CCS 标记的启动器不会被覆盖。 +- 自动 PATH 设置支持 macOS/Linux 的 zsh、bash、POSIX sh;Windows 和其他 shell 的供应商启动器接入会明确报错,不伪装成已生效。 +- 真实 `agy` 的定位顺序:PATH(排除启动器自身及任何带 CCS 标记的脚本)、`~/.local/bin`、`/opt/homebrew/bin`、`/usr/local/bin`、`~/.gemini/antigravity-cli/bin`,最后用登录 shell 执行一次 `command -v agy`(限时 2 秒)。从 Dock / Finder 启动 CCS 时 PATH 只有系统目录,所以后几步不可省。安装时仍找不到则不会写入虚构路径:启动器在运行时按终端的 PATH 再查找(同样跳过自身,避免递归),仍找不到时输出 `command not found` 并以 127 退出;此时本机本来就没有 agy,不会遮蔽任何已有命令。`get_agy_models` 使用同一解析,找不到时提示先安装 agy。 +- 启动器与 PATH 片段只在当前供应商带 Key 或自定义端点(第三方)时安装。切换到官方登录模式、或删除供应商后已没有第三方当前供应商时,CCS 会移除自己安装的启动器(含 CCS 标记)和 `.zshenv`、`.bashrc`、`.bash_profile`、`.bash_login`、`.profile` 中带标记的 PATH 片段;无标记的同名脚本和用户的其他内容原样保留,只含我们片段的文件会随之删除。 +- PATH 只追加带标记的片段,保留现有启动配置。切换后需要新开终端或重启 agy;已经运行的进程不会热切换。 + +Key 文件、模型配置和当前供应商指针共用现有 `AppWrite` 操作日志。写入前验证 JSON;发布中断后使用已有恢复机制补完文件及指针。启动器安装是幂等的基础设施准备,发生在配置事务前;不包含某一家供应商的 Key。损坏配置不会按空文件重建。 + +新增供应商遵循 CCS 原有生命周期,前端不再无条件追加一次切换。原生 `settings.json` 与 `env` 均不存在时返回配置缺失,启动导入不会生成虚构的 default/current,也不会使随后的全量同步创建 agy 启动器或修改 shell 文件。agy 首次启动自己会写出不含供应商信息的 `settings.json`,这样的配置也不会被导入:只有带 Key、端点或模型的配置才会作为 `default` 导入。全量同步(配置导入、云同步)对没有真实内容的当前供应商不执行任何写入,也不会安装启动器、修改 shell 文件,Windows 上也不会再报告 `provider/agy` 失败(Windows 仅在确有第三方供应商需要启动器时才提示不支持)。官方模型按钮读取当前本机 agy 配置;目标供应商尚未启用或尚未登录时仍可手工填入模型。 + +## 数据持久化和兼容性 + +数据库版本从 20 升至 21,为 `mcp_servers`、`skills` 分别增加 `enabled_agy BOOLEAN NOT NULL DEFAULT 0`。新建数据库与升级迁移均覆盖;其他应用的开关和数据不变。旧的导入 JSON 没有 `agy` 字段时默认关闭。 + +MCP 支持 stdio 和 Streamable HTTP,兼容导入旧 `serverUrl`,拒绝 agy 不支持的 SSE。禁用写 `disabled: true`,保留本地选项;删除仍启用于 agy 的服务器会移除对应原生条目。数据库的关闭标记不记录托管历史,因此删除未启用于 agy 的服务器时保留原生配置,即使同名禁用条目的传输字段完全相同;如需删除保留的禁用条目,应在 agy 中操作。切换传输方式时清理旧传输字段,保留非传输选项和未托管服务器。数据库提交失败会恢复原文件;重新同步不会因为数据库中的默认关闭标记而删掉未托管条目。 + +Skills 复用既有 SSOT、下载、链接/复制和备份机制,新增独立持久开关。对同名原生目录先核对内容和归属;外部修改不静默覆盖。更新使用暂存副本,数据库失败时恢复旧文件;显式切换也支持回滚。迁移存储时重定向托管链接,卸载前验证选中的原生副本,保留未启用的外部同名目录。 + +## 会话和 Token 语义 + +会话同时扫描 CLI 的 `~/.gemini/antigravity-cli/brain` 与 Antigravity IDE 的 `~/.gemini/antigravity/brain`(布局相同,各自带同级 `conversation_summaries.db`);缺失的目录会被忽略,同一会话 ID 只计一次。 + +SQLite 以只读方式打开,仅读取 `conversation_summaries` 的标题、预览、工作目录和时间。列表优先使用索引,避免每次刷新都解析所有正文;没有索引时回退解析 JSONL。只有二进制记录、没有可读日志的会话仍可展示摘要和恢复命令,不伪造正文。 + +JSONL 按 `step_index` 排序,同步骤重复写入取最后一条,容忍尾部未写完的数据。当前已实测 `USER_INPUT`、`PLANNER_RESPONSE`,其他步骤保留文本供预览。路径归属经过校验,目录中的链接不能将会话读取引向根目录外。 + +恢复命令为 `agy --conversation=`。CCS 不删除 agy 会话,因为正文、二进制存储和摘要索引需由 agy 一起维护;界面和后端均明确要求在原应用中删除。 + +Token 用量只导入完成的模型响应。实测使用本地模拟 Gemini SSE 响应:输入总量 100、缓存 20、回答 5、思考 7,agy 导出的结果为: + +- `input_tokens = 80`:已经排除缓存,数据库明确标为 fresh input。 +- `cache_read_tokens = 20`:单列展示,不从 80 再扣一次。 +- `output_tokens = 12`:已经包含思考,不再额外累加 7。 + +该合成样例保存在 `tests/fixtures/sessions/agy.jsonl`,不含真实对话或凭据。用量按会话 ID+步骤 ID 去重,去重账本与明细一起事务提交;旧明细被保留策略清理后也不会重复导入。已完成步骤被改写时只更新仍然保留的明细。 + +原生日志通常不含历史模型与真实账单,缺失模型记为 `unknown`,不从当前供应商反推历史归属。费用保留为 0,并在用量页解释“0 不代表免费”;这是可用数据的限制。官方账号 OAuth 凭据、订阅额度及计费不由此次接入托管。 + +## 验证 + +连通性检查对第三方 agy 供应商读取 `env.GOOGLE_GEMINI_BASE_URL`(沿用 Gemini 的提取规则),未设置时使用 `https://generativelanguage.googleapis.com`;官方登录供应商不提供检测目标。 + +新增测试覆盖迁移与新建数据库、独立开关、MCP 格式与失败回滚、Skills 原生文件保护及失败恢复、启动器转义和环境清理、空配置不导入且全量同步不接管启动器/shell、官方模式与删除后的启动器及 PATH 片段清理、真实 agy 的多位置解析与运行时回退、连通性检查、配置事务中断恢复、会话路径验证、JSONL 重写/残缺尾部、原生 Token 语义和持久去重。 + +此外使用本机真实 agy,在临时 HOME 中验证 MCP add/disable 的原生格式、models 输出格式及本地模拟 SSE 请求。模拟端点运行在 `127.0.0.1`,凭据为固定测试值;不连接付费模型,也不修改实际登录状态。 + +可复查命令: + +```sh +pnpm typecheck +pnpm test:unit +cargo test --manifest-path src-tauri/Cargo.toml --lib agy -- --test-threads=1 +cargo test --manifest-path src-tauri/Cargo.toml --lib -- --test-threads=1 +cargo fmt --manifest-path src-tauri/Cargo.toml -- --check +pnpm build:renderer +``` + +此次不增加 agy 本地代理、故障转移或 Stack 模式;这些能力要求单独适配数据平面,不能仅通过复用 Gemini UI 宣称可用。 + +## 原生联网复核与回归 + +补充复核发现并修复两处表单问题: + +- agy 只有 API Key、未覆盖端点时,模型查询应使用 `https://generativelanguage.googleapis.com`,不能要求用户额外填写默认地址。 +- agy 的模型输入框需要按后端相同的优先级读取 `config.model.name`,并在编辑时同步写回;否则旧的 `config.model.name` 会覆盖刚修改的 `GEMINI_MODEL`。Gemini CLI 保持原有环境变量行为。 + +新增 `src-tauri/src/agy_smoke_tests.rs` 原生联网测试,默认 `ignore`,仅在明确授权调用 API 后手工运行。它要求 `CCS_AGY_SMOKE_CONFIG` 指向权限受限的临时供应商 JSON,`CCS_AGY_SMOKE_BIN` 指向真实 agy;测试本身不读取用户数据库。所有切换、文件、Skills 和会话写入均在临时 HOME 和临时工作目录中执行,结束后清理。 + +已使用真实 Gemini API 完成以下验证: + +1. CCS 后端查询模型,配置投影及当前供应商指针生效,生成的启动器实际调用 `gemini-3.8-flash`。 +2. Skill 经 CCS 导入、禁用、重新启用后,被原生 agy 加载并返回预设标记 `CCS_AGY_SKILL_OK`。 +3. 原生 agy 识别 CCS 写出的 MCP 配置并完成本地测试服务的 `initialize` 与 `tools/list`;此测试验证连接和工具发现,不覆盖任意第三方 MCP 业务工具的执行。 +4. CCS 会话预览包含实际回答,导入完成响应的用量明细,重复导入不新增记录。 + +无界面 `--print` 模式不会弹出权限确认。Skill 若需要 `read_file`,必须有对应读取许可,否则 CLI 可能退出为成功、JSON 的 response 却为空并带有 `denied_actions`。测试仅为临时 Skill 的实际路径及 SSOT 路径授予精确读取许可,保留正常运行时的用户权限决策。 + +模型 ID 必须精确匹配所用接口。实测 Gemini API 接受 `gemini-3.8-flash`,而 `gemini-3-8-flash` 返回 404;不应通过全局替换连字符“修复”任意供应商模型名。原生 `agy models` 的档位别名与 Google API 模型列表也并不完全相同,表单按登录模式选择对应来源。 + +## 深链接导入 + +统一入口是 `ccswitch://v1/import`,agy 使用独立的 `app=agy` / `apps=agy`: + +- **供应商**:`resource=provider&app=agy`,支持 `name`、`apiKey`、`endpoint`、`model`、`config` 与 `apiFormat=gemini_native`。不支持 OpenAI/Anthropic 协议。确认框关闭「导入后立即切换」时只保存数据库,不创建原生配置、启动器或修改 shell;开启时走正常供应商切换。Windows 仍受供应商启动器的既有限制。 +- **内联配置**:`config` 是 Base64 编码 JSON,可用上文 CCS 的 `{env, config:{model:{name}}}` 结构,或扁平 `GEMINI_API_KEY`、`GOOGLE_GEMINI_BASE_URL`(兼容 `GEMINI_BASE_URL`)、`GEMINI_MODEL`。模型优先级为 URL 的 `model` > `config.model.name` > `GEMINI_MODEL`。只提取密钥、地址、模型,其他环境变量与原生选项不导入;预览中的密钥打码。远程 `configUrl` 仍不支持。 +- **MCP**:`resource=mcp&apps=agy`;`apps=agy,codex` 可同时指定多个应用。`config` 为 Base64 编码的 `{"mcpServers":{...}}`。同 ID 已存在时保留数据库中的服务器定义,仅合并应用开关,再走统一 MCP 同步;agy 不支持的 SSE 会报告导入失败。 +- **Prompt**:`resource=prompt&app=agy`,`content` 为 Base64 Markdown,确认后保存到 agy 提示词列表;启用时使用既有 Prompt 路径解析和写入逻辑。 +- **Skills**:`resource=skill&repo=owner/name&branch=main` 只添加所有应用共享的技能仓库,不下载或安装技能,也不会因附带 `app=agy` 自动启用。随后在 Skills 页面选择技能并为 agy 启用。 + +供应商示例(所有凭据都是占位值;链接参数需 URL 编码): + +```text +ccswitch://v1/import?resource=provider&app=agy&name=Antigravity&endpoint=https%3A%2F%2Fgenerativelanguage.googleapis.com&apiKey=YOUR_KEY&model=YOUR_MODEL&apiFormat=gemini_native&enabled=false +``` + +深链接供应商沿用现有 API Key 导入规则,需要密钥和端点;不通过链接导入或接管 OAuth 登录。会话预览、Token 统计和会话恢复不是导入资源,没有新增对应 `resource` 类型。 + + +### 原生登录与 API 凭据 + +Key 和自定义地址均留空时保留 agy 自身登录;仅修改模型不要求 API Key。填写 Key 时可留空地址以使用 Google 默认端点;自定义地址需要非空 Key。编辑器中的设置只投影 agy 支持的字段,不会改动 Gemini CLI 全局配置。 + +### 原生登录模型名称 + +原生登录模式应使用 `agy models` 输出第二列的显示名(例如 `Gemini 3.8 Flash (Low)`),模型列表会填入该值。第一列 CLI 别名适用于命令行 `--model`;直接把别名保存到原生 `settings.json.model` 可能静默回退默认模型。API 模式继续使用接口模型 ID。 + +### zsh 启动与恢复 + +API 模式会在 `.zshenv`、`.zprofile`、`.zshrc` 以及已有的 `.zlogin` 末尾维护带标记的 PATH 片段,使登录或交互终端中的后续 PATH 重设不会绕过启动器。重复应用不会重复追加。切回登录或释放启动器时仅移除当前包装目录的完整片段,保留其他目录的同名标记和原文件内容,包括末尾无换行的文件。 diff --git a/docs/guides/deeplink-site-integration-guide-en.md b/docs/guides/deeplink-site-integration-guide-en.md index 6ab35a61b..0d97bb336 100644 --- a/docs/guides/deeplink-site-integration-guide-en.md +++ b/docs/guides/deeplink-site-integration-guide-en.md @@ -62,7 +62,7 @@ Boolean parameters take `true` / `false`. Any other value is treated as if the p 2. CC Switch opens the import confirmation dialog, showing everything in the link: the endpoint, the masked key, the models, the embedded config, and the full usage query script. Suspicious content is highlighted in yellow, for example endpoints pointing at localhost or a private network, environment variables that change how processes load code (`NODE_OPTIONS`, `LD_PRELOAD` and the like), and MCP servers that run a whole command line through a shell. 3. After the user clicks "Import": - **Provider**: added to the provider list of the target app. The bottom of the dialog has a switch whose initial value comes from `enabled` in the link, and the user can change it: - - Claude Code, Codex, Gemini CLI, Grok Build: the switch is "Switch to this provider after import", off by default. When it is on, the dialog warns that "client requests will go to the endpoint above". + - Claude Code, Codex, Gemini CLI, Antigravity CLI, Grok Build: the switch is "Switch to this provider after import", off by default. When it is on, the dialog warns that "client requests will go to the endpoint above". - OpenCode, OpenClaw, Hermes, Pi: these apps can hold several providers at once. The switch is "Add to the config after import" (for Pi, "Enable in Pi after import"), on by default. When it is off, the provider is only saved in CC Switch's list. - **MCP**: written immediately into the config file of every app listed in `apps`. - **Prompt**: added to the prompt list. The dialog has an "Enable after import" switch whose initial value comes from `enabled` in the link. When it is on, it replaces the app's current prompt file, and the previously enabled prompt is disabled. @@ -92,6 +92,7 @@ The only required items are `app`, `name`, the endpoint and the key (the last tw | `claude` | Claude Code | `anthropic` | All four | `{"env": {...}}`, all variables in `env` are kept | | `codex` | Codex | `openai_responses` | `openai_responses`, `openai_chat`, `anthropic` | All or part of a `config.toml`, see below | | `gemini` | Gemini CLI | `gemini_native` | Only `gemini_native` | The three keys `GEMINI_API_KEY`, `GOOGLE_GEMINI_BASE_URL` and `GEMINI_MODEL`; anything else is ignored | +| `agy` | Antigravity CLI | `gemini_native` | Only `gemini_native` | Flat Gemini environment keys or `{env, config:{model:{name}}}`; URL parameters take precedence, then `config.model.name` over `GEMINI_MODEL`; other native options are ignored | | `grokbuild` | Grok Build | `openai_responses` | `openai_responses`, `openai_chat`, `anthropic` | One `[model.""]` table, see below | | `opencode` | OpenCode | `openai_chat` | All four | Only the endpoint and key | | `openclaw` | OpenClaw | `openai_chat` | All four | Only the endpoint and key | @@ -407,14 +408,14 @@ These three types have little to do with your API service, but some sites also d **MCP** (`resource=mcp`) -- `apps` is required, comma-separated: `claude`, `codex`, `gemini`, `grokbuild`, `opencode`, `hermes`, `pi`, `mcode`. `openclaw` is ignored. +- `apps` is required, comma-separated: `claude`, `codex`, `gemini`, `agy`, `grokbuild`, `opencode`, `hermes`, `pi`, `mcode`. `openclaw` is ignored. - `config` is a Base64 `{"mcpServers": {"": {...}}}`. One link can carry several servers. - If the user already has a server with the same ID, only the new apps are checked for it; its config is not overwritten by the link. Use a different ID if you want to update the config. - Importing writes to the client config files right away. The `enabled` parameter currently has no effect. **Prompt** (`resource=prompt`) -- `app` is one of `claude`, `codex`, `gemini`, `grokbuild`, `opencode`, `pi`, `mcode`. In CC Switch, `openclaw` and `hermes` are handled by the workspace editor and memory management, so distributing prompts to them via deep link is not recommended. +- `app` is one of `claude`, `codex`, `gemini`, `agy`, `grokbuild`, `opencode`, `pi`, `mcode`. In CC Switch, `openclaw` and `hermes` are handled by the workspace editor and memory management, so distributing prompts to them via deep link is not recommended. - `name` and `content` (Base64 Markdown) are required; `description` is optional. - `enabled` is the initial value of the "Enable after import" switch in the confirmation dialog. Enabling replaces the user's current prompt file (such as `CLAUDE.md`) with this content, so usually don't set it. diff --git a/docs/guides/deeplink-site-integration-guide-ja.md b/docs/guides/deeplink-site-integration-guide-ja.md index c9cb9721c..51dfb9c58 100644 --- a/docs/guides/deeplink-site-integration-guide-ja.md +++ b/docs/guides/deeplink-site-integration-guide-ja.md @@ -62,7 +62,7 @@ ccswitch://v1/import?resource=<種類>&<パラメーター>... 2. CC Switch がインポート確認ダイアログを開き、リンクの内容をすべて表示します。エンドポイント、マスクされた Key、モデル、同梱された設定の原文、使用量クエリスクリプトの全文です。疑わしい内容は黄色で強調して警告します。たとえばローカルや社内ネットワークを指すアドレス、プロセスの読み込み動作を変えられる環境変数(`NODE_OPTIONS`、`LD_PRELOAD` など)、シェルでコマンド全体を実行する MCP などです。 3. ユーザーが「インポート」を押すと、次のようになります。 - **プロバイダー**:対応するアプリのプロバイダー一覧に追加されます。確認ダイアログの下部にスイッチが 1 つあり、初期値はリンクの `enabled` から取られ、ユーザーが変更できます。 - - Claude Code、Codex、Gemini CLI、Grok Build:スイッチは「インポート後にこのプロバイダーへ切り替える」で、デフォルトはオフです。オンにすると、確認ダイアログに「クライアントのリクエストは上記のエンドポイントに送られます」という注意が表示されます。 + - Claude Code、Codex、Gemini CLI、Antigravity CLI、Grok Build:スイッチは「インポート後にこのプロバイダーへ切り替える」で、デフォルトはオフです。オンにすると、確認ダイアログに「クライアントのリクエストは上記のエンドポイントに送られます」という注意が表示されます。 - OpenCode、OpenClaw、Hermes、Pi:これらのアプリは複数のプロバイダーを同時に置けるため、スイッチは「インポート後に <アプリ名> の設定へ追加する」(Pi では「インポート後に Pi で有効にする」)で、デフォルトはオンです。オフにすると CC Switch の一覧に保存されるだけになります。 - **MCP**:`apps` に挙げた各アプリの設定ファイルにすぐ書き込まれます。 - **プロンプト**:プロンプト一覧に追加されます。確認ダイアログに「インポート後に有効にする」スイッチがあり、初期値はリンクの `enabled` から取られます。オンにすると、そのアプリの現在のプロンプトファイルが置き換えられ、それまで有効だったプロンプトは無効になります。 @@ -92,6 +92,7 @@ ccswitch://v1/import?resource=provider&app=claude&name=Example%20AI&endpoint=htt | `claude` | Claude Code | `anthropic` | 4 種類すべて | `{"env": {...}}`。`env` 内の変数はすべて保持 | | `codex` | Codex | `openai_responses` | `openai_responses`、`openai_chat`、`anthropic` | `config.toml` の全体または一部。後述 | | `gemini` | Gemini CLI | `gemini_native` | `gemini_native` のみ | `GEMINI_API_KEY`、`GOOGLE_GEMINI_BASE_URL`、`GEMINI_MODEL` の 3 キー。ほかは無視 | +| `agy` | Antigravity CLI | `gemini_native` | `gemini_native` のみ | Gemini のフラットな環境キー、または `{env, config:{model:{name}}}`。URL パラメーターが優先され、モデルは `GEMINI_MODEL` より `config.model.name` が優先。ほかのネイティブ設定は無視 | | `grokbuild` | Grok Build | `openai_responses` | `openai_responses`、`openai_chat`、`anthropic` | `[model."<名前>"]` テーブル 1 つ。後述 | | `opencode` | OpenCode | `openai_chat` | 4 種類すべて | エンドポイントと Key のみ取得 | | `openclaw` | OpenClaw | `openai_chat` | 4 種類すべて | エンドポイントと Key のみ取得 | @@ -407,14 +408,14 @@ Sub2API には CC Switch 向けの `GET /v1/usage` API が最初から用意さ **MCP**(`resource=mcp`) -- `apps` は必須で、カンマ区切りです:`claude`、`codex`、`gemini`、`grokbuild`、`opencode`、`hermes`、`pi`、`mcode`。`openclaw` は無視されます。 +- `apps` は必須で、カンマ区切りです:`claude`、`codex`、`gemini`、`agy`、`grokbuild`、`opencode`、`hermes`、`pi`、`mcode`。`openclaw` は無視されます。 - `config` は `{"mcpServers": {"": {...}}}` を Base64 にしたもので、1 本のリンクに複数のサーバーを含められます。 - ユーザーがすでに同じ ID のサーバーを持っている場合は、そのサーバーに新しいアプリのチェックが付くだけで、設定内容はリンクで上書きされません。設定を更新したい場合は別の ID にしてください。 - インポートと同時にクライアントの設定ファイルへ書き込まれます。`enabled` パラメーターは現在効果がありません。 **プロンプト**(`resource=prompt`) -- `app` は `claude`、`codex`、`gemini`、`grokbuild`、`opencode`、`pi`、`mcode` のいずれかです。`openclaw`、`hermes` は CC Switch ではワークスペースエディターとメモリー管理が担当しているため、ディープリンクでプロンプトを配布することはお勧めしません。 +- `app` は `claude`、`codex`、`gemini`、`agy`、`grokbuild`、`opencode`、`pi`、`mcode` のいずれかです。`openclaw`、`hermes` は CC Switch ではワークスペースエディターとメモリー管理が担当しているため、ディープリンクでプロンプトを配布することはお勧めしません。 - `name`、`content`(Base64 にした Markdown)は必須、`description` は任意です。 - `enabled` は確認ダイアログの「インポート後に有効にする」スイッチの初期値です。有効にすると、この内容でユーザーの現在のプロンプトファイル(`CLAUDE.md` など)が置き換えられるため、通常は書かないでください。 diff --git a/docs/guides/deeplink-site-integration-guide-zh.md b/docs/guides/deeplink-site-integration-guide-zh.md index 150a8221b..e6cc5cca6 100644 --- a/docs/guides/deeplink-site-integration-guide-zh.md +++ b/docs/guides/deeplink-site-integration-guide-zh.md @@ -62,7 +62,7 @@ ccswitch://v1/import?resource=<类型>&<参数>... 2. CC Switch 弹出导入确认框,展示链接里的全部内容:地址、打码后的 Key、模型、附带的配置原文、用量查询脚本全文。可疑的内容会标黄提示,例如指向本机或内网的地址、能改变进程加载行为的环境变量(`NODE_OPTIONS`、`LD_PRELOAD` 之类)、用 shell 执行整段命令的 MCP。 3. 用户点「导入」后: - **供应商**:加进对应应用的供应商列表。确认框底部有一个开关,初始值取链接里的 `enabled`,用户可以改: - - Claude Code、Codex、Gemini CLI、Grok Build:开关是「导入后立即切换到这个供应商」,默认关闭。打开时确认框会提示「客户端请求会改发到上面的地址」。 + - Claude Code、Codex、Gemini CLI、Antigravity CLI、Grok Build:开关是「导入后立即切换到这个供应商」,默认关闭。打开时确认框会提示「客户端请求会改发到上面的地址」。 - OpenCode、OpenClaw、Hermes、Pi:这些应用可以同时放多个供应商,开关是「导入后立即添加到应用配置」(Pi 叫「启用」),默认打开;关掉则只保存在 CC Switch 的列表里。 - **MCP**:立即写进 `apps` 里列出的每个应用的配置文件。 - **提示词**:加进提示词列表。确认框里有「导入后立即启用」开关,初始值取链接里的 `enabled`;打开时会替换这个应用当前的提示词文件,原来启用的提示词被停用。 @@ -92,6 +92,7 @@ ccswitch://v1/import?resource=provider&app=claude&name=Example%20AI&endpoint=htt | `claude` | Claude Code | `anthropic` | 四种都可以 | `{"env": {...}}`,`env` 里的变量全部保留 | | `codex` | Codex | `openai_responses` | `openai_responses`、`openai_chat`、`anthropic` | 整份或部分 `config.toml`,见下文 | | `gemini` | Gemini CLI | `gemini_native` | 只有 `gemini_native` | `GEMINI_API_KEY`、`GOOGLE_GEMINI_BASE_URL`、`GEMINI_MODEL` 三个键,其他忽略 | +| `agy` | Antigravity CLI | `gemini_native` | 只有 `gemini_native` | 扁平 Gemini 环境键或 `{env, config:{model:{name}}}`;URL 参数优先,模型优先取 `config.model.name`;其他原生选项忽略 | | `grokbuild` | Grok Build | `openai_responses` | `openai_responses`、`openai_chat`、`anthropic` | 一张 `[model."<名称>"]` 表,见下文 | | `opencode` | OpenCode | `openai_chat` | 四种都可以 | 只取地址和 Key | | `openclaw` | OpenClaw | `openai_chat` | 四种都可以 | 只取地址和 Key | @@ -407,14 +408,14 @@ Sub2API 自带给 CC Switch 用的 `GET /v1/usage` 接口,用 API Key 认证 **MCP**(`resource=mcp`) -- `apps` 必填,逗号分隔:`claude`、`codex`、`gemini`、`grokbuild`、`opencode`、`hermes`、`pi`、`mcode`。`openclaw` 会被忽略。 +- `apps` 必填,逗号分隔:`claude`、`codex`、`gemini`、`agy`、`grokbuild`、`opencode`、`hermes`、`pi`、`mcode`。`openclaw` 会被忽略。 - `config` 是 Base64 的 `{"mcpServers": {"": {...}}}`,一条链接可以带多个服务器。 - 用户已经有同 ID 的服务器时,只会给它勾上新的应用,配置内容不会被链接覆盖。想更新配置请换一个 ID。 - 导入即写入客户端配置文件。`enabled` 参数目前不起作用。 **提示词**(`resource=prompt`) -- `app` 取 `claude`、`codex`、`gemini`、`grokbuild`、`opencode`、`pi`、`mcode`。`openclaw`、`hermes` 在 CC Switch 里由工作区编辑器和记忆管理负责,不建议用深链接分发提示词。 +- `app` 取 `claude`、`codex`、`gemini`、`agy`、`grokbuild`、`opencode`、`pi`、`mcode`。`openclaw`、`hermes` 在 CC Switch 里由工作区编辑器和记忆管理负责,不建议用深链接分发提示词。 - `name`、`content`(Base64 的 Markdown)必填,`description` 可选。 - `enabled` 是确认框里「导入后立即启用」开关的初始值。启用会用这段内容替换用户当前的提示词文件(如 `CLAUDE.md`),一般不要写。 diff --git a/docs/user-manual/en/5-faq/5.3-deeplink.md b/docs/user-manual/en/5-faq/5.3-deeplink.md index be77fff5e..fa0cb2ccd 100644 --- a/docs/user-manual/en/5-faq/5.3-deeplink.md +++ b/docs/user-manual/en/5-faq/5.3-deeplink.md @@ -41,7 +41,7 @@ ccswitch://v1/import?resource={type}&app={app}&name={name}&... | Parameter | Required | Description | |-----------|----------|-------------| | `resource` | Yes | Resource type: `provider` / `mcp` / `prompt` / `skill` | -| `app` | Yes | App type (needed for provider and prompt): `claude` / `codex` / `gemini` / `grokbuild` / `opencode` / `openclaw` / `hermes` / `pi`; prompt also supports `mcode`. Provider deep-link import is not yet supported for Claude Desktop or MiniMax Code | +| `app` | Yes | App type (needed for provider and prompt): `claude` / `codex` / `gemini` / `agy` / `grokbuild` / `opencode` / `openclaw` / `hermes` / `pi`; prompt also supports `mcode`. Provider deep-link import is not yet supported for Claude Desktop or MiniMax Code | | `name` | Required for provider and prompt | Name (for MCP, the server ID comes from the key under `mcpServers` in `config`; not needed for Skill) | **Provider parameters** (resource=provider): @@ -84,7 +84,7 @@ For Grok Build, `config` likewise takes `{"config": ""}` or th | Parameter | Required | Description | |-----------|----------|-------------| -| `apps` | Yes | App list (comma-separated, e.g., `claude,codex,gemini,grokbuild`). Available values: `claude` / `codex` / `gemini` / `grokbuild` (`grok` also works) / `opencode` / `hermes` / `pi` / `mcode`; `openclaw` does not support MCP and is ignored | +| `apps` | Yes | App list (comma-separated, e.g., `claude,codex,gemini,grokbuild`). Available values: `claude` / `codex` / `gemini` / `agy` / `grokbuild` (`grok` also works) / `opencode` / `hermes` / `pi` / `mcode`; `openclaw` does not support MCP and is ignored | | `config` | Yes | MCP configuration JSON in the form `{"mcpServers": {"": {...}}}`, Base64-encoded (then URL-escaped) | | `enabled` | No | Currently has no effect | diff --git a/docs/user-manual/ja/5-faq/5.3-deeplink.md b/docs/user-manual/ja/5-faq/5.3-deeplink.md index 0f948767f..a7bdb59aa 100644 --- a/docs/user-manual/ja/5-faq/5.3-deeplink.md +++ b/docs/user-manual/ja/5-faq/5.3-deeplink.md @@ -41,7 +41,7 @@ ccswitch://v1/import?resource={type}&app={app}&name={name}&... | パラメータ | 必須 | 説明 | |------|------|------| | `resource` | はい | リソースタイプ:`provider` / `mcp` / `prompt` / `skill` | -| `app` | はい | アプリタイプ(provider、prompt で必要):`claude` / `codex` / `gemini` / `grokbuild` / `opencode` / `openclaw` / `hermes` / `pi`。prompt ではさらに `mcode` に対応。Claude Desktop、MiniMax Code のプロバイダーは現在ディープリンクでのインポートに対応していません | +| `app` | はい | アプリタイプ(provider、prompt で必要):`claude` / `codex` / `gemini` / `agy` / `grokbuild` / `opencode` / `openclaw` / `hermes` / `pi`。prompt ではさらに `mcode` に対応。Claude Desktop、MiniMax Code のプロバイダーは現在ディープリンクでのインポートに対応していません | | `name` | provider、prompt で必須 | 名前(MCP のサーバー ID は `config` 内の `mcpServers` のキーから取得。Skill では不要) | **プロバイダーパラメータ**(resource=provider): @@ -85,7 +85,7 @@ Grok Build の `config` も `{"config": ""}` の形 | パラメータ | 必須 | 説明 | |------|------|------| -| `apps` | はい | アプリリスト(カンマ区切り、例:`claude,codex,gemini,grokbuild`)。指定可能な値:`claude` / `codex` / `gemini` / `grokbuild`(`grok` とも書けます)/ `opencode` / `hermes` / `pi` / `mcode`。`openclaw` は MCP に対応しておらず無視されます | +| `apps` | はい | アプリリスト(カンマ区切り、例:`claude,codex,gemini,grokbuild`)。指定可能な値:`claude` / `codex` / `gemini` / `agy` / `grokbuild`(`grok` とも書けます)/ `opencode` / `hermes` / `pi` / `mcode`。`openclaw` は MCP に対応しておらず無視されます | | `config` | はい | MCP 設定の JSON。形式は `{"mcpServers": {"<サーバー ID>": {...}}}` で、Base64 エンコードが必要(さらに URL エスケープ) | | `enabled` | いいえ | 現在は効果がありません | diff --git a/docs/user-manual/zh/5-faq/5.3-deeplink.md b/docs/user-manual/zh/5-faq/5.3-deeplink.md index f5f883947..1e8631cad 100644 --- a/docs/user-manual/zh/5-faq/5.3-deeplink.md +++ b/docs/user-manual/zh/5-faq/5.3-deeplink.md @@ -41,7 +41,7 @@ ccswitch://v1/import?resource={type}&app={app}&name={name}&... | 参数 | 必填 | 说明 | |------|------|------| | `resource` | 是 | 资源类型:`provider` / `mcp` / `prompt` / `skill` | -| `app` | 是 | 应用类型(provider、prompt 需要):`claude` / `codex` / `gemini` / `grokbuild` / `opencode` / `openclaw` / `hermes` / `pi`;prompt 另支持 `mcode`。Claude Desktop、MiniMax Code 的供应商暂不支持深链接导入 | +| `app` | 是 | 应用类型(provider、prompt 需要):`claude` / `codex` / `gemini` / `agy` / `grokbuild` / `opencode` / `openclaw` / `hermes` / `pi`;prompt 另支持 `mcode`。Claude Desktop、MiniMax Code 的供应商暂不支持深链接导入 | | `name` | provider、prompt 必填 | 名称(MCP 的服务器 ID 取自 `config` 中 `mcpServers` 的 key;Skill 不需要) | **供应商参数**(resource=provider): @@ -85,7 +85,7 @@ Grok Build 的 `config` 同样可写成 `{"config": ""}` 或 | 参数 | 必填 | 说明 | |------|------|------| -| `apps` | 是 | 应用列表(逗号分隔,如 `claude,codex,gemini,grokbuild`)。可用值:`claude` / `codex` / `gemini` / `grokbuild`(也可写 `grok`)/ `opencode` / `hermes` / `pi` / `mcode`;`openclaw` 不支持 MCP,会被忽略 | +| `apps` | 是 | 应用列表(逗号分隔,如 `claude,codex,gemini,grokbuild`)。可用值:`claude` / `codex` / `gemini` / `agy` / `grokbuild`(也可写 `grok`)/ `opencode` / `hermes` / `pi` / `mcode`;`openclaw` 不支持 MCP,会被忽略 | | `config` | 是 | MCP 配置 JSON,格式为 `{"mcpServers": {"<服务器 ID>": {...}}}`,须 Base64 编码(再做 URL 转义) | | `enabled` | 否 | 暂不生效 | diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d3e66f1dc..a57bfcb3d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6142,6 +6142,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 99648b241..b4399f124 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -44,7 +44,7 @@ arboard = "3.6" flate2 = "1" brotli = "7" zstd = "0.13" -tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync", "net"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync", "net", "process"] } futures = "0.3" async-stream = "0.3" bytes = "1.5" diff --git a/src-tauri/src/agy_config.rs b/src-tauri/src/agy_config.rs new file mode 100644 index 000000000..92bdde266 --- /dev/null +++ b/src-tauri/src/agy_config.rs @@ -0,0 +1,1222 @@ +//! Antigravity CLI (`agy`) 的 live 配置。 +//! +//! `agy` 只从进程环境读取 `GEMINI_API_KEY` 和 `GOOGLE_GEMINI_BASE_URL`,不读 +//! `.env`。`settings.json` 只负责 `modelProvider` 和 `model`。切换供应商时: +//! +//! 1. 按键补丁写入 `~/.gemini/antigravity-cli/settings.json`,其余键不动。 +//! 2. 把 Key 和地址写进同目录的 `env`(权限 0600)。 +//! 3. 安装 `bin/agy` 包装脚本,启动时注入这些变量再执行真正的 `agy`。 +//! 4. 在 zsh 的各启动阶段把包装目录放到 `PATH` 最前面,避免后续启动文件覆盖。 +//! +//! 只有当前供应商带 Key 或自定义端点(第三方)时才接管启动器和 shell 配置; +//! 官方登录模式、删除供应商或空配置都不会安装它们,且会清掉此前由 CC Switch +//! 写入的带标记启动器与 PATH 片段(用户自己的内容原样保留)。 + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use serde_json::{json, Value}; + +#[cfg(test)] +use crate::config::atomic_write_private; +use crate::config::{get_home_dir, write_text_file}; +use crate::error::AppError; +use crate::live::patch::json::JsonPatch; +use crate::live::patch::{KeyPath, LivePatch}; + +const WRAPPER_MARK: &str = "cc-switch-agy-wrapper"; +const PATH_MARK: &str = "cc-switch agy wrapper"; + +/// `~/.gemini/antigravity-cli` +pub fn agy_dir() -> PathBuf { + get_home_dir().join(".gemini").join("antigravity-cli") +} + +pub fn customizations_dir() -> PathBuf { + get_home_dir().join(".gemini").join("config") +} + +pub fn settings_path() -> PathBuf { + agy_dir().join("settings.json") +} + +pub fn env_path() -> PathBuf { + agy_dir().join("env") +} + +#[cfg(test)] +fn apply_in(root: &Path, settings: &Value, install_shell: bool) -> Result<(), AppError> { + let projection = AgyProjection::of(settings); + write_settings(&root.join("settings.json"), &projection)?; + write_env(&root.join("env"), &projection)?; + install_wrapper(&root.join("bin").join("agy"), &projection)?; + if install_shell { + ensure_wrapper_on_path(&root.join("bin"))?; + } + Ok(()) +} + +/// 从 live 读回编辑器用的 `{ env, config }`。 +pub fn read_live_settings() -> Result { + let settings_file = settings_path(); + if !settings_file.exists() && !env_path().exists() { + return Err(AppError::localized( + "agy.config.missing", + "Antigravity 配置文件不存在", + "Antigravity configuration files are missing", + )); + } + let settings: Value = if settings_file.exists() { + let raw = fs::read(&settings_file).map_err(|e| AppError::io(&settings_file, e))?; + serde_json::from_slice(&raw) + .map_err(|e| AppError::Config(format!("Invalid agy settings.json: {e}")))? + } else { + json!({}) + }; + let env_map = read_env_file(&env_path())?; + let mut env = serde_json::Map::new(); + if let Some(key) = env_map.get("GEMINI_API_KEY") { + env.insert("GEMINI_API_KEY".into(), json!(key)); + } + if let Some(url) = env_map.get("GOOGLE_GEMINI_BASE_URL") { + env.insert("GOOGLE_GEMINI_BASE_URL".into(), json!(url)); + } + let model = settings.get("model").cloned().filter(|v| !v.is_null()); + Ok(json!({ + "env": env, + "config": { + "model": { "name": model.unwrap_or(Value::Null) } + } + })) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct AgyProjection { + api_key: Option, + base_url: Option, + model: Option, +} + +impl AgyProjection { + fn of(settings: &Value) -> Self { + let env = settings.get("env"); + let api_key = non_empty(env_string(env, "GEMINI_API_KEY")); + let base_url = non_empty(env_string(env, "GOOGLE_GEMINI_BASE_URL")); + let model = non_empty( + settings + .pointer("/config/model/name") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| env_string(env, "GEMINI_MODEL")), + ); + Self { + api_key, + base_url, + model, + } + } +} + +impl AgyProjection { + /// 注入 Key 或自定义端点才需要启动器;仅模型由 settings.json 原生承载。 + fn needs_launcher(&self) -> bool { + self.api_key.is_some() || self.base_url.is_some() + } +} + +fn env_string(env: Option<&Value>, key: &str) -> Option { + env?.get(key)?.as_str().map(str::to_string) +} + +fn non_empty(value: Option) -> Option { + value.filter(|s| !s.trim().is_empty()) +} + +fn settings_patch(projection: &AgyProjection) -> JsonPatch { + let mut patch = JsonPatch::default(); + let provider = KeyPath::new(&["modelProvider"]); + let model = KeyPath::new(&["model"]); + if projection.api_key.is_some() { + patch.set.push((provider, json!("gemini"))); + } else { + patch.remove.push(provider); + } + match &projection.model { + Some(name) => patch.set.push((model, json!(name))), + None => patch.remove.push(model), + } + patch +} + +#[cfg(test)] +fn write_settings(path: &Path, projection: &AgyProjection) -> Result<(), AppError> { + let patch = settings_patch(projection); + let pre = if path.exists() { + Some(fs::read(path).map_err(|e| AppError::io(path, e))?) + } else { + None + }; + let bytes = patch + .apply(path, pre.as_deref()) + .map_err(|e| AppError::Message(format!("写入 agy settings.json 失败: {e}")))?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + } + atomic_write_private(path, &bytes) +} + +fn env_bytes(projection: &AgyProjection) -> Vec { + let mut lines = vec![format!("# {PATH_MARK}")]; + if let Some(key) = &projection.api_key { + lines.push(format!("GEMINI_API_KEY={}", shell_quote(key))); + } + if let Some(url) = &projection.base_url { + lines.push(format!("GOOGLE_GEMINI_BASE_URL={}", shell_quote(url))); + } + if let Some(model) = &projection.model { + lines.push(format!("AGY_MODEL={}", shell_quote(model))); + } + lines.push(String::new()); + lines.join("\n").into_bytes() +} + +#[cfg(test)] +fn write_env(path: &Path, projection: &AgyProjection) -> Result<(), AppError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + } + atomic_write_private(path, &env_bytes(projection)) +} + +/// 配置里是否带有值得托管的真实内容(Key、端点或模型)。 +pub(crate) fn has_managed_content(settings: &Value) -> bool { + let projection = AgyProjection::of(settings); + projection.api_key.is_some() || projection.base_url.is_some() || projection.model.is_some() +} + +/// 这份配置是否需要 CC Switch 的启动器(带 Key 或自定义端点)。 +pub(crate) fn needs_launcher(settings: &Value) -> bool { + AgyProjection::of(settings).needs_launcher() +} + +/// Key-bearing files and the current provider pointer share the crash-recoverable operation. +pub(crate) fn apply_managed( + db: &crate::database::Database, + settings: &Value, + operation: &str, + target: crate::mode::state::PendingTarget, +) -> Result { + use crate::live::{engine::LiveFile, patch::WholeFile}; + use crate::mode::operation::{AppWrite, FileChange}; + let projection = AgyProjection::of(settings); + let launcher = projection.needs_launcher(); + if launcher && !cfg!(unix) { + return Err(AppError::Config( + "Antigravity provider switching currently requires a POSIX shell (macOS/Linux)".into(), + )); + } + let write = AppWrite::begin(db, "agy")?; + let patch = settings_patch(&projection); + // Validate before installing the launcher or publishing any changes. + let path = settings_path(); + let pre = if path.exists() { + Some(fs::read(&path).map_err(|e| AppError::io(&path, e))?) + } else { + None + }; + patch.apply(&path, pre.as_deref()).map_err(AppError::from)?; + if launcher { + install_wrapper(&agy_dir().join("bin/agy"), &projection)?; + ensure_wrapper_on_path(&agy_dir().join("bin"))?; + } + let env = WholeFile::Write(env_bytes(&projection)); + let report = write.run( + operation, + &[ + FileChange { + file: LiveFile::private(env_path()), + patch: &env, + }, + FileChange { + file: LiveFile::private(path), + patch: &patch, + }, + ], + target, + )?; + if !launcher { + // 官方登录模式不需要注入环境,撤掉之前装的启动器和 PATH 片段。 + remove_launcher()?; + } + Ok(report) +} + +/// 撤销 CC Switch 安装的启动器和 shell PATH 片段,只动带标记的内容。 +pub(crate) fn remove_launcher() -> Result<(), AppError> { + let bin_dir = agy_dir().join("bin"); + let wrapper = bin_dir.join("agy"); + if wrapper.is_file() && is_wrapper(&wrapper) { + fs::remove_file(&wrapper).map_err(|e| AppError::io(&wrapper, e))?; + } + // 目录为空才会删除;里面有别的东西时保持原样。 + let _ = fs::remove_dir(&bin_dir); + let home = get_home_dir(); + for profile in shell_profiles(&home) { + remove_path_entry(&profile)?; + } + Ok(()) +} + +fn install_wrapper(path: &Path, _projection: &AgyProjection) -> Result<(), AppError> { + if path.exists() && !is_wrapper(path) { + return Err(AppError::Config( + "Refusing to overwrite an unmanaged agy launcher".into(), + )); + } + // 安装时找不到真实 agy(例如从 Dock 启动时 PATH 很短)也能装:启动器在运行时 + // 会按终端里的 PATH 重新查找,找不到就像普通的 command not found。 + let real = real_agy_bin(path) + .map(|found| found.to_string_lossy().into_owned()) + .unwrap_or_default(); + let model_flag = { + r#" +# Subcommands (MCP, plugins, models, help) must not receive a session model flag. +case "${1-}" in + mcp|plugin|plugins|models|help|install|update|agent|agents|changelog|remote-control|mic-serve) exec "$REAL" "$@" ;; +esac +has_model=0 +for arg in "$@"; do + case "$arg" in + --model|--model=*) has_model=1 ;; + esac +done +if [ -n "$MODEL" ] && [ "$has_model" -eq 0 ]; then + exec "$REAL" --model "$MODEL" "$@" +fi +"# + }; + let script = format!( + r#"#!/bin/sh +# {WRAPPER_MARK} +DIR="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)" +if [ -f "$DIR/env" ]; then + unset GEMINI_API_KEY GOOGLE_GEMINI_BASE_URL AGY_MODEL + set -a + # shellcheck disable=SC1091 + . "$DIR/env" + set +a +fi +MODEL="${{AGY_MODEL-}}" +unset AGY_MODEL +DEFAULT_REAL={real} +REAL="${{AGY_REAL_BIN:-$DEFAULT_REAL}}" +if [ -z "$REAL" ] || [ ! -x "$REAL" ]; then + # Resolve at run time, skipping this launcher (and any other CC Switch launcher). + REAL="" + SEARCH="$PATH:$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin" + old_ifs="$IFS" + IFS=: + for d in $SEARCH; do + [ -n "$d" ] || continue + c="$d/agy" + [ -f "$c" ] && [ -x "$c" ] || continue + [ "$(CDPATH= cd -- "$d" 2>/dev/null && pwd)" = "$DIR/bin" ] && continue + if head -c 256 "$c" 2>/dev/null | grep -q {WRAPPER_MARK}; then continue; fi + REAL="$c" + break + done + IFS="$old_ifs" +fi +if [ -z "$REAL" ]; then + echo "agy: command not found (real binary could not be located)" >&2 + exit 127 +fi +{model_flag}exec "$REAL" "$@" +"#, + real = shell_quote(&real), + ); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + } + write_text_file(path, &script)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(path) + .map_err(|e| AppError::io(path, e))? + .permissions(); + perms.set_mode(0o755); + fs::set_permissions(path, perms).map_err(|e| AppError::io(path, e))?; + } + Ok(()) +} + +/// 定位真实的 `agy`:先 PATH,再常见安装目录,最后问一次登录 shell。 +/// 从 Dock / Finder 启动时 PATH 只有系统目录,所以后两步不可省。 +/// 返回 `None` 表示确实找不到;自己的启动器永远不会被返回。 +pub(crate) fn real_agy_bin(wrapper: &Path) -> Option { + let path_var = std::env::var_os("PATH").unwrap_or_default(); + resolve_real_agy( + wrapper.parent(), + &path_var, + &get_home_dir(), + &[ + PathBuf::from("/opt/homebrew/bin"), + PathBuf::from("/usr/local/bin"), + ], + !cfg!(test) && cfg!(unix), + ) +} + +fn resolve_real_agy( + wrapper_dir: Option<&Path>, + path_var: &std::ffi::OsStr, + home: &Path, + system_dirs: &[PathBuf], + probe_login_shell: bool, +) -> Option { + let mut dirs: Vec = std::env::split_paths(path_var) + .filter(|dir| !dir.as_os_str().is_empty()) + .collect(); + dirs.push(home.join(".local").join("bin")); + dirs.extend(system_dirs.iter().cloned()); + dirs.push(home.join(".gemini").join("antigravity-cli").join("bin")); + let names: &[&str] = if cfg!(windows) { + &["agy.exe", "agy.cmd", "agy"] + } else { + &["agy"] + }; + for dir in dirs { + if wrapper_dir == Some(dir.as_path()) { + continue; + } + for name in names { + let candidate = dir.join(name); + if is_real_binary(&candidate) { + return Some(candidate); + } + } + } + if probe_login_shell { + return login_shell_agy().filter(|found| is_real_binary(found)); + } + None +} + +fn is_real_binary(path: &Path) -> bool { + if !path.is_file() || is_wrapper(path) { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::metadata(path) + .map(|meta| meta.permissions().mode() & 0o111 != 0) + .unwrap_or(false) + } + #[cfg(not(unix))] + { + true + } +} + +fn is_wrapper(path: &Path) -> bool { + use std::io::Read; + let mut prefix = [0; 256]; + fs::File::open(path) + .and_then(|mut file| file.read(&mut prefix)) + .map(|len| String::from_utf8_lossy(&prefix[..len]).contains(WRAPPER_MARK)) + .unwrap_or(false) +} + +/// 在登录 shell 里 `command -v agy`,限时 2 秒,避免用户的 rc 文件卡住界面。 +fn login_shell_agy() -> Option { + use std::process::Stdio; + let shell = std::env::var("SHELL").ok().filter(|s| !s.is_empty())?; + let mut child = Command::new(shell) + .args(["-l", "-c", "command -v agy"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + loop { + match child.try_wait().ok()? { + Some(status) if status.success() => break, + Some(_) => return None, + None if std::time::Instant::now() >= deadline => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + None => std::thread::sleep(std::time::Duration::from_millis(25)), + } + } + let output = child.wait_with_output().ok()?; + let text = String::from_utf8(output.stdout).ok()?; + let path = PathBuf::from(text.lines().rev().find(|l| !l.trim().is_empty())?.trim()); + path.is_absolute().then_some(path) +} + +fn ensure_wrapper_on_path(bin_dir: &Path) -> Result<(), AppError> { + let shell = std::env::var("SHELL").unwrap_or_else(|_| { + if cfg!(target_os = "macos") { + "/bin/zsh".into() + } else { + "/bin/sh".into() + } + }); + ensure_wrapper_on_path_in(&get_home_dir(), &shell, bin_dir) +} + +fn ensure_wrapper_on_path_in(home: &Path, shell: &str, bin_dir: &Path) -> Result<(), AppError> { + match Path::new(shell).file_name().and_then(|name| name.to_str()) { + Some("zsh") => { + // macOS path_helper and user startup files can reset PATH after .zshenv. + for name in [".zshenv", ".zprofile", ".zshrc"] { + append_path_entry(&home.join(name), bin_dir)?; + } + let login = home.join(".zlogin"); + if login.exists() { + append_path_entry(&login, bin_dir)?; + } + Ok(()) + } + Some("bash") => { + append_path_entry(&home.join(".bashrc"), bin_dir)?; + let login = [".bash_profile", ".bash_login", ".profile"] + .into_iter() + .map(|name| home.join(name)) + .find(|path| path.exists()) + .unwrap_or_else(|| home.join(".profile")); + append_path_entry(&login, bin_dir) + } + Some("sh" | "dash" | "ksh") => append_path_entry(&home.join(".profile"), bin_dir), + _ => Err(AppError::Config( + "Automatic agy launcher setup supports zsh, bash and POSIX sh".into(), + )), + } +} + +/// 可能被写入过 PATH 片段的所有 shell 配置文件。 +fn shell_profiles(home: &Path) -> Vec { + [ + ".zshenv", + ".zprofile", + ".zshrc", + ".zlogin", + ".bashrc", + ".bash_profile", + ".bash_login", + ".profile", + ] + .into_iter() + .map(|name| home.join(name)) + .collect() +} + +fn path_entry_block(bin_dir: &Path) -> String { + format!( + "\n# {PATH_MARK}\nexport PATH={}:\"$PATH\"\n", + shell_quote(&bin_dir.to_string_lossy()) + ) +} + +/// Only remove the exact block for our wrapper directory, preserving all other bytes. +fn remove_path_entry(profile: &Path) -> Result<(), AppError> { + if !profile.is_file() { + return Ok(()); + } + let existing = fs::read_to_string(profile).map_err(|e| AppError::io(profile, e))?; + let block = path_entry_block(&agy_dir().join("bin")); + if !existing.contains(&block) { + return Ok(()); + } + let next = existing.replace(&block, ""); + if next.is_empty() { + fs::remove_file(profile).map_err(|e| AppError::io(profile, e)) + } else { + write_text_file(profile, &next) + } +} + +fn append_path_entry(zshenv: &Path, bin_dir: &Path) -> Result<(), AppError> { + let existing = if zshenv.exists() { + fs::read_to_string(zshenv).map_err(|e| AppError::io(zshenv, e))? + } else { + String::new() + }; + let line = path_entry_block(bin_dir); + if existing.contains(&line) { + return Ok(()); + } + // The block includes its separator; another newline would remain after cleanup. + let mut next = existing; + next.push_str(&line); + write_text_file(zshenv, &next) +} + +fn shell_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', r"'\''")) +} + +fn read_env_file(path: &Path) -> Result, AppError> { + let mut map = std::collections::HashMap::new(); + if !path.exists() { + return Ok(map); + } + let text = fs::read_to_string(path).map_err(|e| AppError::io(path, e))?; + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + map.insert(key.to_string(), unquote(value)); + } + Ok(map) +} + +fn unquote(value: &str) -> String { + let value = value.trim(); + if let Some(inner) = value.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')) { + return inner.replace(r"'\''", "'"); + } + value.trim_matches('"').to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn api_key_sets_provider_and_keeps_other_settings() { + let dir = std::env::temp_dir().join(format!("cc-switch-agy-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + fs::write( + dir.join("settings.json"), + "{\n \"trustedWorkspaces\": [\n \"/Users/me\"\n ]\n}\n", + ) + .unwrap(); + + apply_in( + &dir, + &json!({ + "env": { + "GEMINI_API_KEY": "test-key", + "GOOGLE_GEMINI_BASE_URL": "https://example.test" + }, + "config": { "model": { "name": "gemini-3.5-flash" } } + }), + false, + ) + .unwrap(); + + let settings: Value = + serde_json::from_str(&fs::read_to_string(dir.join("settings.json")).unwrap()).unwrap(); + assert_eq!(settings["modelProvider"], "gemini"); + assert_eq!(settings["model"], "gemini-3.5-flash"); + assert_eq!(settings["trustedWorkspaces"][0], "/Users/me"); + + let env = fs::read_to_string(dir.join("env")).unwrap(); + assert!(env.contains("GEMINI_API_KEY='test-key'")); + assert!(env.contains("GOOGLE_GEMINI_BASE_URL='https://example.test'")); + assert!(env.contains("AGY_MODEL='gemini-3.5-flash'")); + + let wrapper = fs::read_to_string(dir.join("bin").join("agy")).unwrap(); + assert!(wrapper.contains(WRAPPER_MARK)); + assert!(wrapper.contains("GEMINI_API_KEY") || wrapper.contains("$DIR/env")); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn missing_key_removes_model_provider() { + let dir = std::env::temp_dir().join(format!("cc-switch-agy-login-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + fs::write( + dir.join("settings.json"), + r#"{"modelProvider":"gemini","model":"old","trustedWorkspaces":["/Users/me"]}"#, + ) + .unwrap(); + apply_in(&dir, &json!({ "env": {}, "config": {} }), false).unwrap(); + let settings: Value = + serde_json::from_str(&fs::read_to_string(dir.join("settings.json")).unwrap()).unwrap(); + assert!(settings.get("modelProvider").is_none()); + assert!(settings.get("model").is_none()); + assert_eq!(settings["trustedWorkspaces"][0], "/Users/me"); + let env = fs::read_to_string(dir.join("env")).unwrap(); + assert!(!env.contains("GEMINI_API_KEY=")); + let _ = fs::remove_dir_all(&dir); + } +} + +#[cfg(all(test, unix))] +mod integration_tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + + struct HomeGuard(Option); + impl HomeGuard { + fn set(path: &Path) -> Self { + let guard = Self(std::env::var_os("CC_SWITCH_TEST_HOME")); + std::env::set_var("CC_SWITCH_TEST_HOME", path); + crate::settings::reload_settings().unwrap(); + guard + } + } + impl Drop for HomeGuard { + fn drop(&mut self) { + crate::mode::operation::failpoint::crash_at(None); + match self.0.take() { + Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value), + None => std::env::remove_var("CC_SWITCH_TEST_HOME"), + } + let _ = crate::settings::reload_settings(); + } + } + + #[test] + fn agy_wrapper_quotes_keys_keeps_explicit_models_and_clears_stale_auth() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("space ' $dir"); + let key = "fixture'$(echo not-a-command)`echo no`"; + apply_in(&root,&json!({"env":{"GEMINI_API_KEY":key,"GOOGLE_GEMINI_BASE_URL":"https://example.test"},"config":{"model":{"name":"chosen"}}}),false).unwrap(); + let real = dir.path().join("fake agy"); + fs::write(&real,"#!/bin/sh\nprintf '%s\\n' \"${GEMINI_API_KEY-unset}\" \"${GOOGLE_GEMINI_BASE_URL-unset}\" \"$@\"\n").unwrap(); + fs::set_permissions(&real, fs::Permissions::from_mode(0o755)).unwrap(); + let run = |args: &[&str]| { + let output = Command::new(root.join("bin/agy")) + .args(args) + .env("AGY_REAL_BIN", &real) + .env("GEMINI_API_KEY", "stale") + .env("GOOGLE_GEMINI_BASE_URL", "stale-url") + .output() + .unwrap(); + assert!(output.status.success()); + String::from_utf8(output.stdout).unwrap() + }; + assert_eq!( + run(&["--print", "hello"]), + format!("{key}\nhttps://example.test\n--model\nchosen\n--print\nhello\n") + ); + assert_eq!( + run(&["--model=explicit"]), + format!("{key}\nhttps://example.test\n--model=explicit\n") + ); + assert_eq!( + run(&["mcp", "list"]), + format!("{key}\nhttps://example.test\nmcp\nlist\n") + ); + assert_eq!( + read_env_file(&root.join("env")).unwrap()["GEMINI_API_KEY"], + key + ); + assert_eq!( + fs::metadata(root.join("env")).unwrap().permissions().mode() & 0o777, + 0o600 + ); + apply_in(&root, &json!({"env":{},"config":{}}), false).unwrap(); + assert_eq!(run(&["--print", "hi"]), "unset\nunset\n--print\nhi\n"); + } + + #[test] + fn agy_shell_setup_is_idempotent_and_preserves_existing_lines() { + let dir = tempfile::tempdir().unwrap(); + let profile = dir.path().join(".zshenv"); + fs::write(&profile, "# user settings\nexport KEEP=yes\n").unwrap(); + append_path_entry(&profile, &dir.path().join("quoted ' bin")).unwrap(); + let first = fs::read_to_string(&profile).unwrap(); + append_path_entry(&profile, &dir.path().join("quoted ' bin")).unwrap(); + assert_eq!(first, fs::read_to_string(&profile).unwrap()); + assert!(first.starts_with("# user settings\nexport KEEP=yes\n")); + assert!(Command::new("sh") + .arg("-n") + .arg(&profile) + .status() + .unwrap() + .success()); + } + + #[test] + #[serial_test::serial] + fn agy_switch_recovers_interrupted_files_and_pointer() { + use crate::app_config::AppType; + use crate::mode::{ + current::{provider_for, Purpose}, + operation::{failpoint, AppWrite}, + }; + use crate::{database::Database, provider::Provider, services::provider::agy_direct}; + let dir = tempfile::tempdir().unwrap(); + let _home = HomeGuard::set(dir.path()); + let db = Database::memory().unwrap(); + fs::create_dir_all(agy_dir()).unwrap(); + fs::write( + settings_path(), + r#"{"trustedWorkspaces":["keep"],"theme":"dark"}"#, + ) + .unwrap(); + // Sibling Gemini files are never targets of an Antigravity switch. + let sibling = dir.path().join(".gemini/settings.json"); + fs::write(&sibling, "{\"keep\":true}").unwrap(); + let row = Provider::with_id( + "ag-test".into(), + "Relay".into(), + json!({"env":{"GEMINI_API_KEY":"fixture","GOOGLE_GEMINI_BASE_URL":"https://example.test"},"config":{"model":{"name":"model-one"}}}), + None, + ); + db.save_provider("agy", &row).unwrap(); + failpoint::crash_at(Some("published:0")); + let result = agy_direct::switch_to(&db, &row); + failpoint::crash_at(None); + assert!(result.is_err()); + assert_eq!( + fs::read_to_string(settings_path()).unwrap(), + r#"{"trustedWorkspaces":["keep"],"theme":"dark"}"# + ); + // begin completes the partially published operation and requests a retry. + assert!(AppWrite::begin(&db, "agy").is_err()); + agy_direct::switch_to(&db, &row).unwrap(); + assert_eq!( + provider_for(&db, &AppType::Agy, Purpose::Direct) + .unwrap() + .as_deref(), + Some("ag-test") + ); + assert_eq!( + read_live_settings().unwrap()["config"]["model"]["name"], + "model-one" + ); + let doc: Value = serde_json::from_slice(&fs::read(settings_path()).unwrap()).unwrap(); + assert_eq!(doc["theme"], "dark"); + assert_eq!(doc["trustedWorkspaces"][0], "keep"); + assert_eq!(fs::read_to_string(&sibling).unwrap(), "{\"keep\":true}"); + fs::write(settings_path(), "broken").unwrap(); + let old_env = fs::read(env_path()).unwrap(); + assert!(agy_direct::switch_to(&db, &row).is_err()); + assert_eq!(fs::read(env_path()).unwrap(), old_env); + assert_eq!(fs::read_to_string(settings_path()).unwrap(), "broken"); + assert!(read_live_settings().is_err()); + } +} + +#[cfg(all(test, unix))] +mod launcher_lifecycle_tests { + use super::*; + use crate::app_config::AppType; + use crate::database::Database; + use crate::provider::Provider; + use crate::services::provider::agy_direct; + use crate::store::AppState; + use serial_test::serial; + use std::os::unix::fs::PermissionsExt; + use std::sync::Arc; + + struct EnvGuard(&'static str, Option); + impl EnvGuard { + fn set(key: &'static str, value: impl AsRef) -> Self { + let previous = std::env::var_os(key); + std::env::set_var(key, value); + Self(key, previous) + } + } + impl Drop for EnvGuard { + fn drop(&mut self) { + match self.1.take() { + Some(v) => std::env::set_var(self.0, v), + None => std::env::remove_var(self.0), + } + } + } + + struct Home { + dir: tempfile::TempDir, + _home: EnvGuard, + _shell: EnvGuard, + _reload: Reload, + } + struct Reload; + impl Drop for Reload { + fn drop(&mut self) { + // Runs after the env guards above have restored the variables. + let _ = crate::settings::reload_settings(); + } + } + impl Home { + fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let home = EnvGuard::set("CC_SWITCH_TEST_HOME", dir.path()); + crate::settings::reload_settings().unwrap(); + let shell = EnvGuard::set("SHELL", "/bin/zsh"); + Self { + dir, + _home: home, + _shell: shell, + _reload: Reload, + } + } + fn zshenv(&self) -> PathBuf { + self.dir.path().join(".zshenv") + } + } + impl Drop for Home { + fn drop(&mut self) { + crate::mode::operation::failpoint::crash_at(None); + } + } + + fn provider(id: &str, settings: Value, official: bool) -> Provider { + let mut p = Provider::with_id(id.into(), id.into(), settings, None); + if official { + p.category = Some("official".into()); + } + p + } + + fn managed(id: &str) -> Provider { + provider( + id, + json!({"env":{"GEMINI_API_KEY":"fixture","GOOGLE_GEMINI_BASE_URL":"https://example.test"},"config":{"model":{"name":"m"}}}), + false, + ) + } + + fn empty_agy_settings(home: &Home) { + fs::create_dir_all(agy_dir()).unwrap(); + fs::write(settings_path(), r#"{"theme":"dark"}"#).unwrap(); + fs::write(home.zshenv(), "# user\nexport KEEP=1\n").unwrap(); + } + + #[test] + #[serial] + fn existing_empty_agy_settings_are_not_imported_or_synced_into_a_launcher() { + let home = Home::new(); + empty_agy_settings(&home); + let db = Arc::new(Database::memory().unwrap()); + let state = AppState::new(db.clone()); + + // Startup import skips agy's own first-run settings.json. + assert!( + !crate::services::ProviderService::import_default_config(&state, AppType::Agy).unwrap() + ); + assert!(db.get_all_providers("agy").unwrap().is_empty()); + + // A stored provider without managed content is not applied either. + let blank = provider("blank", json!({"env":{},"config":{}}), false); + db.save_provider("agy", &blank).unwrap(); + db.set_current_provider("agy", "blank").unwrap(); + agy_direct::reapply(&db, &blank).unwrap(); + // The full sync used by import / cloud sync must report no agy failure. + let result = crate::services::ProviderService::sync_current_to_live(&state); + if let Err(error) = &result { + assert!(!error.to_string().contains("provider/agy"), "{error}"); + } + + assert!(!agy_dir().join("bin/agy").exists()); + assert!(!env_path().exists()); + assert_eq!( + fs::read_to_string(home.zshenv()).unwrap(), + "# user\nexport KEEP=1\n" + ); + for name in [".bashrc", ".profile", ".bash_profile"] { + assert!(!home.dir.path().join(name).exists()); + } + assert_eq!( + fs::read_to_string(settings_path()).unwrap(), + r#"{"theme":"dark"}"# + ); + } + + #[test] + #[serial] + fn settings_with_real_content_are_still_imported() { + let home = Home::new(); + empty_agy_settings(&home); + fs::write(settings_path(), r#"{"model":"gemini-x"}"#).unwrap(); + let state = AppState::new(Arc::new(Database::memory().unwrap())); + assert!( + crate::services::ProviderService::import_default_config(&state, AppType::Agy).unwrap() + ); + } + + #[test] + #[serial] + fn login_switch_and_delete_remove_only_our_launcher_and_path_block() { + let home = Home::new(); + fs::create_dir_all(agy_dir()).unwrap(); + fs::write(home.zshenv(), "# user\nexport KEEP=1\n").unwrap(); + let db = Database::memory().unwrap(); + + let relay = managed("relay"); + db.save_provider("agy", &relay).unwrap(); + agy_direct::switch_to(&db, &relay).unwrap(); + let wrapper = agy_dir().join("bin/agy"); + assert!(wrapper.is_file()); + assert!(fs::read_to_string(home.zshenv()) + .unwrap() + .contains(PATH_MARK)); + + // Switching to login mode removes both, keeps user content. + let official = provider("official", json!({"env":{},"config":{}}), true); + db.save_provider("agy", &official).unwrap(); + agy_direct::switch_to(&db, &official).unwrap(); + assert!(!wrapper.exists()); + assert!(!agy_dir().join("bin").exists()); + assert_eq!( + fs::read_to_string(home.zshenv()).unwrap(), + "# user\nexport KEEP=1\n" + ); + + // Reinstall, then deleting the last managed provider releases the launcher. + agy_direct::switch_to(&db, &relay).unwrap(); + assert!(wrapper.is_file()); + agy_direct::release_launcher_if_unmanaged(&db).unwrap(); + assert!( + wrapper.is_file(), + "a managed current provider keeps the launcher" + ); + db.set_current_provider("agy", "official").unwrap(); + db.delete_provider("agy", "relay").unwrap(); + agy_direct::release_launcher_if_unmanaged(&db).unwrap(); + assert!(!wrapper.exists()); + assert_eq!( + fs::read_to_string(home.zshenv()).unwrap(), + "# user\nexport KEEP=1\n" + ); + } + + #[test] + #[serial] + fn cleanup_never_touches_an_unmanaged_launcher_or_foreign_profile_lines() { + let home = Home::new(); + let bin = agy_dir().join("bin"); + fs::create_dir_all(&bin).unwrap(); + fs::write(bin.join("agy"), "#!/bin/sh\necho user script\n").unwrap(); + fs::write( + home.zshenv(), + "export PATH=\"$HOME/x:$PATH\"\n# my own note about cc-switch agy wrapper\n", + ) + .unwrap(); + remove_launcher().unwrap(); + assert!(bin.join("agy").exists()); + assert_eq!( + fs::read_to_string(home.zshenv()).unwrap(), + "export PATH=\"$HOME/x:$PATH\"\n# my own note about cc-switch agy wrapper\n" + ); + } + + #[test] + #[serial] + fn path_block_removal_covers_bash_profiles_and_deletes_files_we_created() { + let home = Home::new(); + let bin = agy_dir().join("bin"); + ensure_wrapper_on_path_in(home.dir.path(), "/bin/bash", &bin).unwrap(); + assert!(home.dir.path().join(".bashrc").exists()); + assert!(home.dir.path().join(".profile").exists()); + remove_launcher().unwrap(); + assert!(!home.dir.path().join(".bashrc").exists()); + assert!(!home.dir.path().join(".profile").exists()); + } + + #[cfg(target_os = "macos")] + #[test] + #[serial] + fn agy_fresh_zsh_keeps_launcher_after_path_resets_and_restores_profiles() { + let home = Home::new(); + let profiles = [".zshenv", ".zprofile", ".zshrc", ".zlogin"]; + let original = "# user PATH reset\nexport PATH=\"$HOME/.local/bin:/usr/bin:/bin\""; + for name in profiles { + fs::write(home.dir.path().join(name), original).unwrap(); + } + let real = fake_agy(&home.dir.path().join(".local/bin")); + fs::write( + &real, + "#!/bin/sh\nprintf '%s\\n' \"$GEMINI_API_KEY\" \"$GOOGLE_GEMINI_BASE_URL\" \"$@\"\n", + ) + .unwrap(); + let db = Database::memory().unwrap(); + let relay = managed("relay"); + db.save_provider("agy", &relay).unwrap(); + agy_direct::switch_to(&db, &relay).unwrap(); + agy_direct::reapply(&db, &relay).unwrap(); + let wrapper = agy_dir().join("bin/agy"); + for flags in ["-lc", "-lic"] { + let output = Command::new("/bin/zsh") + .args([flags, "command -v agy; agy --print fixture"]) + .env("HOME", home.dir.path()) + .env("ZDOTDIR", home.dir.path()) + .env("AGY_REAL_BIN", &real) + .env_remove("GEMINI_API_KEY") + .env_remove("GOOGLE_GEMINI_BASE_URL") + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + assert_eq!( + String::from_utf8(output.stdout).unwrap(), + format!( + "{}\nfixture\nhttps://example.test\n--model\nm\n--print\nfixture\n", + wrapper.display() + ), + "launcher must survive {flags} startup" + ); + } + for name in profiles { + assert_eq!( + fs::read_to_string(home.dir.path().join(name)) + .unwrap() + .matches(&format!("# {PATH_MARK}")) + .count(), + 1 + ); + } + let login = provider("login", json!({"env":{},"config":{}}), true); + db.save_provider("agy", &login).unwrap(); + agy_direct::switch_to(&db, &login).unwrap(); + for name in profiles { + assert_eq!( + fs::read_to_string(home.dir.path().join(name)).unwrap(), + original + ); + } + assert!(!wrapper.exists()); + } + + #[test] + #[serial] + fn cleanup_preserves_whitespace_and_foreign_marked_path_entries() { + let home = Home::new(); + let bin = agy_dir().join("bin"); + for original in [ + "# user without final newline", + " \n\t\n", + "\n# cc-switch agy wrapper\nexport PATH='/some/other/bin':\"$PATH\"\n", + ] { + fs::write(home.zshenv(), original).unwrap(); + append_path_entry(&home.zshenv(), &bin).unwrap(); + assert!(fs::read_to_string(home.zshenv()) + .unwrap() + .contains(&shell_quote(&bin.to_string_lossy()))); + remove_launcher().unwrap(); + assert_eq!(fs::read_to_string(home.zshenv()).unwrap(), original); + } + } + + fn fake_agy(dir: &Path) -> PathBuf { + fs::create_dir_all(dir).unwrap(); + let path = dir.join("agy"); + fs::write(&path, "#!/bin/sh\nprintf 'real:%s\\n' \"$@\"\n").unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap(); + path + } + + #[test] + fn resolver_searches_common_dirs_when_the_gui_path_is_minimal() { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().join("home"); + let wrapper_dir = home.join(".gemini/antigravity-cli/bin"); + let gui_path = std::ffi::OsString::from("/usr/bin:/bin:/usr/sbin:/sbin"); + let brew = tmp.path().join("brew"); + + // Nothing installed: no fabricated ~/.local/bin/agy fallback. + assert_eq!( + resolve_real_agy( + Some(&wrapper_dir), + &gui_path, + &home, + std::slice::from_ref(&brew), + false + ), + None + ); + // Homebrew-style location. + let found = fake_agy(&brew); + assert_eq!( + resolve_real_agy( + Some(&wrapper_dir), + &gui_path, + &home, + std::slice::from_ref(&brew), + false + ), + Some(found) + ); + // ~/.local/bin wins over system dirs. + let local = fake_agy(&home.join(".local/bin")); + assert_eq!( + resolve_real_agy(Some(&wrapper_dir), &gui_path, &home, &[brew], false), + Some(local) + ); + } + + #[test] + fn resolver_skips_our_wrapper_and_non_executables() { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().join("home"); + let wrapper_dir = home.join(".gemini/antigravity-cli/bin"); + fs::create_dir_all(&wrapper_dir).unwrap(); + let wrapper = wrapper_dir.join("agy"); + fs::write(&wrapper, format!("#!/bin/sh\n# {WRAPPER_MARK}\n")).unwrap(); + fs::set_permissions(&wrapper, fs::Permissions::from_mode(0o755)).unwrap(); + // A marked wrapper elsewhere on PATH is not the real binary either. + let other = tmp.path().join("other"); + fs::create_dir_all(&other).unwrap(); + fs::write(other.join("agy"), format!("# {WRAPPER_MARK}\n")).unwrap(); + fs::set_permissions(other.join("agy"), fs::Permissions::from_mode(0o755)).unwrap(); + // A non-executable file is ignored. + let plain = tmp.path().join("plain"); + fs::create_dir_all(&plain).unwrap(); + fs::write(plain.join("agy"), "binary").unwrap(); + let real_dir = tmp.path().join("real"); + let real = fake_agy(&real_dir); + let path = std::env::join_paths([&wrapper_dir, &other, &plain, &real_dir]).unwrap(); + assert_eq!( + resolve_real_agy(Some(&wrapper_dir), &path, &home, &[], false), + Some(real) + ); + } + + #[test] + fn launcher_resolves_the_real_binary_at_run_time_without_recursing() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("agy-dir"); + install_wrapper(&root.join("bin/agy"), &AgyProjection::of(&json!({}))).unwrap(); + // Simulate an install done with a GUI PATH: nothing baked in. + let script = fs::read_to_string(root.join("bin/agy")).unwrap(); + let script = script + .lines() + .map(|line| { + if line.starts_with("DEFAULT_REAL=") { + "DEFAULT_REAL=''" + } else { + line + } + }) + .collect::>() + .join("\n"); + fs::write(root.join("bin/agy"), script).unwrap(); + let real_dir = tmp.path().join("real"); + fake_agy(&real_dir); + // The wrapper's own directory comes first on PATH: it must not exec itself. + let path = std::env::join_paths([ + root.join("bin"), + real_dir, + PathBuf::from("/usr/bin"), + PathBuf::from("/bin"), + ]) + .unwrap(); + let output = Command::new(root.join("bin/agy")) + .arg("hello") + .env("PATH", path) + .env("HOME", tmp.path()) + .env_remove("AGY_REAL_BIN") + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + assert_eq!(String::from_utf8_lossy(&output.stdout), "real:hello\n"); + } +} diff --git a/src-tauri/src/agy_smoke_tests.rs b/src-tauri/src/agy_smoke_tests.rs new file mode 100644 index 000000000..7fbb7797c --- /dev/null +++ b/src-tauri/src/agy_smoke_tests.rs @@ -0,0 +1,225 @@ +//! Explicit opt-in native smoke test. Never reads the real database itself. +use crate::{agy_config, app_config::AppType, database::Database, provider::Provider}; +use serde_json::{json, Value}; +use std::{fs, path::Path, sync::Arc}; + +struct RestoreHome(Option); +impl Drop for RestoreHome { + fn drop(&mut self) { + match self.0.take() { + Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value), + None => std::env::remove_var("CC_SWITCH_TEST_HOME"), + } + let _ = crate::settings::reload_settings(); + } +} + +async fn native(bin: &Path, home: &Path, real: &str, args: &[&str]) -> std::process::Output { + let mut cmd = tokio::process::Command::new(bin); + cmd.args(args) + .current_dir(home.join("workspace")) + .env("HOME", home) + .env("AGY_REAL_BIN", real) + .env_remove("GEMINI_API_KEY") + .env_remove("GOOGLE_GEMINI_BASE_URL") + .kill_on_drop(true); + tokio::time::timeout(std::time::Duration::from_secs(100), cmd.output()) + .await + .expect("native agy timed out") + .expect("launch native agy") +} + +#[tokio::test] +#[ignore = "requires CCS_AGY_SMOKE_CONFIG, CCS_AGY_SMOKE_BIN and explicit paid API authorization"] +#[serial_test::serial] +async fn agy_live_provider_skill_session_and_tokens() { + let config = std::env::var("CCS_AGY_SMOKE_CONFIG").expect("explicit fixture file required"); + let real = std::env::var("CCS_AGY_SMOKE_BIN").expect("explicit native binary required"); + let settings: Value = serde_json::from_slice(&fs::read(config).unwrap()).unwrap(); + let temp = tempfile::tempdir().unwrap(); + let home = temp.path(); + let _restore = RestoreHome(std::env::var_os("CC_SWITCH_TEST_HOME")); + std::env::set_var("CC_SWITCH_TEST_HOME", home); + crate::settings::reload_settings().unwrap(); + fs::create_dir_all(home.join("workspace")).unwrap(); + let db = Arc::new(Database::memory().unwrap()); + let row = Provider::with_id("smoke".into(), "Smoke".into(), settings.clone(), None); + db.save_provider("agy", &row).unwrap(); + crate::services::provider::agy_direct::switch_to(&db, &row).unwrap(); + assert_eq!( + crate::mode::current::provider_for( + &db, + &AppType::Agy, + crate::mode::current::Purpose::Direct + ) + .unwrap() + .as_deref(), + Some("smoke") + ); + let bin = agy_config::agy_dir().join("bin/agy"); + + let key = settings["env"]["GEMINI_API_KEY"].as_str().unwrap(); + let base = settings["env"]["GOOGLE_GEMINI_BASE_URL"] + .as_str() + .filter(|v| !v.is_empty()) + .unwrap_or("https://generativelanguage.googleapis.com"); + let models = crate::services::model_fetch::fetch_models( + base, + key, + false, + None, + None, + Some("google-generative-ai"), + None, + ) + .await + .expect("CCS model discovery"); + let live = agy_config::read_live_settings().unwrap(); + let model = live["config"]["model"]["name"].as_str().unwrap(); + assert!( + models.iter().any(|m| m.id == model), + "configured model absent from API catalog" + ); + println!("SMOKE model_catalog={} selected={model}", models.len()); + + // Install through the existing import + toggle path, then let the real CLI load it. + let skill_dir = agy_config::customizations_dir().join("skills/ccs-smoke"); + fs::create_dir_all(&skill_dir).unwrap(); + fs::write(skill_dir.join("SKILL.md"), "---\nname: ccs-smoke\ndescription: An explicitly requested smoke test.\n---\nWhen invoked, respond with exactly CCS_AGY_SKILL_OK. Do not call tools or access files.\n").unwrap(); + let imported = crate::services::SkillService::import_from_apps( + &db, + vec![crate::services::skill::ImportSkillSelection { + directory: "ccs-smoke".into(), + apps: crate::app_config::SkillApps { + agy: true, + ..Default::default() + }, + }], + ) + .unwrap(); + assert_eq!(imported.len(), 1); + crate::services::SkillService::toggle_app(&db, &imported[0].id, &AppType::Agy, false).unwrap(); + assert!(!skill_dir.exists()); + crate::services::SkillService::toggle_app(&db, &imported[0].id, &AppType::Agy, true).unwrap(); + assert!(skill_dir.join("SKILL.md").is_file()); + println!("SMOKE skill_import_disable_enable=ok"); + + let server = home.join("smoke_mcp.py"); + let events = home.join("mcp-events.jsonl"); + fs::write(&server, r#"import json,sys +for line in sys.stdin: + request=json.loads(line) + method=request.get('method','') + with open(sys.argv[1],'a') as log: + log.write(json.dumps({'method':method})+'\n') + if 'id' not in request: continue + if method=='initialize': + result={'protocolVersion':request['params']['protocolVersion'],'capabilities':{'tools':{}},'serverInfo':{'name':'ccs-smoke','version':'1'}} + elif method=='tools/list': + result={'tools':[{'name':'echo','description':'Return a fixed smoke test marker','inputSchema':{'type':'object','properties':{}},'annotations':{'readOnlyHint':True}}]} + elif method=='tools/call': + result={'content':[{'type':'text','text':'CCS_MCP_OK'}]} + elif method in ('resources/list','prompts/list'): + result={method.split('/')[0]:[]} + else: result={} + print(json.dumps({'jsonrpc':'2.0','id':request['id'],'result':result}),flush=True) +"#).unwrap(); + let spec = json!({"command":"python3","args":[server, events]}); + crate::mcp::agy::sync("ccs-smoke", Some(&spec)).unwrap(); + let list = native(&bin, home, &real, &["mcp", "list"]).await; + assert!(list.status.success()); + assert!(String::from_utf8_lossy(&list.stdout).contains("ccs-smoke")); + crate::mcp::agy::sync("ccs-smoke", None).unwrap(); + println!("SMOKE native_mcp_recognized_and_disabled=ok"); + crate::mcp::agy::sync("ccs-smoke", Some(&spec)).unwrap(); + + // Headless agy denies Skill reads unless explicitly allowed. Limit the test grant + // to this fixture (including its SSOT target), never to the real home directory. + let skill_file = skill_dir.join("SKILL.md"); + let mut native_settings: Value = + serde_json::from_slice(&fs::read(agy_config::settings_path()).unwrap()).unwrap(); + native_settings["permissions"] = json!({"allow":[ + format!("read_file({})", skill_file.display()), + format!("read_file({})", skill_file.canonicalize().unwrap().display()) + ]}); + fs::write( + agy_config::settings_path(), + serde_json::to_vec(&native_settings).unwrap(), + ) + .unwrap(); + + let output = native( + &bin, + home, + &real, + &[ + "--print", + "/ccs-smoke", + "--output-format", + "json", + "--print-timeout", + "75s", + ], + ) + .await; + // No raw stdout/stderr in failures: a third-party CLI may echo credentials. + let stdout = String::from_utf8_lossy(&output.stdout); + if !stdout.contains("CCS_AGY_SKILL_OK") { + println!( + "SMOKE diagnostic={}", + stdout + .replace(key, "[redacted]") + .chars() + .take(3000) + .collect::() + ); + println!( + "SMOKE stderr={}", + String::from_utf8_lossy(&output.stderr) + .replace(key, "[redacted]") + .chars() + .take(1500) + .collect::() + ); + } + assert!( + output.status.success(), + "native request failed (output suppressed)" + ); + assert!( + stdout.contains("CCS_AGY_SKILL_OK"), + "skill response marker missing (output suppressed)" + ); + println!("SMOKE native_generation_and_skill=CCS_AGY_SKILL_OK"); + let response: Value = serde_json::from_str(&stdout).expect("native JSON response"); + println!("SMOKE native_usage={}", response["usage"]); + let mcp_events = fs::read_to_string(&events).expect("native MCP was started"); + assert!(mcp_events.contains("initialize")); + assert!(mcp_events.contains("tools/list")); + println!("SMOKE native_mcp_handshake_and_tool_discovery=ok"); + + let sessions = crate::session_manager::providers::agy::scan_sessions(); + assert!(!sessions.is_empty()); + let mut preview_found = false; + for session in &sessions { + if let Some(path) = &session.source_path { + let messages = + crate::session_manager::providers::agy::load_messages(Path::new(path)).unwrap(); + preview_found |= serde_json::to_string(&messages) + .unwrap() + .contains("CCS_AGY_SKILL_OK"); + } + } + assert!( + preview_found, + "native generated reply absent from CCS preview" + ); + let first = crate::services::session_usage_agy::sync_agy_usage(&db).unwrap(); + let second = crate::services::session_usage_agy::sync_agy_usage(&db).unwrap(); + assert!(first.imported > 0); + assert_eq!(second.imported, 0); + println!( + "SMOKE session_preview=ok imported={} repeated_import=0", + first.imported + ); +} diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index 59d7b5603..6ec6e8c37 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -10,6 +10,8 @@ pub struct McpApps { #[serde(default)] pub mcode: bool, #[serde(default)] + pub agy: bool, + #[serde(default)] pub claude: bool, #[serde(default)] pub codex: bool, @@ -39,6 +41,7 @@ impl McpApps { AppType::Mcode => self.mcode, AppType::Pi => self.pi, AppType::ClaudeDesktop => false, + AppType::Agy => self.agy, } } @@ -54,7 +57,8 @@ impl McpApps { AppType::Hermes => self.hermes = enabled, AppType::Mcode => self.mcode = enabled, AppType::Pi => self.pi = enabled, - AppType::ClaudeDesktop => {} // Claude Desktop 3P provider config doesn't support MCP here + AppType::ClaudeDesktop => {} + AppType::Agy => self.agy = enabled, } } @@ -76,6 +80,9 @@ impl McpApps { if self.opencode { apps.push(AppType::OpenCode); } + if self.agy { + apps.push(AppType::Agy); + } if self.mcode { apps.push(AppType::Mcode); } @@ -96,6 +103,7 @@ impl McpApps { && !self.grokbuild && !self.opencode && !self.hermes + && !self.agy && !self.mcode && !self.pi } @@ -107,6 +115,8 @@ pub struct SkillApps { #[serde(default)] pub mcode: bool, #[serde(default)] + pub agy: bool, + #[serde(default)] pub claude: bool, #[serde(default)] pub codex: bool, @@ -134,7 +144,8 @@ impl SkillApps { AppType::Hermes => self.hermes, AppType::Pi => self.pi, AppType::Mcode => self.mcode, - AppType::OpenClaw => false, // OpenClaw doesn't support Skills + AppType::OpenClaw => false, + AppType::Agy => self.agy, AppType::ClaudeDesktop => false, } } @@ -150,7 +161,8 @@ impl SkillApps { AppType::Hermes => self.hermes = enabled, AppType::Pi => self.pi = enabled, AppType::Mcode => self.mcode = enabled, - AppType::OpenClaw => {} // OpenClaw doesn't support Skills, ignore + AppType::OpenClaw => {} + AppType::Agy => self.agy = enabled, AppType::ClaudeDesktop => {} // Claude Desktop 3P profiles don't use CC Switch skill sync } } @@ -173,6 +185,9 @@ impl SkillApps { if self.opencode { apps.push(AppType::OpenCode); } + if self.agy { + apps.push(AppType::Agy); + } if self.mcode { apps.push(AppType::Mcode); } @@ -193,6 +208,7 @@ impl SkillApps { && !self.grokbuild && !self.opencode && !self.hermes + && !self.agy && !self.mcode && !self.pi } @@ -415,6 +431,7 @@ pub enum AppType { Hermes, Pi, Mcode, + Agy, } impl AppType { @@ -430,6 +447,7 @@ impl AppType { AppType::Hermes => "hermes", AppType::Pi => "pi", AppType::Mcode => "mcode", + AppType::Agy => "agy", } } @@ -472,6 +490,7 @@ impl AppType { AppType::Hermes, AppType::Pi, AppType::Mcode, + AppType::Agy, ] .into_iter() } @@ -493,10 +512,11 @@ impl FromStr for AppType { "hermes" => Ok(AppType::Hermes), "pi" => Ok(AppType::Pi), "mcode" => Ok(AppType::Mcode), + "agy" | "antigravity" | "antigravity-cli" => Ok(AppType::Agy), other => Err(AppError::localized( "unsupported_app", - format!("不支持的应用标识: '{other}'。可选值: claude, claude-desktop, codex, gemini, grokbuild, opencode, openclaw, hermes, pi。"), - format!("Unsupported app id: '{other}'. Allowed: claude, claude-desktop, codex, gemini, grokbuild, opencode, openclaw, hermes, pi."), + format!("不支持的应用标识: '{other}'。可选值: claude, claude-desktop, codex, gemini, grokbuild, opencode, openclaw, hermes, pi, mcode, agy。"), + format!("Unsupported app id: '{other}'. Allowed: claude, claude-desktop, codex, gemini, grokbuild, opencode, openclaw, hermes, pi, mcode, agy."), )), } } @@ -536,7 +556,7 @@ impl CommonConfigSnippets { AppType::OpenCode => self.opencode.as_ref(), AppType::OpenClaw => self.openclaw.as_ref(), AppType::Hermes => self.hermes.as_ref(), - AppType::Pi | AppType::Mcode => None, + AppType::Pi | AppType::Mcode | AppType::Agy => None, } } @@ -551,7 +571,7 @@ impl CommonConfigSnippets { AppType::OpenCode => self.opencode = snippet, AppType::OpenClaw => self.openclaw = snippet, AppType::Hermes => self.hermes = snippet, - AppType::Pi | AppType::Mcode => {} + AppType::Pi | AppType::Mcode | AppType::Agy => {} } } } @@ -596,6 +616,7 @@ impl Default for MultiAppConfig { apps.insert("opencode".to_string(), ProviderManager::default()); apps.insert("openclaw".to_string(), ProviderManager::default()); apps.insert("hermes".to_string(), ProviderManager::default()); + apps.insert("agy".to_string(), ProviderManager::default()); Self { version: 2, @@ -877,7 +898,7 @@ impl MultiAppConfig { AppType::Hermes => &mut config.prompts.hermes.prompts, // Pi was added after prompts moved to SQLite. Keeping it out of // this legacy config avoids a second, unused prompt state. - AppType::Pi | AppType::Mcode => return Ok(false), + AppType::Pi | AppType::Mcode | AppType::Agy => return Ok(false), }; prompts.insert(id, prompt); @@ -921,7 +942,7 @@ impl MultiAppConfig { AppType::OpenCode => &self.mcp.opencode.servers, AppType::OpenClaw => continue, // OpenClaw MCP is still in development, skip AppType::Hermes => continue, // Hermes didn't exist in v3.6.x, skip - AppType::Pi | AppType::Mcode => continue, // Pi didn't exist in v3.6.x, skip + AppType::Pi | AppType::Mcode | AppType::Agy => continue, // Pi didn't exist in v3.6.x, skip }; for (id, entry) in old_servers { diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index 3d616bfd8..4b4719330 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -156,6 +156,13 @@ pub async fn get_config_status( path, }) } + AppType::Agy => { + let dir = crate::agy_config::agy_dir(); + Ok(ConfigStatus { + exists: crate::agy_config::settings_path().exists(), + path: dir.to_string_lossy().into_owned(), + }) + } } } @@ -182,6 +189,7 @@ pub async fn get_config_dir(app: String) -> Result { .parent() .unwrap() .to_path_buf(), + AppType::Agy => crate::agy_config::agy_dir(), }; Ok(dir.to_string_lossy().to_string()) @@ -205,6 +213,7 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result crate::agy_config::agy_dir(), }; if !config_dir.exists() { diff --git a/src-tauri/src/commands/model_fetch.rs b/src-tauri/src/commands/model_fetch.rs index 3fe2d06eb..9eabacce3 100644 --- a/src-tauri/src/commands/model_fetch.rs +++ b/src-tauri/src/commands/model_fetch.rs @@ -13,6 +13,68 @@ pub struct OpenCodeModelRef { pub model_id: String, } +/// Read the current runtime's model list. No shell and no credential values in errors. +#[tauri::command] +pub async fn get_agy_models() -> Result, String> { + let settings = crate::agy_config::read_live_settings().map_err(|e| e.to_string())?; + let path = tokio::task::spawn_blocking(|| { + crate::agy_config::real_agy_bin(&crate::agy_config::agy_dir().join("bin/agy")) + }) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "Antigravity CLI not found; install agy first".to_string())?; + let mut command = tokio::process::Command::new(path); + command + .arg("models") + .kill_on_drop(true) + .env_remove("GEMINI_API_KEY") + .env_remove("GOOGLE_GEMINI_BASE_URL"); + if let Some(env) = settings["env"].as_object() { + for name in ["GEMINI_API_KEY", "GOOGLE_GEMINI_BASE_URL"] { + if let Some(value) = env.get(name).and_then(serde_json::Value::as_str) { + command.env(name, value); + } + } + } + let output = tokio::time::timeout(std::time::Duration::from_secs(20), command.output()) + .await + .map_err(|_| "Antigravity model discovery timed out".to_string())? + .map_err(|_| "Antigravity CLI not found; install agy first".to_string())?; + if !output.status.success() { + return Err("Antigravity could not list models. Check the active provider and sign in with agy for official models.".into()); + } + Ok(parse_agy_models(&String::from_utf8_lossy(&output.stdout))) +} + +fn parse_agy_models(output: &str) -> Vec { + let mut names = BTreeMap::new(); + for line in output.lines() { + let Some((id, name)) = line.trim().split_once('\t') else { + continue; + }; + let name = name.trim(); + if id.is_empty() + || !id + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/' | ':')) + || name.is_empty() + || name.chars().any(char::is_control) + { + continue; + } + names + .entry(id.to_string()) + .or_insert_with(|| name.to_string()); + } + // Native settings.json stores the display name, unlike the --model flag + // which accepts the first-column CLI alias. Persisting that alias silently + // falls back to the runtime's default model in account-login mode. + names + .into_values() + .map(|id| FetchedModel { id, owned_by: None }) + .collect() +} + const OPENCODE_MODELS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20); /// 获取 OpenCode 当前运行时可用的模型。 @@ -148,3 +210,24 @@ mod tests { .is_empty()); } } + +#[cfg(test)] +mod agy_tests { + #[test] + fn agy_model_list_returns_native_setting_names_and_deduplicates_aliases() { + let models = super::parse_agy_models("Fetching available models...\ngemini-test\tGemini Test\nclaude-test\tClaude Test\ngemini-test\tDuplicate\n"); + assert_eq!( + models.iter().map(|m| m.id.as_str()).collect::>(), + ["Claude Test", "Gemini Test"] + ); + } + + #[test] + fn agy_model_list_skips_malformed_rows_and_empty_or_control_character_names() { + let models = super::parse_agy_models( + "notice\n\tNo alias\nbad alias\tName\nempty\t\nspaces\t \ncontrol\tBad\u{1b}Name\nextra\tName\tColumn\nvalid\t Gemini Test \n", + ); + assert_eq!(models.len(), 1); + assert_eq!(models[0].id, "Gemini Test"); + } +} diff --git a/src-tauri/src/database/dao/mcp.rs b/src-tauri/src/database/dao/mcp.rs index 869bb3ad0..fe8ad017b 100644 --- a/src-tauri/src/database/dao/mcp.rs +++ b/src-tauri/src/database/dao/mcp.rs @@ -9,7 +9,7 @@ use indexmap::IndexMap; use rusqlite::{params, OptionalExtension, Row}; const MCP_SERVER_SELECT: &str = - "SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, enabled_opencode, enabled_hermes, enabled_mcode, enabled_pi FROM mcp_servers"; + "SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, enabled_opencode, enabled_hermes, enabled_mcode, enabled_pi, enabled_agy FROM mcp_servers"; fn row_to_mcp_server(row: &Row<'_>) -> rusqlite::Result<(String, McpServer)> { let id: String = row.get(0)?; @@ -44,6 +44,7 @@ fn row_to_mcp_server(row: &Row<'_>) -> rusqlite::Result<(String, McpServer)> { hermes: enabled_hermes, mcode: row.get(13)?, pi: row.get(14)?, + agy: row.get(15)?, }, description, homepage, @@ -96,6 +97,7 @@ impl Database { AppType::Mcode => Some("enabled_mcode"), AppType::Pi => Some("enabled_pi"), AppType::ClaudeDesktop | AppType::OpenClaw => None, + AppType::Agy => Some("enabled_agy"), }; if let Some(column) = column { @@ -124,8 +126,8 @@ impl Database { conn.execute( "INSERT OR REPLACE INTO mcp_servers ( id, name, server_config, description, homepage, docs, tags, - enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, enabled_opencode, enabled_hermes, enabled_mcode, enabled_pi - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)", + enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, enabled_opencode, enabled_hermes, enabled_mcode, enabled_pi, enabled_agy + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)", params![ server.id, server.name, @@ -145,6 +147,7 @@ impl Database { server.apps.hermes, server.apps.mcode, server.apps.pi, + server.apps.agy, ], ) .map_err(|e| AppError::Database(e.to_string()))?; diff --git a/src-tauri/src/database/dao/skills.rs b/src-tauri/src/database/dao/skills.rs index 9c14f21f6..89dfb19fb 100644 --- a/src-tauri/src/database/dao/skills.rs +++ b/src-tauri/src/database/dao/skills.rs @@ -23,7 +23,7 @@ impl Database { .prepare( "SELECT id, name, description, directory, repo_owner, repo_name, repo_branch, readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, - enabled_opencode, enabled_hermes, installed_at, content_hash, updated_at, enabled_mcode + enabled_opencode, enabled_hermes, installed_at, content_hash, updated_at, enabled_mcode, enabled_agy FROM skills ORDER BY name ASC", ) .map_err(|e| AppError::Database(e.to_string()))?; @@ -48,6 +48,7 @@ impl Database { hermes: row.get(13)?, pi: false, mcode: row.get(17)?, + agy: row.get(18)?, }, installed_at: row.get(14)?, content_hash: row.get(15)?, @@ -71,7 +72,7 @@ impl Database { .prepare( "SELECT id, name, description, directory, repo_owner, repo_name, repo_branch, readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, - enabled_opencode, enabled_hermes, installed_at, content_hash, updated_at, enabled_mcode + enabled_opencode, enabled_hermes, installed_at, content_hash, updated_at, enabled_mcode, enabled_agy FROM skills WHERE id = ?1", ) .map_err(|e| AppError::Database(e.to_string()))?; @@ -95,6 +96,7 @@ impl Database { hermes: row.get(13)?, pi: false, mcode: row.get(17)?, + agy: row.get(18)?, }, installed_at: row.get(14)?, content_hash: row.get(15)?, @@ -116,8 +118,8 @@ impl Database { "INSERT OR REPLACE INTO skills (id, name, description, directory, repo_owner, repo_name, repo_branch, readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, enabled_opencode, enabled_hermes, - installed_at, content_hash, updated_at, enabled_mcode) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)", + installed_at, content_hash, updated_at, enabled_mcode, enabled_agy) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)", params![ skill.id, skill.name, @@ -137,6 +139,7 @@ impl Database { skill.content_hash, skill.updated_at, skill.apps.mcode, + skill.apps.agy, ], ) .map_err(|e| AppError::Database(e.to_string()))?; @@ -205,8 +208,8 @@ impl Database { let conn = lock_conn!(self.conn); let affected = conn .execute( - "UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_grokbuild = ?4, enabled_opencode = ?5, enabled_hermes = ?6, enabled_mcode = ?8 WHERE id = ?7", - params![apps.claude, apps.codex, apps.gemini, apps.grokbuild, apps.opencode, apps.hermes, id, apps.mcode], + "UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_grokbuild = ?4, enabled_opencode = ?5, enabled_hermes = ?6, enabled_mcode = ?8, enabled_agy = ?9 WHERE id = ?7", + params![apps.claude, apps.codex, apps.gemini, apps.grokbuild, apps.opencode, apps.hermes, id, apps.mcode, apps.agy], ) .map_err(|e| AppError::Database(e.to_string()))?; Ok(affected > 0) diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index e00598dcc..314293be6 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -51,7 +51,7 @@ use std::sync::Mutex; /// 当前 Schema 版本号 /// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑 -pub(crate) const SCHEMA_VERSION: i32 = 20; +pub(crate) const SCHEMA_VERSION: i32 = 21; /// 安全地序列化 JSON,避免 unwrap panic pub(crate) fn to_json_string(value: &T) -> Result { diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index ea6ddfcc8..88439454c 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -68,6 +68,7 @@ impl Database { enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_grokbuild BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0, enabled_mcode BOOLEAN NOT NULL DEFAULT 0, + enabled_agy BOOLEAN NOT NULL DEFAULT 0, enabled_hermes BOOLEAN NOT NULL DEFAULT 0, enabled_pi BOOLEAN NOT NULL DEFAULT 0 )", @@ -99,6 +100,7 @@ impl Database { enabled_grokbuild BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0, enabled_mcode BOOLEAN NOT NULL DEFAULT 0, + enabled_agy BOOLEAN NOT NULL DEFAULT 0, enabled_hermes BOOLEAN NOT NULL DEFAULT 0, installed_at INTEGER NOT NULL DEFAULT 0, content_hash TEXT, @@ -577,6 +579,19 @@ impl Database { } Self::set_user_version(conn, 20)?; } + 20 => { + for table in ["mcp_servers", "skills"] { + if Self::table_exists(conn, table)? { + Self::add_column_if_missing( + conn, + table, + "enabled_agy", + "BOOLEAN NOT NULL DEFAULT 0", + )?; + } + } + Self::set_user_version(conn, 21)?; + } _ => { return Err(AppError::Database(format!( "未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}" @@ -3880,6 +3895,48 @@ mod tests { Ok(()) } + #[test] + fn agy_migration_preserves_existing_flags_and_defaults_to_disabled() -> Result<(), AppError> { + let conn = Connection::open_in_memory()?; + conn.execute_batch("CREATE TABLE mcp_servers(id TEXT PRIMARY KEY, enabled_gemini BOOLEAN); CREATE TABLE skills(id TEXT PRIMARY KEY, enabled_codex BOOLEAN); INSERT INTO mcp_servers VALUES ('m',1); INSERT INTO skills VALUES ('s',1);")?; + Database::set_user_version(&conn, 20)?; + Database::apply_schema_migrations_on_conn(&conn)?; + assert_eq!(Database::get_user_version(&conn)?, 21); + for (table, flag) in [ + ("mcp_servers", "enabled_gemini"), + ("skills", "enabled_codex"), + ] { + let row = conn.query_row( + &format!("SELECT {flag}, enabled_agy FROM {table}"), + [], + |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)), + )?; + assert_eq!(row, (1, 0)); + } + Database::apply_schema_migrations_on_conn(&conn)?; + Ok(()) + } + + #[test] + fn agy_clean_database_persists_independent_mcp_and_skill_flags() -> Result<(), AppError> { + let db = Database::memory()?; + let mut server: crate::app_config::McpServer = serde_json::from_value(serde_json::json!({"id":"m", "name":"m", "server":{"command":"echo"}, "apps":{"gemini":true,"agy":true}})).unwrap(); + db.save_mcp_server(&server)?; + server.apps.agy = false; + db.update_mcp_server_app_enabled("m", &crate::app_config::AppType::Agy, false)?; + assert_eq!(db.get_all_mcp_servers()?["m"].apps, server.apps); + let skill: crate::app_config::InstalledSkill = serde_json::from_value(serde_json::json!({"id":"s", "name":"s", "directory":"s", "installedAt":0, "apps":{"agy":true,"gemini":true}})).unwrap(); + db.save_skill(&skill)?; + assert!(db.get_installed_skill("s")?.unwrap().apps.agy); + let mut apps = skill.apps; + apps.agy = false; + db.update_skill_apps("s", &apps)?; + let read = db.get_all_installed_skills()?; + assert!(!read["s"].apps.agy); + assert!(read["s"].apps.gemini); + Ok(()) + } + #[test] fn migrate_v19_to_v20_adds_pi_mcp_flag_and_keeps_existing_flags() -> Result<(), AppError> { let conn = Connection::open_in_memory()?; diff --git a/src-tauri/src/deeplink/mcp.rs b/src-tauri/src/deeplink/mcp.rs index 2b1005a8e..6290056cc 100644 --- a/src-tauri/src/deeplink/mcp.rs +++ b/src-tauri/src/deeplink/mcp.rs @@ -153,6 +153,7 @@ pub fn import_mcp_from_deeplink( /// Parse apps string into McpApps struct pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result { let mut apps = McpApps { + agy: false, claude: false, codex: false, gemini: false, @@ -168,6 +169,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result { "claude" => apps.claude = true, "codex" => apps.codex = true, "gemini" => apps.gemini = true, + "agy" => apps.agy = true, "grokbuild" | "grok" => apps.grokbuild = true, "opencode" => apps.opencode = true, "openclaw" => { diff --git a/src-tauri/src/deeplink/mod.rs b/src-tauri/src/deeplink/mod.rs index 2f84d5a7b..d1c1659b7 100644 --- a/src-tauri/src/deeplink/mod.rs +++ b/src-tauri/src/deeplink/mod.rs @@ -96,7 +96,7 @@ pub struct DeepLinkImportRequest { pub resource: String, // ============ Common fields ============ - /// Target application (claude/codex/gemini) - for provider, prompt, skill + /// Target application (e.g. claude/codex/gemini/agy) - for provider and prompt #[serde(skip_serializing_if = "Option::is_none")] pub app: Option, /// Resource name @@ -153,7 +153,7 @@ pub struct DeepLinkImportRequest { pub description: Option, // ============ MCP-specific fields ============ - /// Target applications for MCP (comma-separated: "claude,codex,gemini") + /// Target applications for MCP (comma-separated: "claude,codex,gemini,agy") #[serde(skip_serializing_if = "Option::is_none")] pub apps: Option, diff --git a/src-tauri/src/deeplink/parser.rs b/src-tauri/src/deeplink/parser.rs index 9080e5b2b..ffb611bf3 100644 --- a/src-tauri/src/deeplink/parser.rs +++ b/src-tauri/src/deeplink/parser.rs @@ -91,7 +91,15 @@ fn parse_provider_deeplink( // Validate app type if !matches!( app.as_str(), - "claude" | "codex" | "gemini" | "grokbuild" | "opencode" | "openclaw" | "hermes" | "pi" + "agy" + | "claude" + | "codex" + | "gemini" + | "grokbuild" + | "opencode" + | "openclaw" + | "hermes" + | "pi" ) { return Err(AppError::InvalidInput(format!( "Invalid provider app type: '{app}'" @@ -241,6 +249,7 @@ fn parse_prompt_deeplink( "claude" | "codex" | "gemini" + | "agy" | "grokbuild" | "opencode" | "openclaw" @@ -249,7 +258,7 @@ fn parse_prompt_deeplink( | "mcode" ) { return Err(AppError::InvalidInput(format!( - "Invalid app type: must be 'claude', 'codex', 'gemini', 'grokbuild', 'opencode', 'openclaw', 'hermes', 'pi', or 'mcode', got '{app}'" + "Invalid app type: must be 'claude', 'codex', 'gemini', 'agy', 'grokbuild', 'opencode', 'openclaw', 'hermes', 'pi', or 'mcode', got '{app}'" ))); } @@ -323,6 +332,7 @@ fn parse_mcp_deeplink( "claude" | "codex" | "gemini" + | "agy" | "grokbuild" | "grok" | "opencode" @@ -332,7 +342,7 @@ fn parse_mcp_deeplink( | "mcode" ) { return Err(AppError::InvalidInput(format!( - "Invalid app in 'apps': must be 'claude', 'codex', 'gemini', 'grokbuild', 'opencode', 'hermes', 'pi', or 'mcode', got '{trimmed}'" + "Invalid app in 'apps': must be 'claude', 'codex', 'gemini', 'agy', 'grokbuild', 'opencode', 'hermes', 'pi', or 'mcode', got '{trimmed}'" ))); } } @@ -408,7 +418,7 @@ fn parse_skill_deeplink( directory, branch, icon: None, - app: Some("claude".to_string()), // Skills are Claude-only + app: None, // Skill links add a shared repository; app installation is a separate action. name: None, enabled: None, homepage: None, diff --git a/src-tauri/src/deeplink/provider.rs b/src-tauri/src/deeplink/provider.rs index 7f365498f..44f03c59c 100644 --- a/src-tauri/src/deeplink/provider.rs +++ b/src-tauri/src/deeplink/provider.rs @@ -208,7 +208,7 @@ impl ApiFormat { } (AppType::Claude, GeminiNative) => Some("gemini_native"), (AppType::Codex | AppType::GrokBuild, Anthropic) => Some("anthropic"), - (AppType::Gemini, GeminiNative) => Some("gemini_native"), + (AppType::Gemini | AppType::Agy, GeminiNative) => Some("gemini_native"), (AppType::OpenCode, Anthropic) => Some("@ai-sdk/anthropic"), (AppType::OpenCode, OpenAiChat) => Some("@ai-sdk/openai-compatible"), (AppType::OpenCode, OpenAiResponses) => Some("@ai-sdk/openai"), @@ -229,7 +229,7 @@ impl ApiFormat { match app_type { AppType::Claude | AppType::ClaudeDesktop => Self::Anthropic, AppType::Codex | AppType::GrokBuild => Self::OpenAiResponses, - AppType::Gemini => Self::GeminiNative, + AppType::Gemini | AppType::Agy => Self::GeminiNative, _ => Self::OpenAiChat, } } @@ -345,6 +345,17 @@ pub(crate) fn build_provider_from_request( "Add MCode providers from the MCode page".into(), )) } + AppType::Agy => { + let mut settings = build_gemini_settings(request); + settings["env"] + .as_object_mut() + .unwrap() + .remove("GEMINI_MODEL"); + if let Some(model) = &request.model { + settings["config"] = json!({"model": {"name": model}}); + } + settings + } }; // Build usage script configuration if provided @@ -976,6 +987,7 @@ pub fn parse_and_merge_config( "claude" => merge_claude_config(&mut merged, &config_value)?, "codex" => merge_codex_config(&mut merged, &config_value)?, "gemini" => merge_gemini_config(&mut merged, &config_value)?, + "agy" => merge_agy_config(&mut merged, &config_value)?, "grokbuild" => merge_grokbuild_config(&mut merged, &config_value)?, // Additive mode apps use JSON config directly; pass through as-is "openclaw" | "opencode" | "hermes" | "pi" => { @@ -1128,6 +1140,27 @@ fn merge_codex_config( Ok(()) } +/// Import only credentials, endpoint and model; arbitrary native settings are not applied. +fn merge_agy_config( + request: &mut DeepLinkImportRequest, + config: &serde_json::Value, +) -> Result<(), AppError> { + let env = config.get("env").unwrap_or(config); + if !env.is_object() { + return Err(AppError::InvalidInput( + "Antigravity config/env must be an object".into(), + )); + } + // Match the regular editor: URL model > config.model.name > GEMINI_MODEL. + if request.model.is_none() { + request.model = config + .pointer("/config/model/name") + .and_then(|value| value.as_str()) + .map(str::to_string); + } + merge_gemini_config(request, env) +} + /// Merge Gemini configuration from config file fn merge_gemini_config( request: &mut DeepLinkImportRequest, diff --git a/src-tauri/src/deeplink/tests.rs b/src-tauri/src/deeplink/tests.rs index 21cff1a23..f9d6ed953 100644 --- a/src-tauri/src/deeplink/tests.rs +++ b/src-tauri/src/deeplink/tests.rs @@ -2038,3 +2038,152 @@ fn test_deeplink_non_plan_and_zenmux_outside_claude_stay_off() { let request = coding_plan_request("claude", "https://zenmux.ai/api/anthropic"); assert!(imported_usage_script(AppType::Claude, &request).is_some()); } + +#[test] +fn agy_deeplink_provider_config_precedence_and_protocol() { + let config = BASE64_URL_SAFE_NO_PAD.encode(serde_json::json!({ + "env": {"GEMINI_API_KEY": "fixture", "GOOGLE_GEMINI_BASE_URL": "https://relay.example", "GEMINI_MODEL": "env-model", "NODE_OPTIONS": "untrusted"}, + "config": {"model": {"name": "native-model"}, "unrelated": true} + }).to_string()); + let url = format!("ccswitch://v1/import?resource=provider&app=agy&name=Test&config={config}&apiFormat=gemini_native"); + let request = parse_deeplink_url(&url).unwrap(); + let merged = parse_and_merge_config(&request).unwrap(); + assert_eq!(merged.model.as_deref(), Some("native-model")); + assert_eq!(merged.api_key.as_deref(), Some("fixture")); + let provider = super::provider::build_provider_from_request(&AppType::Agy, &merged).unwrap(); + assert_eq!( + provider.settings_config["config"]["model"]["name"], + "native-model" + ); + assert!(provider.settings_config["env"] + .get("NODE_OPTIONS") + .is_none()); + assert!(provider.settings_config["config"] + .get("unrelated") + .is_none()); + let explicit = parse_deeplink_url(&format!( + "{url}&model=url-model&apiKey=url-key&endpoint=https://explicit.example" + )) + .unwrap(); + let merged = parse_and_merge_config(&explicit).unwrap(); + assert_eq!(merged.model.as_deref(), Some("url-model")); + assert_eq!(merged.api_key.as_deref(), Some("url-key")); + assert_eq!(merged.endpoint.as_deref(), Some("https://explicit.example")); + for format in ["anthropic", "openai_chat", "openai_responses"] { + assert!(parse_deeplink_url(&format!( + "ccswitch://v1/import?resource=provider&app=agy&name=Test&apiFormat={format}" + )) + .is_err()); + } +} + +#[test] +fn agy_deeplink_accepts_flat_env_and_rejects_invalid_env() { + for (config, valid) in [ + ( + serde_json::json!({"GEMINI_API_KEY":"fixture", "GEMINI_BASE_URL":"https://relay.example", "GEMINI_MODEL":"flat-model"}), + true, + ), + (serde_json::json!({"env": []}), false), + ] { + let encoded = BASE64_URL_SAFE_NO_PAD.encode(config.to_string()); + let request = parse_deeplink_url(&format!( + "ccswitch://v1/import?resource=provider&app=agy&name=Test&config={encoded}" + )) + .unwrap(); + let result = parse_and_merge_config(&request); + assert_eq!(result.is_ok(), valid); + if valid { + let merged = result.unwrap(); + assert_eq!(merged.model.as_deref(), Some("flat-model")); + assert_eq!(merged.endpoint.as_deref(), Some("https://relay.example")); + } + } +} + +#[test] +#[serial_test::serial] +fn agy_deeplink_mcp_import_merges_apps_and_writes_native_config() { + let _home = TestHomeGuard::new(); + let db = Arc::new(Database::memory().unwrap()); + let state = AppState::new(db.clone()); + let config = BASE64_URL_SAFE_NO_PAD + .encode(r#"{"mcpServers":{"fixture":{"command":"echo","args":["fixture"]}}}"#); + // First import belongs to another app, then the same ID is enabled for agy. + for apps in ["codex", "agy"] { + let request = parse_deeplink_url(&format!( + "ccswitch://v1/import?resource=mcp&apps={apps}&config={config}" + )) + .unwrap(); + let result = super::mcp::import_mcp_from_deeplink(&state, request).unwrap(); + assert_eq!(result.imported_count, 1); + assert!(result.failed.is_empty()); + } + let rows = db.get_all_mcp_servers().unwrap(); + assert!(rows["fixture"].apps.agy && rows["fixture"].apps.codex); + let native: serde_json::Value = serde_json::from_slice( + &std::fs::read(crate::config::get_home_dir().join(".gemini/config/mcp_config.json")) + .unwrap(), + ) + .unwrap(); + assert_eq!(native["mcpServers"]["fixture"]["command"], "echo"); + assert!(parse_mcp_apps("agy,gemini").unwrap().agy); + assert!(parse_mcp_apps("agy,invalid").is_err()); +} + +#[test] +#[serial_test::serial] +fn agy_deeplink_prompt_import_respects_activation() { + let _home = TestHomeGuard::new(); + let state = AppState::new(Arc::new(Database::memory().unwrap())); + let content = BASE64_URL_SAFE_NO_PAD.encode("Agy instructions"); + let path = crate::prompt_files::prompt_file_path(&AppType::Agy).unwrap(); + for enabled in [false, true] { + let request = parse_deeplink_url(&format!("ccswitch://v1/import?resource=prompt&app=agy&name=Test-{enabled}&content={content}&enabled={enabled}")).unwrap(); + let id = import_prompt_from_deeplink(&state, request).unwrap(); + assert_eq!( + state.db.get_prompts("agy").unwrap()[&id].content, + "Agy instructions" + ); + assert_eq!(path.exists(), enabled); + } + assert_eq!(std::fs::read_to_string(path).unwrap(), "Agy instructions"); + assert!(!crate::gemini_config::get_gemini_dir() + .join("GEMINI.md") + .exists()); +} + +#[test] +#[cfg(unix)] +#[serial_test::serial] +fn agy_deeplink_enabled_provider_applies_native_model() { + let _home = TestHomeGuard::new(); + let state = AppState::new(Arc::new(Database::memory().unwrap())); + let request = parse_deeplink_url("ccswitch://v1/import?resource=provider&app=agy&name=Agy&endpoint=https://relay.example&apiKey=fixture&model=link-model&enabled=true").unwrap(); + let id = super::provider::import_provider_from_deeplink(&state, request).unwrap(); + let current = crate::mode::current::provider_for( + &state.db, + &AppType::Agy, + crate::mode::current::Purpose::Direct, + ) + .unwrap(); + assert_eq!(current.as_deref(), Some(id.as_str())); + let live = crate::agy_config::read_live_settings().unwrap(); + assert_eq!(live["config"]["model"]["name"], "link-model"); + assert_eq!(live["env"]["GEMINI_API_KEY"], "fixture"); + assert!(!crate::config::get_home_dir().join(".gemini/.env").exists()); +} + +#[test] +fn skill_deeplink_is_a_shared_repository_not_an_agy_install() { + let state = AppState::new(Arc::new(Database::memory().unwrap())); + let request = + parse_deeplink_url("ccswitch://v1/import?resource=skill&repo=example/skills&app=agy") + .unwrap(); + assert!(request.app.is_none()); + assert_eq!( + super::skill::import_skill_from_deeplink(&state, request).unwrap(), + "example/skills" + ); + assert!(state.db.get_all_installed_skills().unwrap().is_empty()); +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 84f960757..5b31b0e0a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,6 @@ +mod agy_config; +#[cfg(all(test, unix))] +mod agy_smoke_tests; mod app_config; mod app_store; mod auto_launch; @@ -1566,6 +1569,7 @@ pub fn run() { // Fetch OpenAI-compatible and Anthropic model lists. Response data structure: // data[].id, data[]?.owned_by. Special: supports Zhipu OpenAI Responses models[].slug. commands::fetch_models_for_config, + commands::get_agy_models, commands::get_opencode_models, // ours: endpoint speed test + custom endpoint management commands::test_api_endpoints, diff --git a/src-tauri/src/mcp/agy.rs b/src-tauri/src/mcp/agy.rs new file mode 100644 index 000000000..03ee3697c --- /dev/null +++ b/src-tauri/src/mcp/agy.rs @@ -0,0 +1,256 @@ +use crate::app_config::{McpApps, McpServer}; +use crate::config::atomic_write_private; +use crate::error::AppError; +use crate::store::AppState; +use serde_json::{json, Value}; +use std::{fs, path::Path, sync::Mutex}; + +static WRITE_LOCK: Mutex<()> = Mutex::new(()); +const TRANSPORT_FIELDS: [&str; 6] = ["command", "args", "env", "url", "headers", "type"]; + +fn read(path: &Path) -> Result { + let text = match fs::read_to_string(path) { + Ok(text) => text, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(json!({"mcpServers": {}})), + Err(e) => return Err(AppError::io(path, e)), + }; + let value: Value = serde_json::from_str(&text) + .map_err(|_| AppError::Config("Invalid Antigravity MCP JSON".into()))?; + if !value.is_object() || value.get("mcpServers").is_some_and(|v| !v.is_object()) { + return Err(AppError::Config( + "Invalid Antigravity mcpServers object".into(), + )); + } + Ok(value) +} + +#[derive(Clone, Copy)] +pub(crate) enum AgyChange<'a> { + Upsert(&'a Value), + Disable, + Remove, +} +impl<'a> AgyChange<'a> { + pub(crate) fn from_enabled(spec: Option<&'a Value>) -> Self { + spec.map_or(Self::Disable, Self::Upsert) + } +} + +pub fn sync(id: &str, spec: Option<&Value>) -> Result<(), AppError> { + let _guard = WRITE_LOCK + .lock() + .map_err(|e| AppError::Message(e.to_string()))?; + sync_file( + &crate::agy_config::customizations_dir().join("mcp_config.json"), + id, + AgyChange::from_enabled(spec), + ) +} + +pub(crate) fn sync_and_commit( + id: &str, + change: AgyChange<'_>, + commit: impl FnOnce() -> Result, +) -> Result { + let _guard = WRITE_LOCK + .lock() + .map_err(|e| AppError::Message(e.to_string()))?; + let path = crate::agy_config::customizations_dir().join("mcp_config.json"); + crate::mcode_config::write_and_commit(&path, || sync_file(&path, id, change), commit) +} + +fn sync_file(path: &Path, id: &str, change: AgyChange<'_>) -> Result<(), AppError> { + let mut document = read(path)?; + if document.get("mcpServers").is_none() { + document["mcpServers"] = json!({}); + } + let servers = document["mcpServers"].as_object_mut().unwrap(); + if let AgyChange::Upsert(spec) = change { + super::validation::validate_server_spec(&unified_spec(spec))?; + if spec["type"] == "sse" { + return Err(AppError::Config( + "Antigravity supports stdio and streamable HTTP, not SSE".into(), + )); + } + let mut merged = servers.get(id).cloned().unwrap_or_else(|| json!({})); + let object = merged + .as_object_mut() + .ok_or_else(|| AppError::Config("Invalid Antigravity MCP entry".into()))?; + for field in TRANSPORT_FIELDS { + object.remove(field); + } + object.extend(unified_spec(spec).as_object().unwrap().clone()); + object.remove("enabled"); + object.remove("disabled"); + object.remove("serverUrl"); + object.remove("type"); + object.insert("disabled".into(), json!(false)); + servers.insert(id.into(), merged); + } else if matches!(change, AgyChange::Remove) { + servers.remove(id); + } else { + if let Some(server) = servers.get_mut(id) { + let object = server + .as_object_mut() + .ok_or_else(|| AppError::Config("Invalid Antigravity MCP entry".into()))?; + object.insert("disabled".into(), json!(true)); + } + } + atomic_write_private( + path, + serde_json::to_string_pretty(&document) + .map_err(|e| AppError::Config(e.to_string()))? + .as_bytes(), + ) +} + +pub fn import(state: &AppState) -> Result { + let document = read(&crate::agy_config::customizations_dir().join("mcp_config.json"))?; + let mut existing = state.db.get_all_mcp_servers()?; + let mut count = 0; + let mut skipped = Vec::new(); + for (id, native) in document["mcpServers"].as_object().into_iter().flatten() { + let mut spec = unified_spec(native); + if super::validation::validate_server_spec(&spec).is_err() { + skipped.push(format!("'{id}': invalid transport configuration")); + continue; + } + let enabled = native + .get("disabled") + .and_then(Value::as_bool) + .map(|disabled| !disabled) + .unwrap_or(true); + spec.as_object_mut().unwrap().remove("disabled"); + let server = if let Some(mut server) = existing.shift_remove(id) { + if transport_spec(&server.server) != transport_spec(&spec) { + skipped.push(format!("'{id}': conflicts with an existing server")); + continue; + } + server.apps.agy = enabled; + server + } else { + count += 1; + McpServer { + id: id.clone(), + name: id.clone(), + server: spec, + apps: McpApps { + agy: enabled, + ..Default::default() + }, + description: None, + homepage: None, + docs: None, + tags: vec![], + } + }; + state.db.save_mcp_server(&server)?; + } + if skipped.is_empty() { + Ok(count) + } else { + Err(AppError::InvalidInput(format!( + "Imported {count} Antigravity MCP servers; skipped {}. Native configurations were preserved.", + skipped.join("; ") + ))) + } +} + +fn transport_spec(spec: &Value) -> Value { + let mut spec = unified_spec(spec); + if let Some(object) = spec.as_object_mut() { + object.retain(|key, _| TRANSPORT_FIELDS.contains(&key.as_str())); + } + spec +} + +fn unified_spec(native: &Value) -> Value { + let mut spec = native.clone(); + if let Some(url) = spec.get("serverUrl").cloned() { + if spec.get("url").is_none() { + spec["url"] = url; + } + spec.as_object_mut().unwrap().remove("serverUrl"); + } + if spec["type"] == "streamable-http" + || (spec.is_object() && spec.get("type").is_none() && spec.get("url").is_some()) + { + spec["type"] = json!("http"); + } else if spec.is_object() && spec.get("type").is_none() && spec.get("command").is_some() { + spec["type"] = json!("stdio"); + } + spec +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn agy_mcp_preserves_options_and_distinguishes_disable_from_remove() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("mcp_config.json"); + fs::write(&path, r#"{"extra":1,"mcpServers":{"unmanaged":{"command":"keep"},"managed":{"command":"old","args":["old"],"timeout":3000}}}"#).unwrap(); + sync_file(&path,"managed",AgyChange::Upsert(&json!({"type":"http","url":"https://example.test/mcp","headers":{"X-Test":"test"}}))).unwrap(); + let doc = read(&path).unwrap(); + assert_eq!(doc["extra"], 1); + assert_eq!(doc["mcpServers"]["unmanaged"]["command"], "keep"); + let server = &doc["mcpServers"]["managed"]; + assert_eq!(server["timeout"], 3000); + assert_eq!(server["disabled"], false); + assert_eq!(server["headers"]["X-Test"], "test"); + assert!(server.get("command").is_none()); + assert!(server.get("args").is_none()); + assert!(server.get("type").is_none()); + sync_file(&path, "managed", AgyChange::Disable).unwrap(); + assert_eq!( + read(&path).unwrap()["mcpServers"]["managed"]["disabled"], + true + ); + sync_file(&path, "managed", AgyChange::Remove).unwrap(); + assert!(read(&path).unwrap()["mcpServers"].get("managed").is_none()); + } + #[test] + fn agy_rejects_broken_config_and_sse_without_writing() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("mcp_config.json"); + for original in [ + "{broken", + r#"{"mcpServers":[]}"#, + r#"{"mcpServers":{"m":null}}"#, + ] { + fs::write(&path, original).unwrap(); + assert!(sync_file(&path, "m", AgyChange::Disable).is_err()); + assert_eq!(fs::read_to_string(&path).unwrap(), original); + } + fs::write(&path, "{}").unwrap(); + assert!(sync_file( + &path, + "m", + AgyChange::Upsert(&json!({"type":"sse","url":"https://example.test/sse"})) + ) + .is_err()); + assert_eq!(fs::read_to_string(&path).unwrap(), "{}"); + } + #[test] + fn agy_mcp_restores_native_file_when_database_commit_fails() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("mcp_config.json"); + let original = r#"{"mcpServers":{"m":{"command":"old"}}}"#; + fs::write(&path, original).unwrap(); + let result: Result<(), AppError> = crate::mcode_config::write_and_commit( + &path, + || sync_file(&path, "m", AgyChange::Upsert(&json!({"command":"new"}))), + || Err(AppError::Database("fixture failure".into())), + ); + assert!(result.is_err()); + assert_eq!(fs::read_to_string(path).unwrap(), original); + } + #[test] + fn agy_normalizes_legacy_url_for_import() { + let normalized = + unified_spec(&json!({"serverUrl":"https://example.test/mcp","disabled":true})); + assert_eq!(normalized["url"], "https://example.test/mcp"); + assert_eq!(normalized["type"], "http"); + assert!(normalized.get("serverUrl").is_none()); + } +} diff --git a/src-tauri/src/mcp/claude.rs b/src-tauri/src/mcp/claude.rs index 694772f55..3eff56d35 100644 --- a/src-tauri/src/mcp/claude.rs +++ b/src-tauri/src/mcp/claude.rs @@ -88,6 +88,7 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result Result name: id.clone(), server: spec_v, apps: McpApps { + agy: false, claude: false, codex: true, gemini: false, diff --git a/src-tauri/src/mcp/gemini.rs b/src-tauri/src/mcp/gemini.rs index 002b1f1a9..49662e829 100644 --- a/src-tauri/src/mcp/gemini.rs +++ b/src-tauri/src/mcp/gemini.rs @@ -84,6 +84,7 @@ pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result Result Result Result { AppType::Hermes => crate::hermes_config::get_hermes_dir(), AppType::Pi => crate::pi_config::get_pi_agent_dir()?, AppType::Mcode => crate::mcode_config::data_dir(), + AppType::Agy => crate::agy_config::agy_dir(), AppType::ClaudeDesktop => unreachable!("handled above"), }; @@ -46,7 +47,7 @@ pub fn prompt_file_path(app: &AppType) -> Result { AppType::Gemini => "GEMINI.md", AppType::GrokBuild | AppType::OpenCode | AppType::OpenClaw => "AGENTS.md", AppType::Hermes => "SOUL.md", - AppType::Pi | AppType::Mcode => "AGENTS.md", + AppType::Pi | AppType::Mcode | AppType::Agy => "AGENTS.md", AppType::ClaudeDesktop => unreachable!("handled above"), }; diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 29d904349..9f11151b5 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -189,7 +189,7 @@ impl Provider { (base_url, api_key) } // Gemini uses Google-specific env keys (with a legacy GOOGLE_API_KEY fallback). - AppType::Gemini => { + AppType::Gemini | AppType::Agy => { let env = settings.get("env"); let base_url = str_at(env.and_then(|e| e.get("GOOGLE_GEMINI_BASE_URL"))); let api_key = first_non_empty(env, &["GEMINI_API_KEY", "GOOGLE_API_KEY"]); diff --git a/src-tauri/src/proxy/providers/mod.rs b/src-tauri/src/proxy/providers/mod.rs index e325c08e5..045cc03f8 100644 --- a/src-tauri/src/proxy/providers/mod.rs +++ b/src-tauri/src/proxy/providers/mod.rs @@ -215,7 +215,7 @@ impl ProviderType { } AppType::GrokBuild => ProviderType::Codex, AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => ProviderType::Codex, - AppType::Pi | AppType::Mcode => return None, + AppType::Pi | AppType::Mcode | AppType::Agy => return None, }; Some(provider_type) } @@ -271,7 +271,7 @@ pub fn get_adapter(app_type: &AppType) -> Option> { AppType::Gemini => Box::new(GeminiAdapter::new()), AppType::GrokBuild => Box::new(CodexAdapter::new()), AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => Box::new(CodexAdapter::new()), - AppType::Pi | AppType::Mcode => return None, + AppType::Pi | AppType::Mcode | AppType::Agy => return None, }) } diff --git a/src-tauri/src/services/mcp.rs b/src-tauri/src/services/mcp.rs index e63fb7dc2..43e69011b 100644 --- a/src-tauri/src/services/mcp.rs +++ b/src-tauri/src/services/mcp.rs @@ -36,15 +36,26 @@ impl McpService { .unwrap_or_default(); // MCode / Pi 的文件和数据库一起提交:任一步失败都恢复原样 + let save_with_agy = || { + if server.apps.agy || prev_apps.agy { + mcp::agy::sync_and_commit( + &server.id, + mcp::agy::AgyChange::from_enabled(server.apps.agy.then_some(&server.server)), + || state.db.save_mcp_server(&server), + ) + } else { + state.db.save_mcp_server(&server) + } + }; let save_with_pi = || { if server.apps.pi || prev_apps.pi { mcp::pi::sync_and_commit( &server.id, mcp::pi::PiChange::from_enabled(server.apps.pi.then_some(&server.server)), - || state.db.save_mcp_server(&server), + save_with_agy, ) } else { - state.db.save_mcp_server(&server) + save_with_agy() } }; if server.apps.mcode || prev_apps.mcode { @@ -88,20 +99,29 @@ impl McpService { let server = state.db.get_all_mcp_servers()?.shift_remove(id); if let Some(server) = server { - let delete_with_pi = || { - if server.apps.pi { - mcp::pi::sync_and_commit(id, mcp::pi::PiChange::Remove, || { + let delete_with_agy = || { + if server.apps.agy { + mcp::agy::sync_and_commit(id, mcp::agy::AgyChange::Remove, || { state.db.delete_mcp_server(id) }) } else { state.db.delete_mcp_server(id) } }; + let delete_with_pi = || { + if server.apps.pi { + mcp::pi::sync_and_commit(id, mcp::pi::PiChange::Remove, delete_with_agy) + } else { + delete_with_agy() + } + }; if server.apps.mcode { mcp::mcode::sync_and_commit(id, None, delete_with_pi)?; } else { delete_with_pi()?; } + // A false agy flag carries no ownership history. Even an identical disabled + // native entry may belong to the user; leave it intact without proof of ownership. if !server.apps.pi { // 取消勾选只在 Pi 里禁用、条目还留着:服务器删了就一并清掉(尽力而为) if let Err(err) = mcp::pi::remove_disabled_if_managed(id, &server.server) { @@ -139,7 +159,7 @@ impl McpService { app: AppType, enabled: bool, ) -> Result<(), AppError> { - if matches!(app, AppType::Mcode | AppType::Pi) { + if matches!(app, AppType::Mcode | AppType::Pi | AppType::Agy) { if let Some(server) = state.db.get_all_mcp_servers()?.get(server_id) { let spec = enabled.then_some(&server.server); let commit = || { @@ -147,7 +167,13 @@ impl McpService { .db .update_mcp_server_app_enabled(server_id, &app, enabled) }; - if app == AppType::Mcode { + if app == AppType::Agy { + mcp::agy::sync_and_commit( + server_id, + mcp::agy::AgyChange::from_enabled(spec), + commit, + )?; + } else if app == AppType::Mcode { mcp::mcode::sync_and_commit(server_id, spec, commit)?; } else { mcp::pi::sync_and_commit( @@ -177,7 +203,7 @@ impl McpService { /// 将 MCP 服务器同步到所有启用的应用 fn sync_server_to_apps(_state: &AppState, server: &McpServer) -> Result<(), AppError> { for app in server.apps.enabled_apps() { - if matches!(app, AppType::Mcode | AppType::Pi) { + if matches!(app, AppType::Mcode | AppType::Pi | AppType::Agy) { continue; // Already written before saving the managed state. } Self::sync_server_to_app_no_config(server, &app)?; @@ -234,6 +260,7 @@ impl McpService { } AppType::Mcode => mcp::mcode::sync(&server.id, Some(&server.server))?, AppType::Pi => mcp::pi::sync(&server.id, Some(&server.server))?, + AppType::Agy => mcp::agy::sync(&server.id, Some(&server.server))?, } Ok(()) } @@ -246,7 +273,7 @@ impl McpService { ) -> Result<(), AppError> { // 从所有曾启用的应用中移除 for app in server.apps.enabled_apps() { - if matches!(app, AppType::Mcode | AppType::Pi) { + if matches!(app, AppType::Mcode | AppType::Pi | AppType::Agy) { continue; // Already removed before deleting the managed record. } Self::remove_server_from_app(state, id, &app)?; @@ -275,6 +302,7 @@ impl McpService { } AppType::Mcode => mcp::mcode::sync(id, None)?, AppType::Pi => mcp::pi::sync(id, None)?, + AppType::Agy => mcp::agy::sync(id, None)?, } Ok(()) } @@ -380,7 +408,7 @@ impl McpService { for server in servers.values() { let result = if server.apps.is_enabled_for(app) { Self::sync_server_to_app(state, server, app) - } else if !matches!(app, AppType::Mcode | AppType::Pi) { + } else if !matches!(app, AppType::Mcode | AppType::Pi | AppType::Agy) { Self::remove_server_from_app(state, &server.id, app) } else { // MCode / Pi's false flag also covers pre-existing, unmanaged servers. @@ -684,7 +712,7 @@ impl McpService { let mut total = 0; let mut failures: Vec = Vec::new(); - let results: [(&str, Result); 8] = [ + let results: [(&str, Result); 9] = [ ("claude", Self::import_from_claude(state)), ("codex", Self::import_from_codex(state)), ("gemini", Self::import_from_gemini(state)), @@ -692,6 +720,7 @@ impl McpService { ("opencode", Self::import_from_opencode(state)), ("hermes", Self::import_from_hermes(state)), ("mcode", mcp::mcode::import(state)), + ("agy", mcp::agy::import(state)), ("pi", mcp::pi::import(state)), ]; for (app, result) in results { diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 7479e45cb..5451a06ab 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -55,3 +55,5 @@ pub use usage_stats::{ }; pub mod session_usage_mcode; + +pub mod session_usage_agy; diff --git a/src-tauri/src/services/model_fetch.rs b/src-tauri/src/services/model_fetch.rs index 9a4b27576..2d63080cf 100644 --- a/src-tauri/src/services/model_fetch.rs +++ b/src-tauri/src/services/model_fetch.rs @@ -50,6 +50,12 @@ fn catalog_model_ids(models: Option) -> Vec { ["slug", "id"] .iter() .find_map(|k| m.get(k)?.as_str().filter(|s| !s.is_empty())) + .or_else(|| { + m.get("name")? + .as_str()? + .strip_prefix("models/") + .filter(|s| !s.is_empty()) + }) }) .map(str::to_string) .collect() @@ -89,7 +95,19 @@ pub async fn fetch_models( api_format: Option<&str>, request_headers: Option<&BTreeMap>, ) -> Result, String> { - let candidates = build_models_url_candidates(base_url, is_full_url, models_url_override)?; + let candidates = if api_format == Some("google-generative-ai") + && models_url_override.is_none() + && !is_full_url + { + let base = base_url.trim_end_matches('/'); + let base = base + .strip_suffix("/v1beta") + .or_else(|| base.strip_suffix("/v1")) + .unwrap_or(base); + vec![format!("{base}/v1beta/models")] + } else { + build_models_url_candidates(base_url, is_full_url, models_url_override)? + }; let headers = build_model_fetch_headers(api_key, api_format, user_agent.as_ref(), request_headers)?; let mut last_err: Option = None; @@ -666,6 +684,15 @@ mod tests { assert_eq!(data[1].id, "model-b"); } + #[test] + fn agy_google_model_catalog_keeps_other_protocol_ids() { + let models = catalog_model_ids(Some(serde_json::json!([ + {"name":"models/gemini-test"}, {"id":"models/custom-id"}, + {"slug":"slug","name":"models/ignored"}, {"name":"Display name"} + ]))); + assert_eq!(models, ["gemini-test", "models/custom-id", "slug"]); + } + #[test] fn test_catalog_model_ids_slug_then_id() { // `models` 条目解析:slug 优先,回退 id,两者皆缺时跳过。 diff --git a/src-tauri/src/services/provider/agy_direct.rs b/src-tauri/src/services/provider/agy_direct.rs new file mode 100644 index 000000000..9c0570151 --- /dev/null +++ b/src-tauri/src/services/provider/agy_direct.rs @@ -0,0 +1,173 @@ +//! 切换 Antigravity CLI:写 `settings.json`,并把 Key / 地址注入启动包装。 + +use serde_json::{json, Value}; + +use crate::agy_config; +use crate::database::Database; +use crate::error::AppError; +use crate::mode::operation::OperationReport; +use crate::mode::state::{op, PendingTarget}; +use crate::provider::Provider; + +pub(crate) fn is_official(provider: &Provider) -> bool { + // Provider names are user editable and must never silently select OAuth. + provider.category.as_deref() == Some("official") + || provider + .meta + .as_ref() + .and_then(|meta| meta.partner_promotion_key.as_deref()) + == Some("google-official") +} + +fn live_settings(provider: &Provider) -> Result { + crate::gemini_config::validate_gemini_settings(&provider.settings_config)?; + let mut settings = provider.settings_config.clone(); + if is_official(provider) { + if let Some(env) = settings.get_mut("env").and_then(Value::as_object_mut) { + env.remove("GEMINI_API_KEY"); + env.remove("GOOGLE_GEMINI_BASE_URL"); + } else { + settings + .as_object_mut() + .map(|obj| obj.insert("env".to_string(), json!({}))); + } + } + for value in [ + settings.pointer("/env/GEMINI_API_KEY"), + settings.pointer("/env/GOOGLE_GEMINI_BASE_URL"), + settings.pointer("/config/model/name"), + settings.pointer("/env/GEMINI_MODEL"), + ] + .into_iter() + .flatten() + { + if !value.is_null() && !value.is_string() { + return Err(AppError::Config( + "Antigravity credentials, endpoint and model must be strings".into(), + )); + } + if value + .as_str() + .is_some_and(|text| text.chars().any(char::is_control)) + { + return Err(AppError::Config( + "Antigravity credentials, endpoint and model must be single-line values".into(), + )); + } + } + // A model-only environment still uses native login. Gemini CLI's generic + // validator treats any nonempty env as API authentication, which rejects + // GEMINI_MODEL written by the shared editor when no key is needed. + let has_key = settings["env"]["GEMINI_API_KEY"] + .as_str() + .is_some_and(|value| !value.trim().is_empty()); + let has_endpoint = settings["env"]["GOOGLE_GEMINI_BASE_URL"] + .as_str() + .is_some_and(|value| !value.trim().is_empty()); + if has_endpoint && !has_key { + return Err(AppError::localized( + "gemini.validation.missing_api_key", + "Gemini 配置缺少必需字段: GEMINI_API_KEY", + "Gemini config missing required field: GEMINI_API_KEY", + )); + } + Ok(settings) +} + +pub(crate) fn switch_to(db: &Database, target: &Provider) -> Result { + let settings = live_settings(target)?; + agy_config::apply_managed( + db, + &settings, + op::SWITCH, + PendingTarget::pointer(Some(target.id.clone())), + ) +} + +pub(crate) fn reapply(db: &Database, target: &Provider) -> Result { + let settings = live_settings(target)?; + // 全量同步(导入、云同步)会对每个当前供应商调用这里。没有真实内容的供应商 + // 不能借机接管用户的启动器或 shell,也不应在 Windows 上报错。 + if !agy_config::has_managed_content(&settings) { + return Ok(OperationReport::default()); + } + agy_config::apply_managed(db, &settings, op::APPLY, PendingTarget::default()) +} + +/// 删除供应商后调用:如果已经没有需要启动器的当前供应商,就撤掉 CC Switch 的启动器和 PATH 片段。 +pub(crate) fn release_launcher_if_unmanaged(db: &Database) -> Result<(), AppError> { + use crate::app_config::AppType; + use crate::mode::current::{provider_for, Purpose}; + if let Some(id) = provider_for(db, &AppType::Agy, Purpose::Direct)? { + if let Some(current) = db.get_provider_by_id(&id, "agy")? { + if live_settings(¤t).is_ok_and(|s| agy_config::needs_launcher(&s)) { + return Ok(()); + } + } + } + agy_config::remove_launcher() +} + +#[cfg(test)] +mod tests { + use super::*; + fn provider(name: &str) -> Provider { + Provider::with_id( + "test".into(), + name.into(), + json!({"env":{"GEMINI_API_KEY":"fixture","GOOGLE_GEMINI_BASE_URL":"https://example.test"},"config":{"model":{"name":"test-model"}}}), + None, + ) + } + #[test] + fn agy_provider_names_do_not_override_credentials() { + let row = provider("Google API key"); + assert!(!is_official(&row)); + assert_eq!( + live_settings(&row).unwrap()["env"]["GEMINI_API_KEY"], + "fixture" + ); + let mut official = row; + official.category = Some("official".into()); + let config = live_settings(&official).unwrap(); + assert!(config["env"].get("GEMINI_API_KEY").is_none()); + assert!(config["env"].get("GOOGLE_GEMINI_BASE_URL").is_none()); + assert_eq!(config["config"]["model"]["name"], "test-model"); + } + #[test] + fn agy_model_only_login_does_not_require_api_key() { + let mut row = provider("Imported login"); + row.settings_config["env"] = json!({"GEMINI_MODEL":"gemini-3.8-flash-low"}); + assert!(live_settings(&row).is_ok()); + } + + #[test] + fn agy_endpoint_requires_a_nonempty_key() { + for key in [Value::Null, json!(""), json!(" ")] { + let mut row = provider("Relay"); + row.settings_config["env"]["GEMINI_API_KEY"] = key; + assert!(live_settings(&row).is_err()); + } + } + + #[test] + fn agy_key_can_use_the_default_google_endpoint() { + let mut row = provider("Google API key"); + row.settings_config["env"] = json!({"GEMINI_API_KEY":"fixture"}); + assert!(live_settings(&row).is_ok()); + } + + #[test] + fn agy_rejects_non_string_credentials_before_writing() { + let mut row = provider("Relay"); + row.settings_config["env"]["GEMINI_API_KEY"] = json!(123); + assert!(live_settings(&row).is_err()); + } + + #[test] + fn agy_invalid_credentials_fail_before_writing() { + let mut row = provider("Relay"); + row.settings_config["env"]["GEMINI_API_KEY"] = json!("bad\nkey"); + assert!(live_settings(&row).is_err()); + } +} diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 8f9583d98..8f6171d84 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -301,6 +301,7 @@ fn settings_contain_common_config(app_type: &AppType, settings: &Value, snippet: | AppType::Hermes | AppType::Pi | AppType::Mcode + | AppType::Agy | AppType::ClaudeDesktop => false, } } @@ -377,7 +378,8 @@ pub(crate) fn remove_common_config_from_settings( | AppType::Hermes | AppType::Pi | AppType::Mcode - | AppType::ClaudeDesktop => Ok(settings.clone()), + | AppType::ClaudeDesktop + | AppType::Agy => Ok(settings.clone()), } } @@ -416,6 +418,10 @@ pub(crate) fn write_live_for_state( super::grok_direct::reapply(db, Some(provider), provider)?; return Ok(()); } + if matches!(app_type, AppType::Agy) { + super::agy_direct::reapply(db, provider)?; + return Ok(()); + } if matches!(app_type, AppType::ClaudeDesktop) { crate::claude_desktop_config::apply_provider(db, provider)?; @@ -696,6 +702,11 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re "Pi providers use the Pi provider service".to_string(), )); } + AppType::Agy => { + return Err(AppError::Config( + "Antigravity configuration requires the provider switch flow".into(), + )); + } } Ok(()) } @@ -985,6 +996,7 @@ pub fn read_live_settings(app_type: AppType) -> Result { AppType::Pi => Err(AppError::InvalidInput( "Pi providers are read from Pi's native models file".to_string(), )), + AppType::Agy => crate::agy_config::read_live_settings(), } } @@ -1089,6 +1101,15 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result { + let live = crate::agy_config::read_live_settings()?; + // agy 首次启动就会写出空的 settings.json;没有 Key、端点、模型的配置 + // 不是用户的供应商,导入它只会让后续全量同步接管启动器和 shell。 + if !crate::agy_config::has_managed_content(&live) { + return Ok(false); + } + live + } // OpenCode, OpenClaw and Hermes use additive mode and are handled by early return above AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::Pi | AppType::Mcode => { unreachable!("additive mode apps are handled by early return") diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 06b43bad0..353063f93 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -2,6 +2,7 @@ //! //! Handles provider CRUD operations, switching, and configuration management. +pub(crate) mod agy_direct; pub(crate) mod claude_direct; mod claude_editor; pub(crate) mod codex_client_catalog; @@ -5644,6 +5645,10 @@ impl ProviderService { gemini_direct::switch_to(state.db.as_ref(), &provider)?; return Ok(true); } + AppType::Agy => { + agy_direct::switch_to(state.db.as_ref(), &provider)?; + return Ok(true); + } AppType::GrokBuild => { grok_direct::switch_to(state.db.as_ref(), None, &provider)?; return Ok(true); @@ -5793,6 +5798,7 @@ impl ProviderService { let official = match app_type { AppType::Codex => codex_direct::is_official(&row), AppType::Gemini => gemini_direct::is_official(&row), + AppType::Agy => agy_direct::is_official(&row), _ => false, }; Ok(if official { @@ -6475,7 +6481,13 @@ impl ProviderService { .map_err(|error| AppError::Message(error.message))?; } - state.db.delete_provider(app_type.as_str(), id) + state.db.delete_provider(app_type.as_str(), id)?; + if matches!(app_type, AppType::Agy) { + if let Err(error) = agy_direct::release_launcher_if_unmanaged(state.db.as_ref()) { + log::warn!("清理 agy 启动器失败: {error}"); + } + } + Ok(()) } /// Remove provider from live config only (for additive mode apps like OpenCode, OpenClaw) @@ -6682,6 +6694,10 @@ impl ProviderService { gemini_direct::switch_to(state.db.as_ref(), provider)?; return Ok(SwitchResult::default()); } + if matches!(app_type, AppType::Agy) { + agy_direct::switch_to(state.db.as_ref(), provider)?; + return Ok(SwitchResult::default()); + } if matches!(app_type, AppType::GrokBuild) { return Self::switch_grok_direct(state, provider, providers); } @@ -7017,7 +7033,7 @@ impl ProviderService { AppType::OpenCode => Self::extract_opencode_common_config(&provider.settings_config), AppType::OpenClaw => Self::extract_openclaw_common_config(&provider.settings_config), AppType::Hermes => Ok(String::new()), // Hermes doesn't use common config snippets - AppType::Pi | AppType::Mcode => Ok(String::new()), + AppType::Pi | AppType::Mcode | AppType::Agy => Ok(String::new()), } } @@ -7035,7 +7051,7 @@ impl ProviderService { AppType::OpenCode => Self::extract_opencode_common_config(settings_config), AppType::OpenClaw => Self::extract_openclaw_common_config(settings_config), AppType::Hermes => Ok(String::new()), // Hermes doesn't use common config snippets - AppType::Pi | AppType::Mcode => Ok(String::new()), + AppType::Pi | AppType::Mcode | AppType::Agy => Ok(String::new()), } } @@ -7727,6 +7743,10 @@ impl ProviderService { use crate::gemini_config::validate_gemini_settings; validate_gemini_settings(&provider.settings_config)? } + AppType::Agy => { + use crate::gemini_config::validate_gemini_settings; + validate_gemini_settings(&provider.settings_config)? + } AppType::GrokBuild => { let settings = provider.settings_config.as_object().ok_or_else(|| { AppError::localized( @@ -7947,7 +7967,7 @@ impl ProviderService { Ok((api_key, base_url)) } - AppType::Gemini => { + AppType::Gemini | AppType::Agy => { use crate::gemini_config::json_to_env; let env_map = json_to_env(&provider.settings_config)?; diff --git a/src-tauri/src/services/session_usage.rs b/src-tauri/src/services/session_usage.rs index 549f88e0e..bb0b2ce4c 100644 --- a/src-tauri/src/services/session_usage.rs +++ b/src-tauri/src/services/session_usage.rs @@ -167,6 +167,11 @@ pub fn sync_all_unlocked(db: &Database) -> SessionSyncResult { "MCode", crate::services::session_usage_mcode::sync_mcode_usage(db), ); + merge_sync_step( + &mut result, + "Antigravity", + crate::services::session_usage_agy::sync_agy_usage(db), + ); notify_sync_result(&result); record_sync_completed(chrono::Utc::now().timestamp_millis()); result diff --git a/src-tauri/src/services/session_usage_agy.rs b/src-tauri/src/services/session_usage_agy.rs new file mode 100644 index 000000000..066b216cf --- /dev/null +++ b/src-tauri/src/services/session_usage_agy.rs @@ -0,0 +1,148 @@ +//! Exact counters exported by agy; absent models remain unknown (never use today's provider). +use super::{session_usage::SessionSyncResult, sql_helpers::INPUT_TOKEN_SEMANTICS_FRESH}; +use crate::{ + database::{lock_conn, Database}, + error::AppError, + session_manager::providers::agy, +}; +use rusqlite::params; +use serde_json::Value; + +pub fn sync_agy_usage(db: &Database) -> Result { + let mut result = SessionSyncResult::default(); + for session in agy::scan_sessions() { + let Some(path) = session.source_path else { + continue; + }; + result.files_scanned += 1; + match agy::records(std::path::Path::new(&path)) { + Ok(rows) => match import_records(db, &session.session_id, &rows) { + Ok((imported, skipped)) => { + result.imported += imported; + result.skipped += skipped; + } + Err(e) => result + .errors + .push(format!("Antigravity {}: {e}", session.session_id)), + }, + Err(e) => result.errors.push(e), + } + } + Ok(result) +} + +fn import_records(db: &Database, session: &str, rows: &[Value]) -> Result<(u32, u32), AppError> { + let mut conn = lock_conn!(db.conn); + let tx = conn.transaction()?; + let (mut imported, mut skipped) = (0, 0); + for row in rows { + if row["type"] != "PLANNER_RESPONSE" || row["status"] != "DONE" { + continue; + } + let Some(step) = row["step_index"].as_u64() else { + continue; + }; + let token = |name: &str| { + row[name] + .as_u64() + .and_then(|v| i64::try_from(v).ok()) + .unwrap_or(0) + }; + let input = token("input_tokens"); + let output = token("output_tokens"); + let cache = token("cache_read_tokens"); + let write = token("cache_write_tokens"); + if input == 0 && output == 0 && cache == 0 && write == 0 { + continue; + } + let id = format!("agy:{session}:{step}"); + // Persist a tombstone with the row so retention never imports pruned history again. + let fresh = tx.execute("INSERT OR IGNORE INTO session_usage_dedup(data_source,request_id,semantic_id,has_entry_id) VALUES ('agy_session',?1,?1,1)", [&id])?; + let model = row["model"].as_str().unwrap_or("unknown"); + if fresh == 0 { + // A rewritten completed step may correct counters; update retained detail only. + // If retention already removed it, its tombstone still forbids resurrection. + let changed = tx.execute("UPDATE proxy_request_logs SET model=?2,request_model=?2,input_tokens=?3,output_tokens=?4,cache_read_tokens=?5,cache_creation_tokens=?6 WHERE request_id=?1 AND (model!=?2 OR input_tokens!=?3 OR output_tokens!=?4 OR cache_read_tokens!=?5 OR cache_creation_tokens!=?6)", params![id,model,input,output,cache,write])?; + imported += changed as u32; + skipped += u32::from(changed == 0); + continue; + } + // Native agy was verified with a local mock: input excludes cache, output includes + // thinking. Never subtract cache or add thinking a second time. Missing historical + // models remain unknown; do not manufacture billing or use the active provider. + tx.execute("INSERT INTO proxy_request_logs ( + request_id,provider_id,app_type,model,request_model,input_tokens,output_tokens, + cache_read_tokens,cache_creation_tokens,input_cost_usd,output_cost_usd, + cache_read_cost_usd,cache_creation_cost_usd,total_cost_usd,latency_ms,status_code, + session_id,provider_type,is_streaming,cost_multiplier,created_at,data_source,input_token_semantics + ) VALUES (?1,'_agy_session','agy',?2,?2,?3,?4,?5,?6,'0','0','0','0','0',0,200,?7,'agy_session',1,'1',?8,'agy_session',?9)", + params![id, model, input, output, cache, write, session, agy::timestamp(row).unwrap_or(0)/1000, INPUT_TOKEN_SEMANTICS_FRESH])?; + imported += 1; + } + tx.commit()?; + Ok((imported, skipped)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + #[test] + fn agy_native_fixture_preserves_fresh_input_and_combined_thinking_output() { + let db = Database::memory().unwrap(); + let rows = include_str!("../../../tests/fixtures/sessions/agy.jsonl") + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect::>(); + assert_eq!(import_records(&db, "fixture", &rows).unwrap(), (1, 0)); + let conn = db.conn.lock().unwrap(); + let got=conn.query_row("SELECT input_tokens,output_tokens,cache_read_tokens,input_token_semantics FROM proxy_request_logs",[],|r|Ok((r.get::<_,i64>(0)?,r.get::<_,i64>(1)?,r.get::<_,i64>(2)?,r.get::<_,i64>(3)?))).unwrap(); + assert_eq!(got, (80, 12, 20, INPUT_TOKEN_SEMANTICS_FRESH)); + } + + #[test] + fn agy_rewritten_counters_update_retained_rows_without_reimporting_pruned_rows() { + let db = Database::memory().unwrap(); + let mut row = + json!({"step_index":1,"type":"PLANNER_RESPONSE","status":"DONE","input_tokens":10}); + assert_eq!(import_records(&db, "s", &[row.clone()]).unwrap(), (1, 0)); + row["input_tokens"] = json!(20); + assert_eq!(import_records(&db, "s", &[row.clone()]).unwrap(), (1, 0)); + assert_eq!(import_records(&db, "s", &[row]).unwrap(), (0, 1)); + assert_eq!( + db.conn + .lock() + .unwrap() + .query_row("SELECT input_tokens FROM proxy_request_logs", [], |r| r + .get::<_, i64>(0)) + .unwrap(), + 20 + ); + } + + #[test] + fn agy_usage_is_exact_deduplicated_and_survives_retention() { + let db = Database::memory().unwrap(); + let rows = vec![ + json!({"step_index":1,"type":"PLANNER_RESPONSE","status":"DONE","input_tokens":11830,"output_tokens":137,"cache_read_tokens":25}), + ]; + assert_eq!(import_records(&db, "session", &rows).unwrap(), (1, 0)); + assert_eq!(import_records(&db, "session", &rows).unwrap(), (0, 1)); + let conn = db.conn.lock().unwrap(); + let got = conn.query_row("SELECT input_tokens,output_tokens,cache_read_tokens,model FROM proxy_request_logs WHERE app_type='agy'", [], |r| Ok((r.get::<_,i64>(0)?, r.get::<_,i64>(1)?, r.get::<_,i64>(2)?, r.get::<_,String>(3)?))).unwrap(); + assert_eq!(got, (11830, 137, 25, "unknown".into())); + conn.execute("DELETE FROM proxy_request_logs", []).unwrap(); + drop(conn); + assert_eq!(import_records(&db, "session", &rows).unwrap(), (0, 1)); + assert_eq!(import_records(&db, "other", &rows).unwrap(), (1, 0)); + } + #[test] + fn agy_incomplete_steps_do_not_consume_dedup_ids() { + let db = Database::memory().unwrap(); + let mut row = + json!({"step_index":0,"type":"PLANNER_RESPONSE","status":"RUNNING","input_tokens":10}); + assert_eq!(import_records(&db, "s", &[row.clone()]).unwrap(), (0, 0)); + row["status"] = json!("DONE"); + assert_eq!(import_records(&db, "s", &[row]).unwrap(), (1, 0)); + } +} diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index 3fd861b94..ba7a43ec3 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -691,6 +691,12 @@ impl SkillService { AppType::Pi => { return Ok(crate::pi_config::get_pi_agent_dir()?.join("skills")); } + AppType::Agy => { + return Ok(crate::config::get_home_dir() + .join(".gemini") + .join("config") + .join("skills")); + } } // 默认路径:回退到用户主目录下的标准位置。 @@ -709,6 +715,7 @@ impl SkillService { AppType::OpenClaw => home.join(".openclaw").join("skills"), AppType::Hermes => crate::hermes_config::get_hermes_dir().join("skills"), AppType::Pi => crate::pi_config::get_pi_agent_dir()?.join("skills"), + AppType::Agy => home.join(".gemini").join("config").join("skills"), }) } @@ -1058,18 +1065,21 @@ impl SkillService { Ok(directory) => { let ssot_dir = Self::get_ssot_dir()?; let source = ssot_dir.join(&directory); - let mcode_destination = if skill.apps.mcode { - let destination = - Self::get_distinct_app_skills_dir(&ssot_dir, &AppType::Mcode)? + let mut managed_destinations = Vec::new(); + for app in [AppType::Mcode, AppType::Agy] { + if skill.apps.is_enabled_for(&app) { + let destination = Self::get_distinct_app_skills_dir(&ssot_dir, &app)? .join(&directory); - Self::inspect_pi_skill_destination(&source, &destination, &directory) - .context( - "MiniMax Code Skill could not be verified; uninstall cancelled", - )?; - Some(destination) - } else { - None - }; + Self::inspect_pi_skill_destination(&source, &destination, &directory) + .with_context(|| { + format!( + "{} Skill could not be verified; uninstall cancelled", + app.as_str() + ) + })?; + managed_destinations.push(destination); + } + } let mut preserved_pi_path: Option = None; let mut pi_cleanup_incomplete = false; let mut pi_removal_path = None; @@ -1115,11 +1125,9 @@ impl SkillService { )? .map(|path| path.to_string_lossy().to_string()); - if let Some(destination) = mcode_destination { + for destination in managed_destinations { Self::remove_verified_pi_destination(&source, &destination, &directory) - .context( - "MiniMax Code Skill could not be removed; uninstall cancelled", - )?; + .context("Managed Skill could not be removed; uninstall cancelled")?; } // Pi 目录可能包含用户自己维护的同名 Skill。删除 SSOT 前仅移除 @@ -1138,7 +1146,7 @@ impl SkillService { // 其他应用沿用既有的逐项容错行为。 for app in AppType::all() { - if matches!(app, AppType::Pi | AppType::Mcode) { + if matches!(app, AppType::Pi | AppType::Mcode | AppType::Agy) { continue; } let _ = Self::remove_from_app_preserving( @@ -1604,7 +1612,7 @@ impl SkillService { let dest = ssot_dir.join(&skill.directory); let mut deployments = Vec::new(); - for app in [AppType::Pi, AppType::Mcode] { + for app in [AppType::Pi, AppType::Mcode, AppType::Agy] { if skill.apps.is_enabled_for(&app) { let destination = Self::get_distinct_app_skills_dir(&ssot_dir, &app)?.join(&skill.directory); @@ -1619,7 +1627,7 @@ impl SkillService { // 备份旧文件 let _ = Self::create_uninstall_backup(&skill); - if !skill.apps.mcode { + if !(skill.apps.mcode || skill.apps.agy) { if dest.exists() { fs::remove_dir_all(&dest)?; } @@ -1627,7 +1635,11 @@ impl SkillService { } // 计算新哈希 + 解析新元数据 - let metadata_source = if skill.apps.mcode { &source } else { &dest }; + let metadata_source = if skill.apps.mcode || skill.apps.agy { + &source + } else { + &dest + }; let new_hash = Self::compute_dir_hash(metadata_source).ok(); let skill_md = metadata_source.join("SKILL.md"); let (new_name, new_description) = Self::read_skill_name_desc(&skill_md, &skill.directory); @@ -1655,13 +1667,24 @@ impl SkillService { updated_at: chrono::Utc::now().timestamp(), }; - let mut updated_skill = if skill.apps.mcode { - Self::update_mcode_skill_files(&source, &dest, &skill.directory, &deployments, || { - if !db.update_skill_metadata(&updated_metadata)? { - return Err(anyhow!("Skill no longer installed: {}", skill.id)); - } - Ok(updated_metadata) - })? + let mut updated_skill = if skill.apps.mcode || skill.apps.agy { + Self::update_managed_skill_files( + &source, + &dest, + &skill.directory, + &deployments, + &[AppType::Mcode, AppType::Agy] + .iter() + .filter(|app| skill.apps.is_enabled_for(app)) + .map(|app| Self::get_app_skills_dir(app).map(|dir| dir.join(&skill.directory))) + .collect::>>()?, + || { + if !db.update_skill_metadata(&updated_metadata)? { + return Err(anyhow!("Skill no longer installed: {}", skill.id)); + } + Ok(updated_metadata) + }, + )? } else { for (destination, deployment) in deployments { Self::refresh_pi_skill_destination( @@ -1677,7 +1700,7 @@ impl SkillService { // 同步到所有已启用的应用目录 for app in updated_skill.apps.enabled_apps() { - if matches!(app, AppType::Pi | AppType::Mcode) { + if matches!(app, AppType::Pi | AppType::Mcode | AppType::Agy) { continue; } if let Err(e) = Self::sync_to_app_dir(&updated_skill.directory, &app) { @@ -1691,15 +1714,33 @@ impl SkillService { // Keep the old SSOT and native copies until metadata is committed. In particular, // a failed native write must not make the old copy appear to be a user edit. + #[cfg(test)] fn update_mcode_skill_files( source: &Path, ssot: &Path, directory: &str, deployments: &[(PathBuf, PiSkillDeployment)], commit: impl FnOnce() -> Result, + ) -> Result { + Self::update_managed_skill_files( + source, + ssot, + directory, + deployments, + &[Self::get_app_skills_dir(&AppType::Mcode)?.join(directory)], + commit, + ) + } + + fn update_managed_skill_files( + source: &Path, + ssot: &Path, + directory: &str, + deployments: &[(PathBuf, PiSkillDeployment)], + native_targets: &[PathBuf], + commit: impl FnOnce() -> Result, ) -> Result { Self::validate_sync_source_dir(source, directory)?; - let native = Self::get_app_skills_dir(&AppType::Mcode)?.join(directory); let mut targets = vec![(ssot.to_path_buf(), false)]; for (destination, deployment) in deployments { Self::ensure_pi_skill_destination_matches(ssot, destination, directory)?; @@ -1708,8 +1749,10 @@ impl SkillService { matches!(deployment, PiSkillDeployment::Symlink { .. }), )); } - if !native.exists() && !Self::is_symlink(&native) { - targets.push((native, Self::get_sync_method() != SyncMethod::Copy)); + for native in native_targets { + if !native.exists() && !Self::is_symlink(native) { + targets.push((native.clone(), Self::get_sync_method() != SyncMethod::Copy)); + } } let mut staged = Vec::new(); @@ -1719,7 +1762,7 @@ impl SkillService { .context("Skill destination has no parent")?; fs::create_dir_all(parent)?; let staging = tempfile::Builder::new() - .prefix(".mcode-skill-update-") + .prefix(".managed-skill-update-") .tempdir_in(parent)?; let replacement = staging.path().join("new"); if symlink { @@ -1887,17 +1930,18 @@ impl SkillService { .ok() .flatten() }; - if skill.apps.mcode { - let destination = Self::get_app_skills_dir(&AppType::Mcode)?.join(&directory); - if let Some(deployment) = - Self::inspect_pi_skill_destination(&src, &destination, &directory) - .ok() - .flatten() - { - mcode_deployments.push((directory.clone(), destination, deployment)); + for app in [AppType::Mcode, AppType::Agy] { + if skill.apps.is_enabled_for(&app) { + let destination = Self::get_app_skills_dir(&app)?.join(&directory); + if let Some(deployment) = + Self::inspect_pi_skill_destination(&src, &destination, &directory) + .ok() + .flatten() + { + mcode_deployments.push((directory.clone(), destination, deployment)); + } } } - // 优先 rename(同文件系统原子操作),失败则 copy+delete match fs::rename(&src, &dst) { Ok(()) => { @@ -2106,6 +2150,47 @@ impl SkillService { .get_installed_skill(id)? .ok_or_else(|| anyhow!("Skill not found: {id}"))?; + if matches!(app, AppType::Agy) { + if !enabled && !skill.apps.agy { + return Ok(()); + } + let directory = Self::require_valid_directory(&skill.directory)?; + let ssot = Self::get_ssot_dir()?; + let source = ssot.join(&directory); + let native_dir = Self::get_distinct_app_skills_dir(&ssot, app)?; + let native = native_dir.join(&directory); + Self::ensure_pi_skill_destination_matches(&source, &native, &directory)?; + fs::create_dir_all(&native_dir)?; + let staging = tempfile::Builder::new() + .prefix(".cc-switch-toggle-") + .tempdir_in(&native_dir)?; + let previous = staging.path().join("old"); + let existed = native.exists() || Self::is_symlink(&native); + if existed { + fs::rename(&native, &previous)?; + } + skill.apps.agy = enabled; + let result = (|| -> Result<()> { + if enabled { + Self::sync_to_app_dir(&directory, app)?; + } + if !db.update_skill_apps(id, &skill.apps)? { + return Err(anyhow!("Skill no longer installed: {id}")); + } + Ok(()) + })(); + if let Err(error) = result { + if native.exists() || Self::is_symlink(&native) { + Self::remove_path(&native)?; + } + if existed { + fs::rename(previous, native)?; + } + return Err(error); + } + return Ok(()); + } + // 更新状态 skill.apps.set_enabled_for(app, enabled); @@ -2224,7 +2309,7 @@ impl SkillService { } search_sources.push((ssot_dir.clone(), "cc-switch".to_string())); - for selection in imports { + 'imports: for selection in imports { // selection.directory 由前端 IPC 直接传入、此前全程无校验,而它既被 // 用来探测源目录、又作为 copy_dir_recursive 的目标、最后还原样入库。 // 在入口处拒掉,同时切断「脏值 sink」和「脏值来源」两条线。 @@ -2277,48 +2362,49 @@ impl SkillService { // An explicitly imported MCode link must point at the managed copy // so subsequent toggles and updates can verify its ownership. - if apps.mcode { - let projection = (|| -> Result<()> { - let native = Self::get_distinct_app_skills_dir(&ssot_dir, &AppType::Mcode)? - .join(&dir_name); - if Self::is_symlink(&native) && !Self::paths_alias(&native, &dest) { - if Self::compute_pi_deployment_hash(&native)? - != Self::compute_pi_deployment_hash(&dest)? - { - return Err(anyhow!( - "MCode Skill 与托管副本内容不同,拒绝替换链接: {dir_name}" - )); + for app in [AppType::Mcode, AppType::Agy] { + if apps.is_enabled_for(&app) { + let projection = (|| -> Result<()> { + let native = + Self::get_distinct_app_skills_dir(&ssot_dir, &app)?.join(&dir_name); + if Self::is_symlink(&native) && !Self::paths_alias(&native, &dest) { + if Self::compute_pi_deployment_hash(&native)? + != Self::compute_pi_deployment_hash(&dest)? + { + return Err(anyhow!( + "Skill 与托管副本内容不同,拒绝替换链接: {dir_name}" + )); + } + if let Some(deployment) = + Self::inspect_pi_skill_destination(&native, &native, &dir_name)? + { + Self::refresh_pi_skill_destination( + &dest, + &native, + &dir_name, + &deployment, + )?; + } + } else { + Self::preflight_install_destination(&dest, &dir_name, &app)?; } - if let Some(deployment) = - Self::inspect_pi_skill_destination(&native, &native, &dir_name)? - { - Self::refresh_pi_skill_destination( - &dest, - &native, - &dir_name, - &deployment, - )?; + if !native.exists() { + Self::sync_to_app_dir(&dir_name, &app)?; } - } else { - Self::preflight_install_destination(&dest, &dir_name, &AppType::Mcode)?; + Ok(()) + })(); + if let Err(error) = projection { + skipped_mcode.push(format!("{dir_name}: {error}")); + continue 'imports; } - if !native.exists() { - Self::sync_to_app_dir(&dir_name, &AppType::Mcode)?; - } - Ok(()) - })(); - if let Err(error) = projection { - skipped_mcode.push(format!("{dir_name}: {error}")); - continue; } } - // 勾选的应用里还没有这个 Skill(它是在别处发现的):导入时就投影过去。 // 只存标志的话,界面显示已启用、应用却读不到,要等下次切换供应商才补上。 // 已经在应用目录里的保持原样;投影失败就不记这个应用的启用, // 让库里的标志始终等于磁盘上的事实。 for app in AppType::all() { - if matches!(app, AppType::Pi | AppType::Mcode) + if matches!(app, AppType::Pi | AppType::Mcode | AppType::Agy) || !apps.is_enabled_for(&app) || Self::skill_exists_in_app(&dir_name, &app) { @@ -2423,7 +2509,7 @@ impl SkillService { fn preflight_install_destination(source: &Path, directory: &str, app: &AppType) -> Result<()> { let ssot_dir = Self::get_ssot_dir()?; let app_dir = Self::get_distinct_app_skills_dir(&ssot_dir, app)?; - if !matches!(app, AppType::Pi | AppType::Mcode) { + if !matches!(app, AppType::Pi | AppType::Mcode | AppType::Agy) { return Ok(()); } let destination = app_dir.join(directory); @@ -2597,7 +2683,8 @@ impl SkillService { let dest = app_dir.join(&directory); - if matches!(app, AppType::Pi | AppType::Mcode) && (dest.exists() || Self::is_symlink(&dest)) + if matches!(app, AppType::Pi | AppType::Mcode | AppType::Agy) + && (dest.exists() || Self::is_symlink(&dest)) { Self::ensure_pi_skill_destination_matches(&source, &dest, &directory)?; } @@ -2794,7 +2881,7 @@ impl SkillService { } if skill_path.exists() || Self::is_symlink(&skill_path) { - if matches!(app, AppType::Pi | AppType::Mcode) { + if matches!(app, AppType::Pi | AppType::Mcode | AppType::Agy) { let source = ssot_dir.join(&directory); Self::ensure_pi_skill_destination_matches(&source, &skill_path, &directory)?; } @@ -2865,7 +2952,7 @@ impl SkillService { // Unselected MCode directories may have been installed outside CC Switch. // Explicit disable/uninstall handles removal of managed deployments. - if app_dir.exists() && !matches!(app, AppType::Mcode) { + if app_dir.exists() && !matches!(app, AppType::Mcode | AppType::Agy) { for entry in fs::read_dir(&app_dir)? { let entry = entry?; let path = entry.path(); @@ -3937,7 +4024,9 @@ impl SkillService { } for app in AppType::all() { - if matches!(app, AppType::Mcode) && !skill.apps.mcode { + if (matches!(app, AppType::Mcode) && !skill.apps.mcode) + || (matches!(app, AppType::Agy) && !skill.apps.agy) + { continue; } let app_dir = match Self::get_app_skills_dir(&app) { @@ -5674,6 +5763,68 @@ mod tests { .contains("external")); } + #[test] + #[serial_test::serial] + fn agy_skill_toggle_rolls_back_on_database_failure() { + let temp = tempdir().unwrap(); + let _home = TestHomeGuard::set(temp.path()); + let _location = StorageLocationGuard::set(&_home, SkillStorageLocation::CcSwitch); + let db = Arc::new(Database::memory().unwrap()); + let mut skill = poisoned_skill("local:test-skill", "test-skill"); + skill.apps.agy = true; + db.save_skill(&skill).unwrap(); + let source = SkillService::get_ssot_dir().unwrap().join(&skill.directory); + write_skill(&source, "original"); + SkillService::sync_to_app_dir(&skill.directory, &AppType::Agy).unwrap(); + let native = SkillService::get_app_skills_dir(&AppType::Agy) + .unwrap() + .join(&skill.directory); + let original = fs::read(native.join("SKILL.md")).unwrap(); + db.conn + .lock() + .unwrap() + .execute_batch("PRAGMA query_only=ON") + .unwrap(); + assert!(SkillService::toggle_app(&db, &skill.id, &AppType::Agy, false).is_err()); + assert_eq!(fs::read(native.join("SKILL.md")).unwrap(), original); + assert!(db.get_installed_skill(&skill.id).unwrap().unwrap().apps.agy); + db.conn + .lock() + .unwrap() + .execute_batch("PRAGMA query_only=OFF") + .unwrap(); + SkillService::toggle_app(&db, &skill.id, &AppType::Agy, false).unwrap(); + assert!(!native.exists()); + SkillService::toggle_app(&db, &skill.id, &AppType::Agy, true).unwrap(); + assert_eq!(fs::read(native.join("SKILL.md")).unwrap(), original); + } + + #[test] + #[serial_test::serial] + fn agy_unmanaged_copy_survives_sync_migration_and_uninstall() { + let temp = tempdir().unwrap(); + let _home = TestHomeGuard::set(temp.path()); + let _location = StorageLocationGuard::set(&_home, SkillStorageLocation::CcSwitch); + let _pi_dir = crate::pi_config::test_support::TestAgentDir::new(); + let db = Arc::new(Database::memory().unwrap()); + let mut skill = poisoned_skill("owner/repo:skill", "test-skill"); + skill.apps.claude = true; + db.save_skill(&skill).unwrap(); + let source = SkillService::get_ssot_dir().unwrap().join(&skill.directory); + let native = SkillService::get_app_skills_dir(&AppType::Agy) + .unwrap() + .join(&skill.directory); + write_skill(&source, "same-content"); + SkillService::copy_dir_recursive(&source, &native).unwrap(); + let original = fs::read(native.join("SKILL.md")).unwrap(); + SkillService::sync_to_app(&db, &AppType::Agy).unwrap(); + assert_eq!(fs::read(native.join("SKILL.md")).unwrap(), original); + SkillService::migrate_storage(&db, SkillStorageLocation::Unified).unwrap(); + assert_eq!(fs::read(native.join("SKILL.md")).unwrap(), original); + SkillService::uninstall(&db, &skill.id).unwrap(); + assert_eq!(fs::read(native.join("SKILL.md")).unwrap(), original); + } + #[test] #[serial_test::serial] fn mcode_unmanaged_copy_survives_sync_migration_and_uninstall() { @@ -5700,6 +5851,67 @@ mod tests { assert_eq!(fs::read(native.join("SKILL.md")).unwrap(), original); } + #[test] + #[cfg(unix)] + #[serial_test::serial] + fn agy_imported_external_symlink_can_toggle_without_changing_its_source() { + let temp = tempdir().unwrap(); + let _home = TestHomeGuard::set(temp.path()); + let _location = StorageLocationGuard::set(&_home, SkillStorageLocation::CcSwitch); + let _pi_dir = crate::pi_config::test_support::TestAgentDir::new(); + let db = Arc::new(Database::memory().unwrap()); + let external = temp.path().join("external-skill"); + write_skill(&external, "external"); + let original = fs::read(external.join("SKILL.md")).unwrap(); + let native = SkillService::get_app_skills_dir(&AppType::Agy) + .unwrap() + .join("test-skill"); + fs::create_dir_all(native.parent().unwrap()).unwrap(); + SkillService::create_symlink(&external, &native).unwrap(); + let imported = SkillService::import_from_apps( + &db, + vec![ImportSkillSelection { + directory: "test-skill".into(), + apps: SkillApps { + agy: true, + ..Default::default() + }, + }], + ) + .unwrap(); + assert!(SkillService::paths_alias( + &native, + &SkillService::get_ssot_dir().unwrap().join("test-skill") + )); + SkillService::toggle_app(&db, &imported[0].id, &AppType::Agy, false).unwrap(); + assert!(!native.exists()); + assert_eq!(fs::read(external.join("SKILL.md")).unwrap(), original); + SkillService::toggle_app(&db, &imported[0].id, &AppType::Agy, true).unwrap(); + assert_eq!(fs::read(native.join("SKILL.md")).unwrap(), original); + + SkillService::remove_path(&native).unwrap(); + SkillService::create_symlink(&external, &native).unwrap(); + write_skill( + &SkillService::get_ssot_dir().unwrap().join("test-skill"), + "different", + ); + let empty_db = Arc::new(Database::memory().unwrap()); + assert!(SkillService::import_from_apps( + &empty_db, + vec![ImportSkillSelection { + directory: "test-skill".into(), + apps: SkillApps { + agy: true, + ..Default::default() + }, + }], + ) + .is_err()); + assert!(empty_db.get_all_installed_skills().unwrap().is_empty()); + assert!(SkillService::paths_alias(&native, &external)); + assert_eq!(fs::read(external.join("SKILL.md")).unwrap(), original); + } + #[test] #[cfg(unix)] #[serial_test::serial] diff --git a/src-tauri/src/services/stream_check.rs b/src-tauri/src/services/stream_check.rs index ea84c9f32..174dd67a4 100644 --- a/src-tauri/src/services/stream_check.rs +++ b/src-tauri/src/services/stream_check.rs @@ -24,7 +24,10 @@ use std::time::Instant; use crate::app_config::AppType; use crate::error::AppError; use crate::provider::Provider; -use crate::proxy::providers::{get_adapter, ClaudeAdapter, ProviderAdapter}; +use crate::proxy::providers::{get_adapter, ClaudeAdapter, GeminiAdapter, ProviderAdapter}; + +/// Google Gemini 默认端点(agy 未配置自定义端点时使用)。 +const DEFAULT_GEMINI_BASE_URL: &str = "https://generativelanguage.googleapis.com"; /// 健康状态枚举 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -177,6 +180,20 @@ impl StreamCheckService { AppType::ClaudeDesktop => ClaudeAdapter::new() .extract_base_url(provider) .map_err(|e| AppError::Message(format!("Failed to extract base_url: {e}"))), + // agy 的 settings 与 Gemini 同形(env.GOOGLE_GEMINI_BASE_URL),借用 Gemini + // adapter 提取;未配置端点时 agy 自身走 Google 默认端点,这里保持一致。 + AppType::Agy => { + if crate::services::provider::agy_direct::is_official(provider) { + return Err(AppError::Message( + "Official providers do not expose a reachability-check target".to_string(), + )); + } + Ok(GeminiAdapter::new() + .extract_base_url(provider) + .ok() + .filter(|url| !url.trim().is_empty()) + .unwrap_or_else(|| DEFAULT_GEMINI_BASE_URL.to_string())) + } _ => get_adapter(app_type) .ok_or_else(|| { AppError::InvalidInput(format!( @@ -515,4 +532,56 @@ mod tests { official.category = Some("official".to_string()); assert!(StreamCheckService::resolve_base_url(&AppType::Codex, &official).is_err()); } + + #[test] + fn test_resolve_base_url_agy_uses_gemini_env_and_google_default() { + let custom = make_provider( + serde_json::json!({ "env": { "GEMINI_API_KEY": "k", "GOOGLE_GEMINI_BASE_URL": "https://relay.example/" } }), + ); + assert_eq!( + StreamCheckService::resolve_base_url(&AppType::Agy, &custom).unwrap(), + "https://relay.example" + ); + + let unset = make_provider(serde_json::json!({ "env": { "GEMINI_API_KEY": "k" } })); + assert_eq!( + StreamCheckService::resolve_base_url(&AppType::Agy, &unset).unwrap(), + DEFAULT_GEMINI_BASE_URL + ); + + let mut official = make_provider(serde_json::json!({ "env": {} })); + official.category = Some("official".to_string()); + assert!(StreamCheckService::resolve_base_url(&AppType::Agy, &official).is_err()); + } + + #[tokio::test] + async fn test_agy_reachability_check_end_to_end() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + let mut buf = [0u8; 1024]; + let _ = socket.read(&mut buf).await; + let _ = socket + .write_all( + b"HTTP/1.1 404 Not Found\r\ncontent-length: 0\r\nconnection: close\r\n\r\n", + ) + .await; + } + }); + let provider = make_provider(serde_json::json!({ + "env": { "GEMINI_API_KEY": "k", "GOOGLE_GEMINI_BASE_URL": format!("http://{addr}") } + })); + let result = StreamCheckService::check_with_retry( + &AppType::Agy, + &provider, + &StreamCheckConfig::default(), + None, + ) + .await + .unwrap(); + assert!(result.success, "{}", result.message); + assert_eq!(result.http_status, Some(404)); + } } diff --git a/src-tauri/src/services/usage_stats.rs b/src-tauri/src/services/usage_stats.rs index 3a723f8cf..516dca606 100644 --- a/src-tauri/src/services/usage_stats.rs +++ b/src-tauri/src/services/usage_stats.rs @@ -386,6 +386,7 @@ fn provider_name_coalesce(log_alias: &str, provider_alias: &str) -> String { WHEN '_gemini_session' THEN 'Gemini (Session)' \ WHEN '_opencode_session' THEN 'OpenCode (Session)' \ WHEN '_grok_session' THEN 'Grok Build (Session)' \ + WHEN '_agy_session' THEN 'Antigravity (Session)' \ WHEN '_mcode_session' THEN 'MiniMax Code (Session)' \ WHEN '_pi_session' THEN 'Pi (Session)' \ ELSE {log_alias}.provider_id END)" diff --git a/src-tauri/src/session_manager/content.rs b/src-tauri/src/session_manager/content.rs index 59441af1d..061eb7c40 100644 --- a/src-tauri/src/session_manager/content.rs +++ b/src-tauri/src/session_manager/content.rs @@ -657,6 +657,7 @@ fn project_dir_for(source: &ValidatedSource) -> Option { "hermes" => hermes::scan_sessions(), "pi" => pi::scan_sessions(), "mcode" => mcode::scan_sessions(), + "agy" => super::providers::agy::scan_sessions(), _ => return None, }; let project = sessions diff --git a/src-tauri/src/session_manager/mod.rs b/src-tauri/src/session_manager/mod.rs index 972a9a221..b743d91ed 100644 --- a/src-tauri/src/session_manager/mod.rs +++ b/src-tauri/src/session_manager/mod.rs @@ -13,7 +13,7 @@ use std::time::Instant; // 其余模型类型从 `session_manager::model` 引用 pub use model::SessionMessage; -use providers::{claude, codex, gemini, grokbuild, hermes, mcode, openclaw, opencode, pi}; +use providers::{agy, claude, codex, gemini, grokbuild, hermes, mcode, openclaw, opencode, pi}; #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -87,6 +87,7 @@ pub fn scan_sessions() -> Vec { sessions.extend(r7); sessions.extend(r8); sessions.extend(mcode::scan_sessions()); + sessions.extend(agy::scan_sessions()); sessions.sort_by(|a, b| { let a_ts = a.last_active_at.or(a.created_at).unwrap_or(0); @@ -116,6 +117,7 @@ pub fn load_messages(provider_id: &str, source_path: &str) -> Result opencode::load_messages(path), "openclaw" => openclaw::load_messages(path), "gemini" => gemini::load_messages(path), + "agy" => agy::load_messages(path), "grokbuild" => grokbuild::load_messages(path), "hermes" => hermes::load_messages(path), "pi" => pi::load_messages(path), @@ -166,6 +168,12 @@ fn delete_session_source( session_id: &str, source_path: &str, ) -> Result { + if provider_id == "agy" { + return Err( + "Delete this session in Antigravity to keep its binary store and summaries consistent" + .into(), + ); + } if provider_id == "mcode" { return Err( "Delete this session in MCode so its runtime state and history are removed together" @@ -226,6 +234,7 @@ fn provider_roots(provider_id: &str) -> Result, String> { "grokbuild" => grokbuild::session_roots(), "hermes" => vec![crate::hermes_config::get_hermes_dir().join("sessions")], "pi" => pi::session_roots(), + "agy" => agy::session_roots(), _ => return Err(format!("Unsupported provider: {provider_id}")), }; diff --git a/src-tauri/src/session_manager/providers/agy.rs b/src-tauri/src/session_manager/providers/agy.rs new file mode 100644 index 000000000..02212e4de --- /dev/null +++ b/src-tauri/src/session_manager/providers/agy.rs @@ -0,0 +1,335 @@ +//! Read Antigravity's exported transcript; never open its binary conversation store for writing. +use super::{ + blocks::assign_turn_ids, + utils::{parse_timestamp_to_ms, truncate_summary}, +}; +use crate::session_manager::model::{MessageMeta, SessionBlock}; +use crate::session_manager::{SessionMessage, SessionMeta}; +use serde_json::Value; +use std::{ + collections::BTreeMap, + fs, + io::{BufRead, BufReader}, + path::{Path, PathBuf}, +}; + +/// Antigravity CLI keeps sessions in `~/.gemini/antigravity-cli/brain`, while the IDE writes the +/// same layout to `~/.gemini/antigravity/brain`. Both are scanned. +pub fn session_roots() -> Vec { + let cli = crate::agy_config::agy_dir(); + let ide = cli.with_file_name("antigravity"); + vec![cli.join("brain"), ide.join("brain")] +} + +pub(crate) fn records(path: &Path) -> Result, String> { + let file = + fs::File::open(path).map_err(|e| format!("Cannot read Antigravity transcript: {e}"))?; + // The export can rewrite steps as they finish. The latest record for a step wins. + let mut steps = BTreeMap::new(); + for line in BufReader::new(file).lines() { + let line = line.map_err(|e| format!("Cannot read Antigravity transcript: {e}"))?; + let Ok(value) = serde_json::from_str::(&line) else { + continue; + }; + if let Some(index) = value["step_index"].as_u64() { + steps.insert(index, value); + } + } + Ok(steps.into_values().collect()) +} + +pub fn scan_sessions() -> Vec { + scan_roots(&session_roots()) +} + +/// Missing roots are tolerated; a session id found in several roots is reported once (first wins). +fn scan_roots(roots: &[PathBuf]) -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut sessions = Vec::new(); + for root in roots { + for meta in scan_root(root) { + if seen.insert(meta.session_id.clone()) { + sessions.push(meta); + } + } + } + sessions +} + +fn valid_session_id(id: &str) -> bool { + !id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') +} + +fn scan_root(root: &Path) -> Vec { + let mut summaries = read_summaries( + &root + .parent() + .unwrap_or(root) + .join("conversation_summaries.db"), + ); + let canonical_root = root.canonicalize().ok(); + let mut sessions = Vec::new(); + if let Ok(entries) = fs::read_dir(root) { + for entry in entries.flatten() { + if !entry.file_type().is_ok_and(|t| t.is_dir()) { + continue; + } + let Some(id) = entry + .file_name() + .to_str() + .map(str::to_string) + .filter(|id| valid_session_id(id)) + else { + continue; + }; + let path = entry + .path() + .join(".system_generated/logs/transcript_full.jsonl"); + if !path.is_file() + || !path + .canonicalize() + .ok() + .zip(canonical_root.as_ref()) + .is_some_and(|(path, root)| path.starts_with(root)) + { + continue; + } + let summary = summaries.remove(&id); + // Avoid parsing every transcript on list refresh when agy's own index is available. + let mut meta = if let Some(meta) = summary { + meta + } else { + let Ok(rows) = records(&path) else { continue }; + let first = rows + .iter() + .find(|v| v["type"] == "USER_INPUT") + .and_then(|v| v["content"].as_str()); + SessionMeta { + provider_id: "agy".into(), + session_id: id.clone(), + title: first.map(|s| truncate_summary(s, 80)), + summary: first.map(|s| truncate_summary(s, 160)), + project_dir: None, + created_at: rows.iter().find_map(timestamp), + last_active_at: rows.iter().filter_map(timestamp).max(), + source_path: None, + resume_command: Some(format!("agy --conversation={id}")), + } + }; + meta.source_path = Some(path.to_string_lossy().into_owned()); + sessions.push(meta); + } + } + // A binary-only conversation can still be found and resumed, but has no readable transcript. + sessions.extend(summaries.into_values()); + sessions +} + +fn read_summaries(path: &Path) -> BTreeMap { + let mut result = BTreeMap::new(); + let Ok(conn) = + rusqlite::Connection::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) + else { + return result; + }; + let Ok(mut query) = conn.prepare("SELECT conversation_id, title, workspace_uris, preview, last_modified_time FROM conversation_summaries") else { return result }; + let Ok(rows) = query.query_map([], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, String>(2)?, + r.get::<_, String>(3)?, + r.get::<_, String>(4)?, + )) + }) else { + return result; + }; + for (id, title, uris, preview, modified) in rows.flatten() { + if !valid_session_id(&id) { + continue; + } + let project_dir = serde_json::from_str::>(&uris) + .ok() + .and_then(|v| v.into_iter().next()) + .and_then(|uri| url::Url::parse(&uri).ok()?.to_file_path().ok()) + .map(|p| p.to_string_lossy().into_owned()); + let last_active_at = + parse_timestamp_to_ms(&Value::String(modified.clone())).or_else(|| { + chrono::NaiveDateTime::parse_from_str(&modified, "%Y-%m-%d %H:%M:%S%.f") + .ok() + .map(|t| t.and_utc().timestamp_millis()) + }); + result.insert( + id.clone(), + SessionMeta { + provider_id: "agy".into(), + session_id: id.clone(), + title: (!title.is_empty()).then_some(title), + summary: (!preview.is_empty()).then(|| truncate_summary(&preview, 160)), + project_dir, + created_at: None, + last_active_at, + source_path: None, + resume_command: Some(format!("agy --conversation={id}")), + }, + ); + } + result +} + +pub(crate) fn timestamp(value: &Value) -> Option { + parse_timestamp_to_ms(&value["created_at"]) +} + +pub fn load_messages(path: &Path) -> Result, String> { + let mut messages = Vec::new(); + for value in records(path)? { + let role = match value["type"].as_str() { + Some("USER_INPUT") => "user", + Some("PLANNER_RESPONSE") => "assistant", + _ if value["source"] == "MODEL" => "assistant", + _ => "system", + }; + let content = value["content"].as_str().unwrap_or(""); + // Keep unrecognized steps visible instead of silently dropping future tool/event types. + let content = if content.is_empty() { + serde_json::to_string_pretty(&value).unwrap_or_default() + } else { + content.into() + }; + let mut message = SessionMessage::from_blocks( + role, + timestamp(&value), + vec![SessionBlock::Text { + text: content, + full: None, + }], + ); + message.id = value["step_index"].as_u64().map(|n| n.to_string()); + message.meta = Some(MessageMeta { + model: value["model"].as_str().map(String::from), + input_tokens: value["input_tokens"].as_u64(), + output_tokens: value["output_tokens"].as_u64(), + cache_read_tokens: value["cache_read_tokens"].as_u64(), + cache_write_tokens: value["cache_write_tokens"].as_u64(), + reasoning_tokens: value["reasoning_tokens"].as_u64(), + ..Default::default() + }); + messages.push(message); + } + assign_turn_ids(&mut messages); + Ok(messages) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn agy_binary_only_sessions_keep_index_metadata_without_fabricated_transcripts() { + let dir = tempfile::tempdir().unwrap(); + let project = dir.path().join("project space"); + let workspace_uris = + serde_json::to_string(&[url::Url::from_file_path(&project).unwrap().to_string()]) + .unwrap(); + let db = rusqlite::Connection::open(dir.path().join("conversation_summaries.db")).unwrap(); + db.execute_batch("CREATE TABLE conversation_summaries(conversation_id TEXT,title TEXT,workspace_uris TEXT,preview TEXT,last_modified_time TEXT);").unwrap(); + db.execute( + "INSERT INTO conversation_summaries VALUES (?1,?2,?3,?4,?5)", + rusqlite::params![ + "abc-123", + "Test title", + workspace_uris, + "Preview", + "2026-10-13T17:26:12Z" + ], + ) + .unwrap(); + let sessions = scan_root(&dir.path().join("brain")); + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].title.as_deref(), Some("Test title")); + assert_eq!( + sessions[0].project_dir.as_deref(), + Some(project.to_string_lossy().as_ref()) + ); + assert!(sessions[0].source_path.is_none()); + assert!(sessions[0].last_active_at.is_some()); + } + + #[test] + fn agy_scans_cli_and_ide_roots_deduplicating_and_tolerating_missing() { + let home = tempfile::tempdir().unwrap(); + let write = |root: &str, id: &str| { + let logs = home + .path() + .join(root) + .join("brain") + .join(id) + .join(".system_generated/logs"); + fs::create_dir_all(&logs).unwrap(); + fs::write( + logs.join("transcript_full.jsonl"), + r#"{"step_index":0,"type":"USER_INPUT","content":"hi"}"#, + ) + .unwrap(); + }; + write("antigravity-cli", "cli-1"); + write("antigravity", "ide-1"); + write("antigravity", "cli-1"); + let roots = vec![ + home.path().join("antigravity-cli/brain"), + home.path().join("antigravity/brain"), + home.path().join("missing/brain"), + ]; + let mut ids: Vec<_> = scan_roots(&roots) + .into_iter() + .map(|s| s.session_id) + .collect(); + ids.sort(); + assert_eq!(ids, ["cli-1", "ide-1"]); + } + + #[test] + #[cfg(unix)] + fn agy_discovery_does_not_follow_transcripts_outside_its_root() { + let root = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let session = root.path().join("abc-123"); + fs::create_dir_all(&session).unwrap(); + let logs = outside.path().join("logs"); + fs::create_dir_all(&logs).unwrap(); + fs::write( + logs.join("transcript_full.jsonl"), + r#"{"step_index":0,"type":"USER_INPUT","content":"outside"}"#, + ) + .unwrap(); + std::os::unix::fs::symlink(outside.path(), session.join(".system_generated")).unwrap(); + assert!(scan_root(root.path()).is_empty()); + } + + #[test] + fn agy_reads_tokens_rewritten_steps_and_partial_tail() { + let dir = tempfile::tempdir().unwrap(); + let logs = dir.path().join("abc-123/.system_generated/logs"); + fs::create_dir_all(&logs).unwrap(); + let file = logs.join("transcript_full.jsonl"); + fs::write(&file, concat!( + "{\"step_index\":0,\"type\":\"USER_INPUT\",\"content\":\"hello\",\"created_at\":\"2026-10-13T17:26:12Z\"}\n", + "{\"step_index\":1,\"type\":\"PLANNER_RESPONSE\",\"content\":\"draft\"}\n", + "{\"step_index\":1,\"type\":\"PLANNER_RESPONSE\",\"content\":\"done\",\"input_tokens\":100,\"output_tokens\":12,\"cache_read_tokens\":20}\n{" + )).unwrap(); + let sessions = scan_root(dir.path()); + assert_eq!(sessions.len(), 1); + assert_eq!( + sessions[0].resume_command.as_deref(), + Some("agy --conversation=abc-123") + ); + let messages = load_messages(&file).unwrap(); + assert_eq!(messages.len(), 2); + assert_eq!(messages[1].content, "done"); + assert_eq!(messages[1].meta.as_ref().unwrap().input_tokens, Some(100)); + assert_eq!( + messages[1].meta.as_ref().unwrap().cache_read_tokens, + Some(20) + ); + } +} diff --git a/src-tauri/src/session_manager/providers/mod.rs b/src-tauri/src/session_manager/providers/mod.rs index cea933d80..a194f3db9 100644 --- a/src-tauri/src/session_manager/providers/mod.rs +++ b/src-tauri/src/session_manager/providers/mod.rs @@ -1,3 +1,4 @@ +pub mod agy; mod blocks; pub mod claude; pub mod codex; diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 9e142a2af..5a5903a1d 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -53,6 +53,8 @@ pub struct VisibleApps { pub pi: bool, #[serde(default = "default_true")] pub mcode: bool, + #[serde(default = "default_true")] + pub agy: bool, } impl Default for VisibleApps { @@ -68,6 +70,7 @@ impl Default for VisibleApps { hermes: false, // 默认不显示,需用户手动启用 pi: true, mcode: true, + agy: true, } } } @@ -86,6 +89,7 @@ impl VisibleApps { AppType::Hermes => self.hermes, AppType::Pi => self.pi, AppType::Mcode => self.mcode, + AppType::Agy => self.agy, } } } @@ -556,6 +560,9 @@ pub struct AppSettings { /// 当前 Hermes 供应商 ID(本地存储,保持结构一致) #[serde(default, skip_serializing_if = "Option::is_none")] pub current_provider_hermes: Option, + /// 当前 Antigravity CLI 供应商 ID + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_provider_agy: Option, // ===== 本地路由(设备级)===== /// 写给客户端的代理主机(IP 或主机名),空/未设时按监听地址推(`0.0.0.0` → `127.0.0.1`)。 @@ -670,6 +677,7 @@ impl Default for AppSettings { current_provider_opencode: None, current_provider_openclaw: None, current_provider_hermes: None, + current_provider_agy: None, proxy_client_host: None, skill_sync_method: SyncMethod::default(), skill_storage_location: SkillStorageLocation::default(), @@ -1328,6 +1336,7 @@ pub fn get_current_provider(app_type: &AppType) -> Option { AppType::OpenCode => settings.current_provider_opencode.clone(), AppType::OpenClaw => settings.current_provider_openclaw.clone(), AppType::Hermes => settings.current_provider_hermes.clone(), + AppType::Agy => settings.current_provider_agy.clone(), AppType::Pi | AppType::Mcode => None, } } @@ -1347,6 +1356,7 @@ pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(), AppType::OpenCode => settings.current_provider_opencode = id_owned.clone(), AppType::OpenClaw => settings.current_provider_openclaw = id_owned.clone(), AppType::Hermes => settings.current_provider_hermes = id_owned.clone(), + AppType::Agy => settings.current_provider_agy = id_owned.clone(), AppType::Pi | AppType::Mcode => {} }) } diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index ef59b3623..082d3ef8f 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -55,6 +55,7 @@ fn app_display_name(app: &AppType) -> &'static str { AppType::Hermes => "Hermes", AppType::Pi => "Pi", AppType::Mcode => "MiniMax Code", + AppType::Agy => "Antigravity", } } diff --git a/src-tauri/tests/deeplink_import.rs b/src-tauri/tests/deeplink_import.rs index 460ff0952..9ff96cab9 100644 --- a/src-tauri/tests/deeplink_import.rs +++ b/src-tauri/tests/deeplink_import.rs @@ -123,3 +123,22 @@ fn deeplink_import_codex_provider_builds_auth_and_config() { "config.toml content should contain model setting" ); } + +#[test] +fn agy_deeplink_disabled_provider_does_not_write_native_files() { + let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner()); + reset_test_fs(); + let home = ensure_test_home(); + let request = parse_deeplink_url("ccswitch://v1/import?resource=provider&app=agy&name=Agy&endpoint=https://relay.example&apiKey=fixture&model=test-model&enabled=false").unwrap(); + let db = Arc::new(Database::memory().unwrap()); + let state = AppState::new(db.clone()); + let id = import_provider_from_deeplink(&state, request).unwrap(); + let rows = db.get_all_providers("agy").unwrap(); + assert_eq!( + rows[&id].settings_config["config"]["model"]["name"], + "test-model" + ); + assert!(db.get_current_provider("agy").unwrap().is_none()); + assert!(!home.join(".gemini/antigravity-cli/settings.json").exists()); + assert!(!home.join(".gemini/antigravity-cli/bin/agy").exists()); +} diff --git a/src-tauri/tests/import_export_sync.rs b/src-tauri/tests/import_export_sync.rs index f4c045867..83bd3decb 100644 --- a/src-tauri/tests/import_export_sync.rs +++ b/src-tauri/tests/import_export_sync.rs @@ -417,6 +417,7 @@ command = "echo" "command": "prev" }), apps: cc_switch_lib::McpApps { + agy: false, claude: false, codex: false, // 初始未启用 gemini: false, @@ -549,6 +550,7 @@ fn import_from_claude_merges_into_config() { "command": "prev" }), apps: cc_switch_lib::McpApps { + agy: false, claude: false, // 初始未启用 codex: false, gemini: false, diff --git a/src-tauri/tests/mcp_commands.rs b/src-tauri/tests/mcp_commands.rs index 488474cf2..69a72dcbf 100644 --- a/src-tauri/tests/mcp_commands.rs +++ b/src-tauri/tests/mcp_commands.rs @@ -15,6 +15,47 @@ use support::{ create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex, }; +#[test] +fn deleting_other_app_mcp_preserves_matching_unmanaged_disabled_agy_entry() { + let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner()); + reset_test_fs(); + let state = create_test_state().unwrap(); + let path = ensure_test_home().join(".gemini/config/mcp_config.json"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let original = r#"{"mcpServers":{"shared":{"command":"echo","args":["fixture"],"disabled":true,"timeout":12345,"description":"user-owned"}}}"#; + fs::write(&path, original).unwrap(); + let server: McpServer = serde_json::from_value(json!({ + "id":"shared", "name":"Shared", "server":{"command":"echo","args":["fixture"]}, + "apps":{"claude":true} + })) + .unwrap(); + state.db.save_mcp_server(&server).unwrap(); + assert!(McpService::delete_server(&state, "shared").unwrap()); + assert!(!state + .db + .get_all_mcp_servers() + .unwrap() + .contains_key("shared")); + assert_eq!(fs::read_to_string(&path).unwrap(), original); +} + +#[test] +fn deleting_enabled_agy_mcp_removes_its_native_entry() { + let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner()); + reset_test_fs(); + let state = create_test_state().unwrap(); + let server: McpServer = serde_json::from_value(json!({ + "id":"managed", "name":"Managed", "server":{"command":"echo"}, "apps":{"agy":true} + })) + .unwrap(); + McpService::upsert_server(&state, server).unwrap(); + assert!(McpService::delete_server(&state, "managed").unwrap()); + let path = ensure_test_home().join(".gemini/config/mcp_config.json"); + let native: serde_json::Value = + serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap(); + assert!(native["mcpServers"].get("managed").is_none()); +} + #[test] fn mcode_import_ignores_native_metadata_and_continues_after_conflicts() { let _guard = test_mutex().lock().unwrap(); @@ -711,6 +752,7 @@ command = "echo" "command": "echo" }), apps: McpApps { + agy: false, claude: false, codex: true, gemini: false, @@ -858,6 +900,7 @@ fn set_mcp_enabled_for_codex_writes_live_config() { "command": "echo" }), apps: McpApps { + agy: false, claude: false, codex: false, // 初始未启用 gemini: false, @@ -926,6 +969,7 @@ fn enabling_codex_mcp_skips_when_codex_dir_missing() { "command": "echo" }), apps: McpApps { + agy: false, claude: false, codex: false, gemini: false, @@ -974,6 +1018,7 @@ fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() { "command": "echo" }), apps: McpApps { + agy: false, claude: true, codex: false, gemini: false, @@ -1011,6 +1056,7 @@ fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() { "command": "echo" }), apps: McpApps { + agy: false, claude: false, codex: false, gemini: false, @@ -1194,6 +1240,7 @@ fn enabling_gemini_mcp_skips_when_gemini_dir_missing() { "url": "https://example.com/sse" }), apps: McpApps { + agy: false, claude: false, codex: false, gemini: false, @@ -1252,6 +1299,7 @@ fn enabling_claude_mcp_skips_when_claude_config_absent() { "command": "echo" }), apps: McpApps { + agy: false, claude: false, codex: false, gemini: false, @@ -1310,6 +1358,7 @@ fn explicit_default_claude_dir_keeps_default_split_mcp_path() { "command": "echo" }), apps: McpApps { + agy: false, claude: true, codex: false, gemini: false, @@ -1369,6 +1418,7 @@ fn custom_claude_dir_writes_mcp_inside_config_dir() { "command": "echo" }), apps: McpApps { + agy: false, claude: true, codex: false, gemini: false, @@ -1451,6 +1501,7 @@ fn custom_claude_dir_sync_does_not_copy_default_profile() { "command": "custom-command" }), apps: McpApps { + agy: false, claude: true, codex: false, gemini: false, @@ -1593,6 +1644,7 @@ fn sync_all_enabled_removes_known_disabled_but_preserves_unknown_live_entries() "command": "echo" }), apps: McpApps { + agy: false, claude: false, codex: false, gemini: false, @@ -1618,6 +1670,7 @@ fn sync_all_enabled_removes_known_disabled_but_preserves_unknown_live_entries() "command": "managed" }), apps: McpApps { + agy: false, claude: true, codex: false, gemini: false, @@ -1670,7 +1723,8 @@ fn resync_targets_default_to_managed_apps_and_reject_unsupported() { "opencode", "hermes", "pi", - "mcode" + "mcode", + "agy" ] ); assert_eq!(McpService::resync_targets(Some(&[])).unwrap(), all); diff --git a/src-tauri/tests/provider_commands.rs b/src-tauri/tests/provider_commands.rs index 05608bae7..174cb8a4d 100644 --- a/src-tauri/tests/provider_commands.rs +++ b/src-tauri/tests/provider_commands.rs @@ -19,6 +19,86 @@ fn settings_path(home: &Path) -> PathBuf { home.join(".cc-switch").join("settings.json") } +#[test] +fn agy_missing_native_config_is_not_imported_or_created_by_full_sync() { + let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner()); + reset_test_fs(); + let home = ensure_test_home(); + let shell_files = [ + ".zshenv", + ".bashrc", + ".bash_profile", + ".bash_login", + ".profile", + ]; + for name in shell_files { + std::fs::write(home.join(name), "# existing user shell configuration\n").unwrap(); + } + let state = create_test_state().unwrap(); + let error = import_default_config_test_hook(&state, AppType::Agy).unwrap_err(); + assert!(matches!( + error, + AppError::Localized { + key: "agy.config.missing", + .. + } + )); + assert!(state.db.get_all_providers("agy").unwrap().is_empty()); + assert!(cc_switch_lib::mode::current::provider_for( + &state.db, + &AppType::Agy, + cc_switch_lib::mode::current::Purpose::Direct + ) + .unwrap() + .is_none()); + ProviderService::sync_current_to_live(&state).unwrap(); + assert!(!home.join(".gemini/antigravity-cli").exists()); + for name in shell_files { + assert_eq!( + std::fs::read_to_string(home.join(name)).unwrap(), + "# existing user shell configuration\n" + ); + } +} + +#[test] +fn agy_import_skips_empty_settings_but_keeps_key_and_model_configs() { + let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner()); + for (file, content, should_import, has_key) in [ + ("settings.json", "{}", false, false), + ("settings.json", r#"{"model":"Gemini Test"}"#, true, false), + ("env", "GEMINI_API_KEY='fixture'\n", true, true), + ] { + reset_test_fs(); + let root = ensure_test_home().join(".gemini/antigravity-cli"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join(file), content).unwrap(); + let state = create_test_state().unwrap(); + assert_eq!( + import_default_config_test_hook(&state, AppType::Agy).unwrap(), + should_import + ); + let provider = state.db.get_provider_by_id("default", "agy").unwrap(); + assert_eq!(provider.is_some(), should_import); + if let Some(provider) = provider { + assert_eq!( + provider.settings_config["env"]["GEMINI_API_KEY"].is_string(), + has_key + ); + } else { + assert!(cc_switch_lib::mode::current::provider_for( + &state.db, + &AppType::Agy, + cc_switch_lib::mode::current::Purpose::Direct + ) + .unwrap() + .is_none()); + } + assert_eq!(std::fs::read_to_string(root.join(file)).unwrap(), content); + assert!(!root.join("bin").exists()); + } +} + fn grokbuild_config(name: &str, endpoint: &str, api_key: &str) -> String { format!( r#"[models] @@ -447,6 +527,7 @@ command = "say" "command": "echo" }), apps: McpApps { + agy: false, claude: false, codex: true, // 启用 Codex gemini: false, diff --git a/src-tauri/tests/provider_service.rs b/src-tauri/tests/provider_service.rs index 0abd157a7..9cd85d8ae 100644 --- a/src-tauri/tests/provider_service.rs +++ b/src-tauri/tests/provider_service.rs @@ -161,6 +161,7 @@ command = "say" "command": "echo" }), apps: McpApps { + agy: false, claude: false, codex: true, gemini: false, @@ -1571,6 +1572,7 @@ fn switch_codex_ignores_a_broken_claude_json() { "command": "echo" }), apps: McpApps { + agy: false, claude: false, codex: true, gemini: false, @@ -1634,6 +1636,7 @@ fn sync_all_enabled_reports_broken_app_but_projects_the_rest() { "command": "echo" }), apps: McpApps { + agy: false, claude: false, codex: true, gemini: false, diff --git a/src/App.tsx b/src/App.tsx index 21061e40c..4071826e2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -588,7 +588,7 @@ function App() { useEffect(() => { const checkEnvOnSwitch = async () => { try { - if (activeApp === "mcode") return; + if (activeApp === "mcode" || activeApp === "agy") return; const conflicts = await checkEnvConflicts(activeApp); if (conflicts.length > 0) { diff --git a/src/components/DeepLinkImportDialog.tsx b/src/components/DeepLinkImportDialog.tsx index 571480250..f949fcdf9 100644 --- a/src/components/DeepLinkImportDialog.tsx +++ b/src/components/DeepLinkImportDialog.tsx @@ -758,9 +758,16 @@ export function DeepLinkImportDialog() { )} {/* Gemini config */} - {parsedConfig.type === "gemini" && + {(parsedConfig.type === "gemini" || + parsedConfig.type === "agy") && parsedConfig.env && (
+ {parsedConfig.model && ( + + )} {Object.entries(parsedConfig.env).map( ([key, value]) => ( ; @@ -219,7 +220,7 @@ function isOfficialSubscriptionProvider(provider: Provider, appId: AppId) { (!apiKey || (typeof apiKey === "string" && apiKey.trim() === "")) ); } - if (appId === "gemini") { + if (appId === "gemini" || appId === "agy") { const env = config?.env || {}; const apiKey = env.GEMINI_API_KEY; const baseUrl = env.GOOGLE_GEMINI_BASE_URL; @@ -301,7 +302,7 @@ const UsageScriptModal: React.FC = ({ apiKey, baseUrl: extractCodexBaseUrl(configToml), }; - } else if (appId === "gemini") { + } else if (appId === "gemini" || appId === "agy") { // Gemini: { env: { GEMINI_API_KEY, GOOGLE_GEMINI_BASE_URL } } // Key fallback mirrors the backend resolver (Provider::resolve_usage_credentials). const env = (config as any).env || {}; diff --git a/src/components/mcp/UnifiedMcpPanel.tsx b/src/components/mcp/UnifiedMcpPanel.tsx index 85d34dd7a..50a54f529 100644 --- a/src/components/mcp/UnifiedMcpPanel.tsx +++ b/src/components/mcp/UnifiedMcpPanel.tsx @@ -87,11 +87,16 @@ const IMPORT_SOURCE_FILES: Record< opencode: { file: "~/.config/opencode/opencode.json" }, hermes: { file: "~/.hermes/config.yaml" }, pi: { file: "~/.pi/agent/mcp.json" }, + agy: { file: "~/.gemini/config/mcp_config.json" }, mcode: { file: "~/.minimax/mcp.json" }, }; /** 先写配置文件、成功后才入库的应用:写失败时开关没变,重试要逐行重写 */ -const WRITE_THEN_SAVE_APPS: ReadonlySet = new Set(["mcode", "pi"]); +const WRITE_THEN_SAVE_APPS: ReadonlySet = new Set([ + "mcode", + "pi", + "agy", +]); /** 写入失败:记下想要的状态,「重试」按这个值再写一次 */ interface WriteFailure { diff --git a/src/components/providers/AddProviderDialog.tsx b/src/components/providers/AddProviderDialog.tsx index d145fafba..8c4c89a3c 100644 --- a/src/components/providers/AddProviderDialog.tsx +++ b/src/components/providers/AddProviderDialog.tsx @@ -337,7 +337,7 @@ export function AddProviderDialog({ preset.endpointCandidates.forEach(addUrl); } } - } else if (appId === "gemini") { + } else if (appId === "gemini" || appId === "agy") { const presets = geminiProviderPresets; const presetIndex = parseInt( values.presetId.replace("gemini-", ""), @@ -389,7 +389,7 @@ export function AddProviderDialog({ addUrl(extractedBaseUrl); } } - } else if (appId === "gemini") { + } else if (appId === "gemini" || appId === "agy") { const env = parsedConfig.env as Record | undefined; if (env?.GOOGLE_GEMINI_BASE_URL) { addUrl(env.GOOGLE_GEMINI_BASE_URL); diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index ed6b0f789..97f861510 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -134,7 +134,7 @@ function isOfficialProvider(provider: Provider, appId: AppId): boolean { (!apiKey || (typeof apiKey === "string" && apiKey.trim() === "")) ); } - if (appId === "gemini") { + if (appId === "gemini" || appId === "agy") { // 无 GEMINI_API_KEY 且无 GOOGLE_GEMINI_BASE_URL → Google OAuth 官方模式 const apiKey = config?.env?.GEMINI_API_KEY; const baseUrl = config?.env?.GOOGLE_GEMINI_BASE_URL; diff --git a/src/components/providers/ProviderEmptyState.tsx b/src/components/providers/ProviderEmptyState.tsx index 4fdc4feb8..74f0fcd56 100644 --- a/src/components/providers/ProviderEmptyState.tsx +++ b/src/components/providers/ProviderEmptyState.tsx @@ -27,6 +27,7 @@ export function ProviderEmptyState({ appId === "claude" || appId === "codex" || appId === "gemini" || + appId === "agy" || appId === "grokbuild"; return ( diff --git a/src/components/providers/ProviderList.tsx b/src/components/providers/ProviderList.tsx index a1e6ca494..ecb3868c9 100644 --- a/src/components/providers/ProviderList.tsx +++ b/src/components/providers/ProviderList.tsx @@ -326,6 +326,34 @@ export function ProviderList({ queue: switchMode.queue.filter((id) => known.has(id)), }); } + if (appId === "agy") { + return buildSwitchSections({ + app: appId, + t, + providers: filteredProviders, + active: "direct", + view: "direct", + directId: currentProviderId || null, + routeId: null, + failoverOn: false, + queue: [], + stackMembers: new Map(), + routingReason: () => "", + serviceRunning: false, + actions: { + switchDirect: (provider) => onSwitch(provider), + needsRouteDialog: () => undefined, + exitAndUse: (provider) => onSwitch(provider), + routeTo: () => undefined, + queueAdd: () => undefined, + queueRemove: () => undefined, + queueMove: () => undefined, + stackAdd: () => undefined, + stackRemove: () => undefined, + stackSetDefault: () => undefined, + }, + }); + } const defaultPrimary = (appId === "openclaw" ? openclawDefaultModel?.primary diff --git a/src/components/providers/forms/EndpointSpeedTest.tsx b/src/components/providers/forms/EndpointSpeedTest.tsx index 13f88586c..375e4f736 100644 --- a/src/components/providers/forms/EndpointSpeedTest.tsx +++ b/src/components/providers/forms/EndpointSpeedTest.tsx @@ -20,6 +20,7 @@ const ENDPOINT_TIMEOUT_SECS: Record = { hermes: 8, pi: 8, mcode: 8, + agy: 8, }; interface TestResult { diff --git a/src/components/providers/forms/GeminiConfigEditor.tsx b/src/components/providers/forms/GeminiConfigEditor.tsx index 4d9af0f67..2cd42cfe5 100644 --- a/src/components/providers/forms/GeminiConfigEditor.tsx +++ b/src/components/providers/forms/GeminiConfigEditor.tsx @@ -5,6 +5,7 @@ import type { ProviderEditorInactiveField } from "@/lib/api/providers"; import { InactiveFieldsPanel } from "./InactiveFieldsPanel"; interface GeminiConfigEditorProps { + isAgy?: boolean; envValue: string; configValue: string; onEnvChange: (value: string) => void; @@ -57,6 +58,7 @@ const parseConfig = (configText: string): Record | null => { }; const GeminiConfigEditor: React.FC = ({ + isAgy = false, envValue, configValue, onEnvChange, @@ -100,13 +102,16 @@ const GeminiConfigEditor: React.FC = ({ return (

- {t("geminiConfig.keyFieldsHint", { - defaultValue: - "地址、Key、模型名和认证方式随供应商切换;其余环境变量和 settings.json 是 Gemini CLI 全局设置,保存后对所有供应商生效。", - })} + {isAgy + ? t("provider.form.agy.keyFieldsHint") + : t("geminiConfig.keyFieldsHint", { + defaultValue: + "地址、Key、模型名和认证方式随供应商切换;其余环境变量和 settings.json 是 Gemini CLI 全局设置,保存后对所有供应商生效。", + })}

= ({ /> void; onBlur?: () => void; @@ -13,6 +14,7 @@ interface GeminiEnvSectionProps { * GeminiEnvSection - .env editor section for Gemini environment variables */ export const GeminiEnvSection: React.FC = ({ + isAgy = false, value, onChange, onBlur, @@ -49,7 +51,9 @@ export const GeminiEnvSection: React.FC = ({ htmlFor="geminiEnv" className="block text-sm font-medium text-fg-1" > - {t("geminiConfig.envFile", { defaultValue: "环境变量 (.env)" })} + {isAgy + ? t("provider.form.agy.envFile") + : t("geminiConfig.envFile", { defaultValue: "环境变量 (.env)" })} - {t("geminiConfig.envFileHint", { - defaultValue: "使用 .env 格式配置 Gemini 环境变量", - })} + {isAgy + ? t("provider.form.agy.envFileHint") + : t("geminiConfig.envFileHint", { + defaultValue: "使用 .env 格式配置 Gemini 环境变量", + })}

)}
@@ -78,6 +84,7 @@ GEMINI_MODEL=gemini-3.6-flash`} }; interface GeminiConfigSectionProps { + isAgy?: boolean; value: string; onChange: (value: string) => void; configError?: string; @@ -87,6 +94,7 @@ interface GeminiConfigSectionProps { * GeminiConfigSection - settings.json editor section */ export const GeminiConfigSection: React.FC = ({ + isAgy = false, value, onChange, configError, @@ -115,18 +123,24 @@ export const GeminiConfigSection: React.FC = ({ htmlFor="geminiConfig" className="block text-sm font-medium text-fg-1" > - {t("geminiConfig.configJson", { - defaultValue: "配置文件 (settings.json)", - })} + {isAgy + ? t("provider.form.agy.configJson") + : t("geminiConfig.configJson", { + defaultValue: "配置文件 (settings.json)", + })} = ({ {!configError && (

- {t("geminiConfig.configJsonHint", { - defaultValue: - "Gemini CLI 的 settings.json(MCP 服务器在 MCP 面板里管理)", - })} + {isAgy + ? t("provider.form.agy.configJsonHint") + : t("geminiConfig.configJsonHint", { + defaultValue: + "Gemini CLI 的 settings.json(MCP 服务器在 MCP 面板里管理)", + })}

)}
diff --git a/src/components/providers/forms/GeminiFormFields.tsx b/src/components/providers/forms/GeminiFormFields.tsx index f5235023a..7e7480d50 100644 --- a/src/components/providers/forms/GeminiFormFields.tsx +++ b/src/components/providers/forms/GeminiFormFields.tsx @@ -8,6 +8,7 @@ import EndpointSpeedTest from "./EndpointSpeedTest"; import { ApiKeySection, EndpointField, ModelInputWithFetch } from "./shared"; import { fetchModelsForConfig, + getAgyModels, showFetchModelsError, type FetchedModel, } from "@/lib/api/model-fetch"; @@ -18,6 +19,7 @@ interface EndpointCandidate { } interface GeminiFormFieldsProps { + appId?: "gemini" | "agy"; providerId?: string; // API Key shouldShowApiKey: boolean; @@ -49,6 +51,7 @@ interface GeminiFormFieldsProps { } export function GeminiFormFields({ + appId = "gemini", providerId, shouldShowApiKey, apiKey, @@ -77,15 +80,29 @@ export function GeminiFormFields({ const [isFetchingModels, setIsFetchingModels] = useState(false); const handleFetchModels = useCallback(() => { - if (!baseUrl || !apiKey) { + const nativeAgy = appId === "agy" && !apiKey; + const modelBaseUrl = + baseUrl.trim() || + (appId === "agy" ? "https://generativelanguage.googleapis.com" : ""); + if (!nativeAgy && (!modelBaseUrl || !apiKey)) { showFetchModelsError(null, t, { hasApiKey: !!apiKey, - hasBaseUrl: !!baseUrl, + hasBaseUrl: !!modelBaseUrl, }); return; } setIsFetchingModels(true); - fetchModelsForConfig(baseUrl, apiKey) + (nativeAgy + ? getAgyModels() + : fetchModelsForConfig( + modelBaseUrl, + apiKey, + false, + undefined, + undefined, + appId === "agy" ? { apiFormat: "google-generative-ai" } : undefined, + ) + ) .then((models) => { setFetchedModels(models); if (models.length === 0) { @@ -101,7 +118,7 @@ export function GeminiFormFields({ showFetchModelsError(err, t); }) .finally(() => setIsFetchingModels(false)); - }, [baseUrl, apiKey, t]); + }, [appId, baseUrl, apiKey, t]); // 检测是否为 Google 官方(使用 OAuth) const isGoogleOfficial = @@ -109,6 +126,11 @@ export function GeminiFormFields({ return ( <> + {appId === "agy" && ( +

+ {t("provider.form.agy.connectionHint")} +

+ )} {/* Google OAuth 提示 */} {isGoogleOfficial && (
@@ -137,7 +159,7 @@ export function GeminiFormFields({ value={apiKey} onChange={onApiKeyChange} category={category} - required + required={appId !== "agy" || Boolean(baseUrl.trim())} shouldShowLink={shouldShowApiKeyLink} websiteUrl={websiteUrl} isPartner={isPartner} @@ -149,6 +171,7 @@ export function GeminiFormFields({ {shouldShowSpeedTest && ( @@ -196,7 +223,7 @@ export function GeminiFormFields({ {/* 端点测速弹窗 */} {shouldShowSpeedTest && isEndpointModalOpen && ( !(entry.preset as CodexProviderPreset).hidden); - } else if (appId === "gemini") { + } else if (usesGeminiStyleForm(appId)) { return geminiProviderPresets.map((preset, index) => ({ id: `gemini-${index}`, preset, @@ -1000,7 +1001,8 @@ function ProviderFormFull({ resetGeminiConfig, envStringToObj, } = useGeminiConfigState({ - initialData: appId === "gemini" ? initialData : undefined, + initialData: usesGeminiStyleForm(appId) ? initialData : undefined, + preferConfigModel: appId === "agy", }); const projectGeminiDraft = useCallback( @@ -1020,7 +1022,11 @@ function ProviderFormFull({ projectGeminiDraftRef.current = projectGeminiDraft; useEffect(() => { - if (appId === "gemini" && !initialData && selectedPresetId === "custom") { + if ( + usesGeminiStyleForm(appId) && + !initialData && + selectedPresetId === "custom" + ) { projectGeminiDraftRef.current({}, {}); } }, [appId, initialData, selectedPresetId]); @@ -1621,15 +1627,20 @@ function ProviderFormFull({ ); } } - if (appId === "gemini") { - if (!geminiBaseUrl.trim()) { + if (usesGeminiStyleForm(appId)) { + // agy uses its own login when both fields are empty; an API key may + // use Google's default endpoint without a custom base URL. + if (appId !== "agy" && !geminiBaseUrl.trim()) { issues.push( t("providerForm.endpointRequired", { defaultValue: "非官方供应商请填写 API 端点", }), ); } - if (!geminiApiKey.trim()) { + if ( + !geminiApiKey.trim() && + (appId !== "agy" || Boolean(geminiBaseUrl.trim())) + ) { issues.push( t("providerForm.apiKeyRequired", { defaultValue: "非官方供应商请填写 API Key", @@ -1726,7 +1737,7 @@ function ProviderFormFull({ } catch (err) { settingsConfig = values.settingsConfig.trim(); } - } else if (appId === "gemini") { + } else if (usesGeminiStyleForm(appId)) { try { const envObj = envStringToObj(geminiEnv); const configObj = geminiConfig.trim() ? JSON.parse(geminiConfig) : {}; @@ -1904,7 +1915,7 @@ function ProviderFormFull({ // Claude Code、Codex、Gemini CLI 的通用配置片段已冻结:沿用行里原有的标记,新增时 // 由后端写 true(兼容旧版)。 commonConfigEnabled: - appId === "claude" || appId === "codex" || appId === "gemini" + appId === "claude" || appId === "codex" || usesGeminiStyleForm(appId) ? initialData?.meta?.commonConfigEnabled : undefined, endpointAutoSelect, @@ -2156,7 +2167,7 @@ function ProviderFormFull({ ); projectCodexDraft(template.auth, template.config); } - if (appId === "gemini") { + if (usesGeminiStyleForm(appId)) { resetGeminiConfig({}, {}); projectGeminiDraft({}, {}); } @@ -2220,7 +2231,7 @@ function ProviderFormFull({ return; } - if (appId === "gemini") { + if (usesGeminiStyleForm(appId)) { const preset = entry.preset as GeminiProviderPreset; const env = (preset.settingsConfig as any)?.env ?? {}; const config = (preset.settingsConfig as any)?.config ?? {}; @@ -2759,8 +2770,9 @@ function ProviderFormFull({ /> )} - {appId === "gemini" && ( + {usesGeminiStyleForm(appId) && ( {settingsConfigErrorField} - ) : appId === "gemini" ? ( + ) : usesGeminiStyleForm(appId) ? ( <> ; }; @@ -12,6 +13,7 @@ interface UseGeminiConfigStateProps { */ export function useGeminiConfigState({ initialData, + preferConfigModel = false, }: UseGeminiConfigStateProps) { const [geminiEnv, setGeminiEnvState] = useState(""); const [geminiConfig, setGeminiConfigState] = useState(""); @@ -105,7 +107,18 @@ export function useGeminiConfigState({ const envObj = envStringToObj(geminiEnv); const extractedKey = envObj.GEMINI_API_KEY || ""; const extractedBaseUrl = envObj.GOOGLE_GEMINI_BASE_URL || ""; - const extractedModel = envObj.GEMINI_MODEL || ""; + let extractedModel = envObj.GEMINI_MODEL || ""; + if (preferConfigModel) { + try { + const config = JSON.parse(geminiConfig || "{}"); + if (typeof config.model?.name === "string") { + extractedModel = config.model.name; + } + } catch { + // Keep the last valid model while the JSON editor contains an incomplete draft. + extractedModel = geminiModel; + } + } if (extractedKey !== geminiApiKey) { setGeminiApiKey(extractedKey); @@ -116,7 +129,15 @@ export function useGeminiConfigState({ if (extractedModel !== geminiModel) { setGeminiModel(extractedModel); } - }, [geminiEnv, envStringToObj, geminiApiKey, geminiBaseUrl, geminiModel]); + }, [ + geminiEnv, + geminiConfig, + preferConfigModel, + envStringToObj, + geminiApiKey, + geminiBaseUrl, + geminiModel, + ]); // 验证 Gemini Config JSON const validateGeminiConfig = useCallback((value: string): string => { @@ -188,8 +209,25 @@ export function useGeminiConfigState({ envObj.GEMINI_MODEL = trimmed; const newEnv = envObjToString(envObj); setGeminiEnv(newEnv); + if (preferConfigModel) { + try { + const config = JSON.parse(geminiConfig || "{}"); + config.model = { ...config.model, name: trimmed }; + setGeminiConfig(JSON.stringify(config, null, 2)); + } catch { + // Do not discard an invalid user JSON draft; submission remains blocked. + } + } }, - [geminiEnv, envStringToObj, envObjToString, setGeminiEnv], + [ + geminiEnv, + geminiConfig, + preferConfigModel, + envStringToObj, + envObjToString, + setGeminiEnv, + setGeminiConfig, + ], ); // 处理 env 变化 diff --git a/src/components/providers/forms/hooks/useProviderCategory.ts b/src/components/providers/forms/hooks/useProviderCategory.ts index 8d792c485..aa29e5720 100644 --- a/src/components/providers/forms/hooks/useProviderCategory.ts +++ b/src/components/providers/forms/hooks/useProviderCategory.ts @@ -67,7 +67,7 @@ export function useProviderCategory({ preset.category || (preset.isOfficial ? "official" : undefined), ); } - } else if (type === "gemini" && appId === "gemini") { + } else if (type === "gemini" && (appId === "gemini" || appId === "agy")) { const preset = geminiProviderPresets[index]; if (preset) { setCategory(preset.category || undefined); diff --git a/src/components/providers/forms/presetGroups.ts b/src/components/providers/forms/presetGroups.ts index 4b6e48324..80b1efc8b 100644 --- a/src/components/providers/forms/presetGroups.ts +++ b/src/components/providers/forms/presetGroups.ts @@ -468,6 +468,7 @@ export function loginAccountKey( case "codex": return "chatgpt"; case "gemini": + case "agy": return "google"; case "grokbuild": return "xai"; diff --git a/src/components/providers/forms/shared/EndpointField.tsx b/src/components/providers/forms/shared/EndpointField.tsx index 2e09fda7b..0eabe15f2 100644 --- a/src/components/providers/forms/shared/EndpointField.tsx +++ b/src/components/providers/forms/shared/EndpointField.tsx @@ -12,6 +12,7 @@ interface EndpointFieldProps { onChange: (value: string) => void; placeholder: string; hint?: string; + required?: boolean; /** 填写说明放进字段名后的「?」,不占页面;和 `hint` 二选一。 */ help?: string; showManageButton?: boolean; @@ -26,6 +27,7 @@ export function EndpointField({ onChange, placeholder, hint, + required = true, help, showManageButton = true, onManageClick, @@ -41,7 +43,10 @@ export function EndpointField({
- + {label} {help ? ( @@ -66,7 +71,7 @@ export function EndpointField({ onChange={(e) => onChange(e.target.value)} placeholder={placeholder} autoComplete="off" - aria-required="true" + aria-required={required} /> {hint ? (

{hint.replace(/^💡\s*/u, "")}

diff --git a/src/components/sessions/SessionItem.tsx b/src/components/sessions/SessionItem.tsx index 3ff8ed381..7568ee867 100644 --- a/src/components/sessions/SessionItem.tsx +++ b/src/components/sessions/SessionItem.tsx @@ -105,9 +105,14 @@ export function SessionItem({ .join("\n"); const deleteReason = !deletable - ? t("sessionManager.mcodeDeleteHint", { - defaultValue: "请在 MiniMax Code 中删除", - }) + ? t( + session.providerId === "agy" + ? "sessionManager.agyDeleteHint" + : "sessionManager.mcodeDeleteHint", + { + defaultValue: "请在 MiniMax Code 中删除", + }, + ) : null; return ( diff --git a/src/components/sessions/utils.ts b/src/components/sessions/utils.ts index 9d7e55e39..b477a5fa3 100644 --- a/src/components/sessions/utils.ts +++ b/src/components/sessions/utils.ts @@ -16,6 +16,7 @@ export const SESSION_APP_IDS = [ "grokbuild", "openclaw", "mcode", + "agy", ] as const satisfies readonly AppId[]; export type SessionAppId = (typeof SESSION_APP_IDS)[number]; @@ -34,11 +35,14 @@ export const SESSION_SOURCE_PATHS: Record = { hermes: ["~/.hermes/state.db", "~/.hermes/sessions"], pi: ["~/.pi/agent/sessions"], mcode: ["~/.minimax"], + agy: [ + "~/.gemini/antigravity-cli/brain//.system_generated/logs", + ], }; /** MiniMax Code 的会话只能在 MiniMax Code 里删(后端也会拒绝)。 */ export const canDeleteSession = (session: SessionMeta) => - Boolean(session.sourcePath) && session.providerId !== "mcode"; + Boolean(session.sourcePath) && !["mcode", "agy"].includes(session.providerId); export interface SessionProjectGroup { key: string; diff --git a/src/components/settings/DirectorySettings.tsx b/src/components/settings/DirectorySettings.tsx index a901957c7..c0495c34c 100644 --- a/src/components/settings/DirectorySettings.tsx +++ b/src/components/settings/DirectorySettings.tsx @@ -7,7 +7,7 @@ import { useTranslation } from "react-i18next"; import type { AppId } from "@/lib/api"; import type { ResolvedDirectories } from "@/hooks/useSettings"; -export type DirectoryAppId = Exclude; +export type DirectoryAppId = Exclude; interface DirectorySettingsProps { appConfigDir?: string; diff --git a/src/components/shell/AppGlyph.tsx b/src/components/shell/AppGlyph.tsx index e43d57e04..ebe5eb06a 100644 --- a/src/components/shell/AppGlyph.tsx +++ b/src/components/shell/AppGlyph.tsx @@ -15,6 +15,7 @@ export const APP_DISPLAY_NAME: Record = { hermes: "Hermes", pi: "Pi", mcode: "MiniMax Code", + agy: "Antigravity", }; const APP_ICON_NAME: Record = { @@ -28,6 +29,7 @@ const APP_ICON_NAME: Record = { hermes: "hermes", pi: "pi", mcode: "minimax", + agy: "gemini", }; // Claude Code 和 Claude Desktop 用同一个图标,靠右下角的小角标区分终端与桌面 diff --git a/src/components/skills/SkillImportDialog.tsx b/src/components/skills/SkillImportDialog.tsx index 8b1e23d24..d78bbcc9e 100644 --- a/src/components/skills/SkillImportDialog.tsx +++ b/src/components/skills/SkillImportDialog.tsx @@ -100,6 +100,7 @@ export function SkillImportDialog({ hermes: chosen.has("hermes"), pi: false, mcode: chosen.has("mcode"), + agy: chosen.has("agy"), }, }; }), diff --git a/src/components/usage/RequestLogTable.tsx b/src/components/usage/RequestLogTable.tsx index 8f72e90a0..a2fa69b2c 100644 --- a/src/components/usage/RequestLogTable.tsx +++ b/src/components/usage/RequestLogTable.tsx @@ -102,6 +102,7 @@ const APP_SHORT_NAME: Record = { hermes: "Hermes", pi: "Pi", mcode: "MiniMax", + agy: "Antigravity", }; export function appShortName(appType: string): string { diff --git a/src/components/usage/UsageDashboard.tsx b/src/components/usage/UsageDashboard.tsx index d9f1f85a5..79d2555b3 100644 --- a/src/components/usage/UsageDashboard.tsx +++ b/src/components/usage/UsageDashboard.tsx @@ -745,6 +745,11 @@ export function UsageDashboard({ trailing={tabTrailing} /> + {appType === "agy" && ( +

+ {t("usage.agyUsageHint")} +

+ )}
= { claude: { label: "Claude", @@ -249,6 +257,14 @@ export const APP_ICON_MAP: Record = { badgeClass: "bg-fuchsia-500/10 text-fuchsia-700 dark:text-fuchsia-300 hover:bg-fuchsia-500/20 border-0 gap-1.5", }, + agy: { + label: "Antigravity", + icon: , + activeClass: + "bg-sky-500/10 ring-1 ring-sky-500/20 hover:bg-sky-500/20 text-sky-700 dark:text-sky-300", + badgeClass: + "bg-sky-500/10 text-sky-700 dark:text-sky-300 hover:bg-sky-500/20 border-0 gap-1.5", + }, }; export function getAppLabel(appId: string): string { diff --git a/src/hooks/useDirectorySettings.ts b/src/hooks/useDirectorySettings.ts index a616dd08d..bf95066f8 100644 --- a/src/hooks/useDirectorySettings.ts +++ b/src/hooks/useDirectorySettings.ts @@ -5,7 +5,7 @@ import { homeDir, join } from "@tauri-apps/api/path"; import { settingsApi, type AppId } from "@/lib/api"; import type { SettingsFormState } from "./useSettingsForm"; -export type DirectoryAppId = Exclude; +export type DirectoryAppId = Exclude; type AppDirectoryKey = | "claude" | "codex" diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index d9c2a86e7..2609967cc 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -208,6 +208,14 @@ "geminiConfig": "Gemini Configuration", "geminiConfigHint": "Use .env format to configure Gemini", "form": { + "agy": { + "connectionHint": "Leave the API key and endpoint empty to use agy's Google sign-in. A key without an endpoint uses Google's default API; third-party endpoints must support the Gemini API. Open a new terminal or restart agy after switching. Running sessions keep their configuration. Login-mode model discovery uses the current agy configuration. In login mode, use the display name from the model list; API mode uses API model IDs.", + "keyFieldsHint": "The endpoint, key and model follow the provider; other native Antigravity settings are preserved.", + "envFile": "Launcher environment variables", + "envFileHint": "CC Switch passes the API key, endpoint and model when starting agy. Other environment variables are not applied.", + "configJson": "Model configuration (CC Switch format)", + "configJsonHint": "Only model settings are supported here. Change other Antigravity options in agy; manage MCP in the MCP panel." + }, "gemini": { "model": "Model", "oauthTitle": "OAuth Authentication Mode", @@ -1086,7 +1094,8 @@ "openclaw": "OpenClaw", "hermes": "Hermes", "pi": "Pi", - "mcode": "MiniMax Code" + "mcode": "MiniMax Code", + "agy": "Antigravity" }, "mcode": { "empty": { @@ -1325,6 +1334,7 @@ "deleteSessionEllipsis": "Delete sessions", "deleteEllipsis": "Delete", "selectSessionLabel": "Select {{title}}", + "agyDeleteHint": "Delete this session in Antigravity to also remove its runtime state", "mcodeDeleteHint": "Delete it in MiniMax Code", "groupMeta": "{{count}} sessions · {{time}}", "bucketToday": "Today", @@ -2538,7 +2548,8 @@ "cacheWrite": "Cache write" }, "prevMonth": "Previous month", - "nextMonth": "Next month" + "nextMonth": "Next month", + "agyUsageHint": "Antigravity shows native session token counters; missing historical models are unknown. The log does not provide reliable billing data; a displayed cost of 0 does not mean free usage." }, "usageScript": { "title": "Configure Usage Query", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 7b4de0227..bbff35a8d 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -208,6 +208,14 @@ "geminiConfig": "Gemini 設定", "geminiConfigHint": ".env 形式で Gemini を設定してください", "form": { + "agy": { + "connectionHint": "API Key と接続先を空欄にすると agy の Google ログインを使用します。Key のみを指定すると Google の標準 API を使用します。外部接続先は Gemini API に対応している必要があります。切替後は新しいターミナルを開くか agy を再起動してください。実行中のセッションは切り替わりません。ログイン時のモデル取得には現在の agy 設定を使用します。 ログイン時はモデル一覧の表示名を使用してください。API モードでは API のモデル ID を使用します。", + "keyFieldsHint": "エンドポイント、キー、モデルはプロバイダーとともに切り替わります。Antigravity の他のネイティブ設定は保持されます。", + "envFile": "起動時の環境変数", + "envFileHint": "CC Switch は agy 起動時に API キー、エンドポイント、モデルを渡します。他の環境変数は適用されません。", + "configJson": "モデル設定(CC Switch 形式)", + "configJsonHint": "ここではモデル設定のみ対応しています。他の Antigravity 設定は agy で変更し、MCP は MCP パネルで管理してください。" + }, "gemini": { "model": "モデル", "oauthTitle": "OAuth 認証モード", @@ -1086,7 +1094,8 @@ "openclaw": "OpenClaw", "hermes": "Hermes", "pi": "Pi", - "mcode": "MiniMax Code" + "mcode": "MiniMax Code", + "agy": "Antigravity" }, "mcode": { "empty": { @@ -1325,6 +1334,7 @@ "deleteSessionEllipsis": "セッションを削除", "deleteEllipsis": "削除", "selectSessionLabel": "{{title}} を選択", + "agyDeleteHint": "実行状態も削除するには Antigravity でセッションを削除してください", "mcodeDeleteHint": "MiniMax Code で削除してください", "groupMeta": "{{count}} 件 · {{time}}", "bucketToday": "今日", @@ -2538,7 +2548,8 @@ "cacheWrite": "キャッシュ書き込み" }, "prevMonth": "前月", - "nextMonth": "翌月" + "nextMonth": "翌月", + "agyUsageHint": "Antigravity はログの Token 数を表示し、過去のモデルが不明な場合は unknown と表示します。ログに信頼できる料金情報がないため、料金 0 は無料を意味しません。" }, "usageScript": { "title": "利用状況を設定", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index d1691d6a6..fd54072a8 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -208,6 +208,14 @@ "geminiConfig": "Gemini 設定", "geminiConfigHint": "使用 .env 格式設定 Gemini", "form": { + "agy": { + "connectionHint": "不填 API Key 和請求位址時使用 agy 本身的 Google 登入。填寫 Key 但不填位址時使用 Google 預設 API;第三方位址須相容 Gemini API。切換後請開啟新終端或重新啟動 agy,執行中的工作階段不會自動切換。登入模式的模型清單讀取目前的 agy 設定。 登入模式請使用模型清單中的顯示名稱;API 模式使用介面模型 ID。", + "keyFieldsHint": "位址、Key 和模型隨供應商切換;Antigravity 的其他原生設定保持不變。", + "envFile": "啟動環境變數", + "envFileHint": "CC Switch 會在啟動 agy 時傳入 API Key、請求位址和模型。其他環境變數不會套用。", + "configJson": "模型設定(CC Switch 格式)", + "configJsonHint": "這裡僅支援模型設定,其他 Antigravity 選項請在 agy 中調整;MCP 在 MCP 面板中管理。" + }, "gemini": { "model": "模型", "oauthTitle": "OAuth 驗證模式", @@ -1087,7 +1095,8 @@ "openclaw": "OpenClaw", "hermes": "Hermes", "pi": "Pi", - "mcode": "MiniMax Code" + "mcode": "MiniMax Code", + "agy": "Antigravity" }, "mcode": { "empty": { @@ -1327,6 +1336,7 @@ "deleteEllipsis": "刪除", "selectSessionLabel": "選擇 {{title}}", "mcodeDeleteHint": "請在 MiniMax Code 中刪除", + "agyDeleteHint": "請在 Antigravity 中刪除工作階段,以同步清理執行狀態", "groupMeta": "{{count}} 個工作階段 · {{time}}", "bucketToday": "今天", "bucketYesterday": "昨天", @@ -2539,7 +2549,8 @@ "cacheWrite": "快取寫入" }, "prevMonth": "上個月", - "nextMonth": "下個月" + "nextMonth": "下個月", + "agyUsageHint": "Antigravity 顯示工作階段日誌的原始 Token;缺失的歷史模型記為 unknown。日誌未提供可靠計費資訊,顯示的 0 費用不代表免費。" }, "usageScript": { "title": "設定用量查詢", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 8fbb0e239..5ba9e5fc0 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -208,6 +208,14 @@ "geminiConfig": "Gemini 配置", "geminiConfigHint": "使用 .env 格式配置 Gemini", "form": { + "agy": { + "connectionHint": "不填 API Key 和请求地址时使用 agy 自身的 Google 登录。填写 Key 但不填地址时使用 Google 默认 API;第三方地址须兼容 Gemini API。切换后请新开终端或重启 agy,已运行的会话不会自动切换。登录模式的模型列表读取当前 agy 配置。 登录模式请使用模型列表中的显示名称;API 模式使用接口模型 ID。", + "keyFieldsHint": "地址、Key 和模型随供应商切换;Antigravity 的其他原生设置保持不变。", + "envFile": "启动环境变量", + "envFileHint": "CC Switch 会在启动 agy 时传入 API Key、请求地址和模型。其他环境变量不会应用。", + "configJson": "模型配置(CC Switch 格式)", + "configJsonHint": "这里仅支持模型设置,其他 Antigravity 选项请在 agy 中调整;MCP 在 MCP 面板中管理。" + }, "gemini": { "model": "模型", "oauthTitle": "OAuth 认证模式", @@ -1086,7 +1094,8 @@ "openclaw": "OpenClaw", "hermes": "Hermes", "pi": "Pi", - "mcode": "MiniMax Code" + "mcode": "MiniMax Code", + "agy": "Antigravity" }, "mcode": { "empty": { @@ -1325,6 +1334,7 @@ "deleteSessionEllipsis": "删除会话", "deleteEllipsis": "删除", "selectSessionLabel": "选择 {{title}}", + "agyDeleteHint": "请在 Antigravity 中删除会话,以同步清理运行状态", "mcodeDeleteHint": "请在 MiniMax Code 中删除", "groupMeta": "{{count}} 个会话 · {{time}}", "bucketToday": "今天", @@ -2538,7 +2548,8 @@ "cacheWrite": "缓存写入" }, "prevMonth": "上个月", - "nextMonth": "下个月" + "nextMonth": "下个月", + "agyUsageHint": "Antigravity 显示会话日志的原始 Token;缺失的历史模型记为 unknown。日志未提供可靠计费信息,显示的 0 费用不代表免费。" }, "usageScript": { "title": "配置用量查询", diff --git a/src/lib/api/deeplink.ts b/src/lib/api/deeplink.ts index dd665185a..9eee20283 100644 --- a/src/lib/api/deeplink.ts +++ b/src/lib/api/deeplink.ts @@ -11,6 +11,7 @@ export interface DeepLinkImportRequest { | "claude" | "codex" | "gemini" + | "agy" | "grokbuild" | "opencode" | "openclaw" diff --git a/src/lib/api/model-fetch.ts b/src/lib/api/model-fetch.ts index e056992a0..ab9f1b6d7 100644 --- a/src/lib/api/model-fetch.ts +++ b/src/lib/api/model-fetch.ts @@ -61,6 +61,11 @@ export async function fetchModelsForConfig( }); } +/** Models reported by the locally installed Antigravity runtime. */ +export async function getAgyModels(): Promise { + return invoke("get_agy_models"); +} + export interface OpenCodeModelRef { providerId: string; modelId: string; diff --git a/src/lib/api/skills.ts b/src/lib/api/skills.ts index fb26802ff..cb2c96f8f 100644 --- a/src/lib/api/skills.ts +++ b/src/lib/api/skills.ts @@ -12,7 +12,8 @@ export type AppType = | "openclaw" | "hermes" | "pi" - | "mcode"; + | "mcode" + | "agy"; /** Skill 应用启用状态 */ export interface SkillApps { @@ -26,6 +27,7 @@ export interface SkillApps { hermes: boolean; pi: boolean; mcode?: boolean; + agy?: boolean; } /** 已安装的 Skill(v3.10.0+ 统一结构) */ diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index da9f8f316..b70d4fc69 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -9,4 +9,5 @@ export type AppId = | "openclaw" | "hermes" | "pi" - | "mcode"; + | "mcode" + | "agy"; diff --git a/src/types.ts b/src/types.ts index b254bf096..5a1c17b3e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -315,6 +315,7 @@ export interface VisibleApps { hermes: boolean; pi: boolean; mcode: boolean; + agy: boolean; } // WebDAV 同步状态 @@ -763,6 +764,7 @@ export interface McpServerSpec { // v3.7.0: MCP 服务器应用启用状态 export interface McpApps { mcode?: boolean; + agy?: boolean; claude: boolean; "claude-desktop"?: boolean; codex: boolean; diff --git a/src/types/usage.ts b/src/types/usage.ts index 4fc4f40e5..4ee65e6d7 100644 --- a/src/types/usage.ts +++ b/src/types/usage.ts @@ -228,7 +228,8 @@ export type AppType = | "grokbuild" | "opencode" | "pi" - | "mcode"; + | "mcode" + | "agy"; export type AppTypeFilter = "all" | AppType; @@ -240,6 +241,7 @@ export const KNOWN_APP_TYPES: ReadonlyArray = [ "opencode", "pi", "mcode", + "agy", ]; /** diff --git a/src/utils/deepLinkConfigPreview.ts b/src/utils/deepLinkConfigPreview.ts index 8b89a7cd5..cc5cc8db4 100644 --- a/src/utils/deepLinkConfigPreview.ts +++ b/src/utils/deepLinkConfigPreview.ts @@ -4,8 +4,9 @@ import { decodeBase64Utf8 } from "@/lib/utils/base64"; import { isSensitiveConfigKey, maskSensitiveValue } from "@/utils/deeplinkRisk"; export interface ParsedDeepLinkConfig { - type: "claude" | "codex" | "gemini" | "grokbuild"; + type: "claude" | "codex" | "gemini" | "grokbuild" | "agy"; env?: Record; + model?: string; auth?: Record; tomlConfig?: string; } @@ -71,6 +72,24 @@ export function parseDeepLinkConfigPreview( tomlConfig: config ? sanitizeTomlForPreview(config) : "", }; } + if (request.app === "agy") { + const env = parsed.env ?? parsed; + if (!env || typeof env !== "object" || Array.isArray(env)) return null; + const values = env as Record; + const config = parsed.config as + | { model?: { name?: unknown } } + | undefined; + const model = config?.model?.name ?? values.GEMINI_MODEL; + return { + type: "agy", + env: Object.fromEntries( + ["GEMINI_API_KEY", "GOOGLE_GEMINI_BASE_URL", "GEMINI_BASE_URL"] + .filter((key) => typeof values[key] === "string") + .map((key) => [key, values[key] as string]), + ), + model: typeof model === "string" ? model : undefined, + }; + } if (request.app === "gemini") { return { type: "gemini", diff --git a/tests/components/AppsPage.test.tsx b/tests/components/AppsPage.test.tsx index 3473e5b4a..c2e6ba505 100644 --- a/tests/components/AppsPage.test.tsx +++ b/tests/components/AppsPage.test.tsx @@ -1739,6 +1739,15 @@ describe("AppsPage concurrent CLI upgrades", () => { card("Claude Code").getAllByLabelText("common.loading").length, ).toBeGreaterThan(0), ); + // 等原任务的版本刷新真正发出(初始探测 + 刷新共 2 次)再卸载, + // 否则机器慢时刷新可能落在重新挂载之后,计数就会抖 + await waitFor(() => + expect( + mocks.getToolVersions.mock.calls.filter(([tools]) => + tools.includes("claude"), + ), + ).toHaveLength(2), + ); view.unmount(); const now = vi .spyOn(Date, "now") diff --git a/tests/components/CommonConfigModalBehavior.test.tsx b/tests/components/CommonConfigModalBehavior.test.tsx index 275cd61b9..27712a8c7 100644 --- a/tests/components/CommonConfigModalBehavior.test.tsx +++ b/tests/components/CommonConfigModalBehavior.test.tsx @@ -113,3 +113,27 @@ describe("Common config modals", () => { }); }); }); + +it("describes the Antigravity launcher instead of Gemini CLI global files", () => { + render( + , + ); + for (const key of [ + "keyFieldsHint", + "envFile", + "envFileHint", + "configJson", + "configJsonHint", + ]) { + expect(screen.getByText(`provider.form.agy.${key}`)).toBeInTheDocument(); + } + expect(screen.queryByText(/Gemini CLI/)).not.toBeInTheDocument(); +}); diff --git a/tests/components/DeepLinkImportDialog.test.tsx b/tests/components/DeepLinkImportDialog.test.tsx index d30fd98ab..0c2094ab7 100644 --- a/tests/components/DeepLinkImportDialog.test.tsx +++ b/tests/components/DeepLinkImportDialog.test.tsx @@ -839,3 +839,39 @@ describe("DeepLinkImportDialog", () => { }); }); }); + +it("previews agy model and masks its key before confirmation", async () => { + server.use( + http.post( + "http://tauri.local/merge_deeplink_config", + async ({ request }) => { + const body = (await request.json()) as { + request: Record; + }; + return HttpResponse.json(body.request); + }, + ), + ); + render(, { wrapper: Wrapper }); + act(() => { + deliverDeepLink({ + version: "v1", + resource: "provider", + app: "agy", + name: "Agy fixture", + endpoint: "https://relay.example", + apiKey: "secret-agy-key", + config: btoa( + JSON.stringify({ + env: { GEMINI_API_KEY: "secret-agy-key" }, + config: { model: { name: "agy-preview-model" } }, + }), + ), + }); + }); + await waitFor(() => + expect(screen.getByText("agy-preview-model")).toBeInTheDocument(), + ); + expect(screen.queryByText("secret-agy-key")).not.toBeInTheDocument(); + expect(screen.getAllByText("secr************").length).toBeGreaterThan(0); +}); diff --git a/tests/components/GeminiFormFields.test.tsx b/tests/components/GeminiFormFields.test.tsx new file mode 100644 index 000000000..ca0340148 --- /dev/null +++ b/tests/components/GeminiFormFields.test.tsx @@ -0,0 +1,113 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useForm } from "react-hook-form"; +import { Form } from "@/components/ui/form"; +import { GeminiFormFields } from "@/components/providers/forms/GeminiFormFields"; +import type { ComponentProps } from "react"; + +const api = vi.hoisted(() => ({ + fetchModelsForConfig: vi.fn(), + getAgyModels: vi.fn(), + showFetchModelsError: vi.fn(), +})); +vi.mock("@/lib/api/model-fetch", () => api); + +function Harness(props: Partial>) { + const form = useForm(); + return ( +
+ + + ); +} + +describe("Antigravity model discovery", () => { + beforeEach(() => { + // cmdk scrolls its selected option; jsdom does not implement this browser API. + Element.prototype.scrollIntoView ??= vi.fn(); + vi.clearAllMocks(); + api.fetchModelsForConfig.mockResolvedValue([{ id: "gemini-3.8-flash" }]); + api.getAgyModels.mockResolvedValue([{ id: "Gemini 3.8 Flash (Low)" }]); + }); + + it("uses Google's default API when a key has no endpoint override", async () => { + render(); + fireEvent.click( + screen.getByRole("button", { name: "providerForm.fetchModels" }), + ); + await waitFor(() => + expect(api.fetchModelsForConfig).toHaveBeenCalledWith( + "https://generativelanguage.googleapis.com", + "fixture-key", + false, + undefined, + undefined, + { apiFormat: "google-generative-ai" }, + ), + ); + expect(api.getAgyModels).not.toHaveBeenCalled(); + expect(api.showFetchModelsError).not.toHaveBeenCalled(); + }); + + it("keeps a custom Gemini endpoint", async () => { + render(); + fireEvent.click( + screen.getByRole("button", { name: "providerForm.fetchModels" }), + ); + await waitFor(() => + expect(api.fetchModelsForConfig).toHaveBeenCalledWith( + "https://relay.example/gemini", + "fixture-key", + false, + undefined, + undefined, + { apiFormat: "google-generative-ai" }, + ), + ); + }); + + it("uses native discovery for official login without a key", async () => { + render(); + fireEvent.click( + screen.getByRole("button", { name: "providerForm.fetchModels" }), + ); + await waitFor(() => expect(api.getAgyModels).toHaveBeenCalledOnce()); + expect(api.fetchModelsForConfig).not.toHaveBeenCalled(); + }); + it("selects the native preference name rather than a CLI alias", async () => { + const onModelChange = vi.fn(); + render(); + fireEvent.click( + screen.getByRole("button", { name: "providerForm.fetchModels" }), + ); + fireEvent.click( + await screen.findByRole("button", { + name: "Select model", + }), + ); + fireEvent.click( + await screen.findByRole("option", { name: "Gemini 3.8 Flash (Low)" }), + ); + expect(onModelChange).toHaveBeenCalledWith("Gemini 3.8 Flash (Low)"); + }); +}); diff --git a/tests/components/NewLayoutDialog.test.tsx b/tests/components/NewLayoutDialog.test.tsx index 479ab7e38..4bba41271 100644 --- a/tests/components/NewLayoutDialog.test.tsx +++ b/tests/components/NewLayoutDialog.test.tsx @@ -103,7 +103,7 @@ describe("NewLayoutDialog", () => { it("stays closed when no app has providers", async () => { mocks.getAll.mockResolvedValue({}); renderDialog(); - await waitFor(() => expect(mocks.getAll).toHaveBeenCalledTimes(10)); + await waitFor(() => expect(mocks.getAll).toHaveBeenCalledTimes(11)); expect(screen.queryByText("newLayoutNotice.title")).not.toBeInTheDocument(); }); diff --git a/tests/components/ProviderForm.agyLogin.test.tsx b/tests/components/ProviderForm.agyLogin.test.tsx new file mode 100644 index 000000000..def698d29 --- /dev/null +++ b/tests/components/ProviderForm.agyLogin.test.tsx @@ -0,0 +1,101 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { describe, expect, it, vi } from "vitest"; +import { ProviderForm } from "@/components/providers/forms/ProviderForm"; +import { createTestQueryClient } from "../utils/testQueryClient"; + +vi.mock("@/components/JsonEditor", () => ({ + default: ({ value }: { value: string }) => ( +