diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a75476..f3cb802 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ ## [Unreleased] +### Added +- **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 +- 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 ### Added diff --git a/Editor/MCP/Server/FunplayMCPClientConfigPanel.cs b/Editor/MCP/Server/FunplayMCPClientConfigPanel.cs index 766197d..9710817 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; @@ -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,6 +476,7 @@ private MCPConfigTarget[] CreateTargets(string homePath) ConfigPath = FunplayDeepSeekHarnessPatch.GetDisplayPath(homePath), IsDeepSeekHarness = true, }, + CreateAntigravityTarget(GetProjectRootPath()), }; } @@ -516,7 +520,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(); @@ -826,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)) @@ -840,10 +852,13 @@ private string ConfigureJsonTarget(MCPConfigTarget target, string presetServerKe : GetOrCreateNestedDictionary(root, rootKey); servers[serverName] = entry; - RemoveSupersededFunplayEntries(servers, serverName, _settings.GetLastClientConfigKey(target.Name)); + RemoveSupersededFunplayEntries( + 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; } /// @@ -857,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); @@ -1231,7 +1246,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)) @@ -1248,17 +1266,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); @@ -1425,7 +1445,8 @@ private static bool TargetEntryPointsAtUrl( target.IsToml, target.RootKey, serverKey, - expectedUrl); + expectedUrl, + target.UrlFieldName); } catch (Exception) { @@ -1446,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) || @@ -1482,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); } @@ -1515,21 +1537,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 { - ["url"] = 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) @@ -1749,6 +1796,56 @@ private static string GetOpenCodeConfigPath() return Path.Combine(GetProjectScopeKeyPath(), ".opencode", "opencode.json"); } + /// + /// 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. + /// + 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(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() { var homePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); @@ -1851,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; @@ -1876,6 +1973,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 @@ -1885,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/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..af5d5ee 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,23 +239,52 @@ internal static string GetDshSkillsRoot(string projectRoot) return Path.Combine(FunplayMCPClientConfigPanel.FindGitRootOrSelf(projectRoot), ".dsh", "skills"); } + /// + /// 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.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); + if (syncAntigravity) + SyncAntigravity(projectRoot, normalized); } internal static bool IsPlatformConfigured(string projectRoot, string platformId) @@ -363,23 +394,53 @@ internal static IReadOnlyList GetGeneratedPathsForPlatform(string projec paths.Add(GetCodexAgentsPath(projectRoot)); paths.Add(GetDshSkillsRoot(projectRoot)); break; + case "antigravity": + paths.Add(GetAntigravityAgentsPath(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. - 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); - var agentsPath = GetCodexAgentsPath(projectRoot); + manifest.platforms.Contains("dsh", StringComparer.OrdinalIgnoreCase) || + (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)); @@ -479,6 +540,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 +848,17 @@ private static List GetExpectedVersionedFilesForPlatfo BuildSkillVersionMarker(skill))); } break; + case "antigravity": + AddProjectVersionFile(result, GetAntigravityAgentsPath(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 +998,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 +1016,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 +1111,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 +1132,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 +1769,8 @@ internal enum SkillPlatform Claude, Cursor, OpenCode, - Dsh + Dsh, + Antigravity } [Serializable] diff --git a/README.md b/README.md index 482b388..e4e9c06 100644 --- a/README.md +++ b/README.md @@ -272,6 +272,27 @@ Written as a delimited managed block into every DeepSeek Harness profile's `~/.d +
+Antigravity + +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. + +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 +{ + "mcpServers": { + "funplay-": { + "serverUrl": "http://127.0.0.1:/" + } + } +} +``` + +
+
Windsurf @@ -322,7 +343,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..e64fcf4 100644 --- a/README_CN.md +++ b/README_CN.md @@ -272,6 +272,27 @@ url = "http://127.0.0.1:/"
+
+Antigravity + +写入工作区级 `.agents/mcp_config.json`,在 `mcpServers` 中使用 `serverUrl` 指向 Funplay 的 Streamable HTTP 端点,使服务器配置仅在该工作区中生效。请使用支持[工作区 MCP 配置](https://antigravity.google/docs/mcp/)的当前 Antigravity 版本,配置后重新加载 MCP 服务器。 + +工作区根目录取最近的包含 `.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 +{ + "mcpServers": { + "funplay-": { + "serverUrl": "http://127.0.0.1:/" + } + } +} +``` + +
+
Windsurf @@ -324,7 +345,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/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/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] diff --git a/Tests/Editor/ProjectSkillsManagerTests.cs b/Tests/Editor/ProjectSkillsManagerTests.cs index 3e295bd..9f2574a 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,162 @@ 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.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 + { + 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); + } + } + + [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(