From ee702013ed8c518246efbd1d4de3481d0bf6495c Mon Sep 17 00:00:00 2001 From: execsumo Date: Sat, 13 Jun 2026 12:55:41 -0700 Subject: [PATCH 1/5] feat: integrate Antigravity CLI (agy) harness support --- assets/harness-logos/agy-logo.svg | 19 +++++++ .../src/assets/harness-logos/agy-logo.svg | 19 +++++++ .../components/harness/harnessPresentation.ts | 8 ++- .../skills/screens/ScanConfigPage.test.tsx | 2 +- skill_manager/application/mcp/mappers.py | 54 +++++++++++++++++++ skill_manager/harness/catalog.py | 37 +++++++++++++ tests/integration/test_http_api.py | 6 +-- tests/integration/test_mcp_routes.py | 3 +- tests/integration/test_skills_mutations.py | 4 +- tests/support/fake_home.py | 7 ++- tests/unit/test_backend_container.py | 4 +- 11 files changed, 153 insertions(+), 10 deletions(-) create mode 100644 assets/harness-logos/agy-logo.svg create mode 100644 frontend/src/assets/harness-logos/agy-logo.svg diff --git a/assets/harness-logos/agy-logo.svg b/assets/harness-logos/agy-logo.svg new file mode 100644 index 0000000..24a080a --- /dev/null +++ b/assets/harness-logos/agy-logo.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + 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 0000000..24a080a --- /dev/null +++ b/frontend/src/assets/harness-logos/agy-logo.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/harness/harnessPresentation.ts b/frontend/src/components/harness/harnessPresentation.ts index 7abbca7..6730320 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 002b1db..fd36baa 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/mappers.py b/skill_manager/application/mcp/mappers.py index 77ba29c..385bb0e 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 a546e29..77a8bd9 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 7f50ded..6f32429 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 7410816..0f140b3 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 460e383..d4bcaba 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 1b155ae..50f69be 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 64f289a..79da9c4 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") From dd151e90e66515e8a2f920e5855fc7e01e7ae683 Mon Sep 17 00:00:00 2001 From: execsumo Date: Sat, 13 Jun 2026 13:01:32 -0700 Subject: [PATCH 2/5] fix: handle empty or whitespace-only configuration files in load_document --- skill_manager/application/mcp/adapters.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/skill_manager/application/mcp/adapters.py b/skill_manager/application/mcp/adapters.py index f7e25d4..8c60a6e 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) From 8b71aa500abd0d8da9df8092e1b8a71b2394b47d Mon Sep 17 00:00:00 2001 From: execsumo Date: Sat, 13 Jun 2026 13:09:18 -0700 Subject: [PATCH 3/5] style: update agy harness icon to official Antigravity CLI Full Color logo --- assets/harness-logos/agy-logo.svg | 26 ++++++------------- .../src/assets/harness-logos/agy-logo.svg | 26 ++++++------------- 2 files changed, 16 insertions(+), 36 deletions(-) diff --git a/assets/harness-logos/agy-logo.svg b/assets/harness-logos/agy-logo.svg index 24a080a..fe5fd4c 100644 --- a/assets/harness-logos/agy-logo.svg +++ b/assets/harness-logos/agy-logo.svg @@ -1,19 +1,9 @@ - - - - - - - - - - - - - - - - - - + + + + + + + + diff --git a/frontend/src/assets/harness-logos/agy-logo.svg b/frontend/src/assets/harness-logos/agy-logo.svg index 24a080a..fe5fd4c 100644 --- a/frontend/src/assets/harness-logos/agy-logo.svg +++ b/frontend/src/assets/harness-logos/agy-logo.svg @@ -1,19 +1,9 @@ - - - - - - - - - - - - - - - - - - + + + + + + + + From 8f1ea708fe4430f3cf3953eb3c60efcef8057338 Mon Sep 17 00:00:00 2001 From: execsumo Date: Sat, 13 Jun 2026 13:28:14 -0700 Subject: [PATCH 4/5] feat: change default macOS data path to ~/.skill-manager with legacy fallback --- README.md | 18 +++++++++--------- README.zh-CN.md | 18 +++++++++--------- .../skills/screens/ScanConfigPage.test.tsx | 2 +- skill_manager/paths.py | 3 ++- tests/unit/test_paths.py | 14 ++++++++++++-- 5 files changed, 33 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index ddeb90f..3abfb6b 100644 --- a/README.md +++ b/README.md @@ -193,7 +193,7 @@ Actions that can change local state include: - creating, updating, syncing, importing, or deleting a slash command - changing harness support settings -App-owned files live under `~/Library/Application Support/skill-manager` on macOS and XDG base directories on Linux. +App-owned files live under `~/.skill-manager` on macOS (with a legacy fallback to `~/Library/Application Support/skill-manager` if it already exists) and XDG base directories on Linux. ## How it works @@ -242,17 +242,17 @@ CLI marketplace entries are preview-only. ## Configuration -On macOS, app-owned files live under `~/Library/Application Support/skill-manager`. On Linux, app-owned files use XDG base directories. +On macOS, app-owned files live under `~/.skill-manager` (with a legacy fallback to `~/Library/Application Support/skill-manager` if it already exists). On Linux, app-owned files use XDG base directories. Useful macOS paths: -- shared skills store: `~/Library/Application Support/skill-manager/shared` -- MCP manifest: `~/Library/Application Support/skill-manager/mcp/manifest.json` -- slash command library: `~/Library/Application Support/skill-manager/slash-commands/commands` -- slash command sync state: `~/Library/Application Support/skill-manager/slash-commands/sync-state.json` -- marketplace cache: `~/Library/Application Support/skill-manager/marketplace` -- app database and LLM scan configs: `~/Library/Application Support/skill-manager/skill-manager.db` -- app settings: `~/Library/Application Support/skill-manager/settings.json` +- shared skills store: `~/.skill-manager/shared` +- MCP manifest: `~/.skill-manager/mcp/manifest.json` +- slash command library: `~/.skill-manager/slash-commands/commands` +- slash command sync state: `~/.skill-manager/slash-commands/sync-state.json` +- marketplace cache: `~/.skill-manager/marketplace` +- app database and LLM scan configs: `~/.skill-manager/skill-manager.db` +- app settings: `~/.skill-manager/settings.json` Useful Linux paths: diff --git a/README.zh-CN.md b/README.zh-CN.md index c69a46b..9b3da48 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -163,7 +163,7 @@ Skill Manager 是本地配置管理工具。它在你的机器上运行,并读 - 创建、更新、同步、导入或删除 slash command - 修改 harness 支持设置 -在 macOS 上,应用拥有的文件位于 `~/Library/Application Support/skill-manager`;在 Linux 上使用 XDG base directories。 +在 macOS 上,应用拥有的文件位于 `~/.skill-manager`(如果已存在,则回退到 `~/Library/Application Support/skill-manager`);在 Linux 上使用 XDG base directories。 ## 工作方式 @@ -212,17 +212,17 @@ CLI marketplace 条目仅用于预览。 ## 配置 -在 macOS 上,应用拥有的文件位于 `~/Library/Application Support/skill-manager`;在 Linux 上使用 XDG base directories。 +在 macOS 上,应用拥有的文件位于 `~/.skill-manager`(如果已存在,则回退到 `~/Library/Application Support/skill-manager`);在 Linux 上使用 XDG base directories。 常用 macOS 路径: -- 共享 Skill 存储:`~/Library/Application Support/skill-manager/shared` -- MCP manifest:`~/Library/Application Support/skill-manager/mcp/manifest.json` -- slash command 库:`~/Library/Application Support/skill-manager/slash-commands/commands` -- slash command 同步状态:`~/Library/Application Support/skill-manager/slash-commands/sync-state.json` -- 商城缓存:`~/Library/Application Support/skill-manager/marketplace` -- 应用数据库和 LLM 扫描配置:`~/Library/Application Support/skill-manager/skill-manager.db` -- 应用设置:`~/Library/Application Support/skill-manager/settings.json` +- 共享 Skill 存储:`~/.skill-manager/shared` +- MCP manifest:`~/.skill-manager/mcp/manifest.json` +- slash command 库:`~/.skill-manager/slash-commands/commands` +- slash command 同步状态:`~/.skill-manager/slash-commands/sync-state.json` +- 商城缓存:`~/.skill-manager/marketplace` +- 应用数据库和 LLM 扫描配置:`~/.skill-manager/skill-manager.db` +- 应用设置:`~/.skill-manager/settings.json` 常用 Linux 路径: diff --git a/frontend/src/features/skills/screens/ScanConfigPage.test.tsx b/frontend/src/features/skills/screens/ScanConfigPage.test.tsx index 002b1db..fd36baa 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/paths.py b/skill_manager/paths.py index 9154b70..735ac17 100644 --- a/skill_manager/paths.py +++ b/skill_manager/paths.py @@ -59,7 +59,8 @@ def _base_dirs(context: PlatformContext) -> tuple[Path, Path, Path]: state_override = context.env.get(STATE_DIR_ENV) if context.platform == "macos": - default_macos = context.home / "Library" / "Application Support" / APP_NAME + legacy_dir = context.home / "Library" / "Application Support" / APP_NAME + default_macos = legacy_dir if legacy_dir.is_dir() else context.home / f".{APP_NAME}" config_dir = _xdg_dir(context.env, "XDG_CONFIG_HOME", default_macos) data_dir = _xdg_dir(context.env, "XDG_DATA_HOME", default_macos) state_dir = ( diff --git a/tests/unit/test_paths.py b/tests/unit/test_paths.py index 0dc8845..e3e2af9 100644 --- a/tests/unit/test_paths.py +++ b/tests/unit/test_paths.py @@ -26,11 +26,11 @@ def isolated_env(platform: str): class ResolveAppPathsTests(unittest.TestCase): - def test_macos_default_layout_collapses_to_application_support(self) -> None: + def test_macos_default_layout_collapses_to_dot_dir(self) -> None: with isolated_env("darwin"), TemporaryDirectory() as temp: home = Path(temp) / "home" paths = resolve_app_paths({"HOME": str(home)}) - base = home / "Library" / "Application Support" / APP_NAME + base = home / f".{APP_NAME}" self.assertEqual(paths.config_dir, base) self.assertEqual(paths.data_dir, base) self.assertEqual(paths.state_dir, base) @@ -44,6 +44,16 @@ def test_macos_default_layout_collapses_to_application_support(self) -> None: self.assertEqual(paths.runtime_state_path, base / "runtime.json") self.assertEqual(paths.server_log_path, base / "server.log") + def test_macos_default_layout_falls_back_to_legacy_application_support_if_exists(self) -> None: + with isolated_env("darwin"), TemporaryDirectory() as temp: + home = Path(temp) / "home" + legacy_dir = home / "Library" / "Application Support" / APP_NAME + legacy_dir.mkdir(parents=True) + paths = resolve_app_paths({"HOME": str(home)}) + self.assertEqual(paths.config_dir, legacy_dir) + self.assertEqual(paths.data_dir, legacy_dir) + self.assertEqual(paths.state_dir, legacy_dir) + def test_xdg_overrides_each_dir_independently(self) -> None: with isolated_env("darwin"), TemporaryDirectory() as temp: root = Path(temp) From 97b32cf0cb24fca8ce237c0e1b7f85b9773f5935 Mon Sep 17 00:00:00 2001 From: execsumo Date: Sat, 13 Jun 2026 14:47:28 -0700 Subject: [PATCH 5/5] feat: implement premium light mode using warm cream and amber gold brand colors --- frontend/src/App.tsx | 18 +++-- frontend/src/components/Sidebar.tsx | 10 ++- frontend/src/i18n/common.ts | 2 + frontend/src/lib/theme.tsx | 83 ++++++++++++++++++++++ frontend/src/styles/tokens.css | 102 ++++++++++++++++++++++++++++ 5 files changed, 205 insertions(+), 10 deletions(-) create mode 100644 frontend/src/lib/theme.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d94b43d..678fab2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,8 @@ import ScanConfigPage from "./features/skills/screens/ScanConfigPage"; import SkillsWorkspacePage from "./features/skills/screens/SkillsWorkspacePage"; import { LocaleProvider, useCommonCopy } from "./i18n"; +import { ThemeProvider } from "./lib/theme"; + const MarketplaceLayout = lazy(() => import("./features/marketplace/components/MarketplaceLayout")); const OverviewPage = lazy(() => import("./features/overview/screens/OverviewPage")); const SettingsPage = lazy(() => import("./features/settings/screens/SettingsPage")); @@ -36,13 +38,15 @@ export function App() { return ( - - - - - - - + + + + + + + + + ); } diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 051b1b3..6443c65 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -15,6 +15,7 @@ import { Command, Languages, LayoutDashboard, + Moon, RefreshCw, Settings, Store, @@ -27,6 +28,7 @@ import { useSidebarModel, type SidebarIconKey } from "../app/capability-registry import { LoadingSpinner } from "./LoadingSpinner"; import { useToast } from "./Toast"; import { useCommonCopy, useLocale } from "../i18n"; +import { useTheme } from "../lib/theme"; interface SidebarProps { onRefresh: () => void | Promise; @@ -37,6 +39,7 @@ export function Sidebar({ onRefresh, refreshPending }: SidebarProps) { const model = useSidebarModel(); const { toast } = useToast(); const common = useCommonCopy(); + const { theme, toggleTheme } = useTheme(); return ( {common.nav.light} + {theme === "light" ? : } + {theme === "light" ? common.nav.dark : common.nav.light} void; + setTheme: (theme: Theme) => void; +} + +const ThemeContext = createContext(null); + +const STORAGE_KEY = "skillmgr.theme"; + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [theme, setThemeState] = useState(() => { + try { + const stored = window.localStorage.getItem(STORAGE_KEY); + if (stored === "light" || stored === "dark") { + return stored; + } + } catch { + // noop + } + // Check system preference + if (typeof window !== "undefined" && window.matchMedia) { + if (window.matchMedia("(prefers-color-scheme: light)").matches) { + return "light"; + } + } + return "dark"; // Default is dark + }); + + const setTheme = (nextTheme: Theme) => { + setThemeState(nextTheme); + try { + window.localStorage.setItem(STORAGE_KEY, nextTheme); + } catch { + // noop + } + }; + + const toggleTheme = () => { + setTheme(theme === "light" ? "dark" : "light"); + }; + + useEffect(() => { + const root = window.document.documentElement; + root.setAttribute("data-theme", theme); + }, [theme]); + + // Sync with system preference changes + useEffect(() => { + if (typeof window === "undefined" || !window.matchMedia) return; + const mediaQuery = window.matchMedia("(prefers-color-scheme: light)"); + const handler = (e: MediaQueryListEvent) => { + // Only sync if the user hasn't explicitly set a preference in localStorage + try { + if (!window.localStorage.getItem(STORAGE_KEY)) { + setThemeState(e.matches ? "light" : "dark"); + } + } catch { + setThemeState(e.matches ? "light" : "dark"); + } + }; + mediaQuery.addEventListener("change", handler); + return () => mediaQuery.removeEventListener("change", handler); + }, []); + + return ( + + {children} + + ); +} + +export function useTheme() { + const context = useContext(ThemeContext); + if (!context) { + throw new Error("useTheme must be used within a ThemeProvider"); + } + return context; +} diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css index 946bdd0..f5a2938 100644 --- a/frontend/src/styles/tokens.css +++ b/frontend/src/styles/tokens.css @@ -10,6 +10,8 @@ --font-size-xl: 1.32rem; --font-size-2xl: 2rem; + color-scheme: dark; + /* Surfaces */ --color-bg: #0b0c0f; --color-surface: #1c1d21; @@ -82,3 +84,103 @@ /* Layout */ --sidebar-width: 256px; } + +/* System light theme preference if the user hasn't explicitly selected dark theme */ +@media (prefers-color-scheme: light) { + :root:not([data-theme="dark"]) { + color-scheme: light; + + /* Surfaces */ + --color-bg: #f8f5ed; + --color-surface: #ffffff; + --color-surface-raised: #ffffff; + --color-surface-sunken: #eae6db; + --color-sidebar-bg: #f2efe7; + + /* Borders */ + --color-border: #e2dccb; + --color-border-strong: #c8bfa6; + + /* Text */ + --color-text: #1f1f1c; + --color-text-muted: #6e6859; + --color-text-subtle: #9a8e72; + --color-text-inverted: #ffffff; + + /* Accent (Brand Amber from logo) */ + --color-accent: #c07204; + --color-accent-strong: #965303; + --color-accent-soft: rgba(192, 114, 4, 0.12); + --color-accent-softer: rgba(192, 114, 4, 0.06); + + /* Status */ + --color-success: #0f6e56; + --color-success-soft: #e8f5ee; + --color-danger: #b14828; + --color-danger-soft: #f7e5da; + --color-warning: #9a8e72; + --color-warning-soft: #fbf9f4; + + /* Shadows */ + --shadow-sm: 0 1px 2px rgba(31, 31, 28, 0.05); + --shadow-md: 0 4px 12px rgba(31, 31, 28, 0.08); + --shadow-panel: 0 12px 28px rgba(31, 31, 28, 0.1); + --shadow-lift: 0 10px 32px rgba(31, 31, 28, 0.12), 0 2px 8px rgba(31, 31, 28, 0.06); + + /* Scrollbars */ + --scrollbar-track: rgba(0, 0, 0, 0.02); + --scrollbar-thumb: rgba(154, 142, 114, 0.3); + --scrollbar-thumb-hover: rgba(154, 142, 114, 0.5); + --scrollbar-thumb-active: rgba(154, 142, 114, 0.7); + --scrollbar-corner: #f8f5ed; + } +} + +/* Explicit light theme setting */ +:root[data-theme="light"] { + color-scheme: light; + + /* Surfaces */ + --color-bg: #f8f5ed; + --color-surface: #ffffff; + --color-surface-raised: #ffffff; + --color-surface-sunken: #eae6db; + --color-sidebar-bg: #f2efe7; + + /* Borders */ + --color-border: #e2dccb; + --color-border-strong: #c8bfa6; + + /* Text */ + --color-text: #1f1f1c; + --color-text-muted: #6e6859; + --color-text-subtle: #9a8e72; + --color-text-inverted: #ffffff; + + /* Accent (Brand Amber from logo) */ + --color-accent: #c07204; + --color-accent-strong: #965303; + --color-accent-soft: rgba(192, 114, 4, 0.12); + --color-accent-softer: rgba(192, 114, 4, 0.06); + + /* Status */ + --color-success: #0f6e56; + --color-success-soft: #e8f5ee; + --color-danger: #b14828; + --color-danger-soft: #f7e5da; + --color-warning: #9a8e72; + --color-warning-soft: #fbf9f4; + + /* Shadows */ + --shadow-sm: 0 1px 2px rgba(31, 31, 28, 0.05); + --shadow-md: 0 4px 12px rgba(31, 31, 28, 0.08); + --shadow-panel: 0 12px 28px rgba(31, 31, 28, 0.1); + --shadow-lift: 0 10px 32px rgba(31, 31, 28, 0.12), 0 2px 8px rgba(31, 31, 28, 0.06); + + /* Scrollbars */ + --scrollbar-track: rgba(0, 0, 0, 0.02); + --scrollbar-thumb: rgba(154, 142, 114, 0.3); + --scrollbar-thumb-hover: rgba(154, 142, 114, 0.5); + --scrollbar-thumb-active: rgba(154, 142, 114, 0.7); + --scrollbar-corner: #f8f5ed; +}