+ A local-first control center for AI extensions.
+ Use, review, and discover Skills, MCP servers, and CLI tools across agent harnesses.
+
-
+
-
+
-Manage AI skills across Codex, Claude, Cursor, OpenCode, and OpenClaw from one local app.
+
-If you use more than one agent harness, skills end up scattered across different folders, install flows, and local states. `skill-manager` gives you one place to see what is installed, bring unmanaged skills under control, enable or disable skills per harness, and install new ones safely without turning your local setup into guesswork.
+## Why it exists
-## Why skill-manager exists
+AI extensions are scattered across harness-specific folders, MCP config files, and marketplace sources. Skill Manager gives those pieces one local control surface:
-- Skills get duplicated across multiple harness folders.
-- Local skill copies drift out of sync and become hard to reason about.
-- It is easy to lose track of what is managed, unmanaged, built in, or custom.
-- Editing local skill directories by hand is risky when you are not sure which tool depends on which copy.
+| Product idea | What it means |
+|---|---|
+| **In use** | Skill Manager controls the item and can enable or disable it across harnesses. |
+| **Needs review** | Skill Manager found local state, config differences, or inventory issues that need a decision. |
+| **Discover** | Browse marketplaces and preview external tools. |
## What you can do
-- Discover supported harnesses on your machine automatically.
-- See managed and unmanaged skills in one inventory.
-- Bring local unmanaged skills under management without losing visibility.
-- Enable or disable managed skills per harness.
-- Install new skills from the marketplace and open them directly in `Skills`.
+- See what is in use, what needs review, and where extensions are active.
+- Adopt local Skills into one shared inventory, then enable or disable them per harness.
+- Install or adopt MCP server configs, resolve differences, and enable them where supported.
+- Discover Skills, MCP servers, and preview-only CLI tools from marketplace sources.
## Product tour
-
-
-
+### Overview
-The `Skills` workspace gives you one place to review managed and unmanaged skills, inspect local state, and control harness access.
+Start with the whole extension portfolio: what is in use, what needs review, what can be discovered, and where extensions are active.
-
-
-
+
-The `Marketplace` view lets you browse, preview, and install new skills without leaving the app.
+### Skills
+
+Use Skills as shared local packages instead of maintaining separate copies per harness.
Typical flow:
-1. Open `Skills` to see what is already installed across your supported harnesses.
-2. Bring an unmanaged skill under management so it becomes part of one shared local inventory.
-3. Enable that managed skill only for the harnesses you want.
+1. Review a Skill found in a harness or install one from the marketplace.
+2. Adopt it into the Skill Manager inventory.
+3. Enable it only where it should be available.
+4. Update, remove, or delete it from one place.
+
+
+
+### MCP servers
+
+Use MCP servers as one normalized config that can be written into each harness shape.
+
+Typical flow:
+
+1. Review an MCP server found in a harness or install one from the marketplace.
+2. Adopt it into the Skill Manager inventory.
+3. Enable it where the server should be available.
+4. Resolve config differences, disable harness bindings, or uninstall it from one place.
+
+
+
+### Marketplace
+
+Marketplace is the discovery surface:
+
+- **Skills Marketplace**: browse and install Skills.
+- **MCP Marketplace**: browse and install MCP servers.
+- **CLI Marketplace**: preview external CLI tools from CLIs.dev. This is display-only; Skill Manager does not install or manage CLIs.
+
+
## Install
-### Homebrew (Recommended)
+### Homebrew (recommended)
```bash
brew tap mode-io/tap
@@ -63,8 +91,6 @@ brew install skill-manager
skill-manager start
```
-Use one global install channel at a time. If you previously installed `@mode-io/skill-manager` with npm, uninstall it before switching to Homebrew.
-
### npm
```bash
@@ -72,8 +98,6 @@ npm install -g @mode-io/skill-manager
skill-manager start
```
-Global npm ownership is exclusive. If `skill-manager` is already installed with Homebrew, run `brew uninstall skill-manager` first or keep using the Homebrew install.
-
## Supported harnesses
@@ -106,28 +130,78 @@ Global npm ownership is exclusive. If `skill-manager` is already installed with
-## Safety
+| Harness | Skills | MCP servers |
+|---|---:|---:|
+| Codex CLI | Yes | Yes |
+| Claude Code | Yes | Yes |
+| Cursor | Yes | Yes |
+| OpenCode | Yes | Yes |
+| OpenClaw | Yes | Not Yet |
+
+## Local-first safety
-`skill-manager` is a local-first desktop-style tool. It reads from, and can mutate, local harness skill directories on your machine.
+Skill Manager is a local configuration-management tool. It runs on your machine and reads or writes local harness extension state.
-Actions that change local state include:
+Actions that can change local state include:
-- `Bring Under Management`
-- enable or disable for managed harness links
-- `Update From Source`
-- `Stop Managing`
-- `Delete Skill`
-- marketplace installs into the managed local inventory
+- adopting a local skill folder
+- enabling or disabling a skill for a harness
+- updating a source-backed skill
+- removing or deleting a skill
+- installing an MCP server into a source harness
+- adopting an existing MCP config
+- enabling, disabling, resolving, or uninstalling an MCP server
+- changing harness support settings
-Use it like any other local configuration-management tool: point it at the correct skill roots, understand what is managed versus unmanaged, and review destructive actions before confirming them.
+App-owned files live under `~/Library/Application Support/skill-manager` on macOS.
## How it works
-Before you bring a skill under management, each harness just points at its own local copy. After you bring that skill under management, `skill-manager` stores one managed copy in its shared local inventory and rewires each supported harness to that shared copy with symlinks. That gives you one canonical package to update, disable, or delete while still controlling harness access individually.
+### Skills
-
-
-
+Before adoption, each harness points at its own local skill folder. After adoption, Skill Manager keeps one canonical package in its shared local store and exposes it to selected harnesses with local links. Disabling a harness removes that harness binding without deleting the package.
+
+
+
+### MCP servers
+
+MCP servers are stored as normalized Skill Manager records, then translated into the config shape each harness expects:
+
+- Codex uses TOML under `mcp_servers`.
+- Claude Code and Cursor use `mcpServers` JSON entries.
+- OpenCode uses typed local/remote MCP entries.
+- OpenClaw MCP writes are not yet supported.
+
+When Skill Manager finds different configs for the same MCP server, it asks you to resolve the source of truth first.
+
+
+
+### CLIs
+
+CLI marketplace entries are preview-only.
+
+## Configuration
+
+On macOS, app-owned files live under `~/Library/Application Support/skill-manager`.
+
+Useful paths:
+
+- shared skills store: `~/Library/Application Support/skill-manager/shared`
+- MCP manifest: `~/Library/Application Support/skill-manager/mcp/manifest.json`
+- marketplace cache: `~/Library/Application Support/skill-manager/marketplace`
+- app settings: `~/Library/Application Support/skill-manager/settings.json`
+
+Most users do not need to change these locations. If you manage skills in a custom environment, you can override individual skill roots with environment variables.
+
+| Harness | Env var | Default Skill Manager skill root |
+|---|---|---|
+| Codex | `SKILL_MANAGER_CODEX_ROOT` | `~/.agents/skills` |
+| Claude | `SKILL_MANAGER_CLAUDE_ROOT` | `~/.claude/skills` |
+| Cursor | `SKILL_MANAGER_CURSOR_ROOT` | `~/.cursor/skills` |
+| OpenCode | `SKILL_MANAGER_OPENCODE_ROOT` | `~/.config/opencode/skills` |
+| OpenClaw | `n/a` | `~/.openclaw/skills` |
+
+MCP config locations are harness-owned. Skill Manager writes only to verified config paths and skips unsupported harness writes.
## From source
@@ -137,7 +211,7 @@ Before you bring a skill under management, each harness just points at its own l
- Node.js 18+
- npm
-`skill-manager` supports Python 3.11+. CI validates backend compatibility on Python 3.11 through 3.14, while packaging and release builds stay pinned to Python 3.11 for determinism. GitHub-hosted workflows track the latest stable major versions of the GitHub-maintained actions, while the project build toolchain remains pinned separately inside each workflow.
+`skill-manager` supports Python 3.11+. CI validates backend compatibility on Python 3.11 through 3.14, while packaging and release builds stay pinned to Python 3.11 for determinism.
### Contributor setup
@@ -157,20 +231,7 @@ Stop the managed local instance:
scripts/stop-dev.sh
```
-The traditional split dev flow is still available when you want Vite hot reload:
-
-```bash
-npm run dev
-npm run dev:backend
-```
-
-If you stop the local dev app and want to bring it back:
-
-```bash
-scripts/start-dev.sh
-```
-
-If you are using the split dev flow instead, restart both sides:
+The split dev flow is available when you want Vite hot reload:
```bash
npm run dev
@@ -183,36 +244,7 @@ Default local URLs:
- Backend: `http://127.0.0.1:8000`
- Health: `http://127.0.0.1:8000/api/health`
-## Configuration
-
-`skill-manager` stores its own app data in standard per-user locations.
-
-On macOS, app-owned files live under `~/Library/Application Support/skill-manager`.
-
-Useful paths:
-
-- shared managed store: `~/Library/Application Support/skill-manager/shared`
-- marketplace cache: `~/Library/Application Support/skill-manager/marketplace`
-- app settings: `~/Library/Application Support/skill-manager/settings.json`
-
-Most users do not need to change these locations. If you manage skills in a custom environment, you can override individual harness roots with environment variables.
-
-| Harness | Env var | Default managed root |
-| --- | --- | --- |
-| Codex | `SKILL_MANAGER_CODEX_ROOT` | `~/.agents/skills` |
-| Claude | `SKILL_MANAGER_CLAUDE_ROOT` | `~/.claude/skills` |
-| Cursor | `SKILL_MANAGER_CURSOR_ROOT` | `~/.cursor/skills` |
-| OpenCode | `SKILL_MANAGER_OPENCODE_ROOT` | `~/.config/opencode/skills` |
-| OpenClaw | `n/a` | `~/.openclaw/skills` |
-
-## Troubleshooting
-
-- If Marketplace requests fail with `Marketplace is temporarily unavailable`, verify your network connection and try reinstalling `skill-manager` if the problem persists.
-- If `npm install -g @mode-io/skill-manager` reports that Homebrew already owns `skill-manager`, uninstall the Homebrew formula first. The inverse also applies: uninstall the npm package before switching back to Homebrew.
-
-## Development
-
-Useful local commands:
+Validation:
```bash
scripts/install-dev.sh
@@ -220,28 +252,33 @@ npm run typecheck
bash scripts/test_backend.sh
npm test
npm run build
-./.venv/bin/python -m skill_manager serve --host 127.0.0.1 --port 8000 --no-open-browser
-scripts/ci_validate.sh
```
-Test coverage currently includes:
+## Troubleshooting
-- frontend unit tests
-- backend unit and integration tests
-- Playwright smoke coverage
+- If Marketplace requests fail with `Marketplace is temporarily unavailable`, verify your network connection and try again.
+- If `npm install -g @mode-io/skill-manager` reports that Homebrew already owns `skill-manager`, uninstall the Homebrew formula first. The inverse also applies: uninstall the npm package before switching back to Homebrew.
+- If an MCP harness is shown as unavailable, Skill Manager has detected that the local client is missing or does not support the required config surface.
-## Community
+## More to come
-- See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines.
-- See [SECURITY.md](SECURITY.md) to report vulnerabilities privately.
+### Extension families
+
+- [ ] Hook support
+- [ ] Slash command support
+- [ ] Plugin support
-## Limitations
+### Harness expansion
-- This is a local-first app, not a hosted service.
-- Source-backed operations are currently centered on GitHub-backed skills.
-- Marketplace content is sourced from `skills.sh`.
-- Public distribution is currently macOS-only.
+- [ ] GitHub Copilot
+- [ ] Gemini CLI
+- [ ] Cline
+- [ ] Windsurf
+- [ ] Qwen Code
+- [ ] Kimi Code
+- [ ] Qoder
-## Project status
+## Community
-This repository is in active development as the public `skill-manager` project, with npm and Homebrew distribution backed by native release artifacts.
+- See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines.
+- See [SECURITY.md](SECURITY.md) to report vulnerabilities privately.
diff --git a/assets/Skill-Manager-Hero-shadow.svg b/assets/Skill-Manager-Hero-shadow.svg
deleted file mode 100644
index 03188d0..0000000
--- a/assets/Skill-Manager-Hero-shadow.svg
+++ /dev/null
@@ -1,100 +0,0 @@
-
diff --git a/assets/Skill-Manager-Marketplace.png b/assets/Skill-Manager-Marketplace.png
deleted file mode 100644
index 666d962..0000000
Binary files a/assets/Skill-Manager-Marketplace.png and /dev/null differ
diff --git a/assets/Skill-Manager.png b/assets/Skill-Manager.png
deleted file mode 100644
index a5c19b5..0000000
Binary files a/assets/Skill-Manager.png and /dev/null differ
diff --git a/assets/skill-manager-mark.svg b/assets/skill-manager-mark.svg
new file mode 100644
index 0000000..bae5a5c
--- /dev/null
+++ b/assets/skill-manager-mark.svg
@@ -0,0 +1,19 @@
+
diff --git a/assets/skill-manager-marketplace.png b/assets/skill-manager-marketplace.png
new file mode 100644
index 0000000..dd07b3a
Binary files /dev/null and b/assets/skill-manager-marketplace.png differ
diff --git a/assets/skill-manager-mcp-matrix.png b/assets/skill-manager-mcp-matrix.png
new file mode 100644
index 0000000..7f1da6c
Binary files /dev/null and b/assets/skill-manager-mcp-matrix.png differ
diff --git a/assets/skill-manager-mcp-translation.svg b/assets/skill-manager-mcp-translation.svg
new file mode 100644
index 0000000..ba9f13a
--- /dev/null
+++ b/assets/skill-manager-mcp-translation.svg
@@ -0,0 +1,120 @@
+
\ No newline at end of file
diff --git a/assets/skill-manager-overview.png b/assets/skill-manager-overview.png
new file mode 100644
index 0000000..cfad00f
Binary files /dev/null and b/assets/skill-manager-overview.png differ
diff --git a/assets/skill-manager-skill-matrix.png b/assets/skill-manager-skill-matrix.png
new file mode 100644
index 0000000..f0024ee
Binary files /dev/null and b/assets/skill-manager-skill-matrix.png differ
diff --git a/assets/skill-manager-skill-unification.svg b/assets/skill-manager-skill-unification.svg
new file mode 100644
index 0000000..0a63df5
--- /dev/null
+++ b/assets/skill-manager-skill-unification.svg
@@ -0,0 +1,135 @@
+
\ No newline at end of file
diff --git a/assets/skill_manager_before_after.svg b/assets/skill_manager_before_after.svg
deleted file mode 100644
index 84a6c5a..0000000
--- a/assets/skill_manager_before_after.svg
+++ /dev/null
@@ -1,111 +0,0 @@
-
\ No newline at end of file
diff --git a/assets/skill_manager_logo.svg b/assets/skill_manager_logo.svg
new file mode 100644
index 0000000..7080ff2
--- /dev/null
+++ b/assets/skill_manager_logo.svg
@@ -0,0 +1,18 @@
+
diff --git a/frontend/e2e/smoke.spec.ts b/frontend/e2e/smoke.spec.ts
deleted file mode 100644
index dc142f9..0000000
--- a/frontend/e2e/smoke.spec.ts
+++ /dev/null
@@ -1,139 +0,0 @@
-import { expect, test } from "@playwright/test";
-
-test("renders the managed skills page", async ({ page }) => {
- await page.goto("/");
- await expect(page.getByRole("heading", { name: "Skills", exact: true })).toBeVisible();
- await expect(page.getByRole("heading", { name: "Managed skills" })).toBeVisible();
- await expect(page.getByPlaceholder("Search managed skills by name, description, or state")).toBeVisible();
- await expect(page.getByLabel("Managed skills list")).toBeVisible();
- await expect(page.getByRole("switch").first()).toBeVisible();
- await expect(page.getByText("Shared Audit")).toBeVisible();
- await expect(page.getByRole("navigation", { name: "Skills views" }).getByRole("link", { name: /Unmanaged/i })).toBeVisible();
-});
-
-test("keeps managed skills scroll contained to the list surface on desktop", async ({ page }) => {
- await page.setViewportSize({ width: 1440, height: 900 });
- await page.goto("/");
- await expect(page.getByLabel("Managed skills list")).toBeVisible();
-
- const metrics = await page.evaluate(() => {
- const scroller = document.querySelector(".skills-pane__scroll") as HTMLDivElement | null;
- const chrome = document.querySelector(".skills-pane__chrome") as HTMLElement | null;
- const content = document.querySelector(".skills-pane__content") as HTMLElement | null;
- if (!scroller || !chrome) {
- throw new Error("Skills pane scaffold was not rendered.");
- }
- if (content) {
- content.style.minHeight = `${scroller.clientHeight + 640}px`;
- }
- const chromeTop = Math.round(chrome.getBoundingClientRect().top);
- scroller.scrollTop = 320;
- window.scrollTo(0, 240);
-
- return {
- windowScrollY: window.scrollY,
- bodyScrollHeight: document.body.scrollHeight,
- viewportHeight: window.innerHeight,
- chromeTop,
- chromeTopAfterScroll: Math.round(chrome.getBoundingClientRect().top),
- scrollerClientHeight: scroller.clientHeight,
- scrollerScrollHeight: scroller.scrollHeight,
- scrollerScrollTop: scroller.scrollTop,
- };
- });
-
- expect(metrics.windowScrollY).toBe(0);
- expect(metrics.bodyScrollHeight).toBe(metrics.viewportHeight);
- expect(metrics.scrollerScrollHeight).toBeGreaterThan(metrics.scrollerClientHeight);
- expect(metrics.scrollerScrollTop).toBe(320);
- expect(metrics.chromeTopAfterScroll).toBe(metrics.chromeTop);
-});
-
-test("keeps managed skills scroll contained to the list surface below the old breakpoint", async ({ page }) => {
- await page.setViewportSize({ width: 1100, height: 900 });
- await page.goto("/");
- await expect(page.getByLabel("Managed skills list")).toBeVisible();
-
- const metrics = await page.evaluate(() => {
- const scroller = document.querySelector(".skills-pane__scroll") as HTMLDivElement | null;
- const content = document.querySelector(".skills-pane__content") as HTMLElement | null;
- if (!scroller) {
- throw new Error("Skills pane scroller was not rendered.");
- }
- if (content) {
- content.style.minHeight = `${scroller.clientHeight + 520}px`;
- }
- scroller.scrollTop = 260;
- window.scrollTo(0, 180);
-
- return {
- windowScrollY: window.scrollY,
- bodyScrollHeight: document.body.scrollHeight,
- viewportHeight: window.innerHeight,
- scrollerClientHeight: scroller.clientHeight,
- scrollerScrollHeight: scroller.scrollHeight,
- scrollerScrollTop: scroller.scrollTop,
- };
- });
-
- expect(metrics.windowScrollY).toBe(0);
- expect(metrics.bodyScrollHeight).toBe(metrics.viewportHeight);
- expect(metrics.scrollerScrollHeight).toBeGreaterThan(metrics.scrollerClientHeight);
- expect(metrics.scrollerScrollTop).toBe(260);
-});
-
-test("renders the unmanaged intake page", async ({ page }) => {
- await page.goto("/skills/unmanaged");
- await expect(page.getByRole("heading", { name: "Unmanaged skills" })).toBeVisible();
- await expect(page.getByPlaceholder("Search unmanaged skills by name, description, or tool")).toBeVisible();
- await expect(page.getByLabel("Unmanaged skills list")).toBeVisible();
- await expect(page.getByText("Trace Lens")).toBeVisible();
- await expect(page.getByRole("button", { name: "Bring all eligible skills under management" })).toBeVisible();
-});
-
-test("restores managed list scroll after switching tabs", async ({ page }) => {
- await page.setViewportSize({ width: 1440, height: 900 });
- await page.goto("/");
- await expect(page.getByLabel("Managed skills list")).toBeVisible();
-
- await page.evaluate(() => {
- const style = document.createElement("style");
- style.textContent = ".skills-pane__content { min-height: 1200px; }";
- document.head.appendChild(style);
- const scroller = document.querySelector(".skills-pane__scroll") as HTMLDivElement | null;
- if (!scroller) {
- throw new Error("Managed skills scroller was not rendered.");
- }
- scroller.scrollTop = 280;
- });
-
- const skillsTabs = page.getByRole("navigation", { name: "Skills views" });
- await skillsTabs.getByRole("link", { name: /^Unmanaged/i }).click();
- await expect(page.getByRole("heading", { name: "Unmanaged skills" })).toBeVisible();
- await skillsTabs.getByRole("link", { name: /^Managed/i }).click();
- await expect(page.getByRole("heading", { name: "Managed skills" })).toBeVisible();
-
- await expect
- .poll(async () => {
- return page.evaluate(() => {
- const scroller = document.querySelector(".skills-pane__scroll") as HTMLDivElement | null;
- return scroller?.scrollTop ?? 0;
- });
- })
- .toBe(280);
-});
-
-test("opens the Settings drawer", async ({ page }) => {
- await page.goto("/");
- await page.getByRole("link", { name: "Open settings" }).click();
- await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible();
- await expect(page.getByRole("heading", { name: "Harnesses" })).toBeVisible();
-});
-
-test("navigates to Marketplace", async ({ page }) => {
- await page.goto("/");
- await page.getByRole("link", { name: "Marketplace" }).click();
- await expect(page.getByRole("heading", { name: "Marketplace" })).toBeVisible();
- await expect(page.getByRole("heading", { name: "All-time leaderboard" })).toBeVisible();
- await expect(page.getByRole("link", { name: "mode-io/skills" }).first()).toBeVisible();
-});
diff --git a/frontend/index.html b/frontend/index.html
index fdb9fd1..6368f3c 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -6,6 +6,7 @@
+
Skill Manager
diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg
new file mode 100644
index 0000000..bae5a5c
--- /dev/null
+++ b/frontend/public/favicon.svg
@@ -0,0 +1,19 @@
+
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
new file mode 100644
index 0000000..de87f40
--- /dev/null
+++ b/frontend/src/App.test.tsx
@@ -0,0 +1,184 @@
+import { fireEvent, screen, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { App } from "./App";
+import { createRouteFetchMock, okJson } from "./test/fetch";
+import { mcpInventoryEntry, mcpInventoryPayload } from "./test/fixtures/mcp";
+import { skillsPayload } from "./test/fixtures/skills";
+import { renderWithRouter, stubDesktopMatchMedia } from "./test/render";
+
+const fetchMock = vi.fn();
+
+function renderApp(initialRoute = "/") {
+ return renderWithRouter(, { route: initialRoute });
+}
+
+function stubEmptyApi() {
+ fetchMock.mockImplementation(
+ createRouteFetchMock(
+ [
+ { match: "/api/skills", response: skillsPayload() },
+ { match: "/api/mcp/servers", response: mcpInventoryPayload() },
+ { match: "/api/settings", response: { harnesses: [] } },
+ {
+ match: (url) =>
+ url.startsWith("/api/marketplace/popular") ||
+ url.startsWith("/api/marketplace/search") ||
+ url.startsWith("/api/marketplace/clis/popular") ||
+ url.startsWith("/api/marketplace/clis/search"),
+ response: { items: [], nextOffset: null, hasMore: false },
+ },
+ ],
+ () => okJson({}),
+ ),
+ );
+}
+
+describe("App shell", () => {
+ beforeEach(() => {
+ stubDesktopMatchMedia();
+ stubEmptyApi();
+ vi.stubGlobal("fetch", fetchMock);
+ });
+
+ afterEach(() => {
+ fetchMock.mockReset();
+ vi.unstubAllGlobals();
+ });
+
+ it("renders the sidebar with primary nav groups", async () => {
+ renderApp("/skills/use");
+ await waitFor(() => expect(screen.getByLabelText(/primary navigation/i)).toBeInTheDocument());
+ expect(screen.getByText(/skill-manager/)).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: /^Overview$/i })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /Skills/i })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /MCP Servers/i })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /Marketplace/i })).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: /^Settings$/i })).toBeInTheDocument();
+ });
+
+ it("renders right-aligned section counts for skills and MCP servers", async () => {
+ fetchMock.mockImplementation(async (input: RequestInfo | URL) => {
+ const url = typeof input === "string" ? input : input.toString();
+ if (url === "/api/skills") {
+ return okJson(skillsPayload({ managed: 10, unmanaged: 3 }));
+ }
+ if (url === "/api/mcp/servers") {
+ return okJson(
+ mcpInventoryPayload([
+ mcpInventoryEntry({ name: "exa", kind: "managed" }),
+ mcpInventoryEntry({ name: "context7", kind: "managed" }),
+ mcpInventoryEntry({ name: "firecrawl", kind: "unmanaged" }),
+ ]),
+ );
+ }
+ if (url === "/api/settings") {
+ return okJson({ harnesses: [] });
+ }
+ return okJson({});
+ });
+
+ renderApp("/settings");
+
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: "Skills 13" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "MCP Servers 3" })).toBeInTheDocument();
+ });
+ expect(screen.getByRole("link", { name: "In use 10" })).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "Needs review 3" })).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "In use 2" })).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "Needs review 1" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Marketplace" })).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "Skills" })).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "MCP" })).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "CLIs" })).toBeInTheDocument();
+ });
+
+ it("omits sidebar counts before query data resolves", () => {
+ fetchMock.mockImplementation(
+ () => new Promise(() => {
+ // Keep the query pending so the sidebar renders its unloaded state.
+ }),
+ );
+
+ renderApp("/settings");
+
+ expect(screen.getByRole("button", { name: "Skills" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "MCP Servers" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Marketplace" })).toBeInTheDocument();
+ });
+
+ it.each([
+ ["/overview", "Overview"],
+ ["/skills/use", "Skills in use"],
+ ["/skills/review", "Skills to review"],
+ ["/mcp/use", "MCP servers in use"],
+ ["/mcp/review", "MCP configs to review"],
+ ["/marketplace/skills", "Marketplace"],
+ ["/marketplace/clis", "Marketplace"],
+ ["/settings", "Settings"],
+ ])("renders the expected page heading for %s", async (route, heading) => {
+ renderApp(route);
+ await waitFor(() =>
+ expect(screen.getByRole("heading", { name: heading })).toBeInTheDocument(),
+ );
+ });
+
+ it.each([
+ ["/skills/managed", "Skills in use"],
+ ["/skills/unmanaged", "Skills to review"],
+ ["/mcp/managed", "MCP servers in use"],
+ ["/mcp/unmanaged", "MCP configs to review"],
+ ])("redirects compatibility route %s to the new concept route", async (route, heading) => {
+ renderApp(route);
+ await waitFor(() =>
+ expect(screen.getByRole("heading", { name: heading })).toBeInTheDocument(),
+ );
+ });
+
+ it("shows the preview-only note only on the CLI marketplace tab", async () => {
+ const note = "Preview only · Skill Manager does not install or manage CLIs";
+
+ const cliView = renderApp("/marketplace/clis");
+ await waitFor(() =>
+ expect(screen.getByRole("heading", { name: "Marketplace" })).toBeInTheDocument(),
+ );
+ const previewNote = screen.getByText(note);
+ expect(previewNote).toBeInTheDocument();
+ expect(previewNote.closest(".page-header")).toBeInTheDocument();
+ cliView.unmount();
+
+ renderApp("/marketplace/skills");
+ await waitFor(() =>
+ expect(screen.getByRole("heading", { name: "Marketplace" })).toBeInTheDocument(),
+ );
+ expect(screen.queryByText(note)).not.toBeInTheDocument();
+ });
+
+ it("redirects / to /overview", async () => {
+ renderApp("/");
+ await waitFor(() =>
+ expect(screen.getByRole("heading", { name: "Overview" })).toBeInTheDocument(),
+ );
+ });
+
+ it("redirects retired /harnesses to /overview", async () => {
+ renderApp("/harnesses");
+ await waitFor(() =>
+ expect(screen.getByRole("heading", { name: "Overview" })).toBeInTheDocument(),
+ );
+ });
+
+ it("navigates to overview from the skill-manager brand", async () => {
+ renderApp("/settings");
+ await waitFor(() =>
+ expect(screen.getByRole("heading", { name: "Settings" })).toBeInTheDocument(),
+ );
+
+ fireEvent.click(screen.getByRole("link", { name: /skill-manager/i }));
+
+ await waitFor(() =>
+ expect(screen.getByRole("heading", { name: "Overview" })).toBeInTheDocument(),
+ );
+ });
+});
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 4274981..3f423c2 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -2,18 +2,21 @@ import { QueryClient, QueryClientProvider, useQueryClient } from "@tanstack/reac
import { lazy, Suspense, useState } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
-import { AppShell } from "./components/AppShell";
import RouteLoadingPanel from "./components/RouteLoadingPanel";
-import { invalidateMarketplaceQueries } from "./features/marketplace/api/queries";
-import { invalidateSettingsQueries } from "./features/settings/queries";
+import { Shell } from "./components/Shell";
+import { ToastProvider } from "./components/Toast";
+import { UiTooltipProvider } from "./components/ui/UiTooltipProvider";
+import { invalidateCapabilityQueries } from "./app/capability-registry";
import { SkillsWorkspaceSessionProvider } from "./features/skills/model/session";
-import { invalidateSkillsQueries } from "./features/skills/api/queries";
-import ManagedSkillsPage from "./features/skills/screens/ManagedSkillsPage";
+import SkillsNeedsReviewPage from "./features/skills/screens/SkillsNeedsReviewPage";
+import SkillsInUsePage from "./features/skills/screens/SkillsInUsePage";
import SkillsWorkspacePage from "./features/skills/screens/SkillsWorkspacePage";
-import UnmanagedSkillsPage from "./features/skills/screens/UnmanagedSkillsPage";
-const MarketplacePage = lazy(() => import("./features/marketplace/screens/MarketplacePage"));
+const MarketplaceLayout = lazy(() => import("./features/marketplace/components/MarketplaceLayout"));
+const OverviewPage = lazy(() => import("./features/overview/screens/OverviewPage"));
const SettingsPage = lazy(() => import("./features/settings/screens/SettingsPage"));
+const McpNeedsReviewPage = lazy(() => import("./features/mcp/screens/McpNeedsReviewPage"));
+const McpInUsePage = lazy(() => import("./features/mcp/screens/McpInUsePage"));
export function App() {
const [queryClient] = useState(
@@ -29,7 +32,11 @@ export function App() {
return (
-
+
+
+
+
+
);
}
@@ -41,11 +48,7 @@ function AppContent() {
async function handleRefreshData() {
setRefreshPending(true);
try {
- await Promise.all([
- invalidateSkillsQueries(queryClient),
- invalidateSettingsQueries(queryClient),
- invalidateMarketplaceQueries(queryClient),
- ]);
+ await invalidateCapabilityQueries(queryClient);
} finally {
setRefreshPending(false);
}
@@ -53,22 +56,65 @@ function AppContent() {
return (
-
+
- } />
+ } />
+
+ }>
+
+
+ }
+ />
+
}>
- } />
- } />
- } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+ } />
+ }>
+
+
+ }
+ />
+ }>
+
+
+ }
+ />
+ } />
+ } />
+
}>
-
+
}
- />
+ >
+ } />
+ {/* Child routes exist only so /marketplace/skills, /marketplace/mcp,
+ and /marketplace/clis
+ are valid URLs and NavLink active matching works.
+ MarketplaceLayout renders the panes itself — no Outlet. */}
+
+
+
+
+
}
/>
+
+ } />
-
+
);
}
diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx
deleted file mode 100644
index 2ac80f6..0000000
--- a/frontend/src/__tests__/App.test.tsx
+++ /dev/null
@@ -1,617 +0,0 @@
-import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
-import { MemoryRouter } from "react-router-dom";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-
-import { App } from "../App";
-import { createMarketplaceItem } from "../features/marketplace/test-fixtures";
-
-const fetchMock = vi.fn();
-
-function renderApp(initialRoute = "/") {
- return render(
-
-
- ,
- );
-}
-
-function stubDesktopMatchMedia() {
- Object.defineProperty(window, "matchMedia", {
- writable: true,
- configurable: true,
- value: vi.fn().mockImplementation((query: string) => ({
- matches: false,
- media: query,
- onchange: null,
- addEventListener: vi.fn(),
- removeEventListener: vi.fn(),
- addListener: vi.fn(),
- removeListener: vi.fn(),
- dispatchEvent: vi.fn(),
- })),
- });
-}
-
-function mockSkillsPage(options?: { codexSupportEnabled?: boolean }) {
- let sharedAuditState: "managed" | "unmanaged" | "deleted" = "managed";
- let codexSupportEnabled = options?.codexSupportEnabled ?? true;
-
- fetchMock.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => {
- const url = typeof input === "string" ? input : input.toString();
- if (url === "/api/skills") {
- return {
- ok: true,
- json: async () => ({
- summary: {
- managed: sharedAuditState === "managed" ? 1 : 0,
- unmanaged: sharedAuditState === "unmanaged" ? 2 : 1,
- custom: 1,
- builtIn: 1,
- },
- harnessColumns: codexSupportEnabled ? [{ harness: "codex", label: "Codex", logoKey: "codex" }] : [],
- rows: [
- ...(sharedAuditState === "managed" ? [{
- skillRef: "shared:shared-audit",
- name: "Shared Audit",
- description: "Shared audit workflow",
- displayStatus: "Managed",
- attentionMessage: null,
- actions: { canManage: false },
- cells: codexSupportEnabled ? [{ harness: "codex", label: "Codex", logoKey: "codex", state: "disabled", interactive: true }] : [],
- }] : []),
- ...(sharedAuditState === "unmanaged" ? [{
- skillRef: "unmanaged:shared-audit-restored",
- name: "Shared Audit",
- description: "Shared audit workflow",
- displayStatus: "Unmanaged",
- attentionMessage: null,
- actions: { canManage: true },
- cells: codexSupportEnabled ? [{ harness: "codex", label: "Codex", logoKey: "codex", state: "found", interactive: false }] : [],
- }] : []),
- {
- skillRef: "shared:audit-skill",
- name: "Audit Skill",
- description: "Custom audit workflow",
- displayStatus: "Custom",
- attentionMessage: "Modified locally; source updates are disabled.",
- actions: { canManage: false },
- cells: codexSupportEnabled ? [{ harness: "codex", label: "Codex", logoKey: "codex", state: "enabled", interactive: true }] : [],
- },
- {
- skillRef: "unmanaged:trace-lens",
- name: "Trace Lens",
- description: "Trace review workflow",
- displayStatus: "Unmanaged",
- attentionMessage: null,
- actions: { canManage: true },
- cells: codexSupportEnabled ? [{ harness: "codex", label: "Codex", logoKey: "codex", state: "found", interactive: false }] : [],
- },
- {
- skillRef: "builtin:review-helper",
- name: "Review Helper",
- description: "Bundled with OpenCode",
- displayStatus: "Built-in",
- attentionMessage: null,
- actions: { canManage: false },
- cells: [{ harness: "opencode", label: "OpenCode", state: "builtin", interactive: false }],
- },
- ],
- }),
- };
- }
- if (url === "/api/skills/shared%3Ashared-audit/delete") {
- sharedAuditState = "deleted";
- return {
- ok: true,
- json: async () => ({ ok: true }),
- };
- }
- if (url === "/api/skills/shared%3Ashared-audit/unmanage") {
- sharedAuditState = "unmanaged";
- return {
- ok: true,
- json: async () => ({ ok: true }),
- };
- }
- if (url === "/api/skills/shared%3Ashared-audit/source-status") {
- return {
- ok: true,
- json: async () => ({ updateStatus: "no_update_available" }),
- };
- }
- if (url.startsWith("/api/skills/")) {
- if (sharedAuditState !== "managed" && url === "/api/skills/shared%3Ashared-audit") {
- return {
- ok: false,
- status: 404,
- statusText: "Not Found",
- json: async () => ({ error: "unknown skill ref: shared:shared-audit" }),
- };
- }
- return {
- ok: true,
- json: async () => ({
- skillRef: "shared:shared-audit",
- name: "Shared Audit",
- description: "Shared audit workflow",
- displayStatus: "Managed",
- attentionMessage: null,
- actions: {
- canManage: false,
- stopManagingStatus: "available",
- stopManagingHarnessLabels: ["Codex"],
- canDelete: true,
- deleteHarnessLabels: ["Codex"],
- },
- harnessCells: [
- { harness: "codex", label: "Codex", state: "disabled", interactive: true },
- { harness: "claude", label: "Claude", state: "disabled", interactive: true },
- { harness: "cursor", label: "Cursor", state: "disabled", interactive: true },
- { harness: "opencode", label: "OpenCode", state: "disabled", interactive: true },
- { harness: "openclaw", label: "OpenClaw", state: "disabled", interactive: true },
- ],
- locations: [
- {
- kind: "shared",
- harness: null,
- label: "Shared Store",
- scope: null,
- path: "/tmp/shared-audit",
- revision: "abc",
- sourceKind: "github",
- sourceLocator: "github:mode-io/shared-audit",
- detail: null,
- },
- {
- kind: "harness",
- harness: "codex",
- label: "Codex",
- scope: "canonical",
- path: "/tmp/home/.agents/skills/shared-audit",
- revision: "abc",
- sourceKind: "github",
- sourceLocator: "github:mode-io/shared-audit",
- detail: null,
- },
- ],
- sourceLinks: {
- repoLabel: "mode-io/shared-audit",
- repoUrl: "https://github.com/mode-io/shared-audit",
- folderUrl: "https://github.com/mode-io/shared-audit/tree/main/shared-audit",
- },
- documentMarkdown: "# Shared Audit\n\n## Use when\n\nRun the shared audit workflow.\n",
- }),
- };
- }
- if (url === "/api/marketplace/items/skillssh%3Amode-io%2Fshared-audit%3Ashared-audit/document") {
- return {
- ok: true,
- json: async () => ({
- status: "ready",
- documentMarkdown: "# Shared Audit",
- }),
- };
- }
- if (url.startsWith("/api/marketplace/popular")) {
- return {
- ok: true,
- json: async () => ({
- items: [
- createMarketplaceItem({
- id: "skillssh:mode-io/shared-audit:shared-audit",
- name: "Shared Audit",
- description: "Shared audit workflow",
- installs: 44,
- stars: 33,
- repoLabel: "mode-io/shared-audit",
- installToken: "token-shared-audit",
- }),
- ],
- nextOffset: null,
- hasMore: false,
- }),
- };
- }
- if (url === "/api/settings") {
- return {
- ok: true,
- json: async () => ({
- harnesses: [
- {
- harness: "codex",
- label: "Codex",
- logoKey: "codex",
- supportEnabled: codexSupportEnabled,
- installed: true,
- managedLocation: "/tmp/home/.agents/skills",
- },
- ],
- }),
- };
- }
- if (url === "/api/settings/harnesses/codex/support") {
- const body = init?.body && typeof init.body === "string" ? JSON.parse(init.body) : null;
- codexSupportEnabled = body?.enabled ?? codexSupportEnabled;
- return {
- ok: true,
- json: async () => ({ ok: true, enabled: codexSupportEnabled }),
- };
- }
- return { ok: true, json: async () => ({ ok: true }) };
- });
-}
-
-describe("App routing", () => {
- beforeEach(() => {
- vi.stubGlobal("fetch", fetchMock);
- stubDesktopMatchMedia();
- });
-
- afterEach(() => {
- vi.unstubAllGlobals();
- fetchMock.mockReset();
- });
-
- it("renders Skills page at /", async () => {
- mockSkillsPage();
- renderApp("/");
- await waitFor(() => expect(screen.getByLabelText("Managed skills list")).toBeInTheDocument());
- expect(document.querySelector(".app-main")).toHaveClass("app-main--skills");
- expect(screen.getByRole("heading", { name: "Managed skills" })).toBeInTheDocument();
- expect(screen.getByLabelText("Managed skills list")).toBeInTheDocument();
- expect(screen.getByText("Shared Audit")).toBeInTheDocument();
- expect(screen.getByPlaceholderText("Search managed skills by name, description, or state")).toBeInTheDocument();
- expect(screen.getByRole("switch", { name: "Enable Shared Audit for Codex" })).toBeInTheDocument();
- expect(screen.getByText("Audit Skill")).toBeInTheDocument();
- expect(screen.getByRole("heading", { name: "Built-in skills" })).toBeInTheDocument();
- expect(screen.getByText("Review Helper")).toBeInTheDocument();
- expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
- expect(screen.queryByRole("button", { name: "Bring All Eligible Skills Under Management" })).not.toBeInTheDocument();
- });
-
- it("renders the unmanaged intake page", async () => {
- mockSkillsPage();
- renderApp("/skills/unmanaged");
- await waitFor(() => expect(screen.getByLabelText("Unmanaged skills list")).toBeInTheDocument());
- expect(screen.getByLabelText("Unmanaged skills list")).toBeInTheDocument();
- expect(screen.getByText("Trace Lens")).toBeInTheDocument();
- expect(screen.getByRole("heading", { name: "Unmanaged skills" })).toBeInTheDocument();
- expect(screen.getByRole("link", { name: /Unmanaged/i })).toBeInTheDocument();
- expect(screen.getByRole("button", { name: "Bring All Eligible Skills Under Management" })).toBeInTheDocument();
- expect(screen.getByRole("button", { name: "What happens when you manage all eligible skills" })).toBeInTheDocument();
- expect(
- screen.queryByText("Review skills discovered in local tool folders and bring the ones you want into the shared managed store."),
- ).not.toBeInTheDocument();
- expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
- expect(screen.queryByRole("switch")).not.toBeInTheDocument();
- });
-
- it("shows the bulk-manage help popover on the unmanaged page", async () => {
- mockSkillsPage();
- renderApp("/skills/unmanaged");
-
- await waitFor(() => expect(screen.getByLabelText("Unmanaged skills list")).toBeInTheDocument());
-
- fireEvent.mouseEnter(screen.getByRole("button", { name: "What happens when you manage all eligible skills" }));
-
- await waitFor(() => expect(screen.getByText("Bulk Manage")).toBeInTheDocument());
- expect(
- screen.getByText("Moves local copies into the Shared Store, then replaces tool-folder copies with managed links."),
- ).toBeInTheDocument();
- });
-
- it("renders Marketplace page at /marketplace", async () => {
- mockSkillsPage();
- renderApp("/marketplace");
- await waitFor(() => expect(screen.getByText("Shared Audit")).toBeInTheDocument());
- expect(document.querySelector(".app-main")).not.toHaveClass("app-main--skills");
- expect(screen.getByText("All-time leaderboard")).toBeInTheDocument();
- expect(screen.getByAltText("Avatar for mode-io/shared-audit")).toBeInTheDocument();
- expect(screen.getByRole("link", { name: "mode-io/shared-audit" })).toBeInTheDocument();
- });
-
- it("shows the manual refresh spinner inside the header button only", async () => {
- const pendingRefresh = deferred<{
- ok: boolean;
- json: () => Promise