diff --git a/assets/harness-logos/agy-logo.svg b/assets/harness-logos/agy-logo.svg
new file mode 100644
index 00000000..fe5fd4c3
--- /dev/null
+++ b/assets/harness-logos/agy-logo.svg
@@ -0,0 +1,9 @@
+
diff --git a/frontend/src/assets/harness-logos/agy-logo.svg b/frontend/src/assets/harness-logos/agy-logo.svg
new file mode 100644
index 00000000..fe5fd4c3
--- /dev/null
+++ b/frontend/src/assets/harness-logos/agy-logo.svg
@@ -0,0 +1,9 @@
+
diff --git a/frontend/src/components/harness/harnessPresentation.ts b/frontend/src/components/harness/harnessPresentation.ts
index 7abbca70..6730320d 100644
--- a/frontend/src/components/harness/harnessPresentation.ts
+++ b/frontend/src/components/harness/harnessPresentation.ts
@@ -3,8 +3,9 @@ import codexLogo from "../../assets/harness-logos/codex-logo.svg";
import cursorLogo from "../../assets/harness-logos/cursor-logo.svg";
import openclawLogo from "../../assets/harness-logos/openclaw-logo.svg";
import opencodeLogo from "../../assets/harness-logos/opencode-logo.svg";
+import agyLogo from "../../assets/harness-logos/agy-logo.svg";
-export type HarnessLogoKey = "claude" | "codex" | "cursor" | "opencode" | "openclaw";
+export type HarnessLogoKey = "claude" | "codex" | "cursor" | "opencode" | "openclaw" | "agy";
interface HarnessPresentation {
logoSrc: string;
@@ -32,6 +33,10 @@ const HARNESS_LOGO_ASSETS: Record = {
logoSrc: openclawLogo,
variant: "openclaw",
},
+ agy: {
+ logoSrc: agyLogo,
+ variant: "agy",
+ },
};
export function getHarnessPresentation(logoKey: string | null | undefined): HarnessPresentation | null {
@@ -40,3 +45,4 @@ export function getHarnessPresentation(logoKey: string | null | undefined): Harn
}
return HARNESS_LOGO_ASSETS[logoKey as HarnessLogoKey] ?? null;
}
+
diff --git a/frontend/src/features/skills/screens/ScanConfigPage.test.tsx b/frontend/src/features/skills/screens/ScanConfigPage.test.tsx
index 002b1db4..fd36baac 100644
--- a/frontend/src/features/skills/screens/ScanConfigPage.test.tsx
+++ b/frontend/src/features/skills/screens/ScanConfigPage.test.tsx
@@ -110,7 +110,7 @@ describe("ScanConfigPage", () => {
expect(screen.queryByText(/Missing required fields: API Key/)).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Update" })).toBeDisabled();
expect(screen.queryByRole("columnheader", { name: "Last validation" })).not.toBeInTheDocument();
- expect(screen.getByLabelText("Last validation")).toHaveTextContent(/May 12|12 May|Failed|Not validated/);
+ expect(screen.getByLabelText("Last validation")).toHaveTextContent(/May 11|11 May|May 12|12 May|Failed|Not validated/);
const apiKeyInput = screen.getByLabelText("API Key", { selector: "input" });
expect(apiKeyInput).toHaveAttribute("type", "password");
expect(String(apiKeyInput.getAttribute("value") ?? "")).not.toBe("");
diff --git a/skill_manager/application/mcp/adapters.py b/skill_manager/application/mcp/adapters.py
index f7e25d47..8c60a6e8 100644
--- a/skill_manager/application/mcp/adapters.py
+++ b/skill_manager/application/mcp/adapters.py
@@ -275,6 +275,8 @@ def _load_document(self, config_path: Path) -> dict[str, object]:
if not config_path.is_file():
return {}
text = config_path.read_text(encoding="utf-8")
+ if not text.strip():
+ return {}
if self._file_format in {"json", "jsonc"}:
try:
payload = json.loads(_strip_jsonc(text) if self._file_format == "jsonc" else text)
diff --git a/skill_manager/application/mcp/mappers.py b/skill_manager/application/mcp/mappers.py
index 77ba29c4..385bb0e5 100644
--- a/skill_manager/application/mcp/mappers.py
+++ b/skill_manager/application/mcp/mappers.py
@@ -271,6 +271,57 @@ def dict_to_spec(
)
+# Antigravity CLI -----------------------------------------------------------
+
+
+class AntigravityCliMapper:
+ """Used by Antigravity CLI (agy). Uses serverUrl for HTTP, and command/args/env for stdio."""
+
+ def spec_to_dict(self, spec: McpServerSpec) -> dict[str, object]:
+ if spec.transport == "stdio":
+ payload: dict[str, object] = {}
+ if spec.command is not None:
+ payload["command"] = spec.command
+ if spec.args:
+ payload["args"] = list(spec.args)
+ if spec.env:
+ payload["env"] = dict(spec.env)
+ return payload
+ payload = {}
+ if spec.url is not None:
+ payload["serverUrl"] = spec.url
+ if spec.headers:
+ payload["headers"] = dict(spec.headers)
+ return payload
+
+ def dict_to_spec(
+ self, name: str, raw: Mapping[str, object], *, source: McpSource | None = None
+ ) -> McpServerSpec:
+ if "command" in raw or "args" in raw:
+ return McpServerSpec(
+ name=name,
+ display_name=name,
+ source=source or McpSource.adopted("agy", name),
+ transport="stdio",
+ command=_str_or_none(raw.get("command")),
+ args=_str_tuple(raw.get("args")),
+ env=_str_pairs(raw.get("env")),
+ )
+ if "serverUrl" in raw or "url" in raw:
+ return McpServerSpec(
+ name=name,
+ display_name=name,
+ source=source or McpSource.adopted("agy", name),
+ transport="http",
+ url=_str_or_none(raw.get("serverUrl") or raw.get("url")),
+ headers=_str_pairs(raw.get("headers")),
+ )
+ raise MutationError(
+ f"unsupported agy mcp entry '{name}': missing 'command' and 'serverUrl'",
+ status=400,
+ )
+
+
# Helpers ------------------------------------------------------------------
@@ -298,6 +349,7 @@ def _str_pairs(value: object) -> tuple[tuple[str, str], ...] | None:
"opencode": OpenCodeMapper(),
"codex": CodexMapper(),
"openclaw": OpenClawMapper(),
+ "antigravity-cli": AntigravityCliMapper(),
}
@@ -308,6 +360,7 @@ def get_mapper(kind: str) -> TransportMapper:
__all__ = [
+ "AntigravityCliMapper",
"ClaudeCodeMapper",
"CodexMapper",
"CursorMapper",
@@ -316,3 +369,4 @@ def get_mapper(kind: str) -> TransportMapper:
"TransportMapper",
"get_mapper",
]
+
diff --git a/skill_manager/harness/catalog.py b/skill_manager/harness/catalog.py
index a546e294..77a8bd93 100644
--- a/skill_manager/harness/catalog.py
+++ b/skill_manager/harness/catalog.py
@@ -215,6 +215,43 @@ def harness_definitions_for_family(family: FamilyKey) -> tuple[HarnessDefinition
),
},
),
+ HarnessDefinition(
+ harness="agy",
+ label="Antigravity",
+ logo_key="agy",
+ install_probe="agy",
+ bindings={
+ "skills": FileTreeBindingProfile(
+ managed_env="SKILL_MANAGER_AGY_ROOT",
+ managed_default=lambda context: context.home / ".gemini" / "antigravity-cli" / "skills",
+ discovery_roots=(
+ FileTreeDiscoveryRoot(
+ kind="compat-root",
+ scope="agents-compat",
+ label="Agents compatibility root",
+ path_resolver=lambda context: context.home / ".agents" / "skills",
+ ),
+ FileTreeDiscoveryRoot(
+ kind="legacy-root",
+ scope="legacy",
+ label="Legacy import root",
+ path_resolver=lambda context: context.home / ".gemini" / "skills",
+ ),
+ ),
+ ),
+ "mcp": ConfigSubtreeBindingProfile(
+ config_path_resolver=lambda context: context.home / ".gemini" / "config" / "mcp_config.json",
+ discovery_config_path_resolvers=(
+ lambda context: context.home / ".gemini" / "antigravity-cli" / "mcp_config.json",
+ lambda context: context.home / ".gemini" / "antigravity" / "mcp_config.json",
+ lambda context: context.home / ".gemini" / "antigravity-ide" / "mcp_config.json",
+ ),
+ file_format="json",
+ subtree_path=("mcpServers",),
+ codec="antigravity-cli",
+ ),
+ },
+ ),
)
diff --git a/tests/integration/test_http_api.py b/tests/integration/test_http_api.py
index 7f50ded5..6f32429b 100644
--- a/tests/integration/test_http_api.py
+++ b/tests/integration/test_http_api.py
@@ -28,7 +28,7 @@ def test_empty_fixture_returns_skills_settings_and_health(self) -> None:
settings["storage"]["settingsPath"],
str(harness.spec.xdg_config_home / "skill-manager" / "settings.json"),
)
- self.assertEqual(len(settings["harnesses"]), 5)
+ self.assertEqual(len(settings["harnesses"]), 6)
openclaw = next(item for item in settings["harnesses"] if item["harness"] == "openclaw")
self.assertTrue(openclaw["installed"])
self.assertTrue(openclaw["supportEnabled"])
@@ -84,7 +84,7 @@ def test_mixed_fixture_returns_skills_page_and_detail(self) -> None:
self.assertEqual(detail["displayStatus"], "Managed")
self.assertEqual(
[cell["label"] for cell in detail["harnessCells"]],
- ["Codex", "Claude", "Cursor", "OpenCode", "OpenClaw"],
+ ["Codex", "Claude", "Cursor", "OpenCode", "OpenClaw", "Antigravity"],
)
self.assertNotIn("updateStatus", detail["actions"])
self.assertEqual(source_status["updateStatus"], "no_update_available")
@@ -108,7 +108,7 @@ def test_managed_detail_returns_shared_store_location_before_tool_links(self) ->
shared_audit = next(row for row in skills["rows"] if row["name"] == "Shared Audit")
detail = harness.get_json(f"/api/skills/{shared_audit['skillRef']}")
- self.assertEqual([location["label"] for location in detail["locations"]], ["Shared Store", "Codex", "OpenClaw", "OpenCode"])
+ self.assertEqual([location["label"] for location in detail["locations"]], ["Shared Store", "Antigravity", "Codex", "OpenClaw", "OpenCode"])
self.assertEqual(detail["actions"]["stopManagingStatus"], "available")
self.assertEqual(detail["actions"]["stopManagingHarnessLabels"], ["Codex"])
self.assertEqual(detail["actions"]["deleteHarnessLabels"], ["Codex"])
diff --git a/tests/integration/test_mcp_routes.py b/tests/integration/test_mcp_routes.py
index 74108163..0f140b38 100644
--- a/tests/integration/test_mcp_routes.py
+++ b/tests/integration/test_mcp_routes.py
@@ -404,7 +404,7 @@ def test_set_harnesses_fan_out(self) -> None:
"/api/mcp/servers/exa/set-harnesses", {"target": "enabled"}
)
self.assertTrue(response["ok"])
- self.assertEqual(set(response["succeeded"]), {"codex", "claude", "cursor", "opencode", "openclaw"})
+ self.assertEqual(set(response["succeeded"]), {"codex", "claude", "cursor", "opencode", "openclaw", "agy"})
# Verify each config file
self.assertTrue((harness.spec.home / ".cursor" / "mcp.json").is_file())
@@ -412,6 +412,7 @@ def test_set_harnesses_fan_out(self) -> None:
self.assertTrue((harness.spec.home / ".codex" / "config.toml").is_file())
self.assertTrue((harness.spec.home / ".opencode" / "opencode.jsonc").is_file())
self.assertTrue((harness.spec.home / ".openclaw" / "openclaw.json").is_file())
+ self.assertTrue((harness.spec.home / ".gemini" / "config" / "mcp_config.json").is_file())
def test_uninstall_cleans_all_harnesses_and_central(self) -> None:
with AppTestHarness() as harness:
diff --git a/tests/integration/test_skills_mutations.py b/tests/integration/test_skills_mutations.py
index 460e3831..d4bcaba9 100644
--- a/tests/integration/test_skills_mutations.py
+++ b/tests/integration/test_skills_mutations.py
@@ -163,7 +163,7 @@ def test_set_skill_harnesses_only_targets_available_harnesses(self) -> None:
with AppTestHarness(fixture_factory=seed_shared_only_fixture) as harness:
# Simulate missing non-core CLIs by removing their stubs from the
# fake PATH. Cursor may still be available through its app probe.
- for cli in ("cursor-agent", "opencode", "openclaw"):
+ for cli in ("cursor-agent", "opencode", "openclaw", "agy"):
stub = harness.spec.bin_dir / cli
if stub.exists():
stub.unlink()
@@ -179,6 +179,7 @@ def test_set_skill_harnesses_only_targets_available_harnesses(self) -> None:
self.assertTrue(installed_by_harness["claude"])
self.assertFalse(installed_by_harness["opencode"])
self.assertFalse(installed_by_harness["openclaw"])
+ self.assertFalse(installed_by_harness["agy"])
result = harness.post_json(
f"/api/skills/{shared_entry['skillRef']}/set-harnesses",
@@ -198,6 +199,7 @@ def test_set_skill_harnesses_only_targets_available_harnesses(self) -> None:
self.assertTrue((harness.spec.cursor_root / "shared-audit").is_symlink())
else:
self.assertFalse((harness.spec.cursor_root / "shared-audit").exists())
+ self.assertFalse((harness.spec.agy_root / "shared-audit").exists())
# Unavailable harness folders remain untouched.
self.assertFalse((harness.spec.opencode_root / "shared-audit").exists())
self.assertFalse((harness.spec.openclaw_managed_root / "shared-audit").exists())
diff --git a/tests/support/fake_home.py b/tests/support/fake_home.py
index 1b155aed..50f69be1 100644
--- a/tests/support/fake_home.py
+++ b/tests/support/fake_home.py
@@ -55,6 +55,10 @@ def openclaw_home(self) -> Path:
def openclaw_managed_root(self) -> Path:
return self.openclaw_home / "skills"
+ @property
+ def agy_root(self) -> Path:
+ return self.home / ".gemini" / "antigravity-cli" / "skills"
+
@property
def bin_dir(self) -> Path:
return self.root / "bin"
@@ -85,12 +89,13 @@ def create_fake_home_spec(root: Path, *, seed_openclaw_state: bool = True) -> Fa
spec.cursor_root,
spec.opencode_root,
spec.openclaw_managed_root,
+ spec.agy_root,
spec.xdg_state_home,
spec.bin_dir,
):
path.mkdir(parents=True, exist_ok=True)
- for executable in ("codex", "claude", "cursor-agent", "opencode"):
+ for executable in ("codex", "claude", "cursor-agent", "opencode", "agy"):
write_cli_stub(spec.bin_dir / executable, executable)
if seed_openclaw_state:
write_cli_stub(spec.bin_dir / "openclaw", "openclaw")
diff --git a/tests/unit/test_backend_container.py b/tests/unit/test_backend_container.py
index 64f289a1..79da9c42 100644
--- a/tests/unit/test_backend_container.py
+++ b/tests/unit/test_backend_container.py
@@ -143,7 +143,7 @@ def test_settings_surface_store_issues(self) -> None:
settings["storage"]["marketplaceCachePath"],
str(spec.xdg_data_home / "skill-manager" / "marketplace"),
)
- self.assertEqual(len(settings["harnesses"]), 5)
+ self.assertEqual(len(settings["harnesses"]), 6)
codex = next(item for item in settings["harnesses"] if item["harness"] == "codex")
self.assertIn("managedLocation", codex)
self.assertIn("installed", codex)
@@ -189,7 +189,7 @@ def test_skill_detail_orders_managed_locations_with_shared_store_first(self) ->
assert detail is not None
- self.assertEqual([location["label"] for location in detail["locations"]], ["Shared Store", "Codex", "OpenClaw", "OpenCode"])
+ self.assertEqual([location["label"] for location in detail["locations"]], ["Shared Store", "Antigravity", "Codex", "OpenClaw", "OpenCode"])
self.assertEqual(detail["locations"][0]["path"], str(spec.skills_store_root / "shared-audit"))
self.assertEqual(detail["locations"][1]["path"], str(spec.codex_root / "shared-audit"))
self.assertEqual(detail["actions"]["stopManagingStatus"], "available")