From 7b99a7f059b052e68f9b468c6114ae60c4bade2c Mon Sep 17 00:00:00 2001 From: dehuaichendragonplus Date: Mon, 7 Sep 2026 10:21:51 +0800 Subject: [PATCH 1/3] feat(config): Antigravity as a first-class client for config and Project Skills Antigravity keeps its MCP servers in a single global ~/.gemini/config/mcp_config.json (the alternative, per-plugin plugins//mcp_config.json, only loads with its plugin, so it is not a place a Unity plugin should be writing to). Its remote-server spec names the endpoint serverUrl, not the url key every other JSON client here uses -- Antigravity's McpServerSpec has no url field at all, so an entry written in the usual shape would sit in the config connecting to nothing. Its documentation calls that field "SSE transport", but the shipped language server registers exactly two connectors -- LocalSubprocessConnector for command and StreamableHTTPConnector for serverUrl -- so the streamable-HTTP endpoint this server exposes is what it actually speaks. MCPConfigTarget gains an UrlFieldName (empty means url) so the endpoint key follows the target instead of being hardcoded. Like Cursor/VS Code/Trae/Kiro, Antigravity has no project-scoping concept in its config, so the entry is global and stays distinguishable only by its per-project name. Antigravity is also wired up as a Project Skills platform: SKILL.md bundles are written to .agents/skills/ at the repository root. Its customization discovery walks from the session's working directory up to the repo root, so in a monorepo layout (git root above the Unity project folder) a .agents inside the Unity folder would only ever be found by a session started at or below it -- the same trap DeepSeek Harness's .dsh/skills placement avoids. It reads AGENTS.md natively, so it joins Codex/OpenCode/DSH on the single shared managed block (written while any of them is enabled, removed only when all are disabled); the block's skills bullet gained the .agents/skills/ entry, with the previous wording kept in the legacy-migration variant list so files already on disk keep migrating instead of erroring. ## Tests New EditMode coverage in ProjectSkillsManagerTests: the legacy-variant chain now recognizes four renderings of the shared AGENTS.md wording (current, pre-Antigravity, pre-DSH, Codex-only) instead of three; Antigravity's skills land at the discovered git root and not inside the Unity project folder; and Antigravity shares the AGENTS.md block with Codex while its own skill directory tracks only its own toggle. --- CHANGELOG.md | 3 + .../MCP/Server/FunplayMCPClientConfigPanel.cs | 39 ++++++- .../MCP/Server/FunplayProjectSkillsWindow.cs | 8 +- Editor/MCP/Server/ProjectSkillsManager.cs | 79 ++++++++++++-- README.md | 21 +++- README_CN.md | 21 +++- Tests/Editor/ProjectSkillsManagerTests.cs | 100 ++++++++++++++++-- 7 files changed, 244 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a75476..e066bae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +### Added +- **Antigravity** is now a one-click client-config target. Its entry is written to the global `~/.gemini/config/mcp_config.json` (`{"mcpServers": {"funplay-": {"serverUrl": ...}}}`), the only location a Unity plugin can reasonably own -- the alternative, `plugins//mcp_config.json`, loads only with its plugin. The endpoint key is `serverUrl`, not the `url` every other JSON client here uses: Antigravity's `McpServerSpec` has no `url` field, so an entry written in the usual shape would sit in the config connecting to nothing. Its documentation labels that field "SSE transport", but the shipped language server registers exactly two connectors -- `LocalSubprocessConnector` for `command` and `StreamableHTTPConnector` for `serverUrl` -- so the streamable-HTTP endpoint this server exposes is what it actually speaks. Like Cursor/VS Code/Trae/Kiro, Antigravity has no project-scoping concept in its config, so the entry is global and stays distinguishable only by its per-project name. Antigravity is also a Project Skills platform: `SKILL.md` bundles are written to `.agents/skills/` at the **repository root** -- its customization discovery walks from the session's working directory up to the repo root, so in a monorepo layout (git root above the Unity project folder) a `.agents` inside the Unity folder would only be found by a session started at or below it, the same trap the DeepSeek Harness `.dsh/skills` placement avoids. It reads `AGENTS.md` natively, so it joins Codex/OpenCode/DSH on the single shared managed block (written while any of them is enabled, removed only when all are disabled); the block's skills bullet gained the `.agents/skills/` entry, with the previous wording kept in the legacy-migration variant list so files already on disk keep migrating instead of erroring. + ## [0.6.5] - 2026-09-03 ### Added diff --git a/Editor/MCP/Server/FunplayMCPClientConfigPanel.cs b/Editor/MCP/Server/FunplayMCPClientConfigPanel.cs index 766197d..aeed695 100644 --- a/Editor/MCP/Server/FunplayMCPClientConfigPanel.cs +++ b/Editor/MCP/Server/FunplayMCPClientConfigPanel.cs @@ -108,7 +108,7 @@ public void AddTo(VisualElement parent) var skillsHint = new Label(skillsSupported ? "Configure + Skills also installs the project MCP workflow skill." - : "Project skills are currently available for Claude Code, Cursor, Codex, OpenCode, and DeepSeek Harness."); + : "Project skills are currently available for Claude Code, Cursor, Codex, OpenCode, DeepSeek Harness, and Antigravity."); skillsHint.style.fontSize = 10; skillsHint.style.color = new Color(0.6f, 0.6f, 0.6f); skillsHint.style.marginBottom = 4; @@ -473,6 +473,15 @@ private MCPConfigTarget[] CreateTargets(string homePath) ConfigPath = FunplayDeepSeekHarnessPatch.GetDisplayPath(homePath), IsDeepSeekHarness = true, }, + new MCPConfigTarget + { + Name = "Antigravity", + ConfigPath = GetAntigravityConfigPath(homePath), + UrlFieldName = "serverUrl", + ActivationHint = + "Restart Antigravity for it to take effect. " + + "Its active servers are listed under Additional Options (...) > MCP Servers.", + }, }; } @@ -516,7 +525,7 @@ private void ConfigureMCPAndSkillsForTarget(MCPConfigTarget target) EditorUtility.DisplayDialog( "MCP Configuration", configSummary + "\n\n" + - "Project skills are currently available for Claude Code, Cursor, Codex, OpenCode, and DeepSeek Harness.", + "Project skills are currently available for Claude Code, Cursor, Codex, OpenCode, DeepSeek Harness, and Antigravity.", "OK"); _rebuildWindow?.Invoke(); @@ -1518,7 +1527,7 @@ private Dictionary CreateHttpEntry(MCPConfigTarget target) { var entry = new Dictionary { - ["url"] = GetServerUrl() + [string.IsNullOrEmpty(target.UrlFieldName) ? "url" : target.UrlFieldName] = GetServerUrl() }; if (target.IncludeTypeField) @@ -1749,6 +1758,22 @@ private static string GetOpenCodeConfigPath() return Path.Combine(GetProjectScopeKeyPath(), ".opencode", "opencode.json"); } + /// + /// Antigravity keeps its MCP servers in ~/.gemini/config/mcp_config.json -- one global + /// map of server id to spec, plus per-plugin plugins/<name>/mcp_config.json files + /// that only load with their plugin, which is not a place a Unity plugin should be writing to. + /// The remote spec carries a single serverUrl; Antigravity's own documentation calls + /// that "SSE transport", but the language server has exactly two connectors -- + /// LocalSubprocessConnector for command and StreamableHTTPConnector for + /// serverUrl -- so a streamable-HTTP endpoint like this one is what it actually speaks. + /// There is no project-scoping concept here, so like Cursor/VS Code/Trae/Kiro the entry is + /// global and stays distinguishable only by its per-project name. + /// + private static string GetAntigravityConfigPath(string homePath) + { + return Path.Combine(homePath, ".gemini", "config", "mcp_config.json"); + } + private static string GetUserHomePath() { var homePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); @@ -1876,6 +1901,14 @@ private struct MCPConfigTarget /// public string TypeFieldValue; + /// + /// Key the endpoint is written under inside the entry. Empty means url, which is + /// what every client here uses except Antigravity: its McpServerSpec names the + /// remote endpoint serverUrl and ignores an entry carrying neither that nor + /// command. + /// + public string UrlFieldName; + /// /// True for OpenCode, whose documented remote-server example spells the flag out /// ({"type":"remote","url":...,"enabled":true}). The field is optional in its schema diff --git a/Editor/MCP/Server/FunplayProjectSkillsWindow.cs b/Editor/MCP/Server/FunplayProjectSkillsWindow.cs index 7e7af8c..bce1d2c 100644 --- a/Editor/MCP/Server/FunplayProjectSkillsWindow.cs +++ b/Editor/MCP/Server/FunplayProjectSkillsWindow.cs @@ -102,7 +102,7 @@ private void BuildPlatformSection() var section = CreateSection(); section.Add(CreateSectionHeader("Current Platform")); - _platformTargets = new[] { "Claude Code", "Cursor", "VS Code", "Trae", "Kiro", "Codex", "OpenCode", "DeepSeek Harness" }; + _platformTargets = new[] { "Claude Code", "Cursor", "VS Code", "Trae", "Kiro", "Codex", "OpenCode", "DeepSeek Harness", "Antigravity" }; _selectedTargetIndex = Mathf.Clamp(_selectedTargetIndex, 0, _platformTargets.Length - 1); var persistedTargetName = _settingsController.MCPSelectedConfigTarget; if (!string.IsNullOrWhiteSpace(persistedTargetName)) @@ -136,7 +136,7 @@ private void BuildPlatformSection() if (!currentPlatformSupported) { - section.Add(CreateHint("Project skills integration is not available for this platform yet. Supported platforms: Codex, Claude Code, Cursor, OpenCode.", new Color(1f, 0.75f, 0.45f))); + section.Add(CreateHint("Project skills integration is not available for this platform yet. Supported platforms: Codex, Claude Code, Cursor, OpenCode, DeepSeek Harness, Antigravity.", new Color(1f, 0.75f, 0.45f))); } _mainContainer.Add(section); @@ -331,7 +331,7 @@ private void ApplyProjectSkillsConfiguration() { EditorUtility.DisplayDialog( "Project Skills Configuration", - "Project skills are not supported for the currently selected platform yet.\n\nPlease select Codex, Claude Code, Cursor, or OpenCode.", + "Project skills are not supported for the currently selected platform yet.\n\nPlease select Codex, Claude Code, Cursor, OpenCode, DeepSeek Harness, or Antigravity.", "OK"); return; } @@ -388,7 +388,7 @@ private void UpgradeProjectSkills() { EditorUtility.DisplayDialog( "Project Skills Upgrade", - "Project skills are not supported for the currently selected platform yet.\n\nPlease select Codex, Claude Code, Cursor, or OpenCode.", + "Project skills are not supported for the currently selected platform yet.\n\nPlease select Codex, Claude Code, Cursor, OpenCode, DeepSeek Harness, or Antigravity.", "OK"); return; } diff --git a/Editor/MCP/Server/ProjectSkillsManager.cs b/Editor/MCP/Server/ProjectSkillsManager.cs index 266b860..0875d58 100644 --- a/Editor/MCP/Server/ProjectSkillsManager.cs +++ b/Editor/MCP/Server/ProjectSkillsManager.cs @@ -33,7 +33,7 @@ internal static class ProjectSkillsManager private const string CodexManagedNotice = "This section is managed by Funplay MCP for Unity. Everything between the begin and end markers is regenerated on each sync; edit outside this block."; private const string ClaudeManagedNotice = "This section is managed by Funplay MCP for Unity for Claude Code. Everything between the begin and end markers is regenerated on each sync; edit outside this block."; - private static readonly string[] SupportedPlatforms = { "codex", "claude", "cursor", "opencode", "dsh" }; + private static readonly string[] SupportedPlatforms = { "codex", "claude", "cursor", "opencode", "dsh", "antigravity" }; private static readonly SkillDefinition[] SkillCatalog = { @@ -139,6 +139,8 @@ internal static string GetPlatformIdForConfigTarget(string targetName) return "cursor"; case "deepseek harness": return "dsh"; + case "antigravity": + return "antigravity"; default: return null; } @@ -237,6 +239,21 @@ internal static string GetDshSkillsRoot(string projectRoot) return Path.Combine(FunplayMCPClientConfigPanel.FindGitRootOrSelf(projectRoot), ".dsh", "skills"); } + /// + /// Antigravity discovers workspace customizations in a .agents/ directory (it also + /// accepts .agent, _agents and _agent; the documented primary spelling is + /// used here) and reads skills from .agents/skills/<name>/SKILL.md. It finds that + /// directory by walking from the session's working directory up to the repository root, so -- + /// exactly like DeepSeek Harness -- the skills are written at the discovered git root: in a + /// monorepo layout (git root above the Unity project folder) a .agents inside the Unity + /// folder would only ever be seen by a session started at or below it, while one at the repo + /// root is reachable from anywhere inside the repo. + /// + internal static string GetAntigravitySkillsRoot(string projectRoot) + { + return Path.Combine(FunplayMCPClientConfigPanel.FindGitRootOrSelf(projectRoot), ".agents", "skills"); + } + internal static void ApplyConfiguration(string projectRoot, IEnumerable selectedPlatforms, IEnumerable selectedOptionalSkills) { var manifest = new ProjectSkillsManifest @@ -254,6 +271,7 @@ internal static void ApplyConfiguration(string projectRoot, IEnumerable SyncCursor(projectRoot, normalized); SyncOpenCode(projectRoot, normalized); SyncDsh(projectRoot, normalized); + SyncAntigravity(projectRoot, normalized); } internal static bool IsPlatformConfigured(string projectRoot, string platformId) @@ -363,21 +381,26 @@ internal static IReadOnlyList GetGeneratedPathsForPlatform(string projec paths.Add(GetCodexAgentsPath(projectRoot)); paths.Add(GetDshSkillsRoot(projectRoot)); break; + case "antigravity": + paths.Add(GetCodexAgentsPath(projectRoot)); + paths.Add(GetAntigravitySkillsRoot(projectRoot)); + break; } return paths; } - // AGENTS.md is shared by Codex, OpenCode and DeepSeek Harness (all three read it natively), - // so the single managed block is written while ANY of them is enabled and removed only when - // ALL are disabled. Splitting it per platform would need two blocks in one file, which - // WriteManagedBlock's single begin..end marker model cannot represent. + // AGENTS.md is shared by Codex, OpenCode, DeepSeek Harness and Antigravity (all four read it + // natively), so the single managed block is written while ANY of them is enabled and removed + // only when ALL are disabled. Splitting it per platform would need two blocks in one file, + // which WriteManagedBlock's single begin..end marker model cannot represent. private static void SyncAgentsInstructions(string projectRoot, ProjectSkillsManifest manifest) { var enabled = manifest.platforms.Contains("codex", StringComparer.OrdinalIgnoreCase) || manifest.platforms.Contains("opencode", StringComparer.OrdinalIgnoreCase) || - manifest.platforms.Contains("dsh", StringComparer.OrdinalIgnoreCase); + manifest.platforms.Contains("dsh", StringComparer.OrdinalIgnoreCase) || + manifest.platforms.Contains("antigravity", StringComparer.OrdinalIgnoreCase); var agentsPath = GetCodexAgentsPath(projectRoot); if (!enabled) @@ -479,6 +502,21 @@ private static void SyncDsh(string projectRoot, ProjectSkillsManifest manifest) WriteManagedSkillDirectories(skillsRoot, manifest, SkillPlatform.Dsh); } + private static void SyncAntigravity(string projectRoot, ProjectSkillsManifest manifest) + { + var enabled = manifest.platforms.Contains("antigravity", StringComparer.OrdinalIgnoreCase); + var skillsRoot = GetAntigravitySkillsRoot(projectRoot); + + if (!enabled) + { + DeleteManagedSkillDirectories(skillsRoot); + return; + } + + Directory.CreateDirectory(skillsRoot); + WriteManagedSkillDirectories(skillsRoot, manifest, SkillPlatform.Antigravity); + } + private static void WriteManagedSkillDirectories(string skillsRoot, ProjectSkillsManifest manifest, SkillPlatform platform) { DeleteManagedSkillDirectories(skillsRoot); @@ -772,6 +810,17 @@ private static List GetExpectedVersionedFilesForPlatfo BuildSkillVersionMarker(skill))); } break; + case "antigravity": + AddProjectVersionFile(result, GetCodexAgentsPath(projectRoot), skills); + foreach (var skill in skills) + { + result.Add(new ExpectedSkillVersionFile( + Path.Combine(GetAntigravitySkillsRoot(projectRoot), $"funplay-{skill.Id}", "SKILL.md"), + skill.Id, + skill.Version, + BuildSkillVersionMarker(skill))); + } + break; } return result; @@ -911,7 +960,7 @@ private static string ExtractMarkerValue(string content, string markerPrefix) return content.Substring(start, end - start).Trim(); } - // Shared by Codex and OpenCode (both read AGENTS.md); see SyncAgentsInstructions. + // Shared by every AGENTS.md-reading client; see SyncAgentsInstructions. private static string BuildAgentsManagedBlock(string projectRoot, ProjectSkillsManifest manifest) { var installed = GetInstalledSkills(manifest); @@ -929,7 +978,7 @@ private static string BuildAgentsManagedBlock(string projectRoot, ProjectSkillsM ## Agent workflow rules -- Prefer project-local Funplay skills: `.codex/skills/` for Codex, `.opencode/skills/` for OpenCode, `.dsh/skills/` for DeepSeek Harness. +- Prefer project-local Funplay skills: `.codex/skills/` for Codex, `.opencode/skills/` for OpenCode, `.dsh/skills/` for DeepSeek Harness, `.agents/skills/` for Antigravity. - Use `execute_code` as the primary Unity automation tool. For new snippets, include `using Funplay.Editor.Tools.Scripting;`, implement `IFunplayCommand`, and use `ctx.RegisterObjectCreation` / `RegisterObjectModification` / `DestroyObject` so changes participate in Undo automatically. - Confirm the Unity project root, active scene, and real object/prefab/asset path before edits. Treat user-provided object names as hints, not paths. - Inspect Unity objects through MCP before changing user-named scene or prefab targets. Carry the returned `instanceId` into follow-up calls (`find_method=by_id`) instead of re-resolving by name. @@ -1024,9 +1073,16 @@ private static string[] BuildLegacyCodexAgentsContentVariants( { var current = BuildLegacyCodexAgentsContent(projectRoot, manifest); + // Pre-Antigravity: the skills bullet named Codex, OpenCode and DSH before Antigravity + // joined the shared AGENTS.md block. + var preAntigravity = current + .Replace( + "- Prefer project-local Funplay skills: `.codex/skills/` for Codex, `.opencode/skills/` for OpenCode, `.dsh/skills/` for DeepSeek Harness, `.agents/skills/` for Antigravity.", + "- Prefer project-local Funplay skills: `.codex/skills/` for Codex, `.opencode/skills/` for OpenCode, `.dsh/skills/` for DeepSeek Harness."); + // Pre-DSH: the block named Codex and OpenCode before DeepSeek Harness joined the shared // AGENTS.md block. Files on disk still carrying this wording must keep migrating. - var preDsh = current + var preDsh = preAntigravity .Replace( "- Prefer project-local Funplay skills: `.codex/skills/` for Codex, `.opencode/skills/` for OpenCode, `.dsh/skills/` for DeepSeek Harness.", "- Prefer project-local Funplay skills: `.codex/skills/` for Codex, `.opencode/skills/` for OpenCode."); @@ -1038,7 +1094,7 @@ private static string[] BuildLegacyCodexAgentsContentVariants( "- Prefer project-local Funplay skills: `.codex/skills/` for Codex, `.opencode/skills/` for OpenCode.", "- Prefer project-local Funplay skills under `.codex/skills/`."); - return new[] { current, preDsh, codexOnly }; + return new[] { current, preAntigravity, preDsh, codexOnly }; } private static string BuildLegacyClaudeInstructionsContent(string projectRoot, ProjectSkillsManifest manifest) @@ -1675,7 +1731,8 @@ internal enum SkillPlatform Claude, Cursor, OpenCode, - Dsh + Dsh, + Antigravity } [Serializable] diff --git a/README.md b/README.md index 482b388..b6ba9e9 100644 --- a/README.md +++ b/README.md @@ -272,6 +272,25 @@ Written as a delimited managed block into every DeepSeek Harness profile's `~/.d +
+Antigravity + +Written to the global `~/.gemini/config/mcp_config.json`. Antigravity's remote-server spec names the endpoint `serverUrl` (a plain `url` key is ignored); its language server connects that with the MCP streamable-HTTP transport, which is what this server speaks. Restart Antigravity afterwards, then check **Additional Options (...) > MCP Servers**. + +Project Skills write `SKILL.md` bundles to `.agents/skills/` at the repository root (Antigravity walks up from the session's working directory to find `.agents/`), and Antigravity reads the shared `AGENTS.md` managed block natively. + +```json +{ + "mcpServers": { + "funplay-": { + "serverUrl": "http://127.0.0.1:/" + } + } +} +``` + +
+
Windsurf @@ -322,7 +341,7 @@ Open your AI client and try: *"Create a 3D platformer level with 5 floating plat - **Resources & Prompts** — Live project context, scene/selection/error resources, resource templates, and reusable workflow prompts - **Input Simulation + Screenshots** — Drive play mode with keyboard/mouse simulation and verify results with game/scene captures - **Built-in Updating** — Check for updates from the Unity menu and either re-pull the Git package or auto-import the latest `unitypackage` -- **One-Click Client Configuration** — Generate MCP config entries for Claude Code, Cursor, Kimi, LM Studio, VS Code, Kiro, Trae, Codex, OpenCode, DeepSeek Harness, and similar clients directly from the Unity window +- **One-Click Client Configuration** — Generate MCP config entries for Claude Code, Cursor, Kimi, LM Studio, VS Code, Kiro, Trae, Codex, OpenCode, DeepSeek Harness, Antigravity, and similar clients directly from the Unity window - **Tool Exposure Control** — Edit the exact tools exposed by `core` and `full` - **Project Skills Manager** — Configure project-level skills for supported AI clients, with built-in `unity-mcp-workflow` and `unity-ui-composition` guidance - **MCP Settings** — Adjust `execute_code` safety defaults and enable verbose plugin debug logging when troubleshooting MCP connections or tool execution diff --git a/README_CN.md b/README_CN.md index 2e6faff..10e6aa5 100644 --- a/README_CN.md +++ b/README_CN.md @@ -272,6 +272,25 @@ url = "http://127.0.0.1:/"
+
+Antigravity + +写入全局 `~/.gemini/config/mcp_config.json`。Antigravity 的远程服务器配置把端点字段命名为 `serverUrl`(写成 `url` 会被忽略);其 language server 用 MCP streamable-HTTP transport 连接该端点,正是本 server 使用的协议。配置后重启 Antigravity,可在 **Additional Options (...) > MCP Servers** 中查看。 + +Project Skills 会把 `SKILL.md` 写入仓库根目录的 `.agents/skills/`(Antigravity 从会话工作目录向上查找 `.agents/`),并与其他 agent 客户端共用 `AGENTS.md` 托管块(Antigravity 原生读取该文件)。 + +```json +{ + "mcpServers": { + "funplay-": { + "serverUrl": "http://127.0.0.1:/" + } + } +} +``` + +
+
Windsurf @@ -324,7 +343,7 @@ url = "http://127.0.0.1:/" - **Resources 与 Prompts** — 暴露实时项目上下文、场景/选择/错误资源、资源模板,以及常见 Unity 工作流的可复用 MCP Prompt - **输入模拟 + 截图验证** — 在 Play Mode 中模拟键盘/鼠标,再用 Game View / Scene View 截图验证结果 - **内置更新** — 直接在 Unity 菜单中检查更新,并根据安装方式自动重新拉取 Git 包或导入最新 `unitypackage` -- **一键客户端配置** — 直接在 Unity 窗口里为 Claude Code、Cursor、Kimi、LM Studio、VS Code、Kiro、Trae、Codex、OpenCode、DeepSeek Harness 等客户端生成 MCP 配置 +- **一键客户端配置** — 直接在 Unity 窗口里为 Claude Code、Cursor、Kimi、LM Studio、VS Code、Kiro、Trae、Codex、OpenCode、DeepSeek Harness、Antigravity 等客户端生成 MCP 配置 - **工具暴露控制** — 编辑 `core` 和 `full` 各自暴露的具体工具 - **项目 Skills 管理器** — 为支持的 AI 客户端配置项目级 skills,包含内置的 `unity-mcp-workflow` 与 `unity-ui-composition` 指引 - **插件设置** — 排查 MCP 连接或工具执行问题时,可开关详细 debug 日志 diff --git a/Tests/Editor/ProjectSkillsManagerTests.cs b/Tests/Editor/ProjectSkillsManagerTests.cs index 3e295bd..ecea100 100644 --- a/Tests/Editor/ProjectSkillsManagerTests.cs +++ b/Tests/Editor/ProjectSkillsManagerTests.cs @@ -566,8 +566,9 @@ public void ApplyConfiguration_DshSharesTheAgentsBlockAndTracksItsOwnSkillDirect } /// - /// The AGENTS.md wording gained the DeepSeek Harness bullet when DSH joined the shared block; - /// files generated with the previous (Codex + OpenCode) wording must still migrate. + /// The AGENTS.md wording gains a bullet every time another client joins the shared block, and + /// each older rendering must stay recognizable or files carrying it stop migrating. Newest + /// first: current (with Antigravity), pre-Antigravity, pre-DSH. /// [Test] public void LegacyVariants_StillRecognizeThePreDshWording() @@ -576,16 +577,22 @@ public void LegacyVariants_StillRecognizeThePreDshWording() try { - var manifest = CreateManifest("codex", "opencode", "dsh"); + var manifest = CreateManifest("codex", "opencode", "dsh", "antigravity"); var variants = BuildLegacyCodexContentVariants(projectRoot, manifest); - Assert.GreaterOrEqual(variants.Length, 3, "The pre-DSH wording must still be accepted."); - var preDsh = variants[1]; - StringAssert.Contains("`.opencode/skills/`", preDsh); - StringAssert.DoesNotContain("DeepSeek Harness", preDsh); + Assert.GreaterOrEqual(variants.Length, 4, "The pre-DSH wording must still be accepted."); var current = variants[0]; StringAssert.Contains("`.dsh/skills/`", current); + StringAssert.Contains("`.agents/skills/`", current); + + var preAntigravity = variants[1]; + StringAssert.Contains("`.dsh/skills/`", preAntigravity); + StringAssert.DoesNotContain("Antigravity", preAntigravity); + + var preDsh = variants[2]; + StringAssert.Contains("`.opencode/skills/`", preDsh); + StringAssert.DoesNotContain("DeepSeek Harness", preDsh); } finally { @@ -593,6 +600,85 @@ public void LegacyVariants_StillRecognizeThePreDshWording() } } + /// + /// Antigravity discovers workspace customizations by walking from the session's working + /// directory up to the repository root, so .agents/skills must land at the git root -- + /// in a monorepo (git root above the Unity project folder) a copy inside the Unity folder is + /// only visible to sessions started at or below it. Same reasoning as the DSH case above. + /// + [Test] + public void ApplyConfiguration_WritesAntigravitySkillsToTheGitRootAgentsDirectory() + { + var tempRoot = CreateTempProjectPath(); + var repoRoot = Path.Combine(tempRoot, "repo"); + Directory.CreateDirectory(Path.Combine(repoRoot, ".git")); + var unityProject = Path.Combine(repoRoot, "UnityProject"); + Directory.CreateDirectory(unityProject); + + try + { + ProjectSkillsManager.ApplyConfiguration(unityProject, new[] { "antigravity" }, Array.Empty()); + + var expected = Path.Combine( + repoRoot, ".agents", "skills", "funplay-unity-mcp-workflow", "SKILL.md"); + Assert.IsTrue(File.Exists(expected), expected); + AssertStandardSkillFrontmatter(expected); + + Assert.IsFalse(Directory.Exists(Path.Combine(unityProject, ".agents")), + "Skills belong at the git root Antigravity walks up to, not the Unity project folder."); + + StringAssert.Contains( + ProjectSkillsManager.ManagedEndMarker, + File.ReadAllText(ProjectSkillsManager.GetCodexAgentsPath(unityProject)), + "Antigravity reads AGENTS.md natively and shares the block with the other agent platforms."); + } + finally + { + DeleteTempProjectPath(tempRoot); + } + } + + /// + /// Antigravity shares AGENTS.md with Codex/OpenCode/DSH: the block stays while any of them is + /// enabled, while its own skill directory follows only its own toggle. + /// + [Test] + public void ApplyConfiguration_AntigravitySharesTheAgentsBlockAndTracksItsOwnSkillDirectory() + { + var tempRoot = CreateTempProjectPath(); + var repoRoot = Path.Combine(tempRoot, "repo"); + Directory.CreateDirectory(Path.Combine(repoRoot, ".git")); + + try + { + var agentsPath = ProjectSkillsManager.GetCodexAgentsPath(repoRoot); + var antigravitySkill = Path.Combine( + ProjectSkillsManager.GetAntigravitySkillsRoot(repoRoot), + "funplay-unity-mcp-workflow", + "SKILL.md"); + var codexSkill = GetCodexWorkflowSkillPath(repoRoot); + + ProjectSkillsManager.ApplyConfiguration( + repoRoot, new[] { "codex", "antigravity" }, Array.Empty()); + Assert.IsTrue(File.Exists(agentsPath)); + Assert.IsTrue(File.Exists(antigravitySkill)); + Assert.IsTrue(File.Exists(codexSkill)); + + ProjectSkillsManager.ApplyConfiguration(repoRoot, new[] { "antigravity" }, Array.Empty()); + StringAssert.Contains(ProjectSkillsManager.ManagedEndMarker, File.ReadAllText(agentsPath)); + Assert.IsFalse(File.Exists(codexSkill)); + Assert.IsTrue(File.Exists(antigravitySkill)); + + ProjectSkillsManager.ApplyConfiguration(repoRoot, Array.Empty(), Array.Empty()); + Assert.IsFalse(File.Exists(agentsPath)); + Assert.IsFalse(File.Exists(antigravitySkill)); + } + finally + { + DeleteTempProjectPath(tempRoot); + } + } + private static string GetCodexWorkflowSkillPath(string projectRoot) { return Path.Combine( From b2d17c2498c4c5783b0da55955306e206a565d12 Mon Sep 17 00:00:00 2001 From: dehuaichendragonplus Date: Mon, 7 Sep 2026 10:22:07 +0800 Subject: [PATCH 2/3] fix(config): rename cleanup reads the endpoint under the target's own key RemoveSupersededFunplayEntries decides whether a recorded previous entry is still ours to remove by checking that it points at a loopback URL, but it only ever read that URL under the "url" key. Antigravity's entries carry the endpoint under "serverUrl" instead, so after this project was renamed (or toggled its hash suffix) and reconfigured, its previous Antigravity entry looked hand-edited and was never retired, leaving two funplay entries on the same port in ~/.gemini/config/mcp_config.json. Both IsLoopbackEntry and RemoveSupersededFunplayEntries take an optional urlFieldName that defaults to "url", and the JSON write path threads the target's field name through. CreateHttpEntry gains a static overload taking its inputs directly so the endpoint-key behavior can be tested without constructing an MCPConfigTarget, and GetUrlFieldName centralizes the empty-means-url fallback so every reader and writer of an entry's endpoint resolves the key the same way. ## Tests Two new EditMode tests in MultiEditorPortAndKeyTests: a previous entry written under serverUrl is retired by the rename cleanup exactly like one written under url, and CreateHttpEntry emits the requested key only (an Antigravity-shaped entry carries serverUrl and no url). --- CHANGELOG.md | 3 ++ .../MCP/Server/FunplayMCPClientConfigPanel.cs | 49 +++++++++++++++---- Tests/Editor/MultiEditorPortAndKeyTests.cs | 47 +++++++++++++++++- 3 files changed, 88 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e066bae..08dbfff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ ### Added - **Antigravity** is now a one-click client-config target. Its entry is written to the global `~/.gemini/config/mcp_config.json` (`{"mcpServers": {"funplay-": {"serverUrl": ...}}}`), the only location a Unity plugin can reasonably own -- the alternative, `plugins//mcp_config.json`, loads only with its plugin. The endpoint key is `serverUrl`, not the `url` every other JSON client here uses: Antigravity's `McpServerSpec` has no `url` field, so an entry written in the usual shape would sit in the config connecting to nothing. Its documentation labels that field "SSE transport", but the shipped language server registers exactly two connectors -- `LocalSubprocessConnector` for `command` and `StreamableHTTPConnector` for `serverUrl` -- so the streamable-HTTP endpoint this server exposes is what it actually speaks. Like Cursor/VS Code/Trae/Kiro, Antigravity has no project-scoping concept in its config, so the entry is global and stays distinguishable only by its per-project name. Antigravity is also a Project Skills platform: `SKILL.md` bundles are written to `.agents/skills/` at the **repository root** -- its customization discovery walks from the session's working directory up to the repo root, so in a monorepo layout (git root above the Unity project folder) a `.agents` inside the Unity folder would only be found by a session started at or below it, the same trap the DeepSeek Harness `.dsh/skills` placement avoids. It reads `AGENTS.md` natively, so it joins Codex/OpenCode/DSH on the single shared managed block (written while any of them is enabled, removed only when all are disabled); the block's skills bullet gained the `.agents/skills/` entry, with the previous wording kept in the legacy-migration variant list so files already on disk keep migrating instead of erroring. +### Fixed +- The rename cleanup (`RemoveSupersededFunplayEntries`) now reads the endpoint under the target's own key. It only ever looked at `url`, so an Antigravity entry (written under `serverUrl`) looked hand-edited and this project's previous entry was never retired after a product rename or project-hash toggle, leaving two funplay entries on the same port in `~/.gemini/config/mcp_config.json`. + ## [0.6.5] - 2026-09-03 ### Added diff --git a/Editor/MCP/Server/FunplayMCPClientConfigPanel.cs b/Editor/MCP/Server/FunplayMCPClientConfigPanel.cs index aeed695..3ad1c12 100644 --- a/Editor/MCP/Server/FunplayMCPClientConfigPanel.cs +++ b/Editor/MCP/Server/FunplayMCPClientConfigPanel.cs @@ -849,7 +849,8 @@ private string ConfigureJsonTarget(MCPConfigTarget target, string presetServerKe : GetOrCreateNestedDictionary(root, rootKey); servers[serverName] = entry; - RemoveSupersededFunplayEntries(servers, serverName, _settings.GetLastClientConfigKey(target.Name)); + RemoveSupersededFunplayEntries( + servers, serverName, _settings.GetLastClientConfigKey(target.Name), target.UrlFieldName); File.WriteAllText(target.ConfigPath, SimpleJsonHelper.Serialize(root)); return serverName; @@ -1240,7 +1241,10 @@ internal static bool TryWriteTextAtomicallyIfUnchanged( /// any project on the machine could have written it, so it is surfaced in the panel instead. ///
internal static void RemoveSupersededFunplayEntries( - Dictionary servers, string currentServerName, string previousServerName) + Dictionary servers, + string currentServerName, + string previousServerName, + string urlFieldName = null) { if (string.IsNullOrEmpty(previousServerName) || string.Equals(previousServerName, currentServerName, StringComparison.Ordinal)) @@ -1257,17 +1261,19 @@ internal static void RemoveSupersededFunplayEntries( // A recorded key whose entry now points somewhere non-local was edited by hand after we // wrote it; leave that alone rather than deleting someone's deliberate change. - if (!IsLoopbackEntry(previousEntry)) + if (!IsLoopbackEntry(previousEntry, urlFieldName)) return; servers.Remove(previousServerName); } - private static bool IsLoopbackEntry(object entry) + // The endpoint key follows the target (Antigravity writes serverUrl); reading only "url" + // here would make an Antigravity entry look hand-edited and never retire it. + private static bool IsLoopbackEntry(object entry, string urlFieldName = null) { var entryMap = entry as Dictionary; object url; - if (entryMap == null || !entryMap.TryGetValue("url", out url)) + if (entryMap == null || !entryMap.TryGetValue(GetUrlFieldName(urlFieldName), out url)) return false; return IsLoopbackUrl(url as string); @@ -1524,21 +1530,46 @@ private string BuildLMStudioConfiguredMessage() } private Dictionary CreateHttpEntry(MCPConfigTarget target) + { + return CreateHttpEntry( + GetServerUrl(), + target.UrlFieldName, + target.IncludeTypeField, + target.TypeFieldValue, + target.IncludeEnabledField); + } + + internal static Dictionary CreateHttpEntry( + string serverUrl, + string urlFieldName, + bool includeTypeField, + string typeFieldValue, + bool includeEnabledField) { var entry = new Dictionary { - [string.IsNullOrEmpty(target.UrlFieldName) ? "url" : target.UrlFieldName] = GetServerUrl() + [GetUrlFieldName(urlFieldName)] = serverUrl }; - if (target.IncludeTypeField) - entry["type"] = string.IsNullOrEmpty(target.TypeFieldValue) ? "http" : target.TypeFieldValue; + if (includeTypeField) + entry["type"] = string.IsNullOrEmpty(typeFieldValue) ? "http" : typeFieldValue; - if (target.IncludeEnabledField) + if (includeEnabledField) entry["enabled"] = true; return entry; } + /// + /// The JSON key the endpoint lives under: url unless the target names another one + /// (Antigravity's serverUrl). Every reader of an entry's endpoint must go through this + /// too, or an entry written under the alternate key is invisible to it. + /// + internal static string GetUrlFieldName(string urlFieldName) + { + return string.IsNullOrEmpty(urlFieldName) ? "url" : urlFieldName; + } + private string CreateTomlSection(MCPConfigTarget target, string serverKey) { if (!target.IsToml) diff --git a/Tests/Editor/MultiEditorPortAndKeyTests.cs b/Tests/Editor/MultiEditorPortAndKeyTests.cs index c8f5961..6d88678 100644 --- a/Tests/Editor/MultiEditorPortAndKeyTests.cs +++ b/Tests/Editor/MultiEditorPortAndKeyTests.cs @@ -348,6 +348,49 @@ public void ConfigCleanup_LeavesAHandEditedPreviousEntryAlone() "A recorded entry repointed at a non-loopback host was edited deliberately after we wrote it."); } + /// + /// Antigravity's entries carry the endpoint under serverUrl, not url. The rename + /// cleanup must read the same key the writer used, or this project's own previous entry looks + /// hand-edited and is never retired -- leaving two funplay entries on the same port. + /// + [Test] + public void ConfigCleanup_RetiresAPreviousEntryWrittenUnderAnAlternateUrlKey() + { + var servers = new Dictionary + { + ["funplay-love-town"] = Entry("http://127.0.0.1:24312/mcp", "serverUrl"), + ["funplay-old-name"] = Entry("http://127.0.0.1:24312/mcp", "serverUrl"), + ["funplay-other-project"] = Entry("http://127.0.0.1:21000/mcp", "serverUrl") + }; + + FunplayMCPClientConfigPanel.RemoveSupersededFunplayEntries( + servers, "funplay-love-town", "funplay-old-name", "serverUrl"); + + Assert.IsFalse(servers.ContainsKey("funplay-old-name"), "The serverUrl entry we wrote before is retired."); + Assert.IsTrue(servers.ContainsKey("funplay-love-town")); + Assert.IsTrue(servers.ContainsKey("funplay-other-project")); + } + + /// + /// Antigravity's McpServerSpec has no url field: an entry written in the usual + /// shape sits in its config connecting to nothing. Pin the key the writer emits. + /// + [Test] + public void HttpEntry_UsesTheTargetsUrlFieldName() + { + var antigravity = FunplayMCPClientConfigPanel.CreateHttpEntry( + "http://127.0.0.1:24312/mcp", "serverUrl", false, null, false); + Assert.AreEqual("http://127.0.0.1:24312/mcp", antigravity["serverUrl"]); + Assert.IsFalse(antigravity.ContainsKey("url"), "Antigravity ignores an entry carrying url."); + Assert.IsFalse(antigravity.ContainsKey("type")); + + var standard = FunplayMCPClientConfigPanel.CreateHttpEntry( + "http://127.0.0.1:24312/mcp", null, true, null, true); + Assert.AreEqual("http://127.0.0.1:24312/mcp", standard["url"]); + Assert.AreEqual("http", standard["type"]); + Assert.AreEqual(true, standard["enabled"]); + } + [Test] public void ConfigCleanup_DoesNothingWithoutARecordedPreviousEntry() { @@ -422,9 +465,9 @@ public void TomlCleanup_RemovesOnlyThePreviousSectionAndKeepsNeighbours() "Without a recorded previous name the file is left untouched."); } - private static Dictionary Entry(string url) + private static Dictionary Entry(string url, string urlFieldName = "url") { - return new Dictionary { ["url"] = url, ["type"] = "http" }; + return new Dictionary { [urlFieldName] = url, ["type"] = "http" }; } [Test] From faecf21c97ac416f69c9f189cdfd8c00ced4602f Mon Sep 17 00:00:00 2001 From: Winlife <110321103+Winlifes@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:31:41 +0800 Subject: [PATCH 3/3] fix(config): keep Antigravity integrations workspace-local --- CHANGELOG.md | 12 +- .../MCP/Server/FunplayMCPClientConfigPanel.cs | 103 +++++++---- Editor/MCP/Server/ProjectSkillsManager.cs | 82 ++++++--- README.md | 6 +- README_CN.md | 6 +- Tests/Editor/AntigravityConfigTests.cs | 168 ++++++++++++++++++ Tests/Editor/AntigravityConfigTests.cs.meta | 11 ++ Tests/Editor/ProjectSkillsManagerTests.cs | 81 ++++++++- 8 files changed, 408 insertions(+), 61 deletions(-) create mode 100644 Tests/Editor/AntigravityConfigTests.cs create mode 100644 Tests/Editor/AntigravityConfigTests.cs.meta diff --git a/CHANGELOG.md b/CHANGELOG.md index 08dbfff..f3cb802 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,18 @@ ## [Unreleased] ### Added -- **Antigravity** is now a one-click client-config target. Its entry is written to the global `~/.gemini/config/mcp_config.json` (`{"mcpServers": {"funplay-": {"serverUrl": ...}}}`), the only location a Unity plugin can reasonably own -- the alternative, `plugins//mcp_config.json`, loads only with its plugin. The endpoint key is `serverUrl`, not the `url` every other JSON client here uses: Antigravity's `McpServerSpec` has no `url` field, so an entry written in the usual shape would sit in the config connecting to nothing. Its documentation labels that field "SSE transport", but the shipped language server registers exactly two connectors -- `LocalSubprocessConnector` for `command` and `StreamableHTTPConnector` for `serverUrl` -- so the streamable-HTTP endpoint this server exposes is what it actually speaks. Like Cursor/VS Code/Trae/Kiro, Antigravity has no project-scoping concept in its config, so the entry is global and stays distinguishable only by its per-project name. Antigravity is also a Project Skills platform: `SKILL.md` bundles are written to `.agents/skills/` at the **repository root** -- its customization discovery walks from the session's working directory up to the repo root, so in a monorepo layout (git root above the Unity project folder) a `.agents` inside the Unity folder would only be found by a session started at or below it, the same trap the DeepSeek Harness `.dsh/skills` placement avoids. It reads `AGENTS.md` natively, so it joins Codex/OpenCode/DSH on the single shared managed block (written while any of them is enabled, removed only when all are disabled); the block's skills bullet gained the `.agents/skills/` entry, with the previous wording kept in the legacy-migration variant list so files already on disk keep migrating instead of erroring. +- **Antigravity** is now a one-click client-config and Project Skills target. Configuration uses the workspace-local `.agents/mcp_config.json` with `mcpServers` / `serverUrl`, following the [current Antigravity MCP format](https://antigravity.google/docs/mcp/). MCP config, `.agents/skills/`, and the managed `AGENTS.md` block share the nearest Git root (or Unity project directory outside Git). Existing global entries are reported without being rewritten, and configuration never silently falls back to global scope. ### Fixed -- The rename cleanup (`RemoveSupersededFunplayEntries`) now reads the endpoint under the target's own key. It only ever looked at `url`, so an Antigravity entry (written under `serverUrl`) looked hand-edited and this project's previous entry was never retired after a product rename or project-hash toggle, leaving two funplay entries on the same port in `~/.gemini/config/mcp_config.json`. +- Endpoint matching and rename cleanup use the target's endpoint key, including Antigravity's `serverUrl`, so reconfiguring a renamed project retires its recorded previous entry without deleting other servers. +- Antigravity instruction paths and version checks agree with its workspace Skills location for nested Unity projects. Platform toggles preserve shared instructions and user-authored text; configuring another Unity project without Antigravity no longer deletes workspace Antigravity Skills. Another project's managed workspace guidance is protected from replacement. + +### Pull requests and issues +- [PR #56](https://github.com/FunplayAI/funplay-unity-mcp/pull/56): Antigravity client configuration and Project Skills support, with workspace isolation and shared-instruction fixes. +- No GitHub issues were closed by these changes. + +### Contributors +- Thanks [@dehuaichendragonplus](https://github.com/dehuaichendragonplus) for PR #56. ## [0.6.5] - 2026-09-03 diff --git a/Editor/MCP/Server/FunplayMCPClientConfigPanel.cs b/Editor/MCP/Server/FunplayMCPClientConfigPanel.cs index 3ad1c12..9710817 100644 --- a/Editor/MCP/Server/FunplayMCPClientConfigPanel.cs +++ b/Editor/MCP/Server/FunplayMCPClientConfigPanel.cs @@ -196,6 +196,9 @@ public void RefreshStatus() var resolvedKey = ResolveServerKeyForTarget(target); var details = $"{target.ConfigPath}\nEntry: {resolvedKey} -> {GetServerUrl()}"; + if (target.Name == "Antigravity") + details += GetAntigravityGlobalConfigNotice(GetUserHomePath()); + // Say why the name grew a hash, or the user just sees an unexplained hex suffix. The // occupant can also be this same project under a lost record (settings file deleted, // fresh checkout on the same machine), which we cannot tell apart from another project. @@ -473,15 +476,7 @@ private MCPConfigTarget[] CreateTargets(string homePath) ConfigPath = FunplayDeepSeekHarnessPatch.GetDisplayPath(homePath), IsDeepSeekHarness = true, }, - new MCPConfigTarget - { - Name = "Antigravity", - ConfigPath = GetAntigravityConfigPath(homePath), - UrlFieldName = "serverUrl", - ActivationHint = - "Restart Antigravity for it to take effect. " + - "Its active servers are listed under Additional Options (...) > MCP Servers.", - }, + CreateAntigravityTarget(GetProjectRootPath()), }; } @@ -835,9 +830,17 @@ private bool ConfigureProjectSkillsForPlatform(string platformId) /// private string ConfigureJsonTarget(MCPConfigTarget target, string presetServerKey = null) { - var rootKey = GetRootKey(target); var serverName = presetServerKey ?? ResolveServerKeyForTarget(target); - var entry = CreateHttpEntry(target); + WriteJsonConfiguration(target, serverName, + _settings.GetLastClientConfigKey(target.Name), CreateHttpEntry(target)); + return serverName; + } + + internal static void WriteJsonConfiguration( + MCPConfigTarget target, string serverName, string previousServerName, + Dictionary entry) + { + var rootKey = GetRootKey(target); Dictionary root = null; if (File.Exists(target.ConfigPath)) @@ -850,10 +853,12 @@ private string ConfigureJsonTarget(MCPConfigTarget target, string presetServerKe servers[serverName] = entry; RemoveSupersededFunplayEntries( - servers, serverName, _settings.GetLastClientConfigKey(target.Name), target.UrlFieldName); + servers, serverName, previousServerName, target.UrlFieldName); + var directory = Path.GetDirectoryName(target.ConfigPath); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); File.WriteAllText(target.ConfigPath, SimpleJsonHelper.Serialize(root)); - return serverName; } /// @@ -867,7 +872,7 @@ private string ConfigureJsonTarget(MCPConfigTarget target, string presetServerKe /// nothing, the same "stop instead of destroy" stance WriteManagedBlock takes for a /// legacy AGENTS.md. Returns null for an empty file (nothing to preserve). /// - private Dictionary ParseRewritableConfig( + private static Dictionary ParseRewritableConfig( MCPConfigTarget target, string serverName, Dictionary entry) { var content = File.ReadAllText(target.ConfigPath); @@ -1440,7 +1445,8 @@ private static bool TargetEntryPointsAtUrl( target.IsToml, target.RootKey, serverKey, - expectedUrl); + expectedUrl, + target.UrlFieldName); } catch (Exception) { @@ -1461,7 +1467,8 @@ internal static bool ConfigEntryPointsAtUrl( bool isToml, string rootKey, string serverKey, - string expectedUrl) + string expectedUrl, + string urlFieldName = null) { if (string.IsNullOrEmpty(content) || string.IsNullOrEmpty(serverKey) || @@ -1497,7 +1504,7 @@ internal static bool ConfigEntryPointsAtUrl( var entry = entryValue as Dictionary; object urlValue; return entry != null && - entry.TryGetValue("url", out urlValue) && + entry.TryGetValue(GetUrlFieldName(urlFieldName), out urlValue) && string.Equals(urlValue as string, expectedUrl, StringComparison.OrdinalIgnoreCase); } @@ -1790,19 +1797,53 @@ private static string GetOpenCodeConfigPath() } /// - /// Antigravity keeps its MCP servers in ~/.gemini/config/mcp_config.json -- one global - /// map of server id to spec, plus per-plugin plugins/<name>/mcp_config.json files - /// that only load with their plugin, which is not a place a Unity plugin should be writing to. - /// The remote spec carries a single serverUrl; Antigravity's own documentation calls - /// that "SSE transport", but the language server has exactly two connectors -- - /// LocalSubprocessConnector for command and StreamableHTTPConnector for - /// serverUrl -- so a streamable-HTTP endpoint like this one is what it actually speaks. - /// There is no project-scoping concept here, so like Cursor/VS Code/Trae/Kiro the entry is - /// global and stays distinguishable only by its per-project name. + /// Keep Antigravity's MCP config, skills and AGENTS.md in one workspace. Use the nearest + /// repository root (including worktrees), or the Unity project itself outside Git. + /// See https://antigravity.google/docs/mcp/ for workspace config and serverUrl support. /// - private static string GetAntigravityConfigPath(string homePath) + internal static string GetAntigravityWorkspaceRoot(string projectRoot) + { + if (string.IsNullOrWhiteSpace(projectRoot)) + throw new ArgumentException("A Unity project root is required.", nameof(projectRoot)); + return FindGitRootOrSelf(Path.GetFullPath(projectRoot)); + } + + internal static string GetAntigravityConfigPath(string projectRoot) { - return Path.Combine(homePath, ".gemini", "config", "mcp_config.json"); + return Path.Combine(GetAntigravityWorkspaceRoot(projectRoot), ".agents", "mcp_config.json"); + } + + internal static MCPConfigTarget CreateAntigravityTarget(string projectRoot) + { + return new MCPConfigTarget + { + Name = "Antigravity", + ConfigPath = GetAntigravityConfigPath(projectRoot), + UrlFieldName = "serverUrl", + ActivationHint = + "Open this workspace in a current Antigravity version and reload its MCP servers:\n" + + GetAntigravityWorkspaceRoot(projectRoot) + + "\nNested Unity projects use their repository root as the workspace." + }; + } + + // Older previews wrote a global entry. Merely finding a matching name is not proof that + // it belongs to this project, so report existing entries without rewriting either file. + internal static string GetAntigravityGlobalConfigNotice(string homePath) + { + var notices = new List(); + foreach (var directory in new[] { "config", "antigravity" }) + { + var path = Path.Combine(homePath, ".gemini", directory, "mcp_config.json"); + var names = ReadFunplayEntryNames(new MCPConfigTarget { ConfigPath = path }); + if (names.Count > 0) + notices.Add($"{path}: {string.Join(", ", names.OrderBy(name => name, StringComparer.Ordinal))}"); + } + + return notices.Count == 0 ? string.Empty : + "\nGlobal Funplay entries also exist and remain visible in other workspaces:\n" + + string.Join("\n", notices) + + "\nReview them after configuring each workspace; Configure leaves these global files unchanged."; } private static string GetUserHomePath() @@ -1907,7 +1948,7 @@ private static string GetVSCodeConfigPath(string homePath) return Path.Combine(homePath, ".vscode", "mcp.json"); } - private struct MCPConfigTarget + internal struct MCPConfigTarget { public string Name; public string ConfigPath; @@ -1949,8 +1990,8 @@ private struct MCPConfigTarget /// /// True only for Claude Code. Its config file supports a projects["<path>"] - /// section that Claude Code applies only to sessions opened at that path; every other - /// client here has no such concept and keeps writing at the config's top level. + /// section that Claude Code applies only to sessions opened at that path. Other clients + /// write at the config's top level, which may itself be a workspace-local file. /// public bool UseProjectScope; } diff --git a/Editor/MCP/Server/ProjectSkillsManager.cs b/Editor/MCP/Server/ProjectSkillsManager.cs index 0875d58..af5d5ee 100644 --- a/Editor/MCP/Server/ProjectSkillsManager.cs +++ b/Editor/MCP/Server/ProjectSkillsManager.cs @@ -240,38 +240,51 @@ internal static string GetDshSkillsRoot(string projectRoot) } /// - /// Antigravity discovers workspace customizations in a .agents/ directory (it also - /// accepts .agent, _agents and _agent; the documented primary spelling is - /// used here) and reads skills from .agents/skills/<name>/SKILL.md. It finds that - /// directory by walking from the session's working directory up to the repository root, so -- - /// exactly like DeepSeek Harness -- the skills are written at the discovered git root: in a - /// monorepo layout (git root above the Unity project folder) a .agents inside the Unity - /// folder would only ever be seen by a session started at or below it, while one at the repo - /// root is reachable from anywhere inside the repo. + /// Antigravity's workspace skills, MCP config and instructions share one resolved root. + /// Open that root as the Antigravity workspace, including for a nested Unity project. /// internal static string GetAntigravitySkillsRoot(string projectRoot) { - return Path.Combine(FunplayMCPClientConfigPanel.FindGitRootOrSelf(projectRoot), ".agents", "skills"); + return Path.Combine(FunplayMCPClientConfigPanel.GetAntigravityWorkspaceRoot(projectRoot), ".agents", "skills"); + } + + internal static string GetAntigravityAgentsPath(string projectRoot) + { + return Path.Combine(FunplayMCPClientConfigPanel.GetAntigravityWorkspaceRoot(projectRoot), "AGENTS.md"); } internal static void ApplyConfiguration(string projectRoot, IEnumerable selectedPlatforms, IEnumerable selectedOptionalSkills) { + var previousManifest = LoadManifest(projectRoot); var manifest = new ProjectSkillsManifest { platforms = selectedPlatforms?.Distinct(StringComparer.OrdinalIgnoreCase).ToList() ?? new List(), optionalSkills = selectedOptionalSkills?.Distinct(StringComparer.OrdinalIgnoreCase).ToList() ?? new List() }; - SaveManifest(projectRoot, manifest); + var normalized = NormalizeManifest(manifest); + var syncAntigravity = + normalized.platforms.Contains("antigravity", StringComparer.OrdinalIgnoreCase) || + previousManifest.platforms.Contains("antigravity", StringComparer.OrdinalIgnoreCase); + if (syncAntigravity) + { + var workspaceAgents = GetAntigravityAgentsPath(projectRoot); + if (!string.Equals(Path.GetFullPath(GetCodexAgentsPath(projectRoot)), workspaceAgents, StringComparison.Ordinal) && + IsManagedFile(workspaceAgents) && !AgentsBlockBelongsToProject(workspaceAgents, projectRoot)) + throw new InvalidOperationException( + $"'{workspaceAgents}' already contains Funplay guidance for another Unity project. " + + "Resolve the workspace's shared guidance before applying Antigravity skills."); + } - var normalized = LoadManifest(projectRoot); - SyncAgentsInstructions(projectRoot, normalized); + SaveManifest(projectRoot, normalized); + SyncAgentsInstructions(projectRoot, normalized, syncAntigravity); SyncCodex(projectRoot, normalized); SyncClaude(projectRoot, normalized); SyncCursor(projectRoot, normalized); SyncOpenCode(projectRoot, normalized); SyncDsh(projectRoot, normalized); - SyncAntigravity(projectRoot, normalized); + if (syncAntigravity) + SyncAntigravity(projectRoot, normalized); } internal static bool IsPlatformConfigured(string projectRoot, string platformId) @@ -382,7 +395,7 @@ internal static IReadOnlyList GetGeneratedPathsForPlatform(string projec paths.Add(GetDshSkillsRoot(projectRoot)); break; case "antigravity": - paths.Add(GetCodexAgentsPath(projectRoot)); + paths.Add(GetAntigravityAgentsPath(projectRoot)); paths.Add(GetAntigravitySkillsRoot(projectRoot)); break; } @@ -390,19 +403,44 @@ internal static IReadOnlyList GetGeneratedPathsForPlatform(string projec return paths; } - // AGENTS.md is shared by Codex, OpenCode, DeepSeek Harness and Antigravity (all four read it - // natively), so the single managed block is written while ANY of them is enabled and removed - // only when ALL are disabled. Splitting it per platform would need two blocks in one file, - // which WriteManagedBlock's single begin..end marker model cannot represent. - private static void SyncAgentsInstructions(string projectRoot, ProjectSkillsManifest manifest) + // Platforms share one block when their instruction paths coincide. Antigravity additionally + // needs the block at the workspace root when Unity lives inside a repository subdirectory. + private static void SyncAgentsInstructions( + string projectRoot, ProjectSkillsManifest manifest, bool syncAntigravity) { + var antigravityEnabled = manifest.platforms.Contains("antigravity", StringComparer.OrdinalIgnoreCase); + var agentsPath = GetCodexAgentsPath(projectRoot); + var antigravityPath = GetAntigravityAgentsPath(projectRoot); + var sharedPath = string.Equals(Path.GetFullPath(agentsPath), antigravityPath, StringComparison.Ordinal); + + if (!sharedPath && syncAntigravity) + { + SyncAgentsBlock(antigravityPath, projectRoot, manifest, antigravityEnabled); + } + var enabled = manifest.platforms.Contains("codex", StringComparer.OrdinalIgnoreCase) || manifest.platforms.Contains("opencode", StringComparer.OrdinalIgnoreCase) || manifest.platforms.Contains("dsh", StringComparer.OrdinalIgnoreCase) || - manifest.platforms.Contains("antigravity", StringComparer.OrdinalIgnoreCase); - var agentsPath = GetCodexAgentsPath(projectRoot); + (antigravityEnabled && sharedPath); + + SyncAgentsBlock(agentsPath, projectRoot, manifest, enabled); + } + private static bool AgentsBlockBelongsToProject(string path, string projectRoot) + { + var content = File.ReadAllText(path); + var begin = content.IndexOf(ManagedMarker, StringComparison.Ordinal); + if (begin < 0) + return false; + var end = content.IndexOf(ManagedEndMarker, begin, StringComparison.Ordinal); + var block = end > begin ? content.Substring(begin, end - begin) : content.Substring(begin); + return block.Contains($"- Project root: `{projectRoot}`"); + } + + private static void SyncAgentsBlock( + string agentsPath, string projectRoot, ProjectSkillsManifest manifest, bool enabled) + { if (!enabled) { RemoveManagedBlock(agentsPath, "# AGENTS.md", BuildLegacyCodexAgentsContentVariants(projectRoot, manifest)); @@ -811,7 +849,7 @@ private static List GetExpectedVersionedFilesForPlatfo } break; case "antigravity": - AddProjectVersionFile(result, GetCodexAgentsPath(projectRoot), skills); + AddProjectVersionFile(result, GetAntigravityAgentsPath(projectRoot), skills); foreach (var skill in skills) { result.Add(new ExpectedSkillVersionFile( diff --git a/README.md b/README.md index b6ba9e9..e4e9c06 100644 --- a/README.md +++ b/README.md @@ -275,9 +275,11 @@ Written as a delimited managed block into every DeepSeek Harness profile's `~/.d
Antigravity -Written to the global `~/.gemini/config/mcp_config.json`. Antigravity's remote-server spec names the endpoint `serverUrl` (a plain `url` key is ignored); its language server connects that with the MCP streamable-HTTP transport, which is what this server speaks. Restart Antigravity afterwards, then check **Additional Options (...) > MCP Servers**. +Written to the workspace-local `.agents/mcp_config.json` with `mcpServers` entries using `serverUrl` for Funplay's Streamable HTTP endpoint. This keeps the server out of unrelated workspaces. Use a current Antigravity version supporting [workspace MCP configuration](https://antigravity.google/docs/mcp/), then reload its MCP servers. -Project Skills write `SKILL.md` bundles to `.agents/skills/` at the repository root (Antigravity walks up from the session's working directory to find `.agents/`), and Antigravity reads the shared `AGENTS.md` managed block natively. +The workspace root is the nearest ancestor containing `.git` (including a worktree's `.git` file), or the Unity project directory when it is outside Git. Open that directory as the Antigravity workspace. **Configure + Skills** places `.agents/mcp_config.json`, `.agents/skills/`, and the managed `AGENTS.md` block at this same root, including when the Unity project is nested inside a repository. Other clients retain their existing instruction locations and share the block when paths coincide. + +Existing Funplay entries in `~/.gemini/config/mcp_config.json` or the older `~/.gemini/antigravity/mcp_config.json` are reported in the panel and left unchanged. Review them after configuring each workspace; the one-click action does not fall back to global configuration. Multiple Unity projects in the same repository share a workspace: their MCP entries remain separately named, while Project Skills refuses to replace another project's managed workspace guidance. ```json { diff --git a/README_CN.md b/README_CN.md index 10e6aa5..e64fcf4 100644 --- a/README_CN.md +++ b/README_CN.md @@ -275,9 +275,11 @@ url = "http://127.0.0.1:/"
Antigravity -写入全局 `~/.gemini/config/mcp_config.json`。Antigravity 的远程服务器配置把端点字段命名为 `serverUrl`(写成 `url` 会被忽略);其 language server 用 MCP streamable-HTTP transport 连接该端点,正是本 server 使用的协议。配置后重启 Antigravity,可在 **Additional Options (...) > MCP Servers** 中查看。 +写入工作区级 `.agents/mcp_config.json`,在 `mcpServers` 中使用 `serverUrl` 指向 Funplay 的 Streamable HTTP 端点,使服务器配置仅在该工作区中生效。请使用支持[工作区 MCP 配置](https://antigravity.google/docs/mcp/)的当前 Antigravity 版本,配置后重新加载 MCP 服务器。 -Project Skills 会把 `SKILL.md` 写入仓库根目录的 `.agents/skills/`(Antigravity 从会话工作目录向上查找 `.agents/`),并与其他 agent 客户端共用 `AGENTS.md` 托管块(Antigravity 原生读取该文件)。 +工作区根目录取最近的包含 `.git` 的祖先目录(包括 worktree 的 `.git` 文件);项目不在 Git 仓库中时使用 Unity 工程目录。请在 Antigravity 中打开该目录。**Configure + Skills** 会将 `.agents/mcp_config.json`、`.agents/skills/` 和 `AGENTS.md` 托管块放在同一根目录下,也适用于 Unity 工程嵌套在仓库中的情况。其他客户端沿用现有指引位置,路径相同时共用托管块。 + +如果 `~/.gemini/config/mcp_config.json` 或旧版 `~/.gemini/antigravity/mcp_config.json` 中已有 Funplay 条目,面板会显示提示并保留原文件。完成各工作区配置后再检查这些全局条目;一键配置不会自动回退到全局配置。同一仓库中的多个 Unity 工程共用工作区,其 MCP 条目各自命名;Project Skills 遇到其他工程已有的工作区托管指引时会停止,避免覆盖工程身份。 ```json { diff --git a/Tests/Editor/AntigravityConfigTests.cs b/Tests/Editor/AntigravityConfigTests.cs new file mode 100644 index 0000000..01b7a4d --- /dev/null +++ b/Tests/Editor/AntigravityConfigTests.cs @@ -0,0 +1,168 @@ +// Copyright (C) Funplay. Licensed under MIT. + +using System; +using System.Collections.Generic; +using System.IO; +using Funplay.Editor.MCP.Server; +using NUnit.Framework; + +namespace Funplay.Editor +{ + public sealed class AntigravityConfigTests + { + private string _sandbox; + private string _projectRoot; + + [SetUp] + public void SetUp() + { + _sandbox = Path.Combine(Path.GetTempPath(), "FunplayAntigravityTests_" + Guid.NewGuid().ToString("N")); + _projectRoot = Path.Combine(_sandbox, "Repo"); + Directory.CreateDirectory(_projectRoot); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_sandbox)) + Directory.Delete(_sandbox, true); + } + + [TestCase(false)] + [TestCase(true)] + public void NestedProjectUsesOneWorkspaceForConfigSkillsAndInstructions(bool worktree) + { + var gitPath = Path.Combine(_projectRoot, ".git"); + if (worktree) + File.WriteAllText(gitPath, "gitdir: ../git-worktrees/example\n"); + else + Directory.CreateDirectory(gitPath); + var nestedProject = Path.Combine(_projectRoot, "Games", "UnityProject"); + Directory.CreateDirectory(nestedProject); + + var target = FunplayMCPClientConfigPanel.CreateAntigravityTarget(nestedProject); + + Assert.AreEqual(Path.Combine(_projectRoot, ".agents", "mcp_config.json"), target.ConfigPath); + Assert.AreEqual("serverUrl", target.UrlFieldName); + Assert.IsFalse(target.UseProjectScope, "Workspace JSON must not use Claude's projects[...] wrapper."); + Assert.AreEqual(Path.Combine(_projectRoot, ".agents", "skills"), + ProjectSkillsManager.GetAntigravitySkillsRoot(nestedProject)); + Assert.AreEqual(Path.Combine(_projectRoot, "AGENTS.md"), + ProjectSkillsManager.GetAntigravityAgentsPath(nestedProject)); + } + + [Test] + public void ProjectWithoutGitUsesItsOwnDirectoryAndRejectsAMissingRoot() + { + Assert.AreEqual(Path.Combine(_projectRoot, ".agents", "mcp_config.json"), + FunplayMCPClientConfigPanel.GetAntigravityConfigPath(_projectRoot)); + Assert.Throws(() => FunplayMCPClientConfigPanel.GetAntigravityConfigPath("")); + } + + [Test] + public void WritesWorkspaceConfigPreservingOtherServersAndRetiringOnlyTheRecordedName() + { + Directory.CreateDirectory(Path.Combine(_projectRoot, ".git")); + var target = FunplayMCPClientConfigPanel.CreateAntigravityTarget(_projectRoot); + Write(target.ConfigPath, @"{ + ""theme"": {""name"": ""dark""}, + ""mcpServers"": { + ""funplay-old"": {""serverUrl"": ""http://127.0.0.1:24312/mcp""}, + ""funplay-other"": {""serverUrl"": ""http://127.0.0.1:21000/mcp""}, + ""custom"": {""command"": ""node"", ""args"": [""server.js""]} + } + }"); + var otherBefore = ReadObject(File.ReadAllText(target.ConfigPath)); + var entry = FunplayMCPClientConfigPanel.CreateHttpEntry( + "http://127.0.0.1:24312/mcp", target.UrlFieldName, false, null, false); + + FunplayMCPClientConfigPanel.WriteJsonConfiguration(target, "funplay-new", "funplay-old", entry); + var firstWrite = File.ReadAllText(target.ConfigPath); + FunplayMCPClientConfigPanel.WriteJsonConfiguration(target, "funplay-new", "funplay-new", entry); + + Assert.AreEqual(firstWrite, File.ReadAllText(target.ConfigPath), "Reconfiguring must be idempotent."); + var actual = ReadObject(firstWrite); + var servers = GetObject(actual, "mcpServers"); + Assert.AreEqual("http://127.0.0.1:24312/mcp", GetObject(servers, "funplay-new")["serverUrl"]); + Assert.IsFalse(GetObject(servers, "funplay-new").ContainsKey("url")); + Assert.IsFalse(servers.ContainsKey("funplay-old")); + Assert.IsFalse(actual.ContainsKey("projects")); + Assert.AreEqual(SimpleJsonHelper.Serialize(otherBefore["theme"]), SimpleJsonHelper.Serialize(actual["theme"])); + Assert.AreEqual(SimpleJsonHelper.Serialize(GetObject(otherBefore, "mcpServers")["custom"]), + SimpleJsonHelper.Serialize(servers["custom"])); + Assert.AreEqual(SimpleJsonHelper.Serialize(GetObject(otherBefore, "mcpServers")["funplay-other"]), + SimpleJsonHelper.Serialize(servers["funplay-other"])); + Assert.IsTrue(FunplayMCPClientConfigPanel.ConfigEntryPointsAtUrl( + firstWrite, false, null, "funplay-new", "http://127.0.0.1:24312/mcp", "serverUrl")); + } + + [Test] + public void IndependentWorkspacesCanUseTheSameNameWithoutChangingEachOthersConfig() + { + Directory.CreateDirectory(Path.Combine(_projectRoot, ".git")); + var otherRoot = Path.Combine(_sandbox, "OtherRepo"); + Directory.CreateDirectory(Path.Combine(otherRoot, ".git")); + var first = FunplayMCPClientConfigPanel.CreateAntigravityTarget(_projectRoot); + var second = FunplayMCPClientConfigPanel.CreateAntigravityTarget(otherRoot); + FunplayMCPClientConfigPanel.WriteJsonConfiguration(first, "funplay-game", null, + FunplayMCPClientConfigPanel.CreateHttpEntry("http://127.0.0.1:24312/mcp", "serverUrl", false, null, false)); + var before = File.ReadAllText(first.ConfigPath); + + FunplayMCPClientConfigPanel.WriteJsonConfiguration(second, "funplay-game", null, + FunplayMCPClientConfigPanel.CreateHttpEntry("http://127.0.0.1:21000/mcp", "serverUrl", false, null, false)); + + Assert.AreEqual(before, File.ReadAllText(first.ConfigPath)); + Assert.AreEqual("http://127.0.0.1:21000/mcp", + GetObject(GetObject(ReadObject(File.ReadAllText(second.ConfigPath)), "mcpServers"), "funplay-game")["serverUrl"]); + } + + [Test] + public void CommentedConfigIsLeftUnchanged() + { + var target = FunplayMCPClientConfigPanel.CreateAntigravityTarget(_projectRoot); + const string original = "{\n // preserve this comment\n \"mcpServers\": {}\n}"; + Write(target.ConfigPath, original); + + Assert.Throws(() => FunplayMCPClientConfigPanel.WriteJsonConfiguration( + target, "funplay-game", null, + FunplayMCPClientConfigPanel.CreateHttpEntry("http://127.0.0.1:24312/mcp", "serverUrl", false, null, false))); + Assert.AreEqual(original, File.ReadAllText(target.ConfigPath)); + } + + [TestCase("config")] + [TestCase("antigravity")] + public void ExistingGlobalEntriesAreReportedAndNeverUsedAsTheWriteTarget(string directory) + { + var homePath = Path.Combine(_sandbox, "home"); + var globalPath = Path.Combine(homePath, ".gemini", directory, "mcp_config.json"); + const string globalContent = "{\"mcpServers\":{\"funplay-old\":{\"serverUrl\":\"http://127.0.0.1:21000/mcp\"}}}"; + Write(globalPath, globalContent); + + var notice = FunplayMCPClientConfigPanel.GetAntigravityGlobalConfigNotice(homePath); + StringAssert.Contains(globalPath, notice); + StringAssert.Contains("funplay-old", notice); + var target = FunplayMCPClientConfigPanel.CreateAntigravityTarget(_projectRoot); + FunplayMCPClientConfigPanel.WriteJsonConfiguration(target, "funplay-game", null, + FunplayMCPClientConfigPanel.CreateHttpEntry("http://127.0.0.1:24312/mcp", "serverUrl", false, null, false)); + + Assert.AreEqual(globalContent, File.ReadAllText(globalPath)); + Assert.IsTrue(File.Exists(Path.Combine(_projectRoot, ".agents", "mcp_config.json"))); + } + + private static Dictionary ReadObject(string content) + { + return (Dictionary)SimpleJsonHelper.Deserialize(content); + } + + private static Dictionary GetObject(Dictionary parent, string key) + { + return (Dictionary)parent[key]; + } + + private static void Write(string path, string content) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)); + File.WriteAllText(path, content); + } + } +} diff --git a/Tests/Editor/AntigravityConfigTests.cs.meta b/Tests/Editor/AntigravityConfigTests.cs.meta new file mode 100644 index 0000000..7cff815 --- /dev/null +++ b/Tests/Editor/AntigravityConfigTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 887d7570f032342ddb6b12c939525e5d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/ProjectSkillsManagerTests.cs b/Tests/Editor/ProjectSkillsManagerTests.cs index ecea100..9f2574a 100644 --- a/Tests/Editor/ProjectSkillsManagerTests.cs +++ b/Tests/Editor/ProjectSkillsManagerTests.cs @@ -629,8 +629,14 @@ public void ApplyConfiguration_WritesAntigravitySkillsToTheGitRootAgentsDirector StringAssert.Contains( ProjectSkillsManager.ManagedEndMarker, - File.ReadAllText(ProjectSkillsManager.GetCodexAgentsPath(unityProject)), - "Antigravity reads AGENTS.md natively and shares the block with the other agent platforms."); + File.ReadAllText(ProjectSkillsManager.GetAntigravityAgentsPath(unityProject)), + "Antigravity instructions must be visible beside its workspace config and skills."); + Assert.IsFalse(File.Exists(ProjectSkillsManager.GetCodexAgentsPath(unityProject))); + var manifest = ProjectSkillsManager.LoadManifest(unityProject); + CollectionAssert.Contains( + ProjectSkillsManager.GetGeneratedPathsForPlatform(unityProject, manifest, "antigravity"), + Path.Combine(repoRoot, "AGENTS.md")); + Assert.IsFalse(ProjectSkillsManager.GetUpgradeStatus(unityProject, manifest, "antigravity").HasUpdates); } finally { @@ -679,6 +685,77 @@ public void ApplyConfiguration_AntigravitySharesTheAgentsBlockAndTracksItsOwnSki } } + [Test] + public void NestedAntigravityTogglePreservesUserInstructionsAndOtherPlatforms() + { + var tempRoot = CreateTempProjectPath(); + var repoRoot = Path.Combine(tempRoot, "repo"); + Directory.CreateDirectory(Path.Combine(repoRoot, ".git")); + var unityProject = Path.Combine(repoRoot, "UnityProject"); + Directory.CreateDirectory(unityProject); + var workspaceAgents = Path.Combine(repoRoot, "AGENTS.md"); + var localAgents = ProjectSkillsManager.GetCodexAgentsPath(unityProject); + File.WriteAllText(workspaceAgents, "# Workspace rules\nKeep this workspace guidance.\n"); + File.WriteAllText(localAgents, "# Unity rules\nKeep this Unity guidance.\n"); + + try + { + ProjectSkillsManager.ApplyConfiguration(unityProject, new[] { "codex", "antigravity" }, Array.Empty()); + StringAssert.Contains(ProjectSkillsManager.ManagedEndMarker, File.ReadAllText(workspaceAgents)); + StringAssert.Contains(ProjectSkillsManager.ManagedEndMarker, File.ReadAllText(localAgents)); + + ProjectSkillsManager.ApplyConfiguration(unityProject, new[] { "codex" }, Array.Empty()); + StringAssert.Contains("Keep this workspace guidance.", File.ReadAllText(workspaceAgents)); + StringAssert.DoesNotContain(ProjectSkillsManager.ManagedMarker, File.ReadAllText(workspaceAgents)); + StringAssert.Contains(ProjectSkillsManager.ManagedEndMarker, File.ReadAllText(localAgents)); + Assert.IsTrue(File.Exists(GetCodexWorkflowSkillPath(unityProject))); + Assert.IsFalse(File.Exists(Path.Combine(ProjectSkillsManager.GetAntigravitySkillsRoot(unityProject), + "funplay-unity-mcp-workflow", "SKILL.md"))); + + ProjectSkillsManager.ApplyConfiguration(unityProject, new[] { "antigravity" }, Array.Empty()); + StringAssert.Contains(ProjectSkillsManager.ManagedEndMarker, File.ReadAllText(workspaceAgents)); + StringAssert.Contains("Keep this Unity guidance.", File.ReadAllText(localAgents)); + StringAssert.DoesNotContain(ProjectSkillsManager.ManagedMarker, File.ReadAllText(localAgents)); + Assert.IsFalse(File.Exists(GetCodexWorkflowSkillPath(unityProject))); + } + finally + { + DeleteTempProjectPath(tempRoot); + } + } + + [Test] + public void ConfiguringAnotherNestedProjectDoesNotRemoveWorkspaceAntigravitySkills() + { + var tempRoot = CreateTempProjectPath(); + var repoRoot = Path.Combine(tempRoot, "repo"); + Directory.CreateDirectory(Path.Combine(repoRoot, ".git")); + var first = Path.Combine(repoRoot, "First"); + var second = Path.Combine(repoRoot, "Second"); + Directory.CreateDirectory(first); + Directory.CreateDirectory(second); + try + { + ProjectSkillsManager.ApplyConfiguration(first, new[] { "antigravity" }, Array.Empty()); + var agentsPath = ProjectSkillsManager.GetAntigravityAgentsPath(first); + var before = File.ReadAllText(agentsPath); + var skillPath = Path.Combine(ProjectSkillsManager.GetAntigravitySkillsRoot(first), + "funplay-unity-mcp-workflow", "SKILL.md"); + + ProjectSkillsManager.ApplyConfiguration(second, new[] { "codex" }, Array.Empty()); + Assert.AreEqual(before, File.ReadAllText(agentsPath)); + Assert.IsTrue(File.Exists(skillPath)); + Assert.Throws(() => + ProjectSkillsManager.ApplyConfiguration(second, new[] { "antigravity" }, Array.Empty())); + Assert.AreEqual(before, File.ReadAllText(agentsPath)); + CollectionAssert.AreEqual(new[] { "codex" }, ProjectSkillsManager.LoadManifest(second).platforms); + } + finally + { + DeleteTempProjectPath(tempRoot); + } + } + private static string GetCodexWorkflowSkillPath(string projectRoot) { return Path.Combine(